feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
This commit is contained in:
parent
9155949fda
commit
09735375c2
70 changed files with 5892 additions and 1965 deletions
34
AGENTS.md
34
AGENTS.md
|
|
@ -824,6 +824,12 @@ session with **no other memory of this project**. So:
|
|||
⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
|
||||
defer it); `fast` needs a sticky notification. Never the only path. (Approach
|
||||
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
|
||||
**Categories PUSHED (2026-10-01, supersedes the line above):** `dms`,
|
||||
`trades`, `crew` (invite 36229 / join-request 31969), and `social` (zap 9735 /
|
||||
reply-mention 1 / comment 1111) via `CATEGORY_BY_KIND`. **Reactions/reposts
|
||||
are NOT pushed** (too noisy) and **newsletter has no push channel** — those
|
||||
stay in-app. The **master switch off tears down push + the native poller**
|
||||
(`toggleEnabled(false)` calls `push.disable()` + `background.disable()`).
|
||||
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
|
||||
delivery coordinator** exposed as a **ContextVM server** over Nostr relays
|
||||
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is
|
||||
|
|
@ -1609,6 +1615,34 @@ The credits should read (keep this shape when editing): **Nostr** (protocol) ·
|
|||
· **Zapstore** · **ContextVM**. (`useShakespeare.ts`, `proxy.shakespeare.diy` and
|
||||
similar are internal endpoints/hook names, not credits — leave them.)
|
||||
|
||||
## Internationalisation (EN / ES-Castellano) — decisions (2026-10-01)
|
||||
|
||||
- The app ships **English + Spanish (Castellano, Spain)**. The framework already
|
||||
exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx`
|
||||
(provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object
|
||||
each), `t()` with dot-notation + `{{param}}` interpolation, English fallback and
|
||||
a console warning for a missing key. Toggle in the header (`LanguageToggle`) and
|
||||
Settings → Language; persisted in `localStorage` `app-language`.
|
||||
- **First-visit auto-detect:** `detectLanguage()` in `LanguageContext.tsx` starts
|
||||
Spanish-device visitors in `es` (Castellano), everyone else in English. A stored
|
||||
choice always wins.
|
||||
- **Translation policy (owner decision 2026-10-01):** Castellano; **only skate
|
||||
TRICK names stay English** (kickflip, ollie, nollie, heelflip, 50-50, …).
|
||||
Everything else — UI chrome, descriptions, buttons, errors — translates. Gear
|
||||
words DO translate (decks → tablas, wheels → ruedas); trucks/bearings are common
|
||||
as-is. Keep the do-not-translate list limited to tricks.
|
||||
- **The Sasquatch replies in the app language:** `SasquatchGuide` appends a
|
||||
`languageDirective` to the system prompt (both the first streaming pass and the
|
||||
tool-result synthesis pass) when `language === 'es'`.
|
||||
- **Keep `en.ts` and `es.ts` key-for-key in sync** — the fallback + warn makes
|
||||
drift silent otherwise.
|
||||
- **User-generated content is never translated** (spot / listing / DM / crew text
|
||||
is whatever the author wrote).
|
||||
- ⚠️ Coverage is **partial**: the framework + ~33 namespaces exist, but most files
|
||||
are not yet wired to `t()`. Continue extracting area by area (Map/spots →
|
||||
Marketplace/trades → Lab/crews → Settings/legal). Untranslated text falls back
|
||||
to English, so it degrades gracefully.
|
||||
|
||||
## Nostr Protocol Integration
|
||||
|
||||
This project comes with custom hooks for querying and publishing events on the Nostr network.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@
|
|||
"check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js",
|
||||
"check:ai": "bun build ai-proxy/server.ts --target=bun --outfile=/tmp/ai-check.js",
|
||||
"check:push": "bun build push-service/server.ts --target=bun --outfile=/tmp/push-check.js",
|
||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"check:i18n": "node scripts/check-i18n.mjs",
|
||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
||||
|
|
|
|||
119
scripts/check-i18n.mjs
Normal file
119
scripts/check-i18n.mjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* i18n guard — fails the build on translation drift.
|
||||
*
|
||||
* Because `t('some.key')` takes a plain string, TypeScript can't catch a typo or
|
||||
* a key that only exists in one language. This script is the safety net:
|
||||
*
|
||||
* 1. Every `t('…')` key used anywhere in `src/` MUST exist in BOTH `en` and
|
||||
* `es` (a missing key otherwise degrades to the raw key + a console warning).
|
||||
* 2. `en` and `es` must be key-for-key identical, so neither language can drift.
|
||||
*
|
||||
* Translations are loaded with esbuild (already a dependency) so the TypeScript
|
||||
* source files can be imported directly without a build step.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
const SRC = join(ROOT, 'src');
|
||||
|
||||
/** Flatten a nested translations object into a Set of dotted leaf keys. */
|
||||
function flatten(obj, prefix = '', out = new Set()) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object') {
|
||||
flatten(value, path, out);
|
||||
} else if (typeof value === 'string') {
|
||||
out.add(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Load a TS translation module by bundling it to a temp ESM file. */
|
||||
async function loadTranslations(file) {
|
||||
const result = await build({
|
||||
entryPoints: [file],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
const dir = mkdtempSync(join(tmpdir(), 'i18n-check-'));
|
||||
const tmp = join(dir, 'mod.mjs');
|
||||
writeFileSync(tmp, result.outputFiles[0].text);
|
||||
try {
|
||||
const mod = await import(pathToFileURL(tmp).href);
|
||||
return mod.default;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively list source files under a directory. */
|
||||
function walk(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === 'i18n' || entry === 'test') continue;
|
||||
files.push(...walk(full));
|
||||
} else if (['.ts', '.tsx'].includes(extname(entry))) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Collect every `t('some.key')` usage across the source. */
|
||||
function collectUsedKeys(files) {
|
||||
const used = new Map(); // key -> [files]
|
||||
const re = /(?<![\w.])t\(\s*['"]([^'"]+)['"]/g;
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const key = match[1];
|
||||
if (!used.has(key)) used.set(key, []);
|
||||
used.get(key).push(file.replace(`${ROOT}/`, ''));
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts')));
|
||||
const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts')));
|
||||
|
||||
const problems = [];
|
||||
|
||||
// 1. en/es parity.
|
||||
for (const key of en) if (!es.has(key)) problems.push(`missing in es: ${key}`);
|
||||
for (const key of es) if (!en.has(key)) problems.push(`missing in en: ${key}`);
|
||||
|
||||
// 2. every used key exists in both languages.
|
||||
const used = collectUsedKeys(walk(SRC));
|
||||
for (const [key, files] of used) {
|
||||
if (!en.has(key)) problems.push(`used but missing in en: ${key} (${files[0]})`);
|
||||
if (!es.has(key)) problems.push(`used but missing in es: ${key} (${files[0]})`);
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`\n✖ i18n check failed (${problems.length}):`);
|
||||
for (const p of problems.slice(0, 200)) console.error(` • ${p}`);
|
||||
if (problems.length > 200) console.error(` …and ${problems.length - 200} more`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('i18n check crashed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -19,6 +19,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions';
|
||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface AddPhotoToSpotProps {
|
||||
spotPubkey: string;
|
||||
|
|
@ -27,6 +28,7 @@ interface AddPhotoToSpotProps {
|
|||
}
|
||||
|
||||
export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
|
@ -51,14 +53,14 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
if (url) {
|
||||
setUploadedImage({ url, mimeType });
|
||||
toast({
|
||||
title: 'Photo uploaded',
|
||||
description: 'Add a caption and submit to contribute!',
|
||||
title: t('spotUi.photoUploaded'),
|
||||
description: t('spotUi.photoUploadedDesc'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to upload photo',
|
||||
title: t('spotUi.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -67,8 +69,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
const handleSubmit = async () => {
|
||||
if (!uploadedImage) {
|
||||
toast({
|
||||
title: 'No photo',
|
||||
description: 'Please upload a photo first.',
|
||||
title: t('spotUi.noPhoto'),
|
||||
description: t('spotUi.uploadPhotoFirst'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -93,8 +95,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Photo contributed!',
|
||||
description: 'Your photo has been added to this spot.',
|
||||
title: t('spotUi.photoContributed'),
|
||||
description: t('spotUi.photoContributedDesc'),
|
||||
});
|
||||
|
||||
// Reset and close
|
||||
|
|
@ -106,8 +108,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to contribute',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('spotUi.failedToContribute'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -124,23 +126,23 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Add Photo
|
||||
{t('spotUi.addPhoto')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Camera className="h-5 w-5 text-[#ff0040]" />
|
||||
Add Photo to Spot
|
||||
{t('spotUi.addPhotoToSpot')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Contribute a photo to help others discover this spot.
|
||||
{t('spotUi.addPhotoDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!user ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-muted-foreground mb-4">Log in to contribute photos</p>
|
||||
<p className="text-muted-foreground mb-4">{t('spotUi.logInToContributePhotos')}</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -150,7 +152,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
<div className="relative">
|
||||
<img
|
||||
src={uploadedImage.url}
|
||||
alt="Uploaded"
|
||||
alt={t('spotUi.uploadedAlt')}
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -167,8 +169,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
onFiles={handleFiles}
|
||||
uploading={isUploading}
|
||||
multiple={false}
|
||||
label="Upload a photo"
|
||||
hint="Drag & drop or click · JPG, PNG, WebP"
|
||||
label={t('spotUi.uploadAPhoto')}
|
||||
hint={t('spotUi.uploadAPhotoHint')}
|
||||
className="py-10"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -176,7 +178,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
{/* Caption */}
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="Add a caption (optional) - describe what's in the photo, when it was taken, etc."
|
||||
placeholder={t('spotUi.captionPlaceholder')}
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
rows={3}
|
||||
|
|
@ -186,7 +188,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
|
|
@ -196,12 +198,12 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Contributing...
|
||||
{t('spotUi.contributing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Camera className="h-4 w-4 mr-2" />
|
||||
Contribute Photo
|
||||
{t('spotUi.contributePhoto')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface AddTagsToSpotProps {
|
||||
spotPubkey: string;
|
||||
|
|
@ -25,6 +26,7 @@ interface AddTagsToSpotProps {
|
|||
}
|
||||
|
||||
export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingTags = [] }: AddTagsToSpotProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -38,8 +40,8 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
const handleSubmit = async () => {
|
||||
if (tags.length === 0) {
|
||||
toast({
|
||||
title: 'No tags',
|
||||
description: 'Please add at least one tag.',
|
||||
title: t('spotUi.noTags'),
|
||||
description: t('spotUi.addAtLeastOneTag'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -63,13 +65,13 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
|
||||
await publishEvent({
|
||||
kind: 1111,
|
||||
content: `Suggested tags for ${spotTitle}: ${tags.map(t => `#${t}`).join(' ')}`,
|
||||
content: t('spotUi.suggestedTagsContent', { title: spotTitle, tags: tags.map(tag => `#${tag}`).join(' ') }),
|
||||
tags: eventTags,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Tags suggested!',
|
||||
description: 'Your tag suggestions have been submitted.',
|
||||
title: t('spotUi.tagsSuggested'),
|
||||
description: t('spotUi.tagsSuggestedDesc'),
|
||||
});
|
||||
|
||||
// Reset and close
|
||||
|
|
@ -80,8 +82,8 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
queryClient.invalidateQueries({ queryKey: ['spot-tag-suggestions', spotPubkey, spotIdentifier] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to suggest tags',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('spotUi.failedToSuggestTags'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -104,23 +106,23 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Tags className="h-4 w-4" />
|
||||
Suggest Tags
|
||||
{t('spotUi.suggestTags')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Tags className="h-5 w-5 text-[#ccff00]" />
|
||||
Suggest Tags
|
||||
{t('spotUi.suggestTags')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Help categorize this spot by suggesting relevant tags.
|
||||
{t('spotUi.suggestTagsDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!user ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-muted-foreground mb-4">Log in to suggest tags</p>
|
||||
<p className="text-muted-foreground mb-4">{t('spotUi.logInToSuggestTags')}</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -128,7 +130,7 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
{/* Existing tags display */}
|
||||
{existingTags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">Current tags on this spot:</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('spotUi.currentTags')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{existingTags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 text-xs bg-gray-800 rounded-full text-gray-400">
|
||||
|
|
@ -141,11 +143,11 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
|
||||
{/* Tag input */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Add new tags:</p>
|
||||
<p className="text-sm font-medium mb-2">{t('spotUi.addNewTags')}</p>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={handleTagChange}
|
||||
placeholder="Type to search or add tags..."
|
||||
placeholder={t('spotUi.typeToSearchTags')}
|
||||
maxTags={10}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -153,7 +155,7 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
|
|
@ -163,12 +165,14 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
{t('spotUi.submitting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tags className="h-4 w-4 mr-2" />
|
||||
Suggest {tags.length} Tag{tags.length !== 1 ? 's' : ''}
|
||||
{tags.length === 1
|
||||
? t('spotUi.suggestOneTag')
|
||||
: t('spotUi.suggestNTags', { count: tags.length })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { isValidNoffer } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface ClinkPayButtonProps {
|
||||
/** The CLINK Offer code (noffer1...) */
|
||||
|
|
@ -49,8 +50,10 @@ export function ClinkPayButton({
|
|||
className,
|
||||
variant = 'outline',
|
||||
size = 'default',
|
||||
label = 'Tip in Bitcoin',
|
||||
label,
|
||||
}: ClinkPayButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedLabel = label ?? t('marketUi.clink.tip');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
|
|
@ -88,11 +91,11 @@ export function ClinkPayButton({
|
|||
await navigator.clipboard.writeText(noffer);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast({ title: 'Copied', description: 'CLINK code copied to clipboard.' });
|
||||
toast({ title: t('common.copied'), description: t('marketUi.clink.copiedBody') });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Could not access the clipboard.',
|
||||
title: t('marketUi.clink.copyFailed'),
|
||||
description: t('marketUi.clink.copyFailedBody'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -111,7 +114,7 @@ export function ClinkPayButton({
|
|||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{label}
|
||||
{resolvedLabel}
|
||||
</Button>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
|
|
@ -119,18 +122,16 @@ export function ClinkPayButton({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="What is CLINK?"
|
||||
aria-label={t('marketUi.clink.whatIs')}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-indigo-500/10 hover:text-indigo-500"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[220px] text-xs">
|
||||
<p className="font-semibold">Pay directly with Bitcoin over Nostr</p>
|
||||
<p className="font-semibold">{t('marketUi.clink.tooltipTitle')}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
CLINK is a self-custodial payment code — no app account, no middleman.
|
||||
Scan it with a compatible Lightning wallet and the tip goes straight to
|
||||
them.
|
||||
{t('marketUi.clink.tooltipBody')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -142,12 +143,12 @@ export function ClinkPayButton({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-indigo-500" />
|
||||
{label}
|
||||
{resolvedLabel}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{recipientName
|
||||
? `Send a self-custodial Bitcoin (Lightning) payment to ${recipientName} — no middleman.`
|
||||
: 'Send a self-custodial Bitcoin (Lightning) payment over Nostr — no middleman.'}
|
||||
? t('marketUi.clink.descRecipient', { recipientName })
|
||||
: t('marketUi.clink.descGeneric')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -156,7 +157,7 @@ export function ClinkPayButton({
|
|||
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-black/5">
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="CLINK Offer QR code"
|
||||
alt={t('marketUi.clink.qrAlt')}
|
||||
className="h-[220px] w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -176,7 +177,7 @@ export function ClinkPayButton({
|
|||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy CLINK code"
|
||||
aria-label={t('marketUi.clink.copyCode')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
|
|
@ -189,16 +190,14 @@ export function ClinkPayButton({
|
|||
<Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700">
|
||||
<a href={nostrUri}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in CLINK wallet
|
||||
{t('marketUi.clink.openWallet')}
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground">
|
||||
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-indigo-500" />
|
||||
<span>
|
||||
Scan with a CLINK-compatible wallet (e.g. ShockWallet). If nothing opens, copy the
|
||||
code above — you may not have a CLINK-compatible wallet installed. No custodian,
|
||||
no web server: the invoice is requested directly over Nostr.
|
||||
{t('marketUi.clink.footnote')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
|
|||
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
||||
import { isValidNoffer, type EntityType } from '@/lib/types';
|
||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
|
||||
|
||||
|
|
@ -90,6 +91,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
} = config;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey || '');
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
|
|
@ -168,7 +170,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
|
|
@ -177,11 +179,11 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -194,10 +196,10 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Image uploaded' });
|
||||
toast({ title: t('labUi.become.imageUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -207,18 +209,18 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
// Contact details are optional, but if provided they must be usable — an
|
||||
// invalid email/URL would otherwise be stored and then silently hidden.
|
||||
const contactError = email.trim() && !isValidEmail(email)
|
||||
? 'Enter a valid email address.'
|
||||
? t('labUi.become.invalidEmail')
|
||||
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
||||
? 'Enter a valid website URL.'
|
||||
? t('labUi.become.invalidWebsite')
|
||||
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
|
||||
? 'Enter a valid portfolio URL.'
|
||||
? t('labUi.become.invalidPortfolio')
|
||||
: '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (contactError) {
|
||||
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +228,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
for (const section of requiredSections) {
|
||||
const keys = section.entries.map(([k]) => k);
|
||||
if (!selected.some((s) => keys.includes(s))) {
|
||||
toast({ title: `${section.title} required`, description: `Select at least one option under "${section.title}".`, variant: 'destructive' });
|
||||
toast({ title: t('labUi.craftForm.sectionRequired', { section: section.title }), description: t('labUi.craftForm.sectionRequiredDesc', { section: section.title }), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -285,10 +287,10 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
|
||||
|
||||
await createEvent({ kind, content: bio.trim(), tags });
|
||||
toast({ title: `${heading.replace(/^Become an?/, '').trim()} profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published.' });
|
||||
toast({ title: t(existingProfile ? 'labUi.craftForm.updated' : 'labUi.craftForm.created'), description: t('labUi.become.profilePublished') });
|
||||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -299,8 +301,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to create a {noun} profile.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDescCraft', { noun })}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -319,7 +321,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
<div className="mb-6">
|
||||
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center', accentChip)}>
|
||||
|
|
@ -339,13 +341,13 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-yellow-500">⚠️</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-400 mb-1">Add NIP-05 to build trust</h3>
|
||||
<h3 className="font-bold text-yellow-400 mb-1">{t('labUi.craftForm.nip05Title')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-2">
|
||||
A NIP-05 identifier helps skaters verify you're the real deal. Highly recommended before listing.
|
||||
{t('labUi.craftForm.nip05Desc')}
|
||||
</p>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||||
Add NIP-05 in Settings
|
||||
{t('labUi.become.addNip05')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -357,7 +359,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
|
||||
{existingProfile && (
|
||||
<p className="mb-3 text-xs text-gray-500">
|
||||
Editing your existing {noun} profile — publishing replaces it.
|
||||
{t('labUi.craftForm.editingNote', { noun })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -379,17 +381,17 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Profile image</Label>
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.profileImage')}</Label>
|
||||
<label className={cn('mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 cursor-pointer text-sm w-fit transition-colors', isUploading && 'opacity-50 pointer-events-none')}>
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{isUploading ? 'Uploading…' : 'Upload'}
|
||||
{isUploading ? t('labUi.become.uploading') : t('labUi.become.upload')}
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title" className="text-xs font-bold uppercase tracking-widest text-gray-400">Name / headline</Label>
|
||||
<Label htmlFor="title" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.nameHeadline')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={titleInput}
|
||||
|
|
@ -399,17 +401,17 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
className="mt-1.5 bg-white/5 border-white/10"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Shown as the title on your Lab card — e.g. “Skate Coach Berlin”. Defaults to your name.
|
||||
{t('labUi.become.headlineHint', { example: t('labUi.craftForm.headlineExample') })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">About</Label>
|
||||
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.about')}</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder={`Tell skaters about your ${noun} work…`}
|
||||
placeholder={t('labUi.craftForm.aboutPlaceholder', { noun })}
|
||||
className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
|
||||
maxLength={3000}
|
||||
/>
|
||||
|
|
@ -426,7 +428,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
<CardContent className="p-5">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
||||
{section.title}
|
||||
{section.required && <span className={cn('text-[10px]', accentText)}>• required</span>}
|
||||
{section.required && <span className={cn('text-[10px]', accentText)}>• {t('labUi.craftForm.required')}</span>}
|
||||
</Label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{section.entries.map(([key, label]) => {
|
||||
|
|
@ -489,24 +491,24 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="location" className="text-xs font-bold uppercase tracking-widest text-gray-400">Location</Label>
|
||||
<Label htmlFor="location" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.location')}</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="City, Country" className="bg-white/5 border-white/10" />
|
||||
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} placeholder={t('labUi.become.cityCountry')} className="bg-white/5 border-white/10" />
|
||||
<Button type="button" variant="outline" onClick={getCurrentLocation} disabled={isGettingLocation} className="border-white/10 flex-shrink-0">
|
||||
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{latitude && longitude && <p className="text-xs text-green-400 mt-1">📍 Pinned to map ({latitude}, {longitude})</p>}
|
||||
{latitude && longitude && <p className="text-xs text-green-400 mt-1">{t('labUi.craftForm.pinned', { lat: latitude, lng: longitude })}</p>}
|
||||
</div>
|
||||
<div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}>
|
||||
<div>
|
||||
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">Experience (years)</Label>
|
||||
<Input id="exp" value={experience} onChange={(e) => setExperience(e.target.value)} placeholder="e.g. 5" inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" />
|
||||
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.experienceYears')}</Label>
|
||||
<Input id="exp" value={experience} onChange={(e) => setExperience(e.target.value)} placeholder={t('labUi.craftForm.expPlaceholder')} inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" />
|
||||
</div>
|
||||
{showRate && (
|
||||
<div>
|
||||
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabel}</Label>
|
||||
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="e.g. 20k sats / by project" className="mt-1.5 bg-white/5 border-white/10" />
|
||||
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder={t('labUi.craftForm.ratePlaceholder')} className="mt-1.5 bg-white/5 border-white/10" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -516,12 +518,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
{/* Contact + links */}
|
||||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Contact & Links</Label>
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.craftForm.contactLinks')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Phone (optional)" className="bg-white/5 border-white/10" />
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional)" type="email" className="bg-white/5 border-white/10" />
|
||||
<Input value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="Website" inputMode="url" className="bg-white/5 border-white/10" />
|
||||
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" />
|
||||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder={t('labUi.craftForm.phoneOptional')} className="bg-white/5 border-white/10" />
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('labUi.craftForm.emailOptional')} type="email" className="bg-white/5 border-white/10" />
|
||||
<Input value={website} onChange={(e) => setWebsite(e.target.value)} placeholder={t('labUi.become.website')} inputMode="url" className="bg-white/5 border-white/10" />
|
||||
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder={t('labUi.craftForm.socialPlaceholder')} className="bg-white/5 border-white/10" />
|
||||
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
|
||||
</div>
|
||||
{contactError && <p className="text-xs text-red-400">{contactError}</p>}
|
||||
|
|
@ -532,7 +534,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
||||
Getting Paid
|
||||
{t('labUi.craftForm.gettingPaid')}
|
||||
</Label>
|
||||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
|
|
@ -567,7 +569,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
|||
disabled={isPending}
|
||||
className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
|
||||
>
|
||||
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : existingProfile ? 'Update Profile' : 'Publish Profile'}
|
||||
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : existingProfile ? t('labUi.craftForm.updateProfile') : t('labUi.craftForm.publishProfile')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCrewsForSpot } from '@/hooks/useCrewsForSpot';
|
||||
import { crewAccent } from '@/lib/crewHelpers';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
/**
|
||||
|
|
@ -17,6 +18,7 @@ export function CrewsForSpotCard({
|
|||
spotPubkey?: string;
|
||||
spotIdentifier?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: crews, isLoading } = useCrewsForSpot(spotPubkey, spotIdentifier);
|
||||
|
||||
// Nothing to show and not loading → render nothing (keeps the page clean).
|
||||
|
|
@ -26,7 +28,7 @@ export function CrewsForSpotCard({
|
|||
<Card className="border-2 border-teal-500/20 bg-black/90">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Users className="h-5 w-5 text-teal-400" /> Crews that skate here
|
||||
<Users className="h-5 w-5 text-teal-400" /> {t('spotUi.crewsThatSkateHere')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
|
|
@ -61,8 +63,10 @@ export function CrewsForSpotCard({
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-white">{crew.name}</p>
|
||||
<p className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<Users className="h-3 w-3" /> {crew.members.length} member
|
||||
{crew.members.length === 1 ? '' : 's'}
|
||||
<Users className="h-3 w-3" />{' '}
|
||||
{crew.members.length === 1
|
||||
? t('spotUi.memberOne', { count: crew.members.length })
|
||||
: t('spotUi.memberOther', { count: crew.members.length })}
|
||||
{crew.location && ` · ${crew.location}`}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** A little skateboard-deck glyph for the rating. */
|
||||
function Deck({ className }: { className?: string }) {
|
||||
|
|
@ -28,12 +29,13 @@ interface DeckRatingProps {
|
|||
* Interactive by default; read-only when no `onChange` is given.
|
||||
*/
|
||||
export function DeckRating({ value, onChange, size = 'md', compact = false, className }: DeckRatingProps) {
|
||||
const { t } = useTranslation();
|
||||
const readOnly = !onChange;
|
||||
const count = readOnly && compact ? Math.max(value, 0) : 10;
|
||||
const deckCls = size === 'sm' ? 'h-3 w-6' : 'h-3.5 w-7';
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-0.5', className)} title={`${value}/10 decks`}>
|
||||
<span className={cn('inline-flex items-center gap-0.5', className)} title={t('marketUi.deckRating.title', { value })}>
|
||||
{Array.from({ length: count }).map((_, i) => {
|
||||
const n = i + 1;
|
||||
const filled = n <= value;
|
||||
|
|
@ -45,7 +47,7 @@ export function DeckRating({ value, onChange, size = 'md', compact = false, clas
|
|||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange?.(n === value ? 0 : n)}
|
||||
aria-label={`${n} of 10`}
|
||||
aria-label={t('marketUi.deckRating.aria', { n })}
|
||||
aria-pressed={filled}
|
||||
className={cn(deckCls, 'transition-transform hover:scale-110 active:scale-95', filled ? 'text-[#ccff00]' : 'text-gray-600')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||
import { Lock } from 'lucide-react';
|
||||
import { parseEscrowToken, type EscrowTokenInfo } from '@/lib/cashuEscrow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
|
|
@ -25,6 +26,7 @@ function formatDate(seconds: number): string {
|
|||
* mint-touching create/redeem actions are native-only.
|
||||
*/
|
||||
export function EscrowPanel({ token, className }: { token: string; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const info = useMemo<EscrowTokenInfo | null>(() => {
|
||||
try {
|
||||
return parseEscrowToken(token);
|
||||
|
|
@ -45,28 +47,26 @@ export function EscrowPanel({ token, className }: { token: string; className?: s
|
|||
>
|
||||
<div className="flex items-center gap-2 font-bold text-[#ff6600]">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
Escrowed {info.amount.toLocaleString()} sats
|
||||
{t('marketUi.escrowPanel.escrowed', { amount: info.amount.toLocaleString() })}
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-gray-400">
|
||||
<p>
|
||||
Mint: <span className="text-gray-300">{hostOf(info.mint)}</span>
|
||||
{t('marketUi.escrowPanel.mint')}: <span className="text-gray-300">{hostOf(info.mint)}</span>
|
||||
</p>
|
||||
{locktime ? (
|
||||
<p>
|
||||
{info.refundable ? (
|
||||
<>
|
||||
Buyer can refund since{' '}
|
||||
<>{t('marketUi.escrowPanel.refundSinceBefore')}{' '}
|
||||
<span className="text-[#ccff00]">{formatDate(locktime)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Seller can redeem now · buyer refunds after{' '}
|
||||
<>{t('marketUi.escrowPanel.redeemBefore')}{' '}
|
||||
<span className="text-gray-300">{formatDate(locktime)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>No expiry set on this token.</p>
|
||||
<p>{t('marketUi.escrowPanel.noExpiry')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { ESCROW_DEFAULT_LOCK_DAYS, ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Buyer: lock funds in Cashu escrow (P2PK to the seller, refundable after a
|
||||
|
|
@ -36,6 +37,7 @@ export function EscrowRequestDialog({
|
|||
sellerPubkey: string;
|
||||
sellerName: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const escrow = useCashuEscrow();
|
||||
const { publishRequest, isPending: publishing } = useTradeAttestationRequest();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -49,11 +51,11 @@ export function EscrowRequestDialog({
|
|||
const lockDays = parseInt(days, 10);
|
||||
|
||||
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
||||
toast({ title: 'Enter an escrow amount', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.escrowRequest.enterAmount'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(lockDays) || lockDays < ESCROW_MIN_LOCK_DAYS) {
|
||||
toast({ title: `Lock for at least ${ESCROW_MIN_LOCK_DAYS} day`, variant: 'destructive' });
|
||||
toast({ title: t('marketUi.escrowRequest.minLock', { days: ESCROW_MIN_LOCK_DAYS }), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -64,17 +66,17 @@ export function EscrowRequestDialog({
|
|||
listing,
|
||||
attestor: sellerPubkey,
|
||||
cashuToken: token,
|
||||
note: `Escrowed ${amountSats} sats for our trade — please confirm the sale.`,
|
||||
note: t('marketUi.escrowRequest.requestNote', { amount: amountSats }),
|
||||
});
|
||||
toast({
|
||||
title: 'Escrow created 🎉',
|
||||
description: `${amountSats} sats locked for ${lockDays} days. ${sellerName} can redeem on delivery.`,
|
||||
title: t('marketUi.escrowRequest.created'),
|
||||
description: t('marketUi.escrowRequest.createdBody', { amount: amountSats, days: lockDays, sellerName }),
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not create escrow',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.escrowRequest.createFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -89,32 +91,31 @@ export function EscrowRequestDialog({
|
|||
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ff6600]/40 bg-black sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<Lock className="h-5 w-5 text-[#ff6600]" /> Escrow this trade
|
||||
<Lock className="h-5 w-5 text-[#ff6600]" /> {t('marketUi.escrowRequest.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-400">
|
||||
Funds are locked to {sellerName} and refundable by you if the deal falls
|
||||
through after the deadline. Requires a connected Lightning wallet.
|
||||
{t('marketUi.escrowRequest.description', { sellerName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="escrow-amount" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Amount (sats)
|
||||
{t('marketUi.escrowRequest.amountLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="escrow-amount"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 45000"
|
||||
placeholder={t('marketUi.offer.amountPlaceholder')}
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="escrow-days" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Buyer refund after (days)
|
||||
{t('marketUi.escrowRequest.daysLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="escrow-days"
|
||||
|
|
@ -124,19 +125,19 @@ export function EscrowRequestDialog({
|
|||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
After this many days you can reclaim the funds if the seller hasn't delivered.
|
||||
{t('marketUi.escrowRequest.daysHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!escrow.available && (
|
||||
<p className="text-xs text-amber-400">
|
||||
Cashu escrow is available in the SkateSpots app only (not the web build).
|
||||
{t('marketUi.escrowRequest.nativeOnly')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
|
|
@ -144,7 +145,7 @@ export function EscrowRequestDialog({
|
|||
className="flex-1 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Lock className="h-4 w-4" />}
|
||||
Lock & request
|
||||
{t('marketUi.escrowRequest.lockAndRequest')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
|||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface GearSwapItemCardProps {
|
||||
item: MarketplaceItem;
|
||||
|
|
@ -31,6 +32,7 @@ interface GearSwapItemCardProps {
|
|||
}
|
||||
|
||||
export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
// Distance from the viewer's last known location (cache only — no GPS call).
|
||||
|
|
@ -62,16 +64,16 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
: `SkateSpots admin removed listing "${item.title}"`,
|
||||
});
|
||||
toast({
|
||||
title: ownerDeleting ? 'Listing deleted' : 'Listing removed',
|
||||
title: ownerDeleting ? t('marketUi.card.listingDeleted') : t('marketUi.card.listingRemoved'),
|
||||
description: ownerDeleting
|
||||
? 'Your listing has been removed. Relays that honor deletions will stop serving it.'
|
||||
: 'This listing has been taken down and will be hidden across SkateSpots.',
|
||||
? t('marketUi.card.listingDeletedBody')
|
||||
: t('marketUi.card.listingRemovedBody'),
|
||||
});
|
||||
setShowRemoveDialog(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Remove failed',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('marketUi.card.removeFailed'),
|
||||
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -107,11 +109,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
const handleMarkSold = async () => {
|
||||
try {
|
||||
await markSold(item.event);
|
||||
toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' });
|
||||
toast({ title: t('marketUi.card.markedSold'), description: t('marketUi.card.markedSoldBody') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('marketUi.card.couldNotUpdate'),
|
||||
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -120,11 +122,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
const handleRelist = async () => {
|
||||
try {
|
||||
await relist(item.event);
|
||||
toast({ title: 'Relisted', description: 'Your listing is active again.' });
|
||||
toast({ title: t('marketUi.card.relisted'), description: t('marketUi.card.relistedBody') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('marketUi.card.couldNotUpdate'),
|
||||
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -162,7 +164,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
{isSold && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60">
|
||||
<span className="rotate-[-12deg] rounded-md border-2 border-white/90 px-4 py-1 text-2xl font-black uppercase tracking-widest text-white shadow-lg">
|
||||
Sold
|
||||
{t('marketplace.sold')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -171,7 +173,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
{isWTB && (
|
||||
<Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg">
|
||||
<Search className="h-3 w-3 mr-1" />
|
||||
WTB
|
||||
{t('marketUi.gearSwap.wtb')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
|
@ -212,7 +214,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isUpdatingStatus}
|
||||
aria-label="Listing options"
|
||||
aria-label={t('marketUi.card.listingOptions')}
|
||||
>
|
||||
{isUpdatingStatus ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
|
@ -234,7 +236,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
disabled={isUpdatingStatus}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Relist as active
|
||||
{t('marketUi.listingDetail.relistActive')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
|
|
@ -245,7 +247,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
disabled={isUpdatingStatus}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark as sold
|
||||
{t('marketUi.listingDetail.markSold')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -257,7 +259,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete listing
|
||||
{t('marketUi.listingDetail.deleteListing')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -270,7 +272,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
{t('marketUi.card.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && !isOwnListingCheck && (
|
||||
|
|
@ -282,7 +284,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove (admin)
|
||||
{t('marketUi.card.removeAdmin')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -317,15 +319,15 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
{item.fulfilment && (
|
||||
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
|
||||
{item.fulfilment === 'pickup'
|
||||
? '📍 Pickup'
|
||||
? `📍 ${t('marketUi.create.pickup')}`
|
||||
: item.fulfilment === 'ships'
|
||||
? '📦 Ships'
|
||||
: '📍📦 Both'}
|
||||
? `📦 ${t('marketUi.create.ships')}`
|
||||
: `📍📦 ${t('marketUi.create.both')}`}
|
||||
</Badge>
|
||||
)}
|
||||
{item.shipping && (
|
||||
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
|
||||
postage: {item.shipping}
|
||||
{t('marketUi.card.postage')}: {item.shipping}
|
||||
</Badge>
|
||||
)}
|
||||
{distanceKm != null && (
|
||||
|
|
@ -387,13 +389,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
{sellerRating.rating > 0 ? (
|
||||
<span className="text-sm">{sellerRating.display}</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">🛹 New seller</span>
|
||||
<span className="text-xs text-gray-500">🛹 {t('marketUi.card.newSeller')}</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div className="text-center">
|
||||
<p className="font-bold">{salesCount} sale{salesCount !== 1 ? 's' : ''}</p>
|
||||
<p className="font-bold">{salesCount === 1 ? t('marketUi.card.saleOne', { count: salesCount }) : t('marketUi.card.saleMany', { count: salesCount })}</p>
|
||||
<p className="text-xs text-muted-foreground">{sellerRating.label}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
|
|
@ -419,7 +421,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
}}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||||
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -443,24 +445,24 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
{ownerDeleting ? `Delete "${item.title}"?` : 'Remove this listing? (admin)'}
|
||||
{ownerDeleting ? t('marketUi.card.deleteTitle', { title: item.title }) : t('marketUi.card.adminRemoveTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-300">
|
||||
{ownerDeleting
|
||||
? `This asks relays to remove your listing "${item.title}". It can't be undone from here — you'd have to create a new one.`
|
||||
: `As a SkateSpots admin you're taking down "${item.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`}
|
||||
? t('marketUi.card.deleteBody', { title: item.title })
|
||||
: t('marketUi.card.adminRemoveBody', { title: item.title })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRemove}
|
||||
disabled={isRemoving}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
{isRemoving ? (ownerDeleting ? 'Deleting…' : 'Removing…') : (ownerDeleting ? 'Delete Listing' : 'Remove Listing')}
|
||||
{isRemoving ? (ownerDeleting ? t('marketUi.card.deleting') : t('marketUi.card.removing')) : (ownerDeleting ? t('marketUi.card.deleteListing') : t('marketUi.card.removeListing'))}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import { Button } from '@/components/ui/button';
|
|||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { itemDistanceKm } from '@/lib/sortOptions';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export function HandoffHelper({ item }: { item: MarketplaceItem }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const npub = nip19.npubEncode(item.pubkey);
|
||||
const dm = (message: string) => `/messages/${npub}?message=${encodeURIComponent(message)}`;
|
||||
|
|
@ -46,7 +48,7 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
|
|||
return (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" /> Arrange the handoff
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" /> {t('marketUi.handoff.title')}
|
||||
</p>
|
||||
|
||||
{pickup && (
|
||||
|
|
@ -56,17 +58,17 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full justify-start gap-2 border-white/15"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
dm(`Hi! I'd like to buy “${item.title}”. Suggest a time and place to meet — happy to meet at a public skate spot.`),
|
||||
dm(t('marketUi.handoff.suggestDm', { title: item.title })),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Suggest a time & place
|
||||
<MapPin className="h-4 w-4" /> {t('marketUi.handoff.suggest')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{meetSpots.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] text-gray-500">Or meet at a nearby public spot:</p>
|
||||
<p className="text-[11px] text-gray-500">{t('marketUi.handoff.orMeetAt')}</p>
|
||||
{meetSpots.map(({ spot, km }) => (
|
||||
<Button
|
||||
key={spot.id}
|
||||
|
|
@ -76,7 +78,7 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
|
|||
onClick={() =>
|
||||
navigate(
|
||||
dm(
|
||||
`Hi! I'd like to buy “${item.title}”. Want to meet at ${spot.title}? /spot/${spot.pubkey}/${spot.identifier}`,
|
||||
t('marketUi.handoff.spotDm', { title: item.title, spot: spot.title, url: `/spot/${spot.pubkey}/${spot.identifier}` }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -101,29 +103,27 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full justify-start gap-2 border-white/15"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
dm(`Hi! I'd like to buy “${item.title}”. Here are my delivery details:\n\nName:\nAddress:\n\n(Only share what the courier needs.)`),
|
||||
dm(t('marketUi.handoff.deliveryDm', { title: item.title })),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Package className="h-4 w-4" /> Share delivery details
|
||||
<Package className="h-4 w-4" /> {t('marketUi.handoff.shareDelivery')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 border-white/15"
|
||||
onClick={() =>
|
||||
navigate(dm(`Hi! I'd like to buy “${item.title}”. Could we use a parcel locker / parcel shop instead of a home address?`))
|
||||
navigate(dm(t('marketUi.handoff.lockerDm', { title: item.title })))
|
||||
}
|
||||
>
|
||||
<Lock className="h-4 w-4" /> Use a locker / parcel point
|
||||
<Lock className="h-4 w-4" /> {t('marketUi.handoff.useLocker')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] leading-relaxed text-gray-600">
|
||||
These details stay in the private chat — SkateSpots never sees them. For shipped deals,
|
||||
prefer <strong className="text-gray-400">escrow</strong>, and <strong className="text-gray-400">never
|
||||
pay outside the app's rails</strong>. Meet in public.
|
||||
{t('marketUi.handoff.noteBefore')} <strong className="text-gray-400">{t('marketUi.handoff.escrow')}</strong>{t('marketUi.handoff.noteMiddle')} <strong className="text-gray-400">{t('marketUi.handoff.neverPay')}</strong>{t('marketUi.handoff.noteAfter')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { Users, ChevronRight, MapPin } from 'lucide-react';
|
||||
import { useCrewsByFocus } from '@/hooks/useCrews';
|
||||
import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/** The Lab page's own ServiceType union (kept in sync with LabPage). */
|
||||
|
|
@ -37,6 +38,7 @@ function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed
|
|||
}
|
||||
|
||||
function CrewChip({ crew }: { crew: SkateCrew }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Link
|
||||
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
||||
|
|
@ -58,7 +60,7 @@ function CrewChip({ crew }: { crew: SkateCrew }) {
|
|||
) : (
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-gray-500">
|
||||
<Users className="h-3 w-3 text-teal-400" />
|
||||
{crew.members.length} {crew.members.length === 1 ? 'member' : 'members'}
|
||||
{t(crew.members.length === 1 ? 'labUi.crewsStrip.memberOne' : 'labUi.crewsStrip.memberMany', { count: crew.members.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -73,6 +75,7 @@ function CrewChip({ crew }: { crew: SkateCrew }) {
|
|||
* cross-reference each other. Renders nothing when there are no matching crews.
|
||||
*/
|
||||
export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
|
||||
const { t } = useTranslation();
|
||||
const focus = focusForLabService(serviceType);
|
||||
const { data: crews, isLoading } = useCrewsByFocus(focus, 20);
|
||||
|
||||
|
|
@ -87,7 +90,7 @@ export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
|
|||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-teal-400">
|
||||
<Users className="h-4 w-4" />
|
||||
Crews in this scene
|
||||
{t('labUi.crewsStrip.title')}
|
||||
<span className="rounded-full bg-teal-500/15 px-2 py-0.5 text-[10px] font-bold text-teal-300">
|
||||
{CREW_FOCUS_LABELS[focus]}
|
||||
</span>
|
||||
|
|
@ -96,7 +99,7 @@ export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
|
|||
to="/crews"
|
||||
className="flex items-center gap-0.5 text-xs font-bold text-gray-400 hover:text-teal-300"
|
||||
>
|
||||
All crews <ChevronRight className="h-3.5 w-3.5" />
|
||||
{t('labUi.crewsStrip.allCrews')} <ChevronRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,23 +3,9 @@ import { Label } from '@/components/ui/label';
|
|||
import { Link2, X, GraduationCap } from 'lucide-react';
|
||||
import { useLabProfiles } from '@/hooks/useLabProfiles';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
coach: 'Coach',
|
||||
filmer: 'Filmer',
|
||||
editor: 'Editor',
|
||||
photographer: 'Photographer',
|
||||
builder: 'Builder',
|
||||
artist: 'Artist',
|
||||
graffiti: 'Graffiti',
|
||||
tattoo: 'Tattoo',
|
||||
trainer: 'Trainer',
|
||||
musician: 'Musician',
|
||||
judge: 'Judge',
|
||||
barber: 'Barber',
|
||||
};
|
||||
|
||||
interface LabLinkFieldProps {
|
||||
/** Selected Lab profile coordinates (`kind:pubkey:d`). */
|
||||
value: string[];
|
||||
|
|
@ -33,8 +19,14 @@ interface LabLinkFieldProps {
|
|||
*/
|
||||
export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { data: allProfiles } = useLabProfiles(200);
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
const known = ['coach', 'filmer', 'editor', 'photographer', 'builder', 'artist', 'graffiti', 'tattoo', 'trainer', 'musician', 'judge', 'barber', 'organizer'];
|
||||
return known.includes(type) ? t(`labUi.labLink.${type}`) : t('labUi.labLink.lab');
|
||||
};
|
||||
|
||||
// Only your own Lab profiles — a crew can only vouch a link to listings it controls.
|
||||
const myProfiles = useMemo(
|
||||
() => (allProfiles ?? []).filter((p) => p.pubkey === user?.pubkey),
|
||||
|
|
@ -55,18 +47,18 @@ export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProp
|
|||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-teal-400">
|
||||
<Link2 className="h-4 w-4" /> Linked Lab Profiles
|
||||
<Link2 className="h-4 w-4" /> {t('labUi.labLink.linkedProfiles')}
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional.
|
||||
When the Lab profile also links back to this crew, both show a green{' '}
|
||||
<span className="font-bold text-green-400">Verified</span> badge.
|
||||
{t('labUi.labLink.desc')}{' '}
|
||||
<span className="font-bold text-green-400">{t('common.verified')}</span>{' '}
|
||||
{t('labUi.labLink.descEnd')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{myProfiles.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
You don't have any Lab profiles yet. Create one in The Lab to link it here.
|
||||
{t('labUi.labLink.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -88,7 +80,7 @@ export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProp
|
|||
>
|
||||
<GraduationCap className="h-3.5 w-3.5" />
|
||||
<span className="truncate max-w-[10rem]">{p.title}</span>
|
||||
<span className="opacity-60">· {TYPE_LABEL[p.profileType] ?? 'Lab'}</span>
|
||||
<span className="opacity-60">· {typeLabel(p.profileType)}</span>
|
||||
{selected && <X className="h-3 w-3 opacity-80" />}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Sparkles, Check } from 'lucide-react';
|
|||
import { Switch } from '@/components/ui/switch';
|
||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LabProUpgradeProps {
|
||||
|
|
@ -31,6 +32,7 @@ export function LabProUpgrade({
|
|||
entityCoord,
|
||||
className,
|
||||
}: LabProUpgradeProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee('labProfile');
|
||||
|
||||
return (
|
||||
|
|
@ -48,17 +50,17 @@ export function LabProUpgrade({
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={cn('font-black uppercase tracking-wide text-sm', accentText)}>
|
||||
Go Pro <span className="text-gray-500 font-normal normal-case">· optional</span>
|
||||
{t('labUi.proUpgrade.goPro')} <span className="text-gray-500 font-normal normal-case">{t('labUi.proUpgrade.optional')}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Free to list. Pro adds a <strong className="text-amber-400">Pro badge</strong> and
|
||||
{' '}<strong className="text-amber-400">featured placement</strong> at the top of The Lab.
|
||||
{t('labUi.proUpgrade.freeTo')} <strong className="text-amber-400">{t('labUi.proUpgrade.proBadge')}</strong> and
|
||||
{' '}<strong className="text-amber-400">{t('labUi.proUpgrade.featuredPlacement')}</strong> {t('labUi.proUpgrade.atTop')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1 text-[11px] font-bold text-green-400 flex-shrink-0">
|
||||
<Check className="h-3.5 w-3.5" /> Active
|
||||
<Check className="h-3.5 w-3.5" /> {t('labUi.proUpgrade.active')}
|
||||
</span>
|
||||
) : (
|
||||
<Switch
|
||||
|
|
@ -72,9 +74,9 @@ export function LabProUpgrade({
|
|||
{/* Benefits list */}
|
||||
{!isActive && (
|
||||
<ul className="mt-3 grid gap-1.5 text-xs text-gray-400">
|
||||
<li className="flex items-center gap-2"><Check className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" /> Gold "Pro" badge on your profile & cards</li>
|
||||
<li className="flex items-center gap-2"><Check className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" /> Featured first when skaters browse The Lab</li>
|
||||
<li className="flex items-center gap-2"><Check className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" /> Supports SkateSpots — no ads, no middlemen</li>
|
||||
<li className="flex items-center gap-2"><Check className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" /> {t('labUi.proUpgrade.benefit1')}</li>
|
||||
<li className="flex items-center gap-2"><Check className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" /> {t('labUi.proUpgrade.benefit2')}</li>
|
||||
<li className="flex items-center gap-2"><Check className="h-3.5 w-3.5 text-amber-400 flex-shrink-0" /> {t('labUi.proUpgrade.benefit3')}</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
|
|
@ -82,8 +84,8 @@ export function LabProUpgrade({
|
|||
{isActive && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{isPlatformOwner
|
||||
? 'Platform owner — Pro is always on.'
|
||||
: `Pro active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining. Your profile will be featured.`}
|
||||
? t('labUi.proUpgrade.platformOwner')
|
||||
: t(daysRemaining === 1 ? 'labUi.proUpgrade.proActiveOne' : 'labUi.proUpgrade.proActiveMany', { count: daysRemaining })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -95,7 +97,7 @@ export function LabProUpgrade({
|
|||
type="labProfile"
|
||||
entityCoord={entityCoord}
|
||||
onSuccess={() => { /* hook auto-updates isActive */ }}
|
||||
context="Your profile stays live for free after Pro expires — you just lose the badge & boost."
|
||||
context={t('labUi.proUpgrade.context')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import { genUserName } from '@/lib/genUserName';
|
|||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { PayWithSats } from '@/components/PayWithSats';
|
||||
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
type TFunc = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
function PeerName({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
|
|
@ -31,24 +34,24 @@ function PeerName({ pubkey }: { pubkey: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function statusBadge(status: MarketplaceOffer['status']) {
|
||||
function statusBadge(status: MarketplaceOffer['status'], t: TFunc) {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return <Badge className="bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">Accepted</Badge>;
|
||||
return <Badge className="bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">{t('marketUi.offers.accepted')}</Badge>;
|
||||
case 'declined':
|
||||
return <Badge className="bg-red-500/15 text-red-400 border border-red-500/30">Declined</Badge>;
|
||||
return <Badge className="bg-red-500/15 text-red-400 border border-red-500/30">{t('marketUi.offers.declined')}</Badge>;
|
||||
case 'withdrawn':
|
||||
return <Badge variant="secondary" className="bg-gray-700 text-gray-400">Withdrawn</Badge>;
|
||||
return <Badge variant="secondary" className="bg-gray-700 text-gray-400">{t('marketUi.offers.withdrawn')}</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-orange-500/15 text-orange-400 border border-orange-500/30">Open</Badge>;
|
||||
return <Badge className="bg-orange-500/15 text-orange-400 border border-orange-500/30">{t('marketUi.offers.open')}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function amountLabel(m: MarketplaceOffer): string {
|
||||
function amountLabel(m: MarketplaceOffer, t: TFunc): string {
|
||||
const parts: string[] = [];
|
||||
if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`);
|
||||
if (m.offerItem) parts.push(m.offerItem);
|
||||
return parts.length ? parts.join(' + ') : 'no amount';
|
||||
return parts.length ? parts.join(' + ') : t('marketUi.offers.noAmount');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,6 +60,7 @@ function amountLabel(m: MarketplaceOffer): string {
|
|||
* whose turn it is.
|
||||
*/
|
||||
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
|
||||
const { respondToOffer, counterOffer } = useOfferActions();
|
||||
|
|
@ -97,14 +101,14 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
await nudge(
|
||||
thread.latest.author,
|
||||
status === 'accepted'
|
||||
? '✅ Your offer was accepted! Message to arrange the handoff.'
|
||||
: '❌ Your offer was declined.',
|
||||
? t('marketUi.offers.dmAccepted')
|
||||
: t('marketUi.offers.dmDeclined'),
|
||||
);
|
||||
toast({ title: status === 'accepted' ? 'Offer accepted' : 'Offer declined' });
|
||||
toast({ title: status === 'accepted' ? t('marketUi.offers.offerAccepted') : t('marketUi.offers.offerDeclined') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not respond',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.offers.respondFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -116,11 +120,11 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
setBusy(thread.latest.id);
|
||||
try {
|
||||
await respondToOffer(thread.latest, 'withdrawn');
|
||||
toast({ title: 'Offer withdrawn' });
|
||||
toast({ title: t('marketUi.offers.offerWithdrawn') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not withdraw',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.offers.withdrawFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -132,7 +136,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
const amount = Number(counterAmount);
|
||||
const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
|
||||
if (!hasAmount && !counterItem.trim()) {
|
||||
toast({ title: 'Enter an amount or an item to trade', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.offers.enterAmountOrItem'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setBusy(thread.latest.id);
|
||||
|
|
@ -146,15 +150,15 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
const what = [hasAmount ? `${amount.toLocaleString()} sats` : null, counterItem.trim() || null]
|
||||
.filter(Boolean)
|
||||
.join(' + ');
|
||||
await nudge(thread.latest.author, `↩️ Counter-offer: ${what}`);
|
||||
toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' });
|
||||
await nudge(thread.latest.author, `↩️ ${t('marketUi.offers.counterDm')}: ${what}`);
|
||||
toast({ title: t('marketUi.offers.counterSent'), description: t('marketUi.offers.counterSentBody') });
|
||||
setCounterFor(null);
|
||||
setCounterAmount('');
|
||||
setCounterItem('');
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the counter',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.offers.counterFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -166,7 +170,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
<Card className="bg-black/90 border-2 border-orange-500/25">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400">
|
||||
<Handshake className="h-4 w-4" /> {isOwner ? `Offers (${mine.length})` : 'Your negotiations'}
|
||||
<Handshake className="h-4 w-4" /> {isOwner ? t('marketUi.offers.titleOwner', { count: mine.length }) : t('marketUi.offers.titleBuyer')}
|
||||
</h3>
|
||||
|
||||
{mine.map((thread) => {
|
||||
|
|
@ -188,8 +192,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
<span className="text-sm text-gray-200">
|
||||
<PeerName pubkey={other} />
|
||||
</span>
|
||||
{statusBadge(latest.status)}
|
||||
<span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest)}</span>
|
||||
{statusBadge(latest.status, t)}
|
||||
<span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest, t)}</span>
|
||||
</div>
|
||||
|
||||
{/* Earlier rounds — the volley, so the haggling is visible. */}
|
||||
|
|
@ -197,7 +201,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
<div className="space-y-0.5 border-l border-white/10 pl-2">
|
||||
{history.map((m) => (
|
||||
<p key={m.id} className="text-[11px] text-gray-500">
|
||||
{m.author === user.pubkey ? 'You' : 'Them'}: {amountLabel(m)}
|
||||
{m.author === user.pubkey ? t('marketUi.offers.you') : t('marketUi.offers.them')}: {amountLabel(m, t)}
|
||||
{m.status !== 'open' ? ` · ${m.status}` : ''}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -210,7 +214,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
{latest.status === 'accepted' && user.pubkey === thread.buyer && (
|
||||
paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? (
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-green-400">
|
||||
<CheckCircle className="h-3.5 w-3.5" /> Paid — ask the seller to confirm the trade.
|
||||
<CheckCircle className="h-3.5 w-3.5" /> {t('marketUi.offers.paidAskConfirm')}
|
||||
</p>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -218,7 +222,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
onClick={() => setPayFor(thread)}
|
||||
className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" /> Pay with sats
|
||||
<Zap className="h-3.5 w-3.5" /> {t('marketUi.offers.payWithSats')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
|
@ -234,7 +238,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
className="flex-1 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle className="h-3.5 w-3.5" />}
|
||||
Accept
|
||||
{t('marketUi.offers.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -243,7 +247,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
disabled={busy === latest.id}
|
||||
className="flex-1 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> Decline
|
||||
<X className="h-3.5 w-3.5" /> {t('marketUi.offers.decline')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -255,7 +259,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
}}
|
||||
className="flex-1 gap-1.5 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
|
||||
>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" /> Counter
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" /> {t('marketUi.offers.counter')}
|
||||
</Button>
|
||||
</div>
|
||||
{counterFor === latest.id && (
|
||||
|
|
@ -267,7 +271,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
inputMode="numeric"
|
||||
value={counterAmount}
|
||||
onChange={(e) => setCounterAmount(e.target.value)}
|
||||
placeholder="Your counter (sats)"
|
||||
placeholder={t('marketUi.offers.counterPlaceholder')}
|
||||
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -277,13 +281,13 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
className="shrink-0 gap-1.5 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
|
||||
>
|
||||
{busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Send
|
||||
{t('marketUi.offers.send')}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={counterItem}
|
||||
onChange={(e) => setCounterItem(e.target.value)}
|
||||
placeholder="…or counter with an item to trade (optional)"
|
||||
placeholder={t('marketUi.offers.counterItemPlaceholder')}
|
||||
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -295,7 +299,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
{!myTurn && latest.status === 'open' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex-1 text-xs text-gray-500">
|
||||
Waiting on <PeerName pubkey={other} /> — your turn will come back if they counter.
|
||||
{t('marketUi.offers.waitingOn')} <PeerName pubkey={other} /> {t('marketUi.offers.waitingOnSuffix')}
|
||||
</p>
|
||||
{latest.author === user.pubkey && (
|
||||
<Button
|
||||
|
|
@ -305,7 +309,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
|
|||
disabled={busy === latest.id}
|
||||
className="shrink-0 gap-1.5 border-white/15"
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Withdraw
|
||||
<Undo2 className="h-3.5 w-3.5" /> {t('marketUi.offers.withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { useUsdToSats } from '@/hooks/useExchangeRate';
|
|||
import { parseEscrowToken } from '@/lib/cashuEscrow';
|
||||
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Buyer/seller trade + escrow actions for a marketplace listing, shared by the
|
||||
|
|
@ -34,6 +35,7 @@ import type { MarketplaceItem } from '@/lib/types';
|
|||
* funds on native).
|
||||
*/
|
||||
export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(item.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -153,16 +155,16 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
clearReceipt(coord);
|
||||
clearReceipt(coord, 'deposit');
|
||||
toast({
|
||||
title: 'Trade confirmed',
|
||||
title: t('marketUi.tradeActions.tradeConfirmed'),
|
||||
description:
|
||||
paymentProof || depositProof
|
||||
? 'A verifiable receipt was attached — this counts toward verified trades.'
|
||||
: 'Reputation updated for both of you.',
|
||||
? t('marketUi.tradeActions.tradeConfirmedWithProof')
|
||||
: t('marketUi.tradeActions.tradeConfirmedBody'),
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the trade',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.tradeActions.tradeConfirmFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -174,16 +176,16 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
await publishRequest({
|
||||
listing: listingRef,
|
||||
attestor: item.pubkey,
|
||||
note: `Please confirm our trade for “${item.title}”.`,
|
||||
note: t('marketUi.tradeActions.requestNote', { title: item.title }),
|
||||
});
|
||||
toast({
|
||||
title: 'Confirmation requested',
|
||||
description: 'The seller can now confirm the sale and build both reputations.',
|
||||
title: t('marketUi.tradeActions.requestSent'),
|
||||
description: t('marketUi.tradeActions.requestSentBody'),
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the request',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.tradeActions.requestFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -193,11 +195,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
if (!myEscrowToken) return;
|
||||
try {
|
||||
await escrow.redeemEscrow(myEscrowToken);
|
||||
toast({ title: 'Escrow refunded', description: 'The funds are back in your Cashu wallet.' });
|
||||
toast({ title: t('marketUi.tradeActions.escrowRefunded'), description: t('marketUi.tradeActions.escrowRefundedBody') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not refund escrow',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.tradeActions.escrowRefundFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -217,7 +219,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{confirmingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
|
||||
Confirm this trade
|
||||
{t('marketUi.tradeActions.confirmThisTrade')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -230,21 +232,19 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full gap-2 border-[#ff6600]/40 text-[#ff6600] hover:bg-[#ff6600]/10"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
Escrow & request
|
||||
{t('marketUi.tradeActions.escrowAndRequest')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Shipped deals are the case escrow exists for — nudge it. */}
|
||||
{canEscrow && item.fulfilment === 'ships' && (
|
||||
<p className="text-[11px] text-center text-amber-400/90">
|
||||
📦 Shipped deal — use escrow so neither side ships into the void. Share your
|
||||
address privately in the trade chat, never here.
|
||||
📦 {t('marketUi.tradeActions.shipsEscrowHint')}
|
||||
</p>
|
||||
)}
|
||||
{item.fulfilment === 'ships' && !canEscrow && (
|
||||
<p className="text-[11px] text-center text-amber-400/90">
|
||||
📦 Shipped deal — prefer escrow if the seller offers it, and share your address
|
||||
only in the chat.
|
||||
📦 {t('marketUi.tradeActions.shipsHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -258,12 +258,12 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
{requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Ask seller to confirm
|
||||
{t('marketUi.tradeActions.askSeller')}
|
||||
</Button>
|
||||
)}
|
||||
{iRequestedConfirmation && !iConfirmedTrade && (
|
||||
<p className="text-xs text-center text-gray-500">
|
||||
Confirmation requested — waiting on the seller.
|
||||
{t('marketUi.tradeActions.waitingSeller')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -276,7 +276,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Refund escrow
|
||||
{t('marketUi.tradeActions.refundEscrow')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -288,7 +288,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
Buy with sats{buyAmountSats ? ` · ${buyAmountSats.toLocaleString()}` : ''}
|
||||
{t('marketUi.tradeActions.buyWithSats')}{buyAmountSats ? ` · ${buyAmountSats.toLocaleString()}` : ''}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{isWTB ? 'I Have This!' : 'Contact Seller'}
|
||||
{isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
|
||||
</Button>
|
||||
|
||||
{/* Guided handoff — pre-filled DM templates, buyer only. */}
|
||||
|
|
@ -316,7 +316,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
{canDeposit && (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
Good-faith deposit (optional)
|
||||
{t('marketUi.tradeActions.depositTitle')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
|
|
@ -331,13 +331,12 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
onClick={() => setShowDeposit(true)}
|
||||
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
Pay deposit
|
||||
{t('marketUi.tradeActions.payDeposit')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-gray-600">
|
||||
A swap has no invoice, so this is how it gets a{' '}
|
||||
<strong className="text-gray-400">verifiable cost</strong>. Refund on delivery (or use
|
||||
Cashu escrow for an automatic refund). ⚠️ Proves commitment, not delivery.
|
||||
{t('marketUi.tradeActions.depositBefore')}{' '}
|
||||
<strong className="text-gray-400">{t('marketUi.tradeActions.verifiableCost')}</strong>{t('marketUi.tradeActions.depositAfter')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -351,8 +350,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
onPaid={(receipt) => {
|
||||
if (receipt) saveReceipt(coord, receipt, 'deposit');
|
||||
toast({
|
||||
title: 'Deposit paid',
|
||||
description: 'Saved — it will be attached when the trade is confirmed.',
|
||||
title: t('marketUi.tradeActions.depositPaid'),
|
||||
description: t('marketUi.tradeActions.depositPaidBody'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
@ -375,10 +374,10 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
|
|||
saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt);
|
||||
}
|
||||
toast({
|
||||
title: 'Paid ⚡',
|
||||
title: t('marketUi.tradeActions.paid'),
|
||||
description: receipt?.preimage
|
||||
? 'Saved a verifiable receipt — confirm the trade to make it count.'
|
||||
: 'Ask the seller to confirm the trade so it counts for both of you.',
|
||||
? t('marketUi.tradeActions.paidWithProof')
|
||||
: t('marketUi.tradeActions.paidBody'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
* The instant real spots arrive the parent unmounts this — no layout shift.
|
||||
*/
|
||||
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface Ghost {
|
||||
/** left/top as viewport percentages so it scales on any screen. */
|
||||
left: number;
|
||||
|
|
@ -31,6 +33,7 @@ const GHOSTS: Ghost[] = [
|
|||
];
|
||||
|
||||
export function MapSkeletonPins() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-[900] overflow-hidden"
|
||||
|
|
@ -77,7 +80,7 @@ export function MapSkeletonPins() {
|
|||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#ff0040]" />
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
|
||||
Finding spots
|
||||
{t('mapUi.findingSpots')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function resolvePubkey(input: string): string | null {
|
||||
const t = input.trim();
|
||||
|
|
@ -48,6 +49,7 @@ export function MarkSoldDialog({
|
|||
event: NostrEvent;
|
||||
listing: { pubkey: string; identifier: string };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { publishTrade, isPending: publishing } = useTradeAttestation();
|
||||
const { markSold, isPending: marking } = useMarkListingSold();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -61,7 +63,7 @@ export function MarkSoldDialog({
|
|||
|
||||
const submit = async () => {
|
||||
if (!buyerPubkey) {
|
||||
toast({ title: 'Enter the buyer’s npub', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.markSold.enterBuyer'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -74,16 +76,16 @@ export function MarkSoldDialog({
|
|||
amountSats: amount.trim() ? Number(amount) : undefined,
|
||||
});
|
||||
toast({
|
||||
title: 'Deal closed 🎉',
|
||||
description: 'Listing marked sold and the trade was published for reputation.',
|
||||
title: t('marketUi.markSold.dealClosed'),
|
||||
description: t('marketUi.markSold.dealClosedBody'),
|
||||
});
|
||||
onOpenChange(false);
|
||||
setBuyer('');
|
||||
setAmount('');
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not close the deal',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.markSold.closeFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.markSold.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -93,16 +95,16 @@ export function MarkSoldDialog({
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ccff00]/40 bg-black sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Close the deal</DialogTitle>
|
||||
<DialogTitle className="text-white">{t('marketUi.markSold.title')}</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-400">
|
||||
Marks this listing sold and records a signed trade attestation for you and the buyer.
|
||||
{t('marketUi.markSold.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="buyer" className="flex items-center gap-2 text-xs uppercase tracking-wide text-gray-400">
|
||||
Buyer's npub
|
||||
{t('marketUi.markSold.buyerLabel')}
|
||||
{buyerPubkey && <Check className="h-3.5 w-3.5 text-[#ccff00]" />}
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -115,7 +117,7 @@ export function MarkSoldDialog({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs uppercase tracking-wide text-gray-400">How was it paid? (self-reported)</Label>
|
||||
<Label className="text-xs uppercase tracking-wide text-gray-400">{t('marketUi.markSold.methodLabel')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => (
|
||||
<button
|
||||
|
|
@ -137,21 +139,21 @@ export function MarkSoldDialog({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Amount in sats (optional)
|
||||
{t('marketUi.markSold.amountLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 45000"
|
||||
placeholder={t('marketUi.offer.amountPlaceholder')}
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
|
|
@ -159,7 +161,7 @@ export function MarkSoldDialog({
|
|||
className="flex-1 gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
Mark sold
|
||||
{t('marketUi.markSold.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package,
|
|||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface MarketplaceItemCardProps {
|
||||
item: MarketplaceItem;
|
||||
|
|
@ -37,6 +38,7 @@ interface MarketplaceItemCardProps {
|
|||
}
|
||||
|
||||
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
// Distance from the viewer's last known location (no GPS call — cache only).
|
||||
const viewerLoc = getLastLocation();
|
||||
|
|
@ -119,7 +121,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
|
||||
const handleReport = async () => {
|
||||
if (!user || !reportReason) {
|
||||
toast({ title: 'Please select a reason', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.card.selectReason'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -138,8 +140,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Report submitted',
|
||||
description: 'Thank you for helping keep the marketplace safe.',
|
||||
title: t('marketUi.card.reportSubmitted'),
|
||||
description: t('marketUi.card.reportSubmittedBody'),
|
||||
});
|
||||
setShowReportDialog(false);
|
||||
setReportReason('');
|
||||
|
|
@ -147,8 +149,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to submit report',
|
||||
description: error instanceof Error ? error.message : 'Please try again',
|
||||
title: t('marketUi.card.reportFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.tryAgain'),
|
||||
});
|
||||
} finally {
|
||||
setIsReporting(false);
|
||||
|
|
@ -166,11 +168,11 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
const handleRelist = async () => {
|
||||
try {
|
||||
await relist(item.event);
|
||||
toast({ title: 'Relisted', description: 'Your listing is active again.' });
|
||||
toast({ title: t('marketUi.card.relisted'), description: t('marketUi.card.relistedBody') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('marketUi.card.couldNotUpdate'),
|
||||
description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -189,7 +191,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<div className="absolute top-2 right-2 z-10">
|
||||
<Badge variant="destructive" className="text-xs gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{reportCount} report{reportCount > 1 ? 's' : ''}
|
||||
{reportCount > 1 ? t('marketUi.card.reports', { count: reportCount }) : t('marketUi.card.reportCount', { count: reportCount })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -197,7 +199,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Product Image */}
|
||||
<Link to={detailUrl} className="flex-shrink-0" aria-label={`View ${item.title}`}>
|
||||
<Link to={detailUrl} className="flex-shrink-0" aria-label={t('marketUi.card.viewTitle', { title: item.title })}>
|
||||
{primaryImage ? (
|
||||
<div
|
||||
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
|
||||
|
|
@ -225,7 +227,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
{isWTB && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/50 mb-1 text-xs">
|
||||
<Search className="h-3 w-3 mr-1" />
|
||||
WANT TO BUY
|
||||
{t('marketUi.listingDetail.wantToBuy')}
|
||||
</Badge>
|
||||
)}
|
||||
<Link to={detailUrl}>
|
||||
|
|
@ -252,7 +254,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
)}
|
||||
{priceInSats > 0 && (
|
||||
<div className="text-xs text-[#ccff00] whitespace-nowrap">
|
||||
≈ {priceInSats.toLocaleString()} sats
|
||||
≈ {priceInSats.toLocaleString()} {t('common.sats')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -271,7 +273,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
className="text-red-400 focus:text-red-400"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report Listing
|
||||
{t('marketUi.card.reportListing')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -286,7 +288,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
size="icon"
|
||||
className="h-6 w-6 text-gray-400"
|
||||
disabled={isUpdatingStatus}
|
||||
aria-label="Manage listing"
|
||||
aria-label={t('marketUi.card.manageListing')}
|
||||
>
|
||||
{isUpdatingStatus ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
|
@ -299,12 +301,12 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
{isSold ? (
|
||||
<DropdownMenuItem onClick={handleRelist} disabled={isUpdatingStatus}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Relist as active
|
||||
{t('marketUi.listingDetail.relistActive')}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => setShowMarkSold(true)} disabled={isUpdatingStatus}>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark as sold
|
||||
{t('marketUi.listingDetail.markSold')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -313,7 +315,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
className="text-red-400 focus:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete listing
|
||||
{t('marketUi.listingDetail.deleteListing')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -342,10 +344,10 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
{item.fulfilment && (
|
||||
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
|
||||
{item.fulfilment === 'pickup'
|
||||
? '📍 Pickup'
|
||||
? `📍 ${t('marketUi.create.pickup')}`
|
||||
: item.fulfilment === 'ships'
|
||||
? '📦 Ships'
|
||||
: '📍📦 Pickup or ship'}
|
||||
? `📦 ${t('marketUi.create.ships')}`
|
||||
: `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`}
|
||||
</Badge>
|
||||
)}
|
||||
{distanceKm != null && (
|
||||
|
|
@ -357,14 +359,14 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
)}
|
||||
{item.shipping && (
|
||||
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
|
||||
{item.shipping === 'buyer' ? 'postage: buyer' : item.shipping === 'seller' ? 'postage: seller' : 'postage: split'}
|
||||
{item.shipping === 'buyer' ? t('marketUi.card.postageBuyer') : item.shipping === 'seller' ? t('marketUi.card.postageSeller') : t('marketUi.card.postageSplit')}
|
||||
</Badge>
|
||||
)}
|
||||
{typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-gray-600 bg-gray-800/50"
|
||||
title="NIP-13 proof of work — free anti-spam"
|
||||
title={t('marketUi.card.powTitle')}
|
||||
>
|
||||
⚡ PoW {item.powDifficulty}
|
||||
</Badge>
|
||||
|
|
@ -372,7 +374,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
{sellerRep && sellerRep.verified > 0 && (
|
||||
<Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
{sellerRep.verified} verified trade{sellerRep.verified === 1 ? '' : 's'}
|
||||
{sellerRep.verified === 1 ? t('marketUi.card.verifiedTradeOne', { count: sellerRep.verified }) : t('marketUi.card.verifiedTrades', { count: sellerRep.verified })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -403,13 +405,13 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
{isVerifiedSeller && (
|
||||
<Badge variant="outline" className="text-xs border-green-500/50 text-green-400 bg-green-500/10 gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Verified
|
||||
{t('common.verified')}
|
||||
</Badge>
|
||||
)}
|
||||
{isHighTrust && !isVerifiedSeller && (
|
||||
<Badge variant="outline" className="text-xs border-blue-500/50 text-blue-400 bg-blue-500/10 gap-1">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Trusted
|
||||
{t('marketUi.card.trusted')}
|
||||
</Badge>
|
||||
)}
|
||||
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} />
|
||||
|
|
@ -424,17 +426,17 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<Calendar className="h-3 w-3" />
|
||||
<span>
|
||||
{accountAgeDays < 30
|
||||
? `${accountAgeDays}d old`
|
||||
? t('marketUi.card.daysOld', { n: accountAgeDays })
|
||||
: accountAgeDays < 365
|
||||
? `${Math.floor(accountAgeDays / 30)}mo old`
|
||||
: `${Math.floor(accountAgeDays / 365)}y old`}
|
||||
? t('marketUi.card.monthsOld', { n: Math.floor(accountAgeDays / 30) })
|
||||
: t('marketUi.card.yearsOld', { n: Math.floor(accountAgeDays / 365) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{previousListings > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>{previousListings} listings</span>
|
||||
<span>{t('marketUi.card.listingsCount', { count: previousListings })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -444,7 +446,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
{item.availability && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400 mb-3">
|
||||
<Clock className="h-3 w-3 text-orange-500" />
|
||||
<span>Usually here {item.availability}</span>
|
||||
<span>{t('marketUi.listingDetail.usuallyHere', { availability: item.availability })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -482,7 +484,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
event={item.event}
|
||||
entityLabel="listing"
|
||||
entityName={item.title}
|
||||
triggerText="Delete listing"
|
||||
triggerText={t('marketUi.listingDetail.deleteListing')}
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
/>
|
||||
|
|
@ -493,16 +495,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flag className="h-5 w-5 text-red-400" />
|
||||
Report Listing
|
||||
{t('marketUi.card.reportListing')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Help keep the marketplace safe by reporting inappropriate or misleading listings.
|
||||
{t('marketUi.card.reportDialogBody')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Reason for report *</Label>
|
||||
<Label>{t('marketUi.card.reasonLabel')}</Label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{Object.entries(LISTING_REPORT_REASONS).map(([key, { label, description }]) => (
|
||||
<Button
|
||||
|
|
@ -526,10 +528,10 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="report-details">Additional details (optional)</Label>
|
||||
<Label htmlFor="report-details">{t('marketUi.card.detailsLabel')}</Label>
|
||||
<Textarea
|
||||
id="report-details"
|
||||
placeholder="Provide any additional context that might help..."
|
||||
placeholder={t('marketUi.card.detailsPlaceholder')}
|
||||
value={reportDetails}
|
||||
onChange={(e) => setReportDetails(e.target.value)}
|
||||
rows={3}
|
||||
|
|
@ -540,8 +542,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
<Card className="bg-gray-800/50 border-gray-700">
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Reports are published to Nostr and help the community identify problematic listings.
|
||||
Listings with multiple reports may be automatically hidden.
|
||||
{t('marketUi.card.reportNote')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -549,14 +550,14 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
|||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowReportDialog(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReport}
|
||||
disabled={isReporting || !reportReason}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
{isReporting ? 'Submitting...' : 'Submit Report'}
|
||||
{isReporting ? t('marketUi.card.submitting') : t('marketUi.card.submitReport')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Zap, ShieldCheck, AlertCircle } from 'lucide-react';
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { isValidNoffer } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface NofferFieldProps {
|
||||
value: string;
|
||||
|
|
@ -14,6 +15,7 @@ interface NofferFieldProps {
|
|||
* Purely additive: leaving it blank changes nothing.
|
||||
*/
|
||||
export function NofferField({ value, onChange }: NofferFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const trimmed = value.trim();
|
||||
const showError = trimmed.length > 0 && !isValidNoffer(trimmed);
|
||||
const showValid = isValidNoffer(trimmed);
|
||||
|
|
@ -22,7 +24,7 @@ export function NofferField({ value, onChange }: NofferFieldProps) {
|
|||
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4">
|
||||
<Label htmlFor="noffer" className="flex items-center gap-2 text-indigo-400">
|
||||
<Zap className="h-4 w-4" />
|
||||
CLINK Offer Code <span className="text-xs font-normal text-gray-500">(optional, advanced)</span>
|
||||
{t('marketUi.noffer.label')} <span className="text-xs font-normal text-gray-500">{t('marketUi.noffer.optional')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="noffer"
|
||||
|
|
@ -36,24 +38,23 @@ export function NofferField({ value, onChange }: NofferFieldProps) {
|
|||
{showError ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
That doesn't look like a valid <code className="mx-1">noffer1…</code> code.
|
||||
{t('marketUi.noffer.invalidBefore')} <code className="mx-1">noffer1…</code> {t('marketUi.noffer.invalidAfter')}
|
||||
</p>
|
||||
) : showValid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
Valid CLINK code — a "Pay via CLINK" button will show on your profile.
|
||||
{t('marketUi.noffer.valid')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">
|
||||
Paste a self-custodial CLINK payment code (e.g. from ShockWallet / Lightning.Pub) to accept
|
||||
Nostr-native Lightning payments — no web server or custodian.{' '}
|
||||
{t('marketUi.noffer.help')}{' '}
|
||||
<a
|
||||
href="https://clinkme.dev/specs.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
Learn specs
|
||||
{t('marketUi.noffer.learnSpecs')}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useOfferActions } from '@/hooks/useMarketplaceOffers';
|
|||
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Handshake, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** Buyer: make a structured offer (amount + note) on a listing. */
|
||||
export function OfferDialog({
|
||||
|
|
@ -29,6 +30,7 @@ export function OfferDialog({
|
|||
title: string;
|
||||
sellerName: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { createOffer } = useOfferActions();
|
||||
const { mutateAsync: sendDM } = useSendDirectMessage();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -40,11 +42,11 @@ export function OfferDialog({
|
|||
const submit = async () => {
|
||||
const parsed = amount.trim() ? Number(amount) : undefined;
|
||||
if (parsed !== undefined && (!Number.isFinite(parsed) || parsed <= 0)) {
|
||||
toast({ title: 'Enter a valid amount', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.offer.validAmount'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (!parsed && !offerItem.trim() && !note.trim()) {
|
||||
toast({ title: 'Describe your offer', description: 'Add an amount, an item to trade, or a message.', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.offer.describeOffer'), description: t('marketUi.offer.describeOfferBody'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
|
@ -53,20 +55,20 @@ export function OfferDialog({
|
|||
|
||||
// Best-effort nudge to the seller via NIP-17 DM — never fail the offer.
|
||||
try {
|
||||
const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : '';
|
||||
const itemLine = offerItem.trim() ? ` (offering: ${offerItem.trim()})` : '';
|
||||
const amountLine = parsed ? t('marketUi.offer.dmAmount', { amount: parsed.toLocaleString() }) : '';
|
||||
const itemLine = offerItem.trim() ? t('marketUi.offer.dmItem', { item: offerItem.trim() }) : '';
|
||||
const noteLine = note.trim() ? `\n\n“${note.trim()}”` : '';
|
||||
await sendDM({
|
||||
recipients: listing.pubkey,
|
||||
content: `🛹 New offer on your listing “${title}”${amountLine}${itemLine}.${noteLine}`,
|
||||
content: t('marketUi.offer.dmBody', { title, amount: amountLine, item: itemLine, note: noteLine }),
|
||||
});
|
||||
} catch {
|
||||
/* DM optional (no NIP-44 signer, etc.) */
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Offer sent',
|
||||
description: `${sellerName} can accept or decline it.`,
|
||||
title: t('marketUi.offer.sent'),
|
||||
description: t('marketUi.offer.sentBody', { sellerName }),
|
||||
});
|
||||
onOpenChange(false);
|
||||
setAmount('');
|
||||
|
|
@ -74,8 +76,8 @@ export function OfferDialog({
|
|||
setNote('');
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not send the offer',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.offer.sendFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.offer.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -88,60 +90,59 @@ export function OfferDialog({
|
|||
<DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-orange-500/40 bg-black sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<Handshake className="h-5 w-5 text-orange-400" /> Make an offer
|
||||
<Handshake className="h-5 w-5 text-orange-400" /> {t('marketUi.offer.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-400">
|
||||
Offer sats, or swap something — {sellerName} can accept, decline or counter.
|
||||
You arrange the handover between yourselves.
|
||||
{t('marketUi.offer.description', { sellerName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offer-amount" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Amount (sats, optional)
|
||||
{t('marketUi.offer.amountLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="offer-amount"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="e.g. 45000"
|
||||
placeholder={t('marketUi.offer.amountPlaceholder')}
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offer-item" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Or trading something (optional)
|
||||
{t('marketUi.offer.itemLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="offer-item"
|
||||
value={offerItem}
|
||||
onChange={(e) => setOfferItem(e.target.value)}
|
||||
placeholder="e.g. my 8.25 deck + spare wheels"
|
||||
placeholder={t('marketUi.offer.itemPlaceholder')}
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Swapping? Put what you're offering here — you can combine it with sats.
|
||||
{t('marketUi.offer.itemHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Message (optional)
|
||||
{t('marketUi.offer.messageLabel')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="offer-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Anything the seller should know…"
|
||||
placeholder={t('marketUi.offer.messagePlaceholder')}
|
||||
className="border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
|
|
@ -149,7 +150,7 @@ export function OfferDialog({
|
|||
className="flex-1 gap-2 bg-orange-500 font-bold text-black hover:bg-orange-500/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
|
||||
Send offer
|
||||
{t('marketUi.offer.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { toast } from '@/hooks/useToast';
|
|||
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
|
||||
import type { PaymentReceipt } from '@/lib/receipts';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface PayWithSatsProps {
|
||||
open: boolean;
|
||||
|
|
@ -43,6 +44,7 @@ export function PayWithSats({
|
|||
pointerKind,
|
||||
onPaid,
|
||||
}: PayWithSatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(sellerPubkey);
|
||||
const { pay, isPaying } = usePayPaymentPointer();
|
||||
|
|
@ -75,16 +77,16 @@ export function PayWithSats({
|
|||
const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats });
|
||||
setPaid(true);
|
||||
toast({
|
||||
title: 'Payment sent ⚡',
|
||||
title: t('marketUi.pay.sent'),
|
||||
description: receipt.preimage
|
||||
? 'Saved a verifiable payment receipt — it will count as payment-verified.'
|
||||
: 'Let the seller know — they can confirm the trade so it counts for both of you.',
|
||||
? t('marketUi.pay.sentWithProof')
|
||||
: t('marketUi.pay.sentBody'),
|
||||
});
|
||||
onPaid?.(receipt);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Payment failed.';
|
||||
const message = err instanceof Error ? err.message : t('marketUi.pay.failed');
|
||||
setError(message);
|
||||
toast({ title: 'Payment failed', description: message, variant: 'destructive' });
|
||||
toast({ title: t('marketUi.pay.failed'), description: message, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -95,32 +97,32 @@ export function PayWithSats({
|
|||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-[#ccff00]" /> Pay with sats
|
||||
<Zap className="h-4 w-4 text-[#ccff00]" /> {t('marketUi.pay.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{amountSats
|
||||
? <>Send <strong className="text-white">{amountSats.toLocaleString()} sats</strong> to {sellerName} to settle this deal.</>
|
||||
: <>Settle this deal directly with {sellerName}.</>}
|
||||
? <>{t('marketUi.pay.descSendPrefix')} <strong className="text-white">{amountSats.toLocaleString()} {t('common.sats')}</strong> {t('marketUi.pay.descSendSuffix', { sellerName })}</>
|
||||
: <>{t('marketUi.pay.descSettle', { sellerName })}</>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{paid ? (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-green-500" />
|
||||
<p className="text-sm font-bold text-white">Paid!</p>
|
||||
<p className="text-sm font-bold text-white">{t('marketUi.pay.paid')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ask the seller to confirm the trade — two confirmations build both reputations.
|
||||
{t('marketUi.pay.paidBody')}
|
||||
</p>
|
||||
</div>
|
||||
) : !effectivePointer ? (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<AlertTriangle className="mx-auto h-8 w-8 text-amber-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sellerName} hasn’t set a payment method (no Lightning invoice or address). Message them to arrange payment.
|
||||
{t('marketUi.pay.noMethod', { sellerName })}
|
||||
</p>
|
||||
</div>
|
||||
) : isSelf ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">This is your own listing.</p>
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('marketUi.pay.ownListing')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
|
|
@ -132,10 +134,10 @@ export function PayWithSats({
|
|||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{isPaying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
||||
{isPaying ? 'Paying…' : `Pay${amountSats ? ` ${amountSats.toLocaleString()} sats` : ''}`}
|
||||
{isPaying ? t('marketUi.pay.paying') : (amountSats ? t('marketUi.pay.payAmount', { amount: amountSats.toLocaleString() }) : t('marketUi.pay.pay'))}
|
||||
</Button>
|
||||
<p className="text-center text-[10px] leading-relaxed text-muted-foreground">
|
||||
Pays from your connected wallet (NWC, WebLN, or Cashu) straight to {sellerName} — SkateSpots never holds the funds.
|
||||
{t('marketUi.pay.footnote', { sellerName })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Zap, CheckCircle2, AlertCircle } from 'lucide-react';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { isValidLightningAddress, isValidSilentPayment } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Payment-readiness note for provider / Lab / crew forms.
|
||||
|
|
@ -14,6 +15,7 @@ import { isValidLightningAddress, isValidSilentPayment } from '@/lib/types';
|
|||
* if it isn't, so they always have a clear path to "get paid".
|
||||
*/
|
||||
export function PaymentReadinessNote() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey || '');
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -39,7 +41,7 @@ export function PaymentReadinessNote() {
|
|||
<div className="flex items-center gap-2">
|
||||
<Zap className={ready ? 'h-4 w-4 text-green-400' : 'h-4 w-4 text-yellow-400'} />
|
||||
<span className={ready ? 'text-sm font-bold text-green-300' : 'text-sm font-bold text-yellow-300'}>
|
||||
{ready ? 'You can get paid' : 'Set up payments to get paid'}
|
||||
{ready ? t('marketUi.paymentReadiness.ready') : t('marketUi.paymentReadiness.notReady')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -47,20 +49,19 @@ export function PaymentReadinessNote() {
|
|||
<p className="flex items-start gap-1.5 text-xs text-gray-400">
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-green-400" />
|
||||
<span>
|
||||
Your Lightning address{lud16 ? <> (<span className="font-mono">{lud16}</span>)</> : ''} is on your
|
||||
profile — people can zap and pay you directly.
|
||||
{hasSilentPayment && ' A Bitcoin Silent Payment address is set too.'}
|
||||
{t('marketUi.paymentReadiness.readyBefore')}{lud16 ? <> (<span className="font-mono">{lud16}</span>)</> : ''}{t('marketUi.paymentReadiness.readyAfter')}
|
||||
{hasSilentPayment && t('marketUi.paymentReadiness.silentSet')}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="flex items-start gap-1.5 text-xs text-gray-400">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
|
||||
<span>
|
||||
Add a Lightning address (and optionally a Bitcoin Silent Payment address) to your{' '}
|
||||
{t('marketUi.paymentReadiness.notReadyBefore')}{' '}
|
||||
<Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
|
||||
Nostr profile
|
||||
{t('marketUi.paymentReadiness.nostrProfile')}
|
||||
</Link>{' '}
|
||||
so skaters can pay you. It's set once and works across every profile you have here.
|
||||
{t('marketUi.paymentReadiness.notReadyAfter')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Loader2, Upload } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface PhotoDropzoneProps {
|
||||
/** Called with the image files the user picked or dropped. */
|
||||
|
|
@ -35,13 +36,16 @@ export function PhotoDropzone({
|
|||
uploading = false,
|
||||
disabled = false,
|
||||
multiple = true,
|
||||
label = 'Add Photos',
|
||||
hint = 'Drag & drop or click',
|
||||
label,
|
||||
hint,
|
||||
className,
|
||||
inputLabel,
|
||||
}: PhotoDropzoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const busy = uploading || disabled;
|
||||
const resolvedLabel = label ?? t('spotUi.addPhotos');
|
||||
const resolvedHint = hint ?? t('spotUi.dragDropOrClick');
|
||||
|
||||
const emit = (fileList: FileList | null) => {
|
||||
if (!fileList) return;
|
||||
|
|
@ -78,7 +82,7 @@ export function PhotoDropzone({
|
|||
multiple={multiple}
|
||||
className="hidden"
|
||||
disabled={busy}
|
||||
aria-label={inputLabel ?? label}
|
||||
aria-label={inputLabel ?? resolvedLabel}
|
||||
onChange={(e) => {
|
||||
emit(e.target.files);
|
||||
e.target.value = '';
|
||||
|
|
@ -90,9 +94,9 @@ export function PhotoDropzone({
|
|||
<>
|
||||
<Upload className={cn('h-6 w-6 mb-1', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')} />
|
||||
<span className={cn('text-xs font-bold', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')}>
|
||||
{dragging ? 'Drop photos' : label}
|
||||
{dragging ? t('spotUi.dropPhotos') : resolvedLabel}
|
||||
</span>
|
||||
{hint && <span className="mt-0.5 text-[10px] text-muted-foreground/80">{hint}</span>}
|
||||
{resolvedHint && <span className="mt-0.5 text-[10px] text-muted-foreground/80">{resolvedHint}</span>}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||
import { ProfileBadges } from '@/components/ProfileBadges';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { isSkateSpotsNip05, profileBadges } from '@/lib/badges';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -18,6 +19,7 @@ import { cn } from '@/lib/utils';
|
|||
*/
|
||||
export function ProfilePreviewCard({ className }: { className?: string }) {
|
||||
const { user, metadata, isLoadingProfile } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const [bannerOk, setBannerOk] = useState(true);
|
||||
|
||||
if (!user) return null;
|
||||
|
|
@ -45,7 +47,7 @@ export function ProfilePreviewCard({ className }: { className?: string }) {
|
|||
to={`/${nip19.npubEncode(user.pubkey)}`}
|
||||
className="absolute right-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-black/55 px-3 py-1.5 text-xs font-bold text-white backdrop-blur transition-colors hover:bg-black/75"
|
||||
>
|
||||
View public
|
||||
{t('labUi.profilePreview.viewPublic')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -59,7 +61,7 @@ export function ProfilePreviewCard({ className }: { className?: string }) {
|
|||
</Avatar>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<p className="truncate text-lg font-black leading-tight text-white">
|
||||
{name || (isLoadingProfile ? 'Loading…' : 'Unnamed skater')}
|
||||
{name || (isLoadingProfile ? t('common.loading') : t('labUi.profilePreview.unnamed'))}
|
||||
</p>
|
||||
{nip05 ? (
|
||||
<span
|
||||
|
|
@ -72,7 +74,7 @@ export function ProfilePreviewCard({ className }: { className?: string }) {
|
|||
<span className="truncate">{nip05}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">No handle yet</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{t('labUi.profilePreview.noHandle')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,58 +14,60 @@ import { Textarea } from '@/components/ui/textarea';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Loader2, Flag, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// NIP-56 Report types with skate-specific labels
|
||||
// NIP-56 Report types with skate-specific labels. `labelKey`/`descriptionKey`
|
||||
// resolve to `spotUi.report.*` translations.
|
||||
const REPORT_REASONS = [
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Not a skate spot',
|
||||
description: 'A test entry, or a place that is not for skating at all',
|
||||
labelKey: 'spotUi.report.notASkateSpot.label',
|
||||
descriptionKey: 'spotUi.report.notASkateSpot.description',
|
||||
icon: '🛹',
|
||||
subtype: 'not-a-skate-spot',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
label: 'Fake/Non-existent spot',
|
||||
description: 'This spot does not exist or location is wrong',
|
||||
labelKey: 'spotUi.report.fakeSpot.label',
|
||||
descriptionKey: 'spotUi.report.fakeSpot.description',
|
||||
icon: '📍',
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Stolen/Fake photos',
|
||||
description: 'Photos are stock images or stolen from elsewhere',
|
||||
labelKey: 'spotUi.report.stolenPhotos.label',
|
||||
descriptionKey: 'spotUi.report.stolenPhotos.description',
|
||||
icon: '📸',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
label: 'Spam or advertising',
|
||||
description: 'Promotional content or duplicate listings',
|
||||
labelKey: 'spotUi.report.spamAd.label',
|
||||
descriptionKey: 'spotUi.report.spamAd.description',
|
||||
icon: '🗑️',
|
||||
subtype: 'spam-ad',
|
||||
},
|
||||
{
|
||||
id: 'illegal',
|
||||
label: 'Dangerous location',
|
||||
description: 'Private property, unsafe, or illegal to access',
|
||||
labelKey: 'spotUi.report.dangerousLocation.label',
|
||||
descriptionKey: 'spotUi.report.dangerousLocation.description',
|
||||
icon: '⚠️',
|
||||
},
|
||||
{
|
||||
id: 'nudity',
|
||||
label: 'Inappropriate content',
|
||||
description: 'Contains offensive or adult content',
|
||||
labelKey: 'spotUi.report.inappropriateContent.label',
|
||||
descriptionKey: 'spotUi.report.inappropriateContent.description',
|
||||
icon: '🔞',
|
||||
},
|
||||
{
|
||||
id: 'impersonation',
|
||||
label: 'Impersonation',
|
||||
description: 'Someone pretending to be someone else',
|
||||
labelKey: 'spotUi.report.impersonation.label',
|
||||
descriptionKey: 'spotUi.report.impersonation.description',
|
||||
icon: '👤',
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
description: 'Something else not listed above',
|
||||
labelKey: 'spotUi.report.other.label',
|
||||
descriptionKey: 'spotUi.report.other.description',
|
||||
icon: '📝',
|
||||
subtype: 'other-general',
|
||||
},
|
||||
|
|
@ -92,6 +94,7 @@ export function ReportDialog({
|
|||
targetTitle,
|
||||
onReported,
|
||||
}: ReportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -143,13 +146,13 @@ export function ReportDialog({
|
|||
onReported?.();
|
||||
|
||||
toast({
|
||||
title: 'Report submitted',
|
||||
description: 'Thank you for helping keep SkateSpots safe.',
|
||||
title: t('spotUi.reportSubmitted'),
|
||||
description: t('spotUi.reportSubmittedDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to submit report',
|
||||
description: error instanceof Error ? error.message : 'Please try again',
|
||||
title: t('spotUi.failedToSubmitReport'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.pleaseTryAgain'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -176,13 +179,13 @@ export function ReportDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flag className="h-5 w-5 text-red-500" />
|
||||
Report Content
|
||||
{t('spotUi.reportContent')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-center py-6">
|
||||
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
|
||||
<p className="text-muted-foreground">
|
||||
You need to be logged in to report content.
|
||||
{t('spotUi.logInToReport')}
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
@ -196,11 +199,11 @@ export function ReportDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flag className="h-5 w-5 text-red-500" />
|
||||
Report Content
|
||||
{t('spotUi.reportContent')}
|
||||
</DialogTitle>
|
||||
{step === 'form' && (
|
||||
<DialogDescription>
|
||||
Help keep the community safe by reporting inappropriate content.
|
||||
{t('spotUi.reportContentDesc')}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
|
@ -211,7 +214,7 @@ export function ReportDialog({
|
|||
{targetTitle && (
|
||||
<div className="p-3 rounded-lg bg-muted/50 border">
|
||||
<p className="text-xs text-muted-foreground uppercase mb-1">
|
||||
Reporting {targetType}
|
||||
{t('spotUi.reportingType', { type: t(`spotUi.targetType.${targetType}`) })}
|
||||
</p>
|
||||
<p className="font-medium text-sm truncate">{targetTitle}</p>
|
||||
</div>
|
||||
|
|
@ -219,7 +222,7 @@ export function ReportDialog({
|
|||
|
||||
{/* Reason selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Why are you reporting this?</Label>
|
||||
<Label>{t('spotUi.whyReporting')}</Label>
|
||||
<RadioGroup
|
||||
value={selectedReason}
|
||||
onValueChange={setSelectedReason}
|
||||
|
|
@ -245,10 +248,10 @@ export function ReportDialog({
|
|||
className="flex items-center gap-2 cursor-pointer font-medium"
|
||||
>
|
||||
<span>{reason.icon}</span>
|
||||
{reason.label}
|
||||
{t(reason.labelKey)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{reason.description}
|
||||
{t(reason.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -259,10 +262,10 @@ export function ReportDialog({
|
|||
|
||||
{/* Additional details */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="details">Additional details (optional)</Label>
|
||||
<Label htmlFor="details">{t('spotUi.additionalDetails')}</Label>
|
||||
<Textarea
|
||||
id="details"
|
||||
placeholder="Provide any additional context that might help..."
|
||||
placeholder={t('spotUi.additionalDetailsPlaceholder')}
|
||||
value={additionalDetails}
|
||||
onChange={(e) => setAdditionalDetails(e.target.value)}
|
||||
rows={3}
|
||||
|
|
@ -278,7 +281,7 @@ export function ReportDialog({
|
|||
className="flex-1"
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
|
|
@ -288,12 +291,12 @@ export function ReportDialog({
|
|||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
{t('spotUi.submitting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Submit Report
|
||||
{t('spotUi.submitReport')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -305,13 +308,12 @@ export function ReportDialog({
|
|||
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg mb-2">Report Submitted</h3>
|
||||
<h3 className="font-bold text-lg mb-2">{t('spotUi.reportSubmittedTitle')}</h3>
|
||||
<p className="text-muted-foreground text-sm mb-6">
|
||||
Thanks for helping keep SkateSpots safe and accurate. Your report
|
||||
will be reviewed by trusted community members.
|
||||
{t('spotUi.reportSubmittedLong')}
|
||||
</p>
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
Done
|
||||
{t('common.done')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
|||
import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import type { SpotStatusValue, SpotStatusReport } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ interface ReportSpotStatusDialogProps {
|
|||
}
|
||||
|
||||
function ReportRow({ report }: { report: SpotStatusReport }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(report.pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(report.pubkey);
|
||||
const meta = SPOT_STATUS_META[report.status];
|
||||
|
|
@ -42,7 +44,7 @@ function ReportRow({ report }: { report: SpotStatusReport }) {
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-semibold">{meta.label}</span>
|
||||
<span className="text-muted-foreground truncate">by {name}</span>
|
||||
<span className="text-muted-foreground truncate">{t('spotUi.byAuthor', { name })}</span>
|
||||
<span className="text-muted-foreground ml-auto flex-shrink-0">
|
||||
{statusTimeAgo(report.createdAt)}
|
||||
</span>
|
||||
|
|
@ -61,6 +63,7 @@ export function ReportSpotStatusDialog({
|
|||
spotTitle,
|
||||
trigger,
|
||||
}: ReportSpotStatusDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -81,16 +84,16 @@ export function ReportSpotStatusDialog({
|
|||
note: note.trim() || undefined,
|
||||
});
|
||||
toast({
|
||||
title: 'Status reported',
|
||||
description: 'Thanks for keeping the map fresh. Your report is live.',
|
||||
title: t('spotUi.statusReported'),
|
||||
description: t('spotUi.statusReportedDesc'),
|
||||
});
|
||||
setSelected(null);
|
||||
setNote('');
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not submit',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong.',
|
||||
title: t('spotUi.couldNotSubmit'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -102,7 +105,7 @@ export function ReportSpotStatusDialog({
|
|||
{trigger ?? (
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Flag className="h-4 w-4" />
|
||||
Report Status
|
||||
{t('spotUi.reportStatus')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
|
@ -110,20 +113,20 @@ export function ReportSpotStatusDialog({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flag className="h-5 w-5 text-[#ff0040]" />
|
||||
Spot Status
|
||||
{t('spotUi.spotStatus')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Is "{spotTitle}" still skateable? Let the community know its current state.
|
||||
{t('spotUi.spotStatusDesc', { title: spotTitle })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Current consensus */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Current:</span>
|
||||
<span className="text-muted-foreground">{t('spotUi.current')}</span>
|
||||
{aggregated.status ? (
|
||||
<SpotStatusBadge aggregated={aggregated} />
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">No reports yet</span>
|
||||
<span className="text-muted-foreground italic">{t('spotUi.noReportsYet')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -162,7 +165,7 @@ export function ReportSpotStatusDialog({
|
|||
|
||||
{/* Optional note */}
|
||||
<Textarea
|
||||
placeholder="Optional: add details (e.g. 'new knobs on the ledge', 'guard kicks out after 6pm')"
|
||||
placeholder={t('spotUi.statusNotePlaceholder')}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
||||
rows={2}
|
||||
|
|
@ -177,17 +180,17 @@ export function ReportSpotStatusDialog({
|
|||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
{t('spotUi.submitting')}
|
||||
</>
|
||||
) : (
|
||||
'Submit Report'
|
||||
t('spotUi.submitReport')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in to report this spot's status.
|
||||
{t('spotUi.logInToReportStatus')}
|
||||
</p>
|
||||
<LoginArea className="w-full" />
|
||||
</div>
|
||||
|
|
@ -197,7 +200,7 @@ export function ReportSpotStatusDialog({
|
|||
{reports.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
|
||||
Recent reports
|
||||
{t('spotUi.recentReports')}
|
||||
</h4>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{reports.slice(0, 20).map((r) => (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
|
|||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
import { SasquatchProDialog } from '@/components/SasquatchProDialog';
|
||||
import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { SasquatchActionCard } from '@/components/SasquatchActionCard';
|
||||
import { callSasquatchAI, streamSasquatchAI } from '@/lib/sasquatchBrain';
|
||||
import { buildProAuthHeaders } from '@/lib/proAuth';
|
||||
|
|
@ -644,6 +645,7 @@ function ChatText({ text }: { text: string }) {
|
|||
|
||||
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { language } = useTranslation();
|
||||
const cvm = useCVMClient();
|
||||
const personal = usePersonalContext();
|
||||
const { isPro, expiresAt, proof } = useSasquatchPro();
|
||||
|
|
@ -1078,9 +1080,14 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
// smuggled into a user turn — the model then treats the relay/derived
|
||||
// text below as lower-trust input, blunting prompt-injection via event
|
||||
// content. Personal tools are only offered to logged-in Pro supporters.
|
||||
const systemPrompt = personal.available && isPro
|
||||
// The reply-language directive is appended so The Sasquatch answers in
|
||||
// the app's current language, keeping skate trick names in English.
|
||||
const languageDirective = language === 'es'
|
||||
? '\n\nAlways reply in Spanish (Castellano). Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and never translate them.'
|
||||
: '';
|
||||
const systemPrompt = (personal.available && isPro
|
||||
? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}`
|
||||
: SYSTEM_PROMPT;
|
||||
: SYSTEM_PROMPT) + languageDirective;
|
||||
const historyWithContext: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question },
|
||||
|
|
@ -1321,7 +1328,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
|||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are The Sasquatch, a chill skater AI guide. Present live data naturally and concisely. IMPORTANT: Do NOT output any JSON tool calls — answer in plain conversational text with bullet points.',
|
||||
'You are The Sasquatch, a chill skater AI guide. Present live data naturally and concisely. IMPORTANT: Do NOT output any JSON tool calls — answer in plain conversational text with bullet points.' + languageDirective,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
|
|
@ -1481,7 +1488,7 @@ Instructions:
|
|||
setIsThinking(false);
|
||||
setStreamingText('');
|
||||
}
|
||||
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots]);
|
||||
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots, language]);
|
||||
|
||||
// Listen for external events from FAQ/About pages
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -8,16 +8,18 @@ import { MarkSoldDialog } from '@/components/MarkSoldDialog';
|
|||
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function MyListingRow({ item }: { item: MarketplaceItem }) {
|
||||
const { t } = useTranslation();
|
||||
const [openSold, setOpenSold] = useState(false);
|
||||
const listing = { pubkey: item.pubkey, identifier: item.identifier };
|
||||
const soldOut = item.status === 'sold';
|
||||
const price = item.price
|
||||
? `${Number(item.price.amount).toLocaleString()} ${item.price.currency}`
|
||||
: item.priceType === 'trade'
|
||||
? 'Trade'
|
||||
: 'Free';
|
||||
? t('marketUi.seller.trade')
|
||||
: t('common.free');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -41,9 +43,9 @@ function MyListingRow({ item }: { item: MarketplaceItem }) {
|
|||
variant="outline"
|
||||
className={soldOut ? 'border-red-500/40 text-red-400' : 'border-emerald-500/40 text-emerald-400'}
|
||||
>
|
||||
{soldOut ? 'Sold' : 'Active'}
|
||||
{soldOut ? t('marketplace.sold') : t('marketUi.seller.active')}
|
||||
</Badge>
|
||||
<Button asChild size="sm" variant="outline" className="gap-1 border-white/15" title="View listing">
|
||||
<Button asChild size="sm" variant="outline" className="gap-1 border-white/15" title={t('marketUi.trades.viewListing')}>
|
||||
<Link to={`/listing/${item.pubkey}/${item.identifier}`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
|
|
@ -54,7 +56,7 @@ function MyListingRow({ item }: { item: MarketplaceItem }) {
|
|||
onClick={() => setOpenSold(true)}
|
||||
className="shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
Mark sold
|
||||
{t('marketUi.seller.markSold')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -69,6 +71,7 @@ function MyListingRow({ item }: { item: MarketplaceItem }) {
|
|||
* "mark sold" (which also records the trade for reputation).
|
||||
*/
|
||||
export function SellerDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: items, isLoading } = useMarketplaceItems(200);
|
||||
|
||||
|
|
@ -81,30 +84,30 @@ export function SellerDashboard() {
|
|||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
||||
<Package className="h-4 w-4 text-[#ccff00]" />
|
||||
Your listings
|
||||
{t('marketUi.seller.yourListings')}
|
||||
{mine.length > 0 && (
|
||||
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]">
|
||||
{active.length} active
|
||||
{t('marketUi.seller.activeCount', { count: active.length })}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<Button asChild size="sm" className="gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600">
|
||||
<Link to="/create-listing?returnTo=/marketplace">
|
||||
<Plus className="h-3.5 w-3.5" /> List an item
|
||||
<Plus className="h-3.5 w-3.5" /> {t('marketUi.seller.listItem')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading your listings…</p>
|
||||
<p className="text-sm text-gray-500">{t('marketUi.seller.loading')}</p>
|
||||
) : mine.length === 0 ? (
|
||||
<Card className="border-dashed border-white/10">
|
||||
<CardContent className="space-y-3 py-10 text-center">
|
||||
<Package className="mx-auto h-8 w-8 text-gray-600" />
|
||||
<p className="text-sm text-gray-400">You have no listings yet.</p>
|
||||
<p className="text-sm text-gray-400">{t('marketUi.seller.empty')}</p>
|
||||
<Button asChild className="gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600">
|
||||
<Link to="/create-listing?returnTo=/marketplace">
|
||||
<Plus className="h-3.5 w-3.5" /> List your first item
|
||||
<Plus className="h-3.5 w-3.5" /> {t('marketUi.seller.listFirst')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
@ -116,7 +119,7 @@ export function SellerDashboard() {
|
|||
))}
|
||||
{sold.length > 0 && (
|
||||
<>
|
||||
<h4 className="pt-2 text-xs font-black uppercase tracking-widest text-gray-500">Sold</h4>
|
||||
<h4 className="pt-2 text-xs font-black uppercase tracking-widest text-gray-500">{t('marketplace.sold')}</h4>
|
||||
{sold.map((item) => (
|
||||
<MyListingRow key={item.id} item={item} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import { useListingTrade } from '@/hooks/useTradeAttestations';
|
|||
import { useShipmentStatus, useSetShipmentStatus } from '@/hooks/useShipmentStatus';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { MarketplaceItem } from '@/lib/types';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const listingRef = { pubkey: item.pubkey, identifier: item.identifier };
|
||||
|
|
@ -44,8 +46,8 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
|||
const mark = async (status: 'shipped' | 'received' | 'issue') => {
|
||||
if (!counterparty) {
|
||||
toast({
|
||||
title: 'No counterparty yet',
|
||||
description: 'The seller should record the sale (naming the buyer) first.',
|
||||
title: t('marketUi.shipping.noCounterparty'),
|
||||
description: t('marketUi.shipping.noCounterpartyBody'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -54,16 +56,16 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
|||
await setStatus({ listing: listingRef, counterparty, status, ref: tracking });
|
||||
toast({
|
||||
title:
|
||||
status === 'shipped' ? 'Marked as shipped' : status === 'received' ? 'Marked as received' : 'Issue reported',
|
||||
status === 'shipped' ? t('marketUi.shipping.markedShipped') : status === 'received' ? t('marketUi.shipping.markedReceived') : t('marketUi.shipping.issueReported'),
|
||||
description:
|
||||
status === 'received'
|
||||
? 'Thanks — this does not release escrow; settle directly with the seller.'
|
||||
: 'The other side has been notified.',
|
||||
? t('marketUi.shipping.receivedBody')
|
||||
: t('marketUi.shipping.notifiedBody'),
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.shipping.updateFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.shipping.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -72,16 +74,16 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
|||
return (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
<Package className="h-3.5 w-3.5 text-[#ff6600]" /> Shipping
|
||||
{shipped && <span className="text-[#ccff00]">· shipped</span>}
|
||||
{received && <span className="text-[#ccff00]">· received</span>}
|
||||
{issue && <span className="text-amber-400">· issue</span>}
|
||||
<Package className="h-3.5 w-3.5 text-[#ff6600]" /> {t('marketUi.shipping.title')}
|
||||
{shipped && <span className="text-[#ccff00]">· {t('marketUi.shipping.shipped')}</span>}
|
||||
{received && <span className="text-[#ccff00]">· {t('marketUi.shipping.received')}</span>}
|
||||
{issue && <span className="text-amber-400">· {t('marketUi.shipping.issue')}</span>}
|
||||
</p>
|
||||
|
||||
<Input
|
||||
value={tracking}
|
||||
onChange={(e) => setTracking(e.target.value)}
|
||||
placeholder="Tracking number (optional)"
|
||||
placeholder={t('marketUi.shipping.trackingPlaceholder')}
|
||||
className="h-9 border-white/15 bg-white/5 text-sm text-white"
|
||||
/>
|
||||
|
||||
|
|
@ -94,7 +96,7 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
|||
className="gap-1.5 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
|
||||
I've shipped it
|
||||
{t('marketUi.shipping.iShipped')}
|
||||
</Button>
|
||||
)}
|
||||
{!isSeller && (
|
||||
|
|
@ -105,7 +107,7 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
|||
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <PackageCheck className="h-3.5 w-3.5" />}
|
||||
I received it
|
||||
{t('marketUi.shipping.iReceived')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -115,14 +117,12 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
|
|||
disabled={isPending}
|
||||
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> Report an issue
|
||||
<AlertTriangle className="h-3.5 w-3.5" /> {t('marketUi.shipping.reportIssue')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] leading-relaxed text-gray-600">
|
||||
Self-reported — a logistics note, <strong className="text-gray-400">not proof</strong>. Marking
|
||||
“received” does not release escrow. ⚠️ Don't photograph a parcel with an address visible, and
|
||||
only share a tracking number if you're comfortable with it being public.
|
||||
{t('marketUi.shipping.noteBefore')} <strong className="text-gray-400">{t('marketUi.shipping.notProof')}</strong>{t('marketUi.shipping.noteAfter')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
|
|||
import { PersonStanding, ChevronRight, Sparkles, Check } from 'lucide-react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
||||
|
||||
/**
|
||||
|
|
@ -12,6 +13,7 @@ import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
|||
*/
|
||||
export function SkaterDirectoryCard() {
|
||||
const { user } = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { data: skater, isLoading } = useUserSkaterProfile(user?.pubkey);
|
||||
|
||||
if (!user || isLoading) return null;
|
||||
|
|
@ -27,9 +29,9 @@ export function SkaterDirectoryCard() {
|
|||
<PersonStanding className="h-5 w-5 text-[#ff0040]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm leading-tight">Join the Skater Directory</p>
|
||||
<p className="font-bold text-sm leading-tight">{t('labUi.skaterCard.joinTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add your stance, styles & level so skaters can find you.
|
||||
{t('labUi.skaterCard.joinDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Sparkles className="h-4 w-4 text-[#ff0040] flex-shrink-0" />
|
||||
|
|
@ -51,7 +53,7 @@ export function SkaterDirectoryCard() {
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm leading-tight flex items-center gap-1.5">
|
||||
<Check className="h-3.5 w-3.5 text-[#ccff00]" />
|
||||
You're in the Skater Directory
|
||||
{t('labUi.skaterCard.inDirectory')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
{level ? (
|
||||
|
|
@ -80,7 +82,7 @@ export function SkaterDirectoryCard() {
|
|||
</div>
|
||||
</div>
|
||||
<span className="flex items-center gap-0.5 text-xs font-bold uppercase tracking-wider text-[#ff0040] flex-shrink-0">
|
||||
Edit
|
||||
{t('common.edit')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
|||
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
||||
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
|
||||
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
// qrcode imported lazily in async function
|
||||
|
||||
interface SpamFeePaymentProps {
|
||||
|
|
@ -27,6 +28,7 @@ export function SpamFeePayment({
|
|||
onPaymentSuccess,
|
||||
onPowInstead,
|
||||
}: SpamFeePaymentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { webln, activeNWC } = useWallet();
|
||||
const { sendPayment } = useNWC();
|
||||
|
|
@ -53,7 +55,7 @@ export function SpamFeePayment({
|
|||
const bolt11 = await getInvoiceFromLightningAddress({
|
||||
lightningAddress: feeRecipient,
|
||||
amountSats: LISTING_FEE_SATS,
|
||||
comment: 'SkateSpots listing fee',
|
||||
comment: t('marketUi.spamFee.invoiceComment'),
|
||||
});
|
||||
|
||||
// SECURITY: verify the returned invoice really requests the listing fee
|
||||
|
|
@ -82,7 +84,7 @@ export function SpamFeePayment({
|
|||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate invoice:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate invoice');
|
||||
setError(err instanceof Error ? err.message : t('marketUi.spamFee.generateFailed'));
|
||||
setPaymentState('error');
|
||||
}
|
||||
};
|
||||
|
|
@ -98,8 +100,8 @@ export function SpamFeePayment({
|
|||
await sendPayment(activeNWC, invoice);
|
||||
setPaymentState('success');
|
||||
toast({
|
||||
title: 'Payment successful!',
|
||||
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`,
|
||||
title: t('marketUi.spamFee.paymentSuccess'),
|
||||
description: t('marketUi.spamFee.satsSent', { amount: LISTING_FEE_SATS.toLocaleString(), name: feeRecipientName }),
|
||||
});
|
||||
setTimeout(onPaymentSuccess, 1500);
|
||||
return;
|
||||
|
|
@ -113,8 +115,8 @@ export function SpamFeePayment({
|
|||
await webln.sendPayment(invoice);
|
||||
setPaymentState('success');
|
||||
toast({
|
||||
title: 'Payment successful!',
|
||||
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`,
|
||||
title: t('marketUi.spamFee.paymentSuccess'),
|
||||
description: t('marketUi.spamFee.satsSent', { amount: LISTING_FEE_SATS.toLocaleString(), name: feeRecipientName }),
|
||||
});
|
||||
setTimeout(onPaymentSuccess, 1500);
|
||||
return;
|
||||
|
|
@ -127,8 +129,8 @@ export function SpamFeePayment({
|
|||
// Fall back to manual payment
|
||||
setPaymentState('waiting');
|
||||
toast({
|
||||
title: 'Auto-pay failed',
|
||||
description: 'Please pay manually using the QR code or invoice',
|
||||
title: t('marketUi.spamFee.autoPayFailed'),
|
||||
description: t('marketUi.spamFee.autoPayFailedBody'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -138,9 +140,9 @@ export function SpamFeePayment({
|
|||
if (!invoice) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
toast({ title: 'Invoice copied!' });
|
||||
toast({ title: t('marketUi.spamFee.invoiceCopied') });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.spamFee.copyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -155,9 +157,9 @@ export function SpamFeePayment({
|
|||
<Card className="bg-green-950/30 border-2 border-green-500/50">
|
||||
<CardContent className="py-8 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
|
||||
<h3 className="font-bold text-green-400 text-lg mb-2">Payment Received!</h3>
|
||||
<h3 className="font-bold text-green-400 text-lg mb-2">{t('marketUi.spamFee.received')}</h3>
|
||||
<p className="text-gray-300">
|
||||
Your {LISTING_FEE_SATS} sat fee has been paid. You can now create your listing.
|
||||
{t('marketUi.spamFee.receivedBody', { amount: LISTING_FEE_SATS.toLocaleString() })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -170,36 +172,36 @@ export function SpamFeePayment({
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-yellow-400">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
One-Time Listing Fee
|
||||
{t('marketUi.spamFee.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-300">
|
||||
As a new member, a small fee is required for your first listing.
|
||||
{t('marketUi.spamFee.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Why this fee exists */}
|
||||
<div className="bg-black/30 rounded-lg p-4 space-y-3">
|
||||
<h4 className="font-bold text-white text-sm">Why do I need to pay?</h4>
|
||||
<h4 className="font-bold text-white text-sm">{t('marketUi.spamFee.whyTitle')}</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-gray-300">
|
||||
<span className="text-white font-medium">Spam prevention:</span> This fee stops bots and scammers from flooding the marketplace with fake listings.
|
||||
<span className="text-white font-medium">{t('marketUi.spamFee.spamLabel')}</span> {t('marketUi.spamFee.spamBody')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-gray-300">
|
||||
<span className="text-white font-medium">Trust building:</span> New accounts need to prove commitment before listing. After a few successful listings, this fee is waived.
|
||||
<span className="text-white font-medium">{t('marketUi.spamFee.trustLabel')}</span> {t('marketUi.spamFee.trustBody')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="h-4 w-4 text-[#ff6600] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-gray-300">
|
||||
<span className="text-white font-medium">Supports the community:</span> Fee goes to{' '}
|
||||
<span className="text-white font-medium">{t('marketUi.spamFee.supportsLabel')}</span> {t('marketUi.spamFee.supportsPrefix')}{' '}
|
||||
<span className="font-semibold text-yellow-400">{feeRecipientName}</span>
|
||||
{feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && ' — the person who created this spot!'}
|
||||
{feeRecipient === PLATFORM_LIGHTNING_ADDRESS && ' — helping keep SkateSpots running.'}
|
||||
{feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && t('marketUi.spamFee.supportsSpotOwner')}
|
||||
{feeRecipient === PLATFORM_LIGHTNING_ADDRESS && t('marketUi.spamFee.supportsPlatform')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -208,9 +210,9 @@ export function SpamFeePayment({
|
|||
{/* Fee amount */}
|
||||
<div className="text-center py-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<div className="text-3xl font-black text-yellow-400">
|
||||
⚡ {LISTING_FEE_SATS.toLocaleString()} sats
|
||||
⚡ {LISTING_FEE_SATS.toLocaleString()} {t('common.sats')}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">~${(LISTING_FEE_SATS * 0.0006).toFixed(2)} USD • One-time fee</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('marketUi.spamFee.oneTimeFee', { usd: (LISTING_FEE_SATS * 0.0006).toFixed(2) })}</p>
|
||||
</div>
|
||||
|
||||
{/* Initial state — pay, or prove work instead (free) */}
|
||||
|
|
@ -221,21 +223,20 @@ export function SpamFeePayment({
|
|||
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Pay {LISTING_FEE_SATS} sats to Continue
|
||||
{t('marketUi.spamFee.payToContinue', { amount: LISTING_FEE_SATS.toLocaleString() })}
|
||||
</Button>
|
||||
{onPowInstead && (
|
||||
<>
|
||||
<p className="text-center text-xs uppercase tracking-widest text-gray-500">or</p>
|
||||
<p className="text-center text-xs uppercase tracking-widest text-gray-500">{t('common.or')}</p>
|
||||
<Button
|
||||
onClick={onPowInstead}
|
||||
variant="outline"
|
||||
className="w-full font-bold border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
||||
>
|
||||
⚡ Prove work instead — free
|
||||
⚡ {t('marketUi.spamFee.proveWork')}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
Spend a few seconds of CPU instead of {LISTING_FEE_SATS.toLocaleString()} sats. No
|
||||
money, no wallet — a spam tax, not a payment.
|
||||
{t('marketUi.spamFee.proveWorkBody', { amount: LISTING_FEE_SATS.toLocaleString() })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -246,7 +247,7 @@ export function SpamFeePayment({
|
|||
{paymentState === 'generating' && (
|
||||
<div className="text-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" />
|
||||
<p className="text-gray-400 mt-2">Generating invoice...</p>
|
||||
<p className="text-gray-400 mt-2">{t('marketUi.spamFee.generating')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -254,7 +255,7 @@ export function SpamFeePayment({
|
|||
{paymentState === 'paying' && (
|
||||
<div className="text-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" />
|
||||
<p className="text-gray-400 mt-2">Processing payment...</p>
|
||||
<p className="text-gray-400 mt-2">{t('marketUi.spamFee.processing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -264,7 +265,7 @@ export function SpamFeePayment({
|
|||
{/* QR Code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<img src={qrCodeUrl} alt="Payment QR Code" className="w-48 h-48" />
|
||||
<img src={qrCodeUrl} alt={t('marketUi.spamFee.qrAlt')} className="w-48 h-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -276,7 +277,7 @@ export function SpamFeePayment({
|
|||
className="border-yellow-500/50 hover:bg-yellow-500/10"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Invoice
|
||||
{t('marketUi.spamFee.copyInvoice')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -284,7 +285,7 @@ export function SpamFeePayment({
|
|||
className="border-yellow-500/50 hover:bg-yellow-500/10"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Wallet
|
||||
{t('marketUi.spamFee.openWallet')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -295,12 +296,12 @@ export function SpamFeePayment({
|
|||
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Pay with Connected Wallet
|
||||
{t('marketUi.spamFee.payWithWallet')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Scan the QR code with a Lightning wallet or use the buttons above
|
||||
{t('marketUi.spamFee.scanHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -316,7 +317,7 @@ export function SpamFeePayment({
|
|||
variant="outline"
|
||||
className="w-full border-yellow-500/50 hover:bg-yellow-500/10"
|
||||
>
|
||||
Try Again
|
||||
{t('common.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -324,24 +325,24 @@ export function SpamFeePayment({
|
|||
{/* How to avoid future fees */}
|
||||
<div className="bg-green-500/10 rounded-lg p-3 border border-green-500/30">
|
||||
<p className="text-green-400 font-semibold text-sm mb-2">
|
||||
✨ How to skip this fee in the future:
|
||||
✨ {t('marketUi.spamFee.skipTitle')}
|
||||
</p>
|
||||
<ul className="text-gray-300 text-sm space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-green-400">→</span>
|
||||
Add a skate spot to the map
|
||||
{t('marketUi.spamFee.skip1')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-green-400">→</span>
|
||||
Complete 2-3 successful listings
|
||||
{t('marketUi.spamFee.skip2')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="text-green-400">→</span>
|
||||
Build your community reputation
|
||||
{t('marketUi.spamFee.skip3')}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Trusted members get unlimited free listings!
|
||||
{t('marketUi.spamFee.skipNote')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { SpotStatusBadge } from '@/components/SpotStatusBadge';
|
|||
import type { SkateSpot } from '@/lib/types';
|
||||
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types';
|
||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SpotCardProps {
|
||||
|
|
@ -33,6 +34,7 @@ interface SpotCardProps {
|
|||
}
|
||||
|
||||
export function SpotCard({ spot, status }: SpotCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(spot.pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -89,7 +91,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
|||
<div className="absolute top-3 left-3 flex flex-col gap-1">
|
||||
{isShop ? (
|
||||
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs">
|
||||
🏪 SKATE SHOP
|
||||
🏪 {t('mapUi.skateShop')}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -217,7 +219,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
{t('mapUi.report')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
|||
import { NoteContent } from '@/components/NoteContent';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
|
|
@ -125,6 +126,7 @@ export function SpotChat({
|
|||
defaultPrecision = 'block',
|
||||
className,
|
||||
}: SpotChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const [precision, setPrecision] = useState<ChatPrecision>(defaultPrecision);
|
||||
|
|
@ -181,11 +183,14 @@ export function SpotChat({
|
|||
if (text === '/who' || text.startsWith('/who ')) {
|
||||
const recent = new Set((rawMessages ?? []).map((m) => m.pubkey));
|
||||
toast({
|
||||
title: `${recent.size} skater${recent.size === 1 ? '' : 's'} active here`,
|
||||
title:
|
||||
recent.size === 1
|
||||
? t('spotUi.skatersActiveOne', { count: recent.size })
|
||||
: t('spotUi.skatersActiveOther', { count: recent.size }),
|
||||
description:
|
||||
recent.size > 0
|
||||
? 'People who posted in this channel in the last 24h.'
|
||||
: 'Be the first to say something.',
|
||||
? t('spotUi.skatersActiveDesc')
|
||||
: t('spotUi.beFirstToSay'),
|
||||
});
|
||||
setDraft('');
|
||||
return;
|
||||
|
|
@ -196,8 +201,8 @@ export function SpotChat({
|
|||
setDraft('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Message failed',
|
||||
description: err instanceof Error ? err.message : 'Could not send message.',
|
||||
title: t('spotUi.messageFailed'),
|
||||
description: err instanceof Error ? err.message : t('spotUi.couldNotSendMessage'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -209,14 +214,14 @@ export function SpotChat({
|
|||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
|
||||
<CardTitle className="text-lg flex flex-wrap items-center gap-2 min-w-0">
|
||||
<MessageCircle className="h-5 w-5 text-orange-500" />
|
||||
Spot Chat
|
||||
{t('spotUi.spotChat')}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] font-bold uppercase tracking-wide"
|
||||
title="Anyone can read messages in this channel — it is public and unencrypted."
|
||||
title={t('spotUi.publicChatTitle')}
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
Public
|
||||
{t('spotUi.public')}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
@ -253,7 +258,7 @@ export function SpotChat({
|
|||
>
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading chat…
|
||||
{t('spotUi.loadingChat')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!messages || messages.length === 0) && (
|
||||
|
|
@ -261,12 +266,13 @@ export function SpotChat({
|
|||
<MessageCircle className="h-8 w-8 text-muted-foreground/40" />
|
||||
{hiddenCount > 0 ? (
|
||||
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||
{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter.
|
||||
Lower the filter to see more.
|
||||
{hiddenCount === 1
|
||||
? t('spotUi.messagesHiddenOne', { count: hiddenCount })
|
||||
: t('spotUi.messagesHiddenOther', { count: hiddenCount })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||
No messages yet in this local channel. Say hi — "anyone skating tonight?"
|
||||
{t('spotUi.noMessagesYet')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -276,7 +282,9 @@ export function SpotChat({
|
|||
))}
|
||||
{!isLoading && messages && messages.length > 0 && hiddenCount > 0 && (
|
||||
<div className="pt-2 text-center text-[11px] text-muted-foreground">
|
||||
{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter
|
||||
{hiddenCount === 1
|
||||
? t('spotUi.messagesHiddenShortOne', { count: hiddenCount })
|
||||
: t('spotUi.messagesHiddenShortOther', { count: hiddenCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -288,7 +296,7 @@ export function SpotChat({
|
|||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Message this spot… (try /me or /who)"
|
||||
placeholder={t('spotUi.messagePlaceholder')}
|
||||
maxLength={500}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
|
@ -303,13 +311,12 @@ export function SpotChat({
|
|||
</form>
|
||||
<p className="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Info className="h-3 w-3 shrink-0" />
|
||||
Anyone can read this — messages are public & ephemeral. For private
|
||||
chats, use Direct Messages.
|
||||
{t('spotUi.chatPublicNote')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-1 text-center">
|
||||
<p className="text-sm text-muted-foreground">Log in to join the local chat.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('spotUi.logInToChat')}</p>
|
||||
<LoginArea className="max-w-60" />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
|||
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** Translation function shape, so module-level popup builders can receive `t`. */
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
|
||||
|
|
@ -526,7 +530,8 @@ interface SpotMapProps {
|
|||
function createPopupContent(
|
||||
spot: SkateSpot,
|
||||
onViewDetails: () => void,
|
||||
weatherData?: SpotWeatherData
|
||||
weatherData: SpotWeatherData | undefined,
|
||||
t: Translate,
|
||||
): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'p-2 min-w-[200px]';
|
||||
|
|
@ -581,9 +586,9 @@ function createPopupContent(
|
|||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
|
||||
Conditions · ${escapeHtml(weatherData.skateability.label)}
|
||||
${escapeHtml(t('mapUi.conditions'))} · ${escapeHtml(weatherData.skateability.label)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100 weather</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(t('mapUi.weatherScoreSuffix', { score: weatherData.skateability.score }))}</div>
|
||||
</div>
|
||||
`;
|
||||
weatherSection.appendChild(currentWeather);
|
||||
|
|
@ -592,9 +597,9 @@ function createPopupContent(
|
|||
const details = document.createElement('div');
|
||||
details.className = 'text-xs space-y-1 text-gray-600';
|
||||
details.innerHTML = `
|
||||
<div>💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
|
||||
<div>💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%</div>
|
||||
${weatherData.airQuality ? `<div>🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
|
||||
<div>💨 ${escapeHtml(t('mapUi.wind'))}: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
|
||||
<div>💧 ${escapeHtml(t('mapUi.humidity'))}: ${escapeHtml(weatherData.weather.humidity)}%</div>
|
||||
${weatherData.airQuality ? `<div>🌫️ ${escapeHtml(t('mapUi.airQuality'))}: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
|
||||
`;
|
||||
weatherSection.appendChild(details);
|
||||
|
||||
|
|
@ -605,7 +610,7 @@ function createPopupContent(
|
|||
|
||||
const forecastTitle = document.createElement('div');
|
||||
forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700';
|
||||
forecastTitle.textContent = '3-Day Forecast';
|
||||
forecastTitle.textContent = t('mapUi.threeDayForecast');
|
||||
forecastDiv.appendChild(forecastTitle);
|
||||
|
||||
const forecastGrid = document.createElement('div');
|
||||
|
|
@ -648,7 +653,7 @@ function createPopupContent(
|
|||
// Button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'w-full inline-flex items-center justify-center rounded-md text-sm font-medium bg-orange-500 text-white hover:bg-orange-600 h-8 px-3 transition-colors';
|
||||
button.textContent = 'View Details';
|
||||
button.textContent = t('mapUi.viewDetails');
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
onViewDetails();
|
||||
|
|
@ -662,7 +667,8 @@ function createPopupContent(
|
|||
function createEnhancedPopupContent(
|
||||
spot: SkateSpot,
|
||||
marketplaceItems: MarketplaceItem[],
|
||||
onViewDetails: () => void
|
||||
onViewDetails: () => void,
|
||||
t: Translate,
|
||||
): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'p-2 min-w-[250px] max-w-[350px]';
|
||||
|
|
@ -682,11 +688,11 @@ function createEnhancedPopupContent(
|
|||
|
||||
const isShop = spot.types.includes('shop');
|
||||
const spotIcon = isShop ? '🏪' : '🛹';
|
||||
const spotLabel = isShop ? 'SKATE SHOP' : 'SKATE SPOT';
|
||||
const spotLabel = isShop ? t('mapUi.skateShop') : t('mapUi.skateSpot');
|
||||
|
||||
const spotTitle = document.createElement('div');
|
||||
spotTitle.className = 'font-bold text-sm text-gray-200 mb-2';
|
||||
spotTitle.textContent = `${spotIcon} ${spotLabel}`;
|
||||
spotTitle.textContent = `${spotIcon} ${spotLabel.toUpperCase()}`;
|
||||
spotSection.appendChild(spotTitle);
|
||||
|
||||
// Spot types
|
||||
|
|
@ -705,7 +711,7 @@ function createEnhancedPopupContent(
|
|||
// View spot button
|
||||
const spotButton = document.createElement('button');
|
||||
spotButton.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-red-500 text-white hover:bg-red-600 h-7 px-3 transition-colors mt-2';
|
||||
spotButton.textContent = 'View Details →';
|
||||
spotButton.textContent = t('mapUi.viewDetailsArrow');
|
||||
spotButton.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
onViewDetails();
|
||||
|
|
@ -724,7 +730,7 @@ function createEnhancedPopupContent(
|
|||
|
||||
const gearTitle = document.createElement('div');
|
||||
gearTitle.className = 'font-bold text-sm text-orange-400 mb-2';
|
||||
gearTitle.textContent = `🛒 GEAR FOR SALE (${marketplaceItems.length})`;
|
||||
gearTitle.textContent = `🛒 ${t('mapUi.gearForSale', { count: marketplaceItems.length }).toUpperCase()}`;
|
||||
gearSection.appendChild(gearTitle);
|
||||
|
||||
// Show up to 3 items
|
||||
|
|
@ -746,7 +752,7 @@ function createEnhancedPopupContent(
|
|||
// View all gear button
|
||||
const gearButton = document.createElement('button');
|
||||
gearButton.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-orange-500 text-white hover:bg-orange-600 h-7 px-3 transition-colors';
|
||||
gearButton.textContent = marketplaceItems.length > 3 ? `View All ${marketplaceItems.length} Items →` : 'View Gear →';
|
||||
gearButton.textContent = marketplaceItems.length > 3 ? t('mapUi.viewAllItems', { count: marketplaceItems.length }) : t('mapUi.viewGear');
|
||||
gearButton.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
onViewDetails(); // Goes to spot page which has gear section
|
||||
|
|
@ -786,6 +792,7 @@ export function SpotMap({
|
|||
flyTo = null,
|
||||
userLocation = null,
|
||||
}: SpotMapProps) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const markersRef = useRef<L.Marker[]>([]);
|
||||
|
|
@ -1043,9 +1050,9 @@ export function SpotMap({
|
|||
keyboard: false,
|
||||
})
|
||||
.addTo(map)
|
||||
.bindPopup('<b>You are here</b>');
|
||||
.bindPopup(`<b>${escapeHtml(t('mapUi.youAreHere'))}</b>`);
|
||||
}
|
||||
}, [userLocation]);
|
||||
}, [userLocation, t]);
|
||||
|
||||
// Update markers
|
||||
useEffect(() => {
|
||||
|
|
@ -1161,8 +1168,8 @@ export function SpotMap({
|
|||
|
||||
// Use enhanced popup if there are marketplace items, otherwise use standard
|
||||
const popupContent = hasGear
|
||||
? createEnhancedPopupContent(primarySpot, group.marketplaceItems, () => handleViewDetails(primarySpot))
|
||||
: createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather);
|
||||
? createEnhancedPopupContent(primarySpot, group.marketplaceItems, () => handleViewDetails(primarySpot), t)
|
||||
: createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather, t);
|
||||
|
||||
marker.bindPopup(popupContent, {
|
||||
maxWidth: 350,
|
||||
|
|
@ -1180,7 +1187,7 @@ export function SpotMap({
|
|||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'font-bold text-sm text-orange-400 mb-2';
|
||||
title.textContent = `🛒 GEAR FOR SALE (${gearCount})`;
|
||||
title.textContent = `🛒 ${t('mapUi.gearForSale', { count: gearCount }).toUpperCase()}`;
|
||||
container.appendChild(title);
|
||||
|
||||
const itemsList = document.createElement('div');
|
||||
|
|
@ -1198,7 +1205,7 @@ export function SpotMap({
|
|||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-medium bg-orange-500 text-white hover:bg-orange-600 h-7 px-3 transition-colors';
|
||||
button.textContent = 'View Items →';
|
||||
button.textContent = t('mapUi.viewItems');
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
// Navigate to marketplace or first item's linked spot
|
||||
|
|
@ -1261,7 +1268,7 @@ export function SpotMap({
|
|||
tourTitle.textContent = tour.title;
|
||||
const tourKicker = document.createElement('p');
|
||||
tourKicker.className = 'text-xs text-cyan-400 font-medium';
|
||||
tourKicker.textContent = 'LOCAL TOUR';
|
||||
tourKicker.textContent = t('mapUi.localTour').toUpperCase();
|
||||
tourHeadText.appendChild(tourTitle);
|
||||
tourHeadText.appendChild(tourKicker);
|
||||
header.appendChild(tourIconBox);
|
||||
|
|
@ -1276,17 +1283,17 @@ export function SpotMap({
|
|||
details.appendChild(popupRow('📍', tour.location));
|
||||
}
|
||||
if (tour.meetingPoint) {
|
||||
details.appendChild(popupRow('🚩', `Meet at: ${tour.meetingPoint}`));
|
||||
details.appendChild(popupRow('🚩', t('mapUi.meetAt', { place: tour.meetingPoint })));
|
||||
}
|
||||
if (tour.duration) {
|
||||
details.appendChild(popupRow('⏱️', `${tour.duration} hours`));
|
||||
details.appendChild(popupRow('⏱️', t('mapUi.hours', { count: tour.duration })));
|
||||
}
|
||||
if (tour.skillLevel) {
|
||||
const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹';
|
||||
details.appendChild(popupRow(skillEmoji, `${tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1)} level`));
|
||||
details.appendChild(popupRow(skillEmoji, t('mapUi.level', { level: tour.skillLevel.charAt(0).toUpperCase() + tour.skillLevel.slice(1) })));
|
||||
}
|
||||
if (tour.price) {
|
||||
details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} sats`));
|
||||
details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} ${t('common.sats')}`));
|
||||
}
|
||||
container.appendChild(details);
|
||||
|
||||
|
|
@ -1306,7 +1313,7 @@ export function SpotMap({
|
|||
// View button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-cyan-500 text-white hover:bg-cyan-600 h-8 px-3 transition-colors';
|
||||
button.textContent = 'View Tour →';
|
||||
button.textContent = t('mapUi.viewTour');
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/tour/${tour.pubkey}/${tour.identifier}`);
|
||||
|
|
@ -1353,7 +1360,7 @@ export function SpotMap({
|
|||
evtTitle.textContent = evt.title;
|
||||
const evtKicker = document.createElement('p');
|
||||
evtKicker.className = 'text-xs text-[#a3cc00] font-medium';
|
||||
evtKicker.textContent = 'UPCOMING EVENT';
|
||||
evtKicker.textContent = t('mapUi.upcomingEvent').toUpperCase();
|
||||
evtHeadText.appendChild(evtTitle);
|
||||
evtHeadText.appendChild(evtKicker);
|
||||
header.appendChild(evtIconBox);
|
||||
|
|
@ -1375,7 +1382,7 @@ export function SpotMap({
|
|||
// View button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 h-8 px-3 transition-colors';
|
||||
button.textContent = 'View Event →';
|
||||
button.textContent = t('mapUi.viewEvent');
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/event/${evt.pubkey}/${evt.identifier}`);
|
||||
|
|
@ -1438,7 +1445,7 @@ export function SpotMap({
|
|||
crewName.textContent = crew.name;
|
||||
const crewKicker = document.createElement('p');
|
||||
crewKicker.className = 'text-xs text-teal-400 font-medium';
|
||||
crewKicker.textContent = 'SKATE CREW';
|
||||
crewKicker.textContent = t('mapUi.skateCrew').toUpperCase();
|
||||
crewHeadText.appendChild(crewName);
|
||||
crewHeadText.appendChild(crewKicker);
|
||||
header.appendChild(crewIconBox);
|
||||
|
|
@ -1452,10 +1459,10 @@ export function SpotMap({
|
|||
details.appendChild(popupRow('📍', crew.location));
|
||||
}
|
||||
if (crew.members.length > 0) {
|
||||
details.appendChild(popupRow('👥', `${crew.members.length} member${crew.members.length === 1 ? '' : 's'}`));
|
||||
details.appendChild(popupRow('👥', t('mapUi.members', { count: crew.members.length })));
|
||||
}
|
||||
if (crew.founded) {
|
||||
details.appendChild(popupRow('📅', `Since ${crew.founded}`));
|
||||
details.appendChild(popupRow('📅', t('mapUi.since', { year: crew.founded })));
|
||||
}
|
||||
container.appendChild(details);
|
||||
|
||||
|
|
@ -1475,7 +1482,7 @@ export function SpotMap({
|
|||
// View button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-teal-500 text-white hover:bg-teal-600 h-8 px-3 transition-colors';
|
||||
button.textContent = 'View Crew →';
|
||||
button.textContent = t('mapUi.viewCrew');
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/crew/${crew.pubkey}/${crew.identifier}`);
|
||||
|
|
@ -1522,7 +1529,7 @@ export function SpotMap({
|
|||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}
|
||||
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, showSpots, showShops, showGear, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
|
||||
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, showSpots, showShops, showGear, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate, t]);
|
||||
|
||||
// Update BTC Map markers
|
||||
useEffect(() => {
|
||||
|
|
@ -1561,7 +1568,7 @@ export function SpotMap({
|
|||
btcName.textContent = place.name;
|
||||
const btcKicker = document.createElement('p');
|
||||
btcKicker.className = 'text-[10px] text-[#f7931a] font-medium uppercase';
|
||||
btcKicker.textContent = 'Accepts Bitcoin';
|
||||
btcKicker.textContent = t('mapUi.acceptsBitcoin');
|
||||
btcHeadText.appendChild(btcName);
|
||||
btcHeadText.appendChild(btcKicker);
|
||||
header.appendChild(btcIconBox);
|
||||
|
|
@ -1572,10 +1579,10 @@ export function SpotMap({
|
|||
const badges = document.createElement('div');
|
||||
badges.className = 'flex flex-wrap gap-1 mb-2';
|
||||
if (boosted) {
|
||||
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-yellow-500/20 text-yellow-300 border border-yellow-500/30">⭐ Boosted</span>`;
|
||||
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-yellow-500/20 text-yellow-300 border border-yellow-500/30">⭐ ${escapeHtml(t('mapUi.boosted'))}</span>`;
|
||||
}
|
||||
if (recentlyVerified) {
|
||||
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-green-500/20 text-green-300 border border-green-500/30">✓ Verified</span>`;
|
||||
badges.innerHTML += `<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-green-500/20 text-green-300 border border-green-500/30">✓ ${escapeHtml(t('common.verified'))}</span>`;
|
||||
}
|
||||
if (badges.innerHTML) {
|
||||
container.appendChild(badges);
|
||||
|
|
@ -1625,7 +1632,7 @@ export function SpotMap({
|
|||
websiteBtn.target = '_blank';
|
||||
websiteBtn.rel = 'noopener noreferrer';
|
||||
websiteBtn.className = 'flex-1 inline-flex items-center justify-center rounded-md text-xs font-medium bg-[#f7931a] text-white hover:bg-[#f7931a]/90 h-7 px-2 transition-colors';
|
||||
websiteBtn.textContent = 'Website';
|
||||
websiteBtn.textContent = t('mapUi.website');
|
||||
actions.appendChild(websiteBtn);
|
||||
}
|
||||
|
||||
|
|
@ -1647,7 +1654,7 @@ export function SpotMap({
|
|||
marker.addTo(map);
|
||||
btcMapMarkersRef.current.push(marker);
|
||||
});
|
||||
}, [btcMapPlaces, showBtcMap]);
|
||||
}, [btcMapPlaces, showBtcMap, t]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
// 6 months in seconds
|
||||
|
|
@ -42,6 +43,7 @@ interface SpotOwnershipProps {
|
|||
* 2. Claim abandoned spot (any user can claim if owner inactive 6+ months)
|
||||
*/
|
||||
export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -67,16 +69,16 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(months / 12);
|
||||
|
||||
if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`;
|
||||
if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`;
|
||||
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
}, [spot.createdAt]);
|
||||
if (years > 0) return years > 1 ? t('spotUi.yearsAgoPlural', { count: years }) : t('spotUi.yearsAgo', { count: years });
|
||||
if (months > 0) return months > 1 ? t('spotUi.monthsAgoPlural', { count: months }) : t('spotUi.monthsAgo', { count: months });
|
||||
return days > 1 ? t('spotUi.daysAgoPlural', { count: days }) : t('spotUi.daysAgo', { count: days });
|
||||
}, [spot.createdAt, t]);
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!recipientNpub.trim()) {
|
||||
toast({
|
||||
title: 'Recipient required',
|
||||
description: 'Please enter the npub of the new owner.',
|
||||
title: t('spotUi.recipientRequired'),
|
||||
description: t('spotUi.recipientRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -92,8 +94,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
recipientPubkey = decoded.data;
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Invalid npub',
|
||||
description: 'Please enter a valid npub address.',
|
||||
title: t('spotUi.invalidNpub'),
|
||||
description: t('spotUi.invalidNpubDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -101,8 +103,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
|
||||
if (recipientPubkey === spot.pubkey) {
|
||||
toast({
|
||||
title: 'Same owner',
|
||||
description: 'Cannot transfer to the current owner.',
|
||||
title: t('spotUi.sameOwner'),
|
||||
description: t('spotUi.sameOwnerDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -112,7 +114,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
// Publish a transfer event (kind 1111 comment with transfer marker)
|
||||
await publishEvent({
|
||||
kind: 1111,
|
||||
content: `Ownership transfer request for spot "${spot.title}"`,
|
||||
content: t('spotUi.transferRequestContent', { title: spot.title }),
|
||||
tags: [
|
||||
['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`],
|
||||
['K', String(SKATE_SPOT_KIND)],
|
||||
|
|
@ -122,8 +124,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Transfer requested',
|
||||
description: 'The new owner will need to claim the spot to complete the transfer.',
|
||||
title: t('spotUi.transferRequested'),
|
||||
description: t('spotUi.transferRequestedDesc'),
|
||||
});
|
||||
|
||||
setTransferOpen(false);
|
||||
|
|
@ -131,8 +133,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Transfer failed',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('spotUi.transferFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -165,8 +167,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Spot claimed!',
|
||||
description: 'You are now the owner of this spot. You can edit it from your profile.',
|
||||
title: t('spotUi.spotClaimed'),
|
||||
description: t('spotUi.spotClaimedDesc'),
|
||||
});
|
||||
|
||||
setClaimOpen(false);
|
||||
|
|
@ -174,8 +176,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Claim failed',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('spotUi.claimFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -194,30 +196,30 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Transfer Ownership
|
||||
{t('spotUi.transferOwnership')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5 text-[#ff0040]" />
|
||||
Transfer Spot Ownership
|
||||
{t('spotUi.transferSpotOwnership')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Transfer "{spot.title}" to another user. This action cannot be undone.
|
||||
{t('spotUi.transferDesc', { title: spot.title })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">New Owner's npub</label>
|
||||
<label className="text-sm font-medium">{t('spotUi.newOwnerNpub')}</label>
|
||||
<Input
|
||||
placeholder="npub1..."
|
||||
value={recipientNpub}
|
||||
onChange={(e) => setRecipientNpub(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the npub of the user you want to transfer this spot to.
|
||||
{t('spotUi.newOwnerNpubHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -226,9 +228,9 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
<div className="flex gap-2 text-yellow-500">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Warning</p>
|
||||
<p className="font-medium">{t('spotUi.warning')}</p>
|
||||
<p className="text-yellow-400/80">
|
||||
After transfer, you will no longer be able to edit or delete this spot.
|
||||
{t('spotUi.transferWarningDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -237,7 +239,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setTransferOpen(false)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTransfer}
|
||||
|
|
@ -247,10 +249,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Transferring...
|
||||
{t('spotUi.transferring')}
|
||||
</>
|
||||
) : (
|
||||
'Transfer Ownership'
|
||||
t('spotUi.transferOwnership')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -267,11 +269,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
<Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
|
||||
Spot May Be Abandoned
|
||||
{t('spotUi.spotMayBeAbandoned')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Last updated {lastUpdatedText}. If the original owner is inactive,
|
||||
you can claim this spot to maintain and improve it.
|
||||
{t('spotUi.abandonedDesc', { time: lastUpdatedText })}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -279,7 +280,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
onClick={() => setClaimOpen(true)}
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
Claim This Spot
|
||||
{t('spotUi.claimThisSpot')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -295,10 +296,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
<Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
|
||||
Spot May Be Abandoned
|
||||
{t('spotUi.spotMayBeAbandoned')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Last updated {lastUpdatedText}. Log in to claim and maintain this spot.
|
||||
{t('spotUi.abandonedLoginDesc', { time: lastUpdatedText })}
|
||||
</p>
|
||||
<LoginArea className="justify-start" />
|
||||
</div>
|
||||
|
|
@ -313,27 +314,26 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Crown className="h-5 w-5 text-orange-500" />
|
||||
Claim "{spot.title}"?
|
||||
{t('spotUi.claimTitle', { title: spot.title })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
You're about to claim ownership of this spot. This will create your own
|
||||
version of the spot that you can edit and maintain.
|
||||
{t('spotUi.claimIntro')}
|
||||
</p>
|
||||
<div className="bg-muted p-3 rounded-lg text-sm">
|
||||
<p className="font-medium mb-1">What happens when you claim:</p>
|
||||
<p className="font-medium mb-1">{t('spotUi.claimWhatHappens')}</p>
|
||||
<ul className="list-disc list-inside text-muted-foreground space-y-1">
|
||||
<li>You become the owner and can edit the spot</li>
|
||||
<li>The original spot remains (Nostr is decentralized)</li>
|
||||
<li>Your version may appear alongside the original</li>
|
||||
<li>{t('spotUi.claimBullet1')}</li>
|
||||
<li>{t('spotUi.claimBullet2')}</li>
|
||||
<li>{t('spotUi.claimBullet3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClaim}
|
||||
disabled={isPublishing}
|
||||
|
|
@ -342,10 +342,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Claiming...
|
||||
{t('spotUi.claiming')}
|
||||
</>
|
||||
) : (
|
||||
'Claim Spot'
|
||||
t('spotUi.claimSpot')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { MapPin, Search, X, Plus } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SpotPickerProps {
|
||||
|
|
@ -23,6 +24,7 @@ export function SpotPicker({
|
|||
label = 'Spots Where You\'re Available',
|
||||
description = 'Select spots where you offer your services',
|
||||
}: SpotPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: allSpots, isLoading: spotsLoading } = useSkateSpots(100);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
|
@ -112,7 +114,7 @@ export function SpotPicker({
|
|||
onClick={() => setIsExpanded(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Spot
|
||||
{t('mapUi.addSpot')}
|
||||
</Button>
|
||||
) : (
|
||||
<Card className="bg-gray-900/50 border-gray-700">
|
||||
|
|
@ -121,7 +123,7 @@ export function SpotPicker({
|
|||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="Search spots..."
|
||||
placeholder={t('mapUi.searchSpotsPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 pl-9 h-9"
|
||||
|
|
@ -170,7 +172,7 @@ export function SpotPicker({
|
|||
</div>
|
||||
{isSelected && (
|
||||
<Badge variant="outline" className="text-[10px] border-[#ff0040]/50 text-[#ff0040]">
|
||||
Selected
|
||||
{t('mapUi.selected')}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -178,11 +180,11 @@ export function SpotPicker({
|
|||
})}
|
||||
{spotsLoading ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
Loading spots…
|
||||
{t('mapUi.loadingSpots')}
|
||||
</p>
|
||||
) : filteredSpots.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
No spots found. Try a different search.
|
||||
{t('mapUi.noSpotsFound')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -194,7 +196,7 @@ export function SpotPicker({
|
|||
|
||||
{selectedSpots.length === 0 && (
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
Adding spots helps users find you when browsing specific locations
|
||||
{t('mapUi.addingSpotsHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { SPOT_STATUS_META, statusTimeAgo } from '@/lib/spotStatus';
|
||||
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import {
|
||||
|
|
@ -27,6 +28,7 @@ export function SpotStatusBadge({
|
|||
className,
|
||||
hideWhenEmpty = true,
|
||||
}: SpotStatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const status = aggregated?.status ?? null;
|
||||
|
||||
if (!status) {
|
||||
|
|
@ -39,7 +41,7 @@ export function SpotStatusBadge({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
Unverified
|
||||
{t('spotUi.unverified')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -76,8 +78,8 @@ export function SpotStatusBadge({
|
|||
<p className="font-medium">{meta.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{reporterCount} {reporterCount === 1 ? 'report' : 'reports'}
|
||||
{lastAt ? ` · last ${statusTimeAgo(lastAt)}` : ''}
|
||||
{reporterCount} {reporterCount === 1 ? t('spotUi.reportOne') : t('spotUi.reports')}
|
||||
{lastAt ? ` · ${t('spotUi.lastReported', { time: statusTimeAgo(lastAt) })}` : ''}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { DeckRating } from '@/components/DeckRating';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
interface TradeConfirmRowProps {
|
||||
tone: 'sale' | 'purchase';
|
||||
|
|
@ -33,6 +34,7 @@ export function TradeConfirmRow({
|
|||
actionLabel,
|
||||
onConfirm,
|
||||
}: TradeConfirmRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
const cardCls = tone === 'sale' ? 'border-[#ff6600]/30 bg-[#ff6600]/5' : 'border-[#ccff00]/30 bg-[#ccff00]/5';
|
||||
|
|
@ -47,7 +49,7 @@ export function TradeConfirmRow({
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
<Link to={listingUrl} className="text-xs text-gray-400 hover:text-white">
|
||||
View listing <ArrowRight className="inline h-3 w-3" />
|
||||
{t('marketUi.trades.viewListing')} <ArrowRight className="inline h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -61,7 +63,7 @@ export function TradeConfirmRow({
|
|||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-8">
|
||||
<span className="shrink-0 text-[11px] text-gray-500">Rate the trade:</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-500">{t('marketUi.confirmRow.rateTrade')}</span>
|
||||
<DeckRating value={rating} onChange={setRating} size="sm" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -5,18 +5,19 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
const TYPE_META: Record<
|
||||
TradeNotification['type'],
|
||||
{ icon: typeof Handshake; className: string; label: string; actionable: boolean }
|
||||
{ icon: typeof Handshake; className: string; labelKey: string; actionable: boolean }
|
||||
> = {
|
||||
offer: { icon: Handshake, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'New offer', actionable: true },
|
||||
counter: { icon: ArrowLeftRight, className: 'bg-orange-500/15 text-orange-400', label: 'Counter-offer', actionable: true },
|
||||
accepted: { icon: CheckCircle2, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'Offer accepted', actionable: false },
|
||||
declined: { icon: XCircle, className: 'bg-red-500/15 text-red-400', label: 'Offer declined', actionable: false },
|
||||
withdrawn: { icon: Undo2, className: 'bg-gray-700/40 text-gray-400', label: 'Offer withdrawn', actionable: false },
|
||||
'trade-request': { icon: BadgeCheck, className: 'bg-teal-500/15 text-teal-300', label: 'Confirm a trade', actionable: true },
|
||||
'trade-confirm': { icon: BadgeCheck, className: 'bg-[#ccff00]/15 text-[#ccff00]', label: 'Trade confirmed', actionable: true },
|
||||
offer: { icon: Handshake, className: 'bg-[#ccff00]/15 text-[#ccff00]', labelKey: 'marketUi.notification.newOffer', actionable: true },
|
||||
counter: { icon: ArrowLeftRight, className: 'bg-orange-500/15 text-orange-400', labelKey: 'marketUi.notification.counterOffer', actionable: true },
|
||||
accepted: { icon: CheckCircle2, className: 'bg-[#ccff00]/15 text-[#ccff00]', labelKey: 'marketUi.notification.offerAccepted', actionable: false },
|
||||
declined: { icon: XCircle, className: 'bg-red-500/15 text-red-400', labelKey: 'marketUi.notification.offerDeclined', actionable: false },
|
||||
withdrawn: { icon: Undo2, className: 'bg-gray-700/40 text-gray-400', labelKey: 'marketUi.notification.offerWithdrawn', actionable: false },
|
||||
'trade-request': { icon: BadgeCheck, className: 'bg-teal-500/15 text-teal-300', labelKey: 'marketUi.notification.confirmTrade', actionable: true },
|
||||
'trade-confirm': { icon: BadgeCheck, className: 'bg-[#ccff00]/15 text-[#ccff00]', labelKey: 'marketUi.notification.tradeConfirmed', actionable: true },
|
||||
};
|
||||
|
||||
/** A buying/selling step in the notification bell. */
|
||||
|
|
@ -29,6 +30,7 @@ export function TradeNotificationCard({
|
|||
unread: boolean;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(notification.counterparty);
|
||||
const name = author.data?.metadata?.name ?? genUserName(notification.counterparty);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
|
|
@ -49,7 +51,7 @@ export function TradeNotificationCard({
|
|||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{meta.label}</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{t(meta.labelKey)}</p>
|
||||
<p className="flex items-center gap-1.5 text-sm leading-snug text-gray-200">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarImage src={pic} alt={name} />
|
||||
|
|
@ -59,7 +61,7 @@ export function TradeNotificationCard({
|
|||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">{tradeNotificationText(notification)}</p>
|
||||
<p className="mt-1 text-[10px] font-bold uppercase tracking-wide text-[#ccff00]">
|
||||
{meta.actionable ? 'Review the offer' : 'Open listing'} →
|
||||
{meta.actionable ? t('marketUi.notification.reviewOffer') : t('marketUi.notification.openListing')} →
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@/hooks/useTraderRecommendations';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function Recommender({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
|
|
@ -36,6 +37,7 @@ function Recommender({ pubkey }: { pubkey: string }) {
|
|||
* for this trader, and lets a logged-in viewer add their own recommendation.
|
||||
*/
|
||||
export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: recs, isLoading } = useTraderRecommendations(pubkey);
|
||||
const { data: mine } = useMyRecommendation(pubkey);
|
||||
|
|
@ -53,11 +55,11 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
|
|||
setBusy(true);
|
||||
try {
|
||||
await recommend(pubkey);
|
||||
toast({ title: 'Recommendation published', description: 'Thanks for vouching.' });
|
||||
toast({ title: t('marketUi.recommendations.published'), description: t('marketUi.recommendations.publishedBody') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not recommend',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.recommendations.failed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.recommendations.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -70,18 +72,17 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
Trusted trader
|
||||
{t('marketUi.recommendations.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-gray-300">
|
||||
{count > 0 ? (
|
||||
<>
|
||||
Recommended by <span className="font-bold text-white">{count}</span> skater
|
||||
{count === 1 ? '' : 's'}.
|
||||
{t('marketUi.recommendations.recommendedBy')} <span className="font-bold text-white">{count}</span> {count === 1 ? t('marketUi.recommendations.skaterOne') : t('marketUi.recommendations.skaterMany')}.
|
||||
</>
|
||||
) : (
|
||||
'No recommendations yet.'
|
||||
t('marketUi.recommendations.none')
|
||||
)}
|
||||
</p>
|
||||
|
||||
|
|
@ -95,7 +96,7 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
|
|||
|
||||
{user && !isSelf && (
|
||||
mine ? (
|
||||
<p className="text-xs text-[#ccff00]">You recommend this trader ✓</p>
|
||||
<p className="text-xs text-[#ccff00]">{t('marketUi.recommendations.youRecommend')}</p>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -104,7 +105,7 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
|
|||
className="w-full gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
|
||||
Recommend as trusted trader
|
||||
{t('marketUi.recommendations.recommend')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
|||
import { useBatchActivity, useTradeRings } from '@/hooks/useTradeIntegrity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Handshake } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
return (
|
||||
|
|
@ -22,6 +23,7 @@ function Stat({ label, value, accent }: { label: string; value: number; accent?:
|
|||
* an attestation for the same listing. Renders nothing when there are no trades.
|
||||
*/
|
||||
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useTradeReputation(pubkey);
|
||||
const verifiedWith = data?.verifiedWith ?? [];
|
||||
const partnerPubkeys = verifiedWith.map((v) => v.pubkey);
|
||||
|
|
@ -48,7 +50,7 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
|||
0,
|
||||
);
|
||||
const trustLabel =
|
||||
weighted >= 5 ? 'Highly trusted trader' : weighted >= 1 ? 'Building reputation' : 'New trader';
|
||||
weighted >= 5 ? t('marketUi.reputation.highlyTrusted') : weighted >= 1 ? t('marketUi.reputation.building') : t('marketUi.reputation.newTrader');
|
||||
// The classic fake-reputation pattern: several "trades", all with one account.
|
||||
const singlePartnerWarning = data.verified >= 3 && people <= 1;
|
||||
// A bigger ring: lots of confirmations from only a couple of accounts.
|
||||
|
|
@ -59,59 +61,55 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<Handshake className="h-5 w-5" />
|
||||
Trades
|
||||
{t('marketUi.reputation.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<Stat label="Verified" value={data.verified} accent />
|
||||
<Stat label="Sales" value={data.sales} />
|
||||
<Stat label="Purchases" value={data.purchases} />
|
||||
<Stat label={t('marketUi.reputation.verified')} value={data.verified} accent />
|
||||
<Stat label={t('marketUi.reputation.sales')} value={data.sales} />
|
||||
<Stat label={t('marketUi.reputation.purchases')} value={data.purchases} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{data.verified > 0
|
||||
? `${data.verified} mutually-confirmed ${data.verified === 1 ? 'trade' : 'trades'} with ${people} ${people === 1 ? 'person' : 'people'}`
|
||||
: 'Awaiting mutual confirmation'}
|
||||
? `${data.verified} ${data.verified === 1 ? t('marketUi.reputation.tradeConfirmedOne') : t('marketUi.reputation.tradeConfirmedMany')} ${t('marketUi.reputation.with')} ${people} ${people === 1 ? t('marketUi.reputation.personOne') : t('marketUi.reputation.personMany')}`
|
||||
: t('marketUi.reputation.awaiting')}
|
||||
</p>
|
||||
{data.withPaymentProof > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> with a payment
|
||||
proof attached <span className="text-gray-600">(self-reported — not verified)</span>
|
||||
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>
|
||||
</p>
|
||||
)}
|
||||
{data.paymentVerified > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="font-bold text-[#ccff00]">{data.paymentVerified}</span> payment-verified
|
||||
<span className="text-gray-600"> (zap receipt)</span>
|
||||
<span className="font-bold text-[#ccff00]">{data.paymentVerified}</span> {t('marketUi.reputation.paymentVerified')}
|
||||
<span className="text-gray-600"> {t('marketUi.reputation.zapReceipt')}</span>
|
||||
</p>
|
||||
)}
|
||||
{singlePartnerWarning && (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||
⚠ Every confirmation is with the same account. Mutual confirmation is
|
||||
self-reported, so treat this record with care.
|
||||
⚠ {t('marketUi.reputation.singlePartnerWarning')}
|
||||
</p>
|
||||
)}
|
||||
{lowDiversityWarning && (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||
⚠ Most confirmations come from very few accounts. A small group vouching
|
||||
for each other can look busy — treat this record with care.
|
||||
⚠ {t('marketUi.reputation.lowDiversityWarning')}
|
||||
</p>
|
||||
)}
|
||||
{rings?.isRing && (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||
🔁 These accounts mostly trade with each other — a closed cluster. Mutual
|
||||
confirmation inside a ring is weak evidence.
|
||||
🔁 {t('marketUi.reputation.ringWarning')}
|
||||
</p>
|
||||
)}
|
||||
{data.verified > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Trust & activity weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
|
||||
{t('marketUi.reputation.weighted')} <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
|
||||
{' · '}
|
||||
{trustLabel}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] leading-tight text-gray-600">
|
||||
Self-reported and mutually signed — a signal, not proof of payment.
|
||||
{t('marketUi.reputation.footnote')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTr
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/** One trade record / review row. */
|
||||
function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const role = ev.tags.find(([t]) => t === 'd')?.[1]?.split(':')[2] as TradeRole | undefined;
|
||||
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? '';
|
||||
const method = ev.tags.find(([t]) => t === 'method')?.[1];
|
||||
|
|
@ -47,20 +49,20 @@ function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
|
|||
role === 'sold' ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'bg-sky-500/15 text-sky-300',
|
||||
)}
|
||||
>
|
||||
{role === 'sold' ? 'Sold' : 'Bought'}
|
||||
{role === 'sold' ? t('marketUi.trades.roleSold') : t('marketUi.trades.roleBought')}
|
||||
</span>
|
||||
{!given && <span className="text-[9px] font-bold uppercase tracking-wide text-emerald-400">★ Review of you</span>}
|
||||
{given && <span className="text-[9px] font-bold uppercase tracking-wide text-gray-500">your record</span>}
|
||||
{!given && <span className="text-[9px] font-bold uppercase tracking-wide text-emerald-400">★ {t('marketUi.reputationPanel.reviewOfYou')}</span>}
|
||||
{given && <span className="text-[9px] font-bold uppercase tracking-wide text-gray-500">{t('marketUi.reputationPanel.yourRecord')}</span>}
|
||||
{rating > 0 && <DeckRating value={rating} compact className="ml-auto" />}
|
||||
</div>
|
||||
{ev.content && <p className="mt-1 line-clamp-2 text-xs text-gray-300">{ev.content}</p>}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-gray-500">
|
||||
{method && <span>{TRADE_METHODS[method as keyof typeof TRADE_METHODS] ?? method}</span>}
|
||||
{amount && <span>{Number(amount).toLocaleString()} sats</span>}
|
||||
{amount && <span>{Number(amount).toLocaleString()} {t('common.sats')}</span>}
|
||||
<span>{date}</span>
|
||||
{listingPubkey && listingId && (
|
||||
<Link to={`/listing/${listingPubkey}/${listingId}`} className="inline-flex items-center gap-0.5 text-gray-400 hover:text-[#ccff00]">
|
||||
Listing <ArrowUpRight className="h-3 w-3" />
|
||||
{t('marketUi.reputationPanel.listing')} <ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -74,6 +76,7 @@ function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
|
|||
* and their trade records / reviews (from kind-31871 attestations).
|
||||
*/
|
||||
export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useTradeReputation(pubkey);
|
||||
const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at);
|
||||
const reviews = attestations.filter((a) => a.pubkey !== pubkey);
|
||||
|
|
@ -90,7 +93,7 @@ export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
|
|||
<span className="text-lg font-black tabular-nums text-[#ccff00]">{avg.toFixed(1)}/10</span>
|
||||
<DeckRating value={Math.round(avg)} compact className="text-[#ccff00]" />
|
||||
<span className="text-xs text-gray-400">
|
||||
average from {ratings.length} rating{ratings.length === 1 ? '' : 's'}
|
||||
{ratings.length === 1 ? t('marketUi.reputationPanel.averageOne', { count: ratings.length }) : t('marketUi.reputationPanel.averageMany', { count: ratings.length })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -99,23 +102,22 @@ export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
|
|||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
||||
<Star className="h-4 w-4 text-[#ccff00]" />
|
||||
Reviews & trade records
|
||||
{t('marketUi.reputationPanel.reviewsTitle')}
|
||||
{reviews.length > 0 && (
|
||||
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]">
|
||||
{reviews.length} from others
|
||||
{t('marketUi.reputationPanel.fromOthers', { count: reviews.length })}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
<p className="text-sm text-gray-500">{t('common.loading')}</p>
|
||||
) : attestations.length === 0 ? (
|
||||
<Card className="border-dashed border-white/10">
|
||||
<CardContent className="py-10 text-center">
|
||||
<ThumbsUp className="mx-auto mb-3 h-8 w-8 text-gray-600" />
|
||||
<p className="text-sm text-gray-400">
|
||||
No trade records yet. Complete a deal and confirm it in <b>My Trades</b> — each
|
||||
confirmation becomes a review on both profiles.
|
||||
{t('marketUi.reputationPanel.emptyBefore')} <b>{t('marketUi.trades.title')}</b> {t('marketUi.reputationPanel.emptyAfter')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,24 @@ interface LanguageProviderProps {
|
|||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the starting language from the visitor's browser on their FIRST visit.
|
||||
* A device set to Spanish (`es-ES`, `es-MX`, …) lands in Castellano; anything
|
||||
* else falls back to English. Once the user taps the language toggle their
|
||||
* choice is stored and always wins.
|
||||
*/
|
||||
function detectLanguage(): Language {
|
||||
if (typeof navigator === 'undefined') return 'en';
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])];
|
||||
return tags.some((tag) => tag?.toLowerCase().startsWith('es')) ? 'es' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides language state + translation. The context, types and hooks live in
|
||||
* `./language` so this file exports only the component (React Fast Refresh).
|
||||
*/
|
||||
export function LanguageProvider({ children }: LanguageProviderProps) {
|
||||
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', 'en');
|
||||
const [storedLanguage, setStoredLanguage] = useLocalStorage<Language>('app-language', detectLanguage());
|
||||
const [language, setLanguageState] = useState<Language>(storedLanguage);
|
||||
|
||||
// Sync with localStorage
|
||||
|
|
|
|||
1805
src/i18n/en.ts
1805
src/i18n/en.ts
File diff suppressed because it is too large
Load diff
1805
src/i18n/es.ts
1805
src/i18n/es.ts
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,7 @@ import {
|
|||
type EntityType,
|
||||
} from '@/lib/types';
|
||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
|
||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||
|
|
@ -75,6 +76,7 @@ function ChipGrid({
|
|||
}
|
||||
|
||||
export default function BecomeBuilderPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Become a Builder - SkateSpots',
|
||||
description: 'List your ramp, park and DIY building services — what you build, materials and scale.',
|
||||
|
|
@ -171,7 +173,7 @@ export default function BecomeBuilderPage() {
|
|||
const npub = nip19.npubEncode(user.pubkey);
|
||||
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
|
||||
}
|
||||
return 'Anonymous Builder';
|
||||
return t('labUi.builderForm.anonymous');
|
||||
};
|
||||
|
||||
const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
|
||||
|
|
@ -180,7 +182,7 @@ export default function BecomeBuilderPage() {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
|
|
@ -189,11 +191,11 @@ export default function BecomeBuilderPage() {
|
|||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -207,10 +209,10 @@ export default function BecomeBuilderPage() {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Image uploaded' });
|
||||
toast({ title: t('labUi.become.imageUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -218,23 +220,23 @@ export default function BecomeBuilderPage() {
|
|||
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
||||
// be stored and then silently hidden.
|
||||
const contactError = email.trim() && !isValidEmail(email)
|
||||
? 'Enter a valid email address.'
|
||||
? t('labUi.become.invalidEmail')
|
||||
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
||||
? 'Enter a valid website URL.'
|
||||
? t('labUi.become.invalidWebsite')
|
||||
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
|
||||
? 'Enter a valid portfolio URL.'
|
||||
? t('labUi.become.invalidPortfolio')
|
||||
: '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (contactError) {
|
||||
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedServices.length === 0) {
|
||||
toast({ title: 'Build types required', description: 'Select at least one thing you build.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.builderForm.buildTypesRequired'), description: t('labUi.builderForm.selectBuild'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -302,10 +304,10 @@ export default function BecomeBuilderPage() {
|
|||
tags,
|
||||
});
|
||||
|
||||
toast({ title: `Builder profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published.' });
|
||||
toast({ title: existingProfile ? t('labUi.builderForm.updated') : t('labUi.builderForm.created'), description: t('labUi.become.profilePublished') });
|
||||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -316,8 +318,8 @@ export default function BecomeBuilderPage() {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<HardHat className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to create a builder profile.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDesc', { role: t('labUi.role.builder') })}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -334,19 +336,19 @@ export default function BecomeBuilderPage() {
|
|||
<div className="mb-6">
|
||||
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase text-amber-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
Become a Builder
|
||||
{t('labUi.builderForm.heading')}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Pour it, weld it, build it — list your ramp & park building</p>
|
||||
<p className="text-gray-400 mt-1">{t('labUi.builderForm.tagline')}</p>
|
||||
|
||||
{!hasNip05 && (
|
||||
<Nip05Required
|
||||
variant="banner"
|
||||
color="#eab308"
|
||||
role="builders"
|
||||
reason="Add a NIP-05 identifier to verify your identity and build trust with clients."
|
||||
reason={t('labUi.builderForm.nip05Reason')}
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -357,42 +359,42 @@ export default function BecomeBuilderPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Wallet className="h-6 w-6 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-amber-400 mb-2">⚡ Payment Setup Required</h3>
|
||||
<h3 className="font-bold text-amber-400 mb-2">{t('labUi.become.paymentSetup')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
To get paid for builds, connect a Lightning wallet. Clients zap you directly for your work.
|
||||
{t('labUi.builderForm.paymentDesc')}
|
||||
</p>
|
||||
{hasWallet ? (
|
||||
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<p className="text-sm font-bold text-green-400">Wallet Connected — Ready to Receive Payments!</p>
|
||||
<p className="text-sm font-bold text-green-400">{t('labUi.become.walletConnected')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}</p>
|
||||
<p className="text-xs text-gray-400">{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
|
||||
<p className="text-sm font-bold text-red-400">{t('labUi.become.noWallet')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<WalletModal>
|
||||
<Button className="gap-2 bg-amber-500 hover:bg-amber-600 text-black flex-1">
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet with NWC
|
||||
{t('labUi.become.connectWalletNwc')}
|
||||
</Button>
|
||||
</WalletModal>
|
||||
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
|
||||
<Button variant="outline" className="gap-2 w-full border-amber-500/50 text-amber-400 hover:bg-amber-500/10">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Learn How
|
||||
{t('labUi.become.learnHow')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos).
|
||||
{t('labUi.become.tip')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -406,21 +408,21 @@ export default function BecomeBuilderPage() {
|
|||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardHat className="h-5 w-5 text-amber-400" /> Builder Profile
|
||||
<HardHat className="h-5 w-5 text-amber-400" /> {t('labUi.builderForm.profileTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">Tell the scene what you build and how you work</CardDescription>
|
||||
<CardDescription className="text-gray-400">{t('labUi.builderForm.profileDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Your Builder Identity</Label>
|
||||
<Label>{t('labUi.builderForm.identity')}</Label>
|
||||
<div className="p-4 rounded-lg border-2 border-amber-500/30 bg-amber-500/5">
|
||||
<p className="font-black text-lg">{getDisplayName()}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Headline</Label>
|
||||
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={titleInput}
|
||||
|
|
@ -430,13 +432,13 @@ export default function BecomeBuilderPage() {
|
|||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Shown as the title on your Lab card — e.g. “Ramp & Park Builder”. Defaults to your name.
|
||||
{t('labUi.become.headlineHint', { example: t('labUi.builderForm.headlineExample') })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio / About Your Builds</Label>
|
||||
<Textarea id="bio" placeholder="Your crew, notable builds, how you approach a project..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="bio">{t('labUi.builderForm.bioLabel')}</Label>
|
||||
<Textarea id="bio" placeholder={t('labUi.builderForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -456,7 +458,7 @@ export default function BecomeBuilderPage() {
|
|||
|
||||
{/* Build types */}
|
||||
<div className="space-y-3">
|
||||
<Label>What You Build * (select at least one)</Label>
|
||||
<Label>{t('labUi.builderForm.buildTypesLabel')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SERVICE_ENTRIES.map(([service, label]) => (
|
||||
<button
|
||||
|
|
@ -481,82 +483,82 @@ export default function BecomeBuilderPage() {
|
|||
|
||||
{/* Materials / trade skills */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2"><Hammer className="h-4 w-4 text-amber-400" /> Materials & Trade Skills</Label>
|
||||
<Label className="flex items-center gap-2"><Hammer className="h-4 w-4 text-amber-400" /> {t('labUi.builderForm.materialsLabel')}</Label>
|
||||
<ChipGrid entries={SKILL_ENTRIES} selected={selectedSkills} onToggle={toggle(setSelectedSkills)} />
|
||||
</div>
|
||||
|
||||
{/* Scale */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2">📐 Project Scale</Label>
|
||||
<Label className="flex items-center gap-2">📐 {t('labUi.builderForm.scaleLabel')}</Label>
|
||||
<ChipGrid entries={SCALE_ENTRIES} selected={selectedScale} onToggle={toggle(setSelectedScale)} />
|
||||
</div>
|
||||
|
||||
{/* Freeform "how we work" */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gear">How You Work / Equipment (optional)</Label>
|
||||
<Label htmlFor="gear">{t('labUi.builderForm.gearLabel')}</Label>
|
||||
<Textarea
|
||||
id="gear"
|
||||
placeholder="e.g. Full concrete crew, own mixer + shotcrete rig, mobile welding, we handle permits and drainage..."
|
||||
placeholder={t('labUi.builderForm.gearPlaceholder')}
|
||||
value={gear}
|
||||
onChange={(e) => setGear(e.target.value)}
|
||||
rows={3}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Crews and clients love knowing your kit and process.</p>
|
||||
<p className="text-xs text-gray-500">{t('labUi.builderForm.gearHelper')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Years Building</Label>
|
||||
<Label htmlFor="experience">{t('labUi.builderForm.yearsBuilding')}</Label>
|
||||
<Input id="experience" type="number" placeholder="10" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Rate / Quote Basis</Label>
|
||||
<Input id="rate" placeholder="per m² / by project" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="rate">{t('labUi.builderForm.rateLabel')}</Label>
|
||||
<Input id="rate" placeholder={t('labUi.builderForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serviceArea">Service Area</Label>
|
||||
<Input id="serviceArea" placeholder="e.g. Catalonia, will travel across EU" value={serviceArea} onChange={(e) => setServiceArea(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="serviceArea">{t('labUi.builderForm.serviceArea')}</Label>
|
||||
<Input id="serviceArea" placeholder={t('labUi.builderForm.serviceAreaPlaceholder')} value={serviceArea} onChange={(e) => setServiceArea(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/60 p-3">
|
||||
<div>
|
||||
<Label htmlFor="insured" className="font-bold">Licensed / Insured</Label>
|
||||
<p className="text-xs text-gray-500">Relevant for public park & council work</p>
|
||||
<Label htmlFor="insured" className="font-bold">{t('labUi.builderForm.insured')}</Label>
|
||||
<p className="text-xs text-gray-500">{t('labUi.builderForm.insuredHelper')}</p>
|
||||
</div>
|
||||
<Switch id="insured" checked={insured} onCheckedChange={setInsured} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">City / Base</Label>
|
||||
<Label htmlFor="location">{t('labUi.builderForm.cityBase')}</Label>
|
||||
<Input id="location" placeholder="Malmö, SE" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Contact & Portfolio</Label>
|
||||
<p className="text-sm text-gray-400">How the scene can see your builds and reach you (all optional, but recommended)</p>
|
||||
<Label className="text-base font-bold">{t('labUi.builderForm.contactPortfolio')}</Label>
|
||||
<p className="text-sm text-gray-400">{t('labUi.builderForm.contactPortfolioDesc')}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio">Past Builds / Instagram</Label>
|
||||
<Label htmlFor="portfolio">{t('labUi.builderForm.pastBuilds')}</Label>
|
||||
<Input id="portfolio" type="url" placeholder="https://instagram.com/crew" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Label htmlFor="website">{t('labUi.become.website')}</Label>
|
||||
<Input id="website" type="url" placeholder="https://ourbuilds.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Label htmlFor="phone">{t('labUi.become.phoneNumber')}</Label>
|
||||
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('labUi.become.email')}</Label>
|
||||
<Input id="email" type="email" placeholder="crew@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="social">Other Social</Label>
|
||||
<Label htmlFor="social">{t('labUi.become.otherSocial')}</Label>
|
||||
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -564,7 +566,7 @@ export default function BecomeBuilderPage() {
|
|||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
|
||||
<p className="text-sm text-gray-300">💬 Clients can message you directly through SkateSpots' encrypted messaging</p>
|
||||
<p className="text-sm text-gray-300">{t('labUi.builderForm.dmNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -573,23 +575,23 @@ export default function BecomeBuilderPage() {
|
|||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
maxSpots={8}
|
||||
label="Spots You've Built"
|
||||
description="Link spots and parks you built so people can see your work on the map"
|
||||
label={t('labUi.builderForm.spotsLabel')}
|
||||
description={t('labUi.builderForm.spotsDesc')}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Coordinates (optional)</Label>
|
||||
<Label>{t('labUi.become.coordinatesOptional')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Logo / Photo</Label>
|
||||
<Label>{t('labUi.builderForm.logoPhoto')}</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||||
|
|
@ -600,7 +602,7 @@ export default function BecomeBuilderPage() {
|
|||
) : (
|
||||
<label className="w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">{t('labUi.become.upload')}</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -614,9 +616,9 @@ export default function BecomeBuilderPage() {
|
|||
disabled={isPublishing || isUploading}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.publishing')}</>
|
||||
) : (
|
||||
<><HardHat className="h-4 w-4 mr-2" />{existingProfile ? 'UPDATE BUILDER PROFILE' : isPro ? 'CREATE PRO BUILDER PROFILE' : 'CREATE BUILDER PROFILE'}</>
|
||||
<><HardHat className="h-4 w-4 mr-2" />{existingProfile ? t('labUi.builderForm.update') : isPro ? t('labUi.builderForm.createPro') : t('labUi.builderForm.create')}</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { EntityTypeField } from '@/components/EntityTypeField';
|
|||
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
|
||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
|
||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||
|
|
@ -33,6 +34,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
|||
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
|
||||
|
||||
export default function BecomeCoachPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Become a Coach - SkateSpots',
|
||||
description: 'Create your skateboarding coach profile.',
|
||||
|
|
@ -115,7 +117,7 @@ export default function BecomeCoachPage() {
|
|||
const npub = nip19.npubEncode(user.pubkey);
|
||||
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
|
||||
}
|
||||
return 'Anonymous Coach';
|
||||
return t('labUi.coachForm.anonymous');
|
||||
};
|
||||
|
||||
const toggleSkill = (skill: string) => {
|
||||
|
|
@ -124,7 +126,7 @@ export default function BecomeCoachPage() {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,11 +136,11 @@ export default function BecomeCoachPage() {
|
|||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -153,10 +155,10 @@ export default function BecomeCoachPage() {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Image uploaded' });
|
||||
toast({ title: t('labUi.become.imageUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -164,21 +166,21 @@ export default function BecomeCoachPage() {
|
|||
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
||||
// be stored and then silently hidden.
|
||||
const contactError = email.trim() && !isValidEmail(email)
|
||||
? 'Enter a valid email address.'
|
||||
? t('labUi.become.invalidEmail')
|
||||
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
||||
? 'Enter a valid website URL.'
|
||||
? t('labUi.become.invalidWebsite')
|
||||
: '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (contactError) {
|
||||
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSkills.length === 0) {
|
||||
toast({ title: 'Skills required', description: 'Select at least one skill.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.coachForm.skillsRequired'), description: t('labUi.coachForm.selectSkill'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -240,10 +242,10 @@ export default function BecomeCoachPage() {
|
|||
tags,
|
||||
});
|
||||
|
||||
toast({ title: `Coach profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published.' });
|
||||
toast({ title: existingProfile ? t('labUi.coachForm.updated') : t('labUi.coachForm.created'), description: t('labUi.become.profilePublished') });
|
||||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -254,8 +256,8 @@ export default function BecomeCoachPage() {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<GraduationCap className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to create a coach profile.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDesc', { role: t('labUi.role.coach') })}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -272,19 +274,19 @@ export default function BecomeCoachPage() {
|
|||
<div className="mb-6">
|
||||
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase neon-lime" style={{
|
||||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||||
}}>Become a Coach</h1>
|
||||
<p className="text-gray-400 mt-1">Share your skateboarding knowledge</p>
|
||||
}}>{t('labUi.coachForm.heading')}</h1>
|
||||
<p className="text-gray-400 mt-1">{t('labUi.coachForm.tagline')}</p>
|
||||
|
||||
{!hasNip05 && (
|
||||
<Nip05Required
|
||||
variant="banner"
|
||||
color="#eab308"
|
||||
role="coaches"
|
||||
reason="Add a NIP-05 identifier to verify your identity and build trust with students — it shows them you're a legitimate coach."
|
||||
reason={t('labUi.coachForm.nip05Reason')}
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -295,19 +297,19 @@ export default function BecomeCoachPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-orange-400 mb-2">⚡ Payment Setup Required</h3>
|
||||
<h3 className="font-bold text-orange-400 mb-2">{t('labUi.become.paymentSetup')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
To receive lesson payments from students, you need to connect a Lightning wallet. Students will zap you directly for coaching sessions.
|
||||
{t('labUi.coachForm.paymentDesc')}
|
||||
</p>
|
||||
|
||||
{hasWallet ? (
|
||||
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<p className="text-sm font-bold text-green-400">Wallet Connected - Ready to Receive Payments!</p>
|
||||
<p className="text-sm font-bold text-green-400">{t('labUi.become.walletConnected')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}
|
||||
{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -315,7 +317,7 @@ export default function BecomeCoachPage() {
|
|||
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
|
||||
<p className="text-sm font-bold text-red-400">{t('labUi.become.noWallet')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -323,7 +325,7 @@ export default function BecomeCoachPage() {
|
|||
<WalletModal>
|
||||
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1">
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet with NWC
|
||||
{t('labUi.become.connectWalletNwc')}
|
||||
</Button>
|
||||
</WalletModal>
|
||||
<a
|
||||
|
|
@ -334,13 +336,13 @@ export default function BecomeCoachPage() {
|
|||
>
|
||||
<Button variant="outline" className="gap-2 w-full border-orange-500/50 text-orange-400 hover:bg-orange-500/10">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Learn How
|
||||
{t('labUi.become.learnHow')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). Takes 30 seconds to set up.
|
||||
{t('labUi.become.tipWithTime')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -353,20 +355,20 @@ export default function BecomeCoachPage() {
|
|||
<form onSubmit={handleSubmit}>
|
||||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Coach Profile</CardTitle>
|
||||
<CardDescription className="text-gray-400">Tell students about your experience</CardDescription>
|
||||
<CardTitle>{t('labUi.coachForm.profileTitle')}</CardTitle>
|
||||
<CardDescription className="text-gray-400">{t('labUi.coachForm.profileDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Your Coach Identity</Label>
|
||||
<Label>{t('labUi.coachForm.identity')}</Label>
|
||||
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
|
||||
<p className="font-black text-lg">{getDisplayName()}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Headline</Label>
|
||||
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={titleInput}
|
||||
|
|
@ -376,13 +378,13 @@ export default function BecomeCoachPage() {
|
|||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Shown as the title on your Lab card — e.g. “Skate Coach Berlin”. Defaults to your name.
|
||||
{t('labUi.become.headlineHint', { example: t('labUi.coachForm.headlineExample') })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio / Coaching Philosophy</Label>
|
||||
<Textarea id="bio" placeholder="Your background, teaching style..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="bio">{t('labUi.coachForm.bioLabel')}</Label>
|
||||
<Textarea id="bio" placeholder={t('labUi.coachForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -400,7 +402,7 @@ export default function BecomeCoachPage() {
|
|||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Skills I Teach * (select at least one)</Label>
|
||||
<Label>{t('labUi.coachForm.skillsLabel')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SKILL_ENTRIES.map(([skill, label]) => (
|
||||
<button
|
||||
|
|
@ -425,39 +427,39 @@ export default function BecomeCoachPage() {
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Years Experience</Label>
|
||||
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
|
||||
<Input id="experience" type="number" placeholder="15" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Hourly Rate</Label>
|
||||
<Input id="rate" placeholder="5000 sats/hr" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="rate">{t('labUi.coachForm.hourlyRate')}</Label>
|
||||
<Input id="rate" placeholder={t('labUi.coachForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">City / Area</Label>
|
||||
<Label htmlFor="location">{t('labUi.become.cityArea')}</Label>
|
||||
<Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Contact Information</Label>
|
||||
<p className="text-sm text-gray-400">How students can reach you (all optional, but recommended)</p>
|
||||
<Label className="text-base font-bold">{t('labUi.become.contactInfo')}</Label>
|
||||
<p className="text-sm text-gray-400">{t('labUi.become.contactInfoDesc')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Label htmlFor="phone">{t('labUi.become.phoneNumber')}</Label>
|
||||
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('labUi.become.email')}</Label>
|
||||
<Input id="email" type="email" placeholder="coach@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Label htmlFor="website">{t('labUi.become.website')}</Label>
|
||||
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instagram">Instagram</Label>
|
||||
<Label htmlFor="instagram">{t('labUi.become.instagram')}</Label>
|
||||
<Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -468,7 +470,7 @@ export default function BecomeCoachPage() {
|
|||
|
||||
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
|
||||
<p className="text-sm text-gray-300">
|
||||
💬 Students can message you directly through SkateSpots' encrypted messaging
|
||||
{t('labUi.coachForm.dmNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -478,23 +480,23 @@ export default function BecomeCoachPage() {
|
|||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
maxSpots={5}
|
||||
label="Spots Where You Coach"
|
||||
description="Select spots where you offer coaching sessions"
|
||||
label={t('labUi.coachForm.spotsLabel')}
|
||||
description={t('labUi.coachForm.spotsDesc')}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Coordinates (optional)</Label>
|
||||
<Label>{t('labUi.become.coordinatesOptional')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Profile Photo</Label>
|
||||
<Label>{t('labUi.become.profilePhoto')}</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||||
|
|
@ -505,7 +507,7 @@ export default function BecomeCoachPage() {
|
|||
) : (
|
||||
<label className="block w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">{t('labUi.become.upload')}</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -521,12 +523,12 @@ export default function BecomeCoachPage() {
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Publishing...
|
||||
{t('labUi.become.publishing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GraduationCap className="h-4 w-4 mr-2" />
|
||||
{existingProfile ? 'UPDATE COACH PROFILE' : isPro ? 'CREATE PRO COACH PROFILE' : 'CREATE COACH PROFILE'}
|
||||
{existingProfile ? t('labUi.coachForm.update') : isPro ? t('labUi.coachForm.createPro') : t('labUi.coachForm.create')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
|
||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||
|
|
@ -40,6 +41,7 @@ const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
|
|||
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
|
||||
|
||||
export default function BecomeEditorPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Become a Video Editor - SkateSpots',
|
||||
description: 'Create your skateboarding video editor profile on SkateSpots.',
|
||||
|
|
@ -123,7 +125,7 @@ export default function BecomeEditorPage() {
|
|||
const npub = nip19.npubEncode(user.pubkey);
|
||||
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
|
||||
}
|
||||
return 'Anonymous Editor';
|
||||
return t('labUi.editorForm.anonymous');
|
||||
};
|
||||
|
||||
const toggleService = (s: string) =>
|
||||
|
|
@ -134,7 +136,7 @@ export default function BecomeEditorPage() {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
|
|
@ -143,11 +145,11 @@ export default function BecomeEditorPage() {
|
|||
setLatitude(pos.coords.latitude.toFixed(6));
|
||||
setLongitude(pos.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(err) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: err.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -158,9 +160,9 @@ export default function BecomeEditorPage() {
|
|||
try {
|
||||
const tags = await uploadFile(files[0]);
|
||||
const url = tags[0]?.[1];
|
||||
if (url) { setImage(url); toast({ title: 'Image uploaded' }); }
|
||||
if (url) { setImage(url); toast({ title: t('labUi.become.imageUploaded') }); }
|
||||
} catch (err) {
|
||||
toast({ title: 'Upload failed', description: err instanceof Error ? err.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.uploadFailed'), description: err instanceof Error ? err.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -168,23 +170,23 @@ export default function BecomeEditorPage() {
|
|||
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
||||
// be stored and then silently hidden.
|
||||
const contactError = email.trim() && !isValidEmail(email)
|
||||
? 'Enter a valid email address.'
|
||||
? t('labUi.become.invalidEmail')
|
||||
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
||||
? 'Enter a valid website URL.'
|
||||
? t('labUi.become.invalidWebsite')
|
||||
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
|
||||
? 'Enter a valid portfolio URL.'
|
||||
? t('labUi.become.invalidPortfolio')
|
||||
: '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (contactError) {
|
||||
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedServices.length === 0) {
|
||||
toast({ title: 'Services required', description: 'Select at least one edit type.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.editorForm.servicesRequired'), description: t('labUi.editorForm.selectService'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -235,10 +237,10 @@ export default function BecomeEditorPage() {
|
|||
|
||||
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
|
||||
|
||||
toast({ title: `Editor profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published to SkateSpots.' });
|
||||
toast({ title: existingProfile ? t('labUi.editorForm.updated') : t('labUi.editorForm.created'), description: t('labUi.editorForm.published') });
|
||||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||||
} catch (err) {
|
||||
toast({ title: 'Failed to publish', description: err instanceof Error ? err.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: err instanceof Error ? err.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -249,8 +251,8 @@ export default function BecomeEditorPage() {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Scissors className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">Log in to create your video editor profile.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.editorForm.loginDesc')}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -267,12 +269,12 @@ export default function BecomeEditorPage() {
|
|||
<div className="mb-6">
|
||||
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase" style={{ color: '#c084fc', fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 20px rgba(192,132,252,0.6)' }}>
|
||||
Become a Video Editor
|
||||
{t('labUi.editorForm.heading')}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Offer your video editing services to the skate community</p>
|
||||
<p className="text-gray-400 mt-1">{t('labUi.editorForm.tagline')}</p>
|
||||
|
||||
{!hasNip05 && (
|
||||
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
||||
|
|
@ -280,11 +282,11 @@ export default function BecomeEditorPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<span className="text-yellow-500">⚠️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Recommended</h3>
|
||||
<p className="text-sm text-gray-300 mb-2">Verify your identity to build trust with clients.</p>
|
||||
<h3 className="font-bold text-yellow-400 mb-1">{t('labUi.become.nip05Recommended')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-2">{t('labUi.become.nip05RecommendedDesc')}</p>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||||
Add NIP-05 in Settings
|
||||
{t('labUi.become.addNip05')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -298,13 +300,13 @@ export default function BecomeEditorPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Wallet className="h-6 w-6 text-purple-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-purple-400 mb-2">⚡ Lightning Wallet for Payments</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">Receive Bitcoin ⚡ payments directly from clients for your editing work.</p>
|
||||
<h3 className="font-bold text-purple-400 mb-2">{t('labUi.editorForm.walletTitle')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">{t('labUi.editorForm.walletDesc')}</p>
|
||||
{hasWallet ? (
|
||||
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<p className="text-sm font-bold text-green-400">Wallet Connected ✓</p>
|
||||
<p className="text-sm font-bold text-green-400">{t('labUi.editorForm.walletConnected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -312,12 +314,12 @@ export default function BecomeEditorPage() {
|
|||
<WalletModal>
|
||||
<Button className="gap-2 bg-purple-600 hover:bg-purple-700 flex-1">
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet
|
||||
{t('labUi.become.connectWallet')}
|
||||
</Button>
|
||||
</WalletModal>
|
||||
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
|
||||
<Button variant="outline" className="gap-2 w-full border-purple-500/50 text-purple-400 hover:bg-purple-500/10">
|
||||
<ExternalLink className="h-4 w-4" /> Learn How
|
||||
<ExternalLink className="h-4 w-4" /> {t('labUi.become.learnHow')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -333,22 +335,22 @@ export default function BecomeEditorPage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Scissors className="h-5 w-5 text-purple-400" />
|
||||
Editor Profile
|
||||
{t('labUi.editorForm.profileTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">Tell the community about your editing style and skills</CardDescription>
|
||||
<CardDescription className="text-gray-400">{t('labUi.editorForm.profileDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Identity */}
|
||||
<div className="space-y-2">
|
||||
<Label>Your Identity</Label>
|
||||
<Label>{t('labUi.editorForm.identity')}</Label>
|
||||
<div className="p-4 rounded-lg border-2 border-purple-500/30 bg-purple-500/5">
|
||||
<p className="font-black text-lg">{getDisplayName()}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Headline</Label>
|
||||
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={titleInput}
|
||||
|
|
@ -358,13 +360,13 @@ export default function BecomeEditorPage() {
|
|||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Shown as the title on your Lab card — e.g. “Skate Video Editor”. Defaults to your name.
|
||||
{t('labUi.become.headlineHint', { example: t('labUi.editorForm.headlineExample') })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile image */}
|
||||
<div className="space-y-2">
|
||||
<Label>Profile / Showreel Thumbnail</Label>
|
||||
<Label>{t('labUi.editorForm.thumbnail')}</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32">
|
||||
<img src={image} alt="profile" className="w-32 h-32 object-cover rounded-lg border-2 border-purple-500/40" />
|
||||
|
|
@ -375,7 +377,7 @@ export default function BecomeEditorPage() {
|
|||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-700 rounded-lg cursor-pointer hover:border-purple-500/50 transition-colors bg-gray-800/50">
|
||||
<Upload className="h-8 w-8 text-gray-500 mb-2" />
|
||||
<span className="text-sm text-gray-500">{isUploading ? 'Uploading…' : 'Upload image'}</span>
|
||||
<span className="text-sm text-gray-500">{isUploading ? t('labUi.become.uploading') : t('labUi.become.uploadImage')}</span>
|
||||
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
|
||||
</label>
|
||||
)}
|
||||
|
|
@ -383,8 +385,8 @@ export default function BecomeEditorPage() {
|
|||
|
||||
{/* Bio */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio / About Your Work</Label>
|
||||
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</Label>
|
||||
<Textarea id="bio" placeholder={t('labUi.editorForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -403,7 +405,7 @@ export default function BecomeEditorPage() {
|
|||
|
||||
{/* Edit Types */}
|
||||
<div className="space-y-3">
|
||||
<Label>Edit Types Offered * (select at least one)</Label>
|
||||
<Label>{t('labUi.editorForm.editTypes')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SERVICE_ENTRIES.map(([service, label]) => (
|
||||
<button
|
||||
|
|
@ -426,7 +428,7 @@ export default function BecomeEditorPage() {
|
|||
|
||||
{/* Software / Hardware Skills */}
|
||||
<div className="space-y-3">
|
||||
<Label>Software & Hardware Skills</Label>
|
||||
<Label>{t('labUi.editorForm.softwareHardware')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{SKILL_ENTRIES.map(([skill, label]) => (
|
||||
<button
|
||||
|
|
@ -451,53 +453,53 @@ export default function BecomeEditorPage() {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hardware" className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-gray-400" /> Hardware
|
||||
<Monitor className="h-4 w-4 text-gray-400" /> {t('labUi.editorForm.hardware')}
|
||||
</Label>
|
||||
<Input id="hardware" placeholder="e.g. MacBook Pro M3, Sony FX3" value={hardware} onChange={(e) => setHardware(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input id="hardware" placeholder={t('labUi.editorForm.hardwarePlaceholder')} value={hardware} onChange={(e) => setHardware(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="software" className="flex items-center gap-2">
|
||||
<Scissors className="h-4 w-4 text-gray-400" /> Software
|
||||
<Scissors className="h-4 w-4 text-gray-400" /> {t('labUi.editorForm.software')}
|
||||
</Label>
|
||||
<Input id="software" placeholder="e.g. DaVinci Resolve, Premiere" value={software} onChange={(e) => setSoftware(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input id="software" placeholder={t('labUi.editorForm.softwarePlaceholder')} value={software} onChange={(e) => setSoftware(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experience & Rate */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Years of Experience</Label>
|
||||
<Input id="experience" placeholder="e.g. 5 years" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="experience">{t('labUi.editorForm.yearsExperience')}</Label>
|
||||
<Input id="experience" placeholder={t('labUi.editorForm.yearsPlaceholder')} value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Rate / Pricing</Label>
|
||||
<Input id="rate" placeholder="e.g. 50k sats/min, DM for quote" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="rate">{t('labUi.editorForm.rate')}</Label>
|
||||
<Input id="rate" placeholder={t('labUi.editorForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="space-y-2">
|
||||
<Label>Location (optional)</Label>
|
||||
<Input placeholder="City, Country" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label>{t('labUi.editorForm.locationOptional')}</Label>
|
||||
<Input placeholder={t('labUi.become.cityCountry')} value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
|
||||
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
|
||||
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="gap-2 border-gray-700">
|
||||
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
|
||||
Use My Location
|
||||
{t('labUi.become.useMyLocation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Contact & Portfolio */}
|
||||
<div className="space-y-4">
|
||||
<Label>Contact & Portfolio</Label>
|
||||
<Label>{t('labUi.editorForm.contactPortfolio')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Phone / Telegram" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Portfolio (Vimeo, YouTube…)" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.email')} value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.editorForm.phoneTelegram')} value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.editorForm.portfolioPlaceholder')} value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.website')} value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.editorForm.socialPlaceholder')} value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
||||
<PaymentReadinessNote />
|
||||
|
|
@ -513,7 +515,7 @@ export default function BecomeEditorPage() {
|
|||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link to="/lab" className="flex-1">
|
||||
<Button type="button" variant="outline" className="w-full border-gray-700">Cancel</Button>
|
||||
<Button type="button" variant="outline" className="w-full border-gray-700">{t('common.cancel')}</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
@ -521,9 +523,9 @@ export default function BecomeEditorPage() {
|
|||
className="flex-1 bg-purple-600 hover:bg-purple-700 font-black uppercase gap-2"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Publishing…</>
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />{t('labUi.become.publishing')}</>
|
||||
) : (
|
||||
<><Scissors className="h-4 w-4" />{existingProfile ? 'Update Editor Profile' : isPro ? 'Publish Pro Editor Profile' : 'Publish Editor Profile'}</>
|
||||
<><Scissors className="h-4 w-4" />{existingProfile ? t('labUi.editorForm.update') : isPro ? t('labUi.editorForm.createPro') : t('labUi.editorForm.create')}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { EntityTypeField } from '@/components/EntityTypeField';
|
|||
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
|
||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
|
||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||
|
|
@ -34,6 +35,7 @@ const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
|
|||
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
|
||||
|
||||
export default function BecomeFilmerPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Become a Filmer - SkateSpots',
|
||||
description: 'Create your skateboarding filming profile.',
|
||||
|
|
@ -125,7 +127,7 @@ export default function BecomeFilmerPage() {
|
|||
const npub = nip19.npubEncode(user.pubkey);
|
||||
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
|
||||
}
|
||||
return 'Anonymous Filmer';
|
||||
return t('labUi.filmerForm.anonymous');
|
||||
};
|
||||
|
||||
const toggleService = (service: string) => {
|
||||
|
|
@ -138,7 +140,7 @@ export default function BecomeFilmerPage() {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -148,11 +150,11 @@ export default function BecomeFilmerPage() {
|
|||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -167,10 +169,10 @@ export default function BecomeFilmerPage() {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Image uploaded' });
|
||||
toast({ title: t('labUi.become.imageUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -178,28 +180,28 @@ export default function BecomeFilmerPage() {
|
|||
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
||||
// be stored and then silently hidden.
|
||||
const contactError = email.trim() && !isValidEmail(email)
|
||||
? 'Enter a valid email address.'
|
||||
? t('labUi.become.invalidEmail')
|
||||
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
||||
? 'Enter a valid website URL.'
|
||||
? t('labUi.become.invalidWebsite')
|
||||
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
|
||||
? 'Enter a valid portfolio URL.'
|
||||
? t('labUi.become.invalidPortfolio')
|
||||
: '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (contactError) {
|
||||
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedServices.length === 0) {
|
||||
toast({ title: 'Services required', description: 'Select at least one service.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.filmerForm.servicesRequired'), description: t('labUi.filmerForm.selectService'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSkills.length === 0) {
|
||||
toast({ title: 'Skills required', description: 'Select at least one filming skill.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.filmerForm.skillsRequired'), description: t('labUi.filmerForm.selectSkill'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -264,10 +266,10 @@ export default function BecomeFilmerPage() {
|
|||
tags,
|
||||
});
|
||||
|
||||
toast({ title: `Filming profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published.' });
|
||||
toast({ title: existingProfile ? t('labUi.filmerForm.updated') : t('labUi.filmerForm.created'), description: t('labUi.become.profilePublished') });
|
||||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -278,8 +280,8 @@ export default function BecomeFilmerPage() {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Video className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to create a filming profile.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDesc', { role: t('labUi.role.filmer') })}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -296,19 +298,19 @@ export default function BecomeFilmerPage() {
|
|||
<div className="mb-6">
|
||||
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase neon-lime" style={{
|
||||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||||
}}>Become a Filmer</h1>
|
||||
<p className="text-gray-400 mt-1">Offer your skate video filming — clips, lines, full parts & contest coverage</p>
|
||||
}}>{t('labUi.filmerForm.heading')}</h1>
|
||||
<p className="text-gray-400 mt-1">{t('labUi.filmerForm.tagline')}</p>
|
||||
|
||||
{!hasNip05 && (
|
||||
<Nip05Required
|
||||
variant="banner"
|
||||
color="#eab308"
|
||||
role="filmers"
|
||||
reason="Add a NIP-05 identifier to verify your identity and build trust with clients — it shows them you're a legitimate filmer."
|
||||
reason={t('labUi.filmerForm.nip05Reason')}
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -319,19 +321,19 @@ export default function BecomeFilmerPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-orange-400 mb-2">⚡ Payment Setup Required</h3>
|
||||
<h3 className="font-bold text-orange-400 mb-2">{t('labUi.become.paymentSetup')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
To receive payments from clients for filming services, you need to connect a Lightning wallet. Clients will zap you directly for your work.
|
||||
{t('labUi.filmerForm.paymentDesc')}
|
||||
</p>
|
||||
|
||||
{hasWallet ? (
|
||||
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<p className="text-sm font-bold text-green-400">Wallet Connected - Ready to Receive Payments!</p>
|
||||
<p className="text-sm font-bold text-green-400">{t('labUi.become.walletConnected')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}
|
||||
{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -339,7 +341,7 @@ export default function BecomeFilmerPage() {
|
|||
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
|
||||
<p className="text-sm font-bold text-red-400">{t('labUi.become.noWallet')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -347,7 +349,7 @@ export default function BecomeFilmerPage() {
|
|||
<WalletModal>
|
||||
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1">
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet with NWC
|
||||
{t('labUi.become.connectWalletNwc')}
|
||||
</Button>
|
||||
</WalletModal>
|
||||
<a
|
||||
|
|
@ -358,13 +360,13 @@ export default function BecomeFilmerPage() {
|
|||
>
|
||||
<Button variant="outline" className="gap-2 w-full border-orange-500/50 text-orange-400 hover:bg-orange-500/10">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Learn How
|
||||
{t('labUi.become.learnHow')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). Takes 30 seconds to set up.
|
||||
{t('labUi.become.tipWithTime')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -377,20 +379,20 @@ export default function BecomeFilmerPage() {
|
|||
<form onSubmit={handleSubmit}>
|
||||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Filming Profile</CardTitle>
|
||||
<CardDescription className="text-gray-400">Tell clients about your filming expertise</CardDescription>
|
||||
<CardTitle>{t('labUi.filmerForm.profileTitle')}</CardTitle>
|
||||
<CardDescription className="text-gray-400">{t('labUi.filmerForm.profileDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Your Filmer Identity</Label>
|
||||
<Label>{t('labUi.filmerForm.identity')}</Label>
|
||||
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
|
||||
<p className="font-black text-lg">{getDisplayName()}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Headline</Label>
|
||||
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={titleInput}
|
||||
|
|
@ -400,13 +402,13 @@ export default function BecomeFilmerPage() {
|
|||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Shown as the title on your Lab card — e.g. “Skate Filmer Berlin”. Defaults to your name.
|
||||
{t('labUi.become.headlineHint', { example: t('labUi.filmerForm.headlineExample') })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio / About Your Work</Label>
|
||||
<Textarea id="bio" placeholder="Your background, style, equipment..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</Label>
|
||||
<Textarea id="bio" placeholder={t('labUi.filmerForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -424,7 +426,7 @@ export default function BecomeFilmerPage() {
|
|||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Services Offered * (select at least one)</Label>
|
||||
<Label>{t('labUi.filmerForm.servicesLabel')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SERVICE_ENTRIES.map(([service, label]) => (
|
||||
<button
|
||||
|
|
@ -448,7 +450,7 @@ export default function BecomeFilmerPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Cameras & Equipment * (select at least one)</Label>
|
||||
<Label>{t('labUi.filmerForm.camerasLabel')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SKILL_ENTRIES.map(([skill, label]) => (
|
||||
<button
|
||||
|
|
@ -473,57 +475,57 @@ export default function BecomeFilmerPage() {
|
|||
|
||||
{/* Freeform gear list */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gear">What's in the Bag (optional)</Label>
|
||||
<Label htmlFor="gear">{t('labUi.filmerForm.gearLabel')}</Label>
|
||||
<Textarea
|
||||
id="gear"
|
||||
placeholder="e.g. Sony VX1000 + MK1 fisheye, Panasonic HVX200, DJI RS3 gimbal, GoPro Hero 12, Sennheiser mic..."
|
||||
placeholder={t('labUi.filmerForm.gearPlaceholder')}
|
||||
value={gear}
|
||||
onChange={(e) => setGear(e.target.value)}
|
||||
rows={3}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">List your exact cameras, lenses and rigs — skaters care about the setup.</p>
|
||||
<p className="text-xs text-gray-500">{t('labUi.filmerForm.gearHelper')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Years Experience</Label>
|
||||
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
|
||||
<Input id="experience" type="number" placeholder="5" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Rate</Label>
|
||||
<Input id="rate" placeholder="10000 sats/video" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="rate">{t('labUi.filmerForm.rate')}</Label>
|
||||
<Input id="rate" placeholder={t('labUi.filmerForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">City / Area</Label>
|
||||
<Label htmlFor="location">{t('labUi.become.cityArea')}</Label>
|
||||
<Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Contact Information</Label>
|
||||
<p className="text-sm text-gray-400">How clients can reach you (all optional, but recommended)</p>
|
||||
<Label className="text-base font-bold">{t('labUi.become.contactInfo')}</Label>
|
||||
<p className="text-sm text-gray-400">{t('labUi.become.contactInfoDesc')}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Label htmlFor="phone">{t('labUi.become.phoneNumber')}</Label>
|
||||
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('labUi.become.email')}</Label>
|
||||
<Input id="email" type="email" placeholder="filmer@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio">Reel / Portfolio</Label>
|
||||
<Label htmlFor="portfolio">{t('labUi.filmerForm.reelLabel')}</Label>
|
||||
<Input id="portfolio" type="url" placeholder="https://vimeo.com/you" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Label htmlFor="website">{t('labUi.become.website')}</Label>
|
||||
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="social">Social Media</Label>
|
||||
<Label htmlFor="social">{t('labUi.become.socialMedia')}</Label>
|
||||
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -534,7 +536,7 @@ export default function BecomeFilmerPage() {
|
|||
|
||||
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
|
||||
<p className="text-sm text-gray-300">
|
||||
💬 Clients can message you directly through SkateSpots' encrypted messaging
|
||||
{t('labUi.filmerForm.dmNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -544,23 +546,23 @@ export default function BecomeFilmerPage() {
|
|||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
maxSpots={5}
|
||||
label="Spots Where You Film"
|
||||
description="Select spots where you offer filming services"
|
||||
label={t('labUi.filmerForm.spotsLabel')}
|
||||
description={t('labUi.filmerForm.spotsDesc')}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Coordinates (optional)</Label>
|
||||
<Label>{t('labUi.become.coordinatesOptional')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Profile Photo</Label>
|
||||
<Label>{t('labUi.become.profilePhoto')}</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||||
|
|
@ -571,7 +573,7 @@ export default function BecomeFilmerPage() {
|
|||
) : (
|
||||
<label className="block w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">{t('labUi.become.upload')}</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -587,12 +589,12 @@ export default function BecomeFilmerPage() {
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Publishing...
|
||||
{t('labUi.become.publishing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Video className="h-4 w-4 mr-2" />
|
||||
{existingProfile ? 'UPDATE FILMING PROFILE' : isPro ? 'CREATE PRO FILMING PROFILE' : 'CREATE FILMING PROFILE'}
|
||||
{existingProfile ? t('labUi.filmerForm.update') : isPro ? t('labUi.filmerForm.createPro') : t('labUi.filmerForm.create')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
isValidNoffer,
|
||||
} from '@/lib/types';
|
||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
|
||||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||||
|
|
@ -81,6 +82,7 @@ function ChipGrid({
|
|||
}
|
||||
|
||||
export default function BecomePhotographerPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Become a Photographer - SkateSpots',
|
||||
description: 'Create your skate photography profile — cameras, lenses, lighting and services.',
|
||||
|
|
@ -182,7 +184,7 @@ export default function BecomePhotographerPage() {
|
|||
const npub = nip19.npubEncode(user.pubkey);
|
||||
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
|
||||
}
|
||||
return 'Anonymous Photographer';
|
||||
return t('labUi.photoForm.anonymous');
|
||||
};
|
||||
|
||||
const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
|
||||
|
|
@ -191,7 +193,7 @@ export default function BecomePhotographerPage() {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
|
|
@ -200,11 +202,11 @@ export default function BecomePhotographerPage() {
|
|||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
@ -218,10 +220,10 @@ export default function BecomePhotographerPage() {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Image uploaded' });
|
||||
toast({ title: t('labUi.become.imageUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
|
@ -229,27 +231,27 @@ export default function BecomePhotographerPage() {
|
|||
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
||||
// be stored and then silently hidden.
|
||||
const contactError = email.trim() && !isValidEmail(email)
|
||||
? 'Enter a valid email address.'
|
||||
? t('labUi.become.invalidEmail')
|
||||
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
||||
? 'Enter a valid website URL.'
|
||||
? t('labUi.become.invalidWebsite')
|
||||
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
|
||||
? 'Enter a valid portfolio URL.'
|
||||
? t('labUi.become.invalidPortfolio')
|
||||
: '';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (contactError) {
|
||||
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedServices.length === 0) {
|
||||
toast({ title: 'Services required', description: 'Select at least one service you offer.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.photoForm.servicesRequired'), description: t('labUi.photoForm.selectService'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (selectedCameras.length === 0) {
|
||||
toast({ title: 'Camera required', description: 'Select at least one camera type you shoot on.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.photoForm.cameraRequired'), description: t('labUi.photoForm.selectCamera'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -323,10 +325,10 @@ export default function BecomePhotographerPage() {
|
|||
tags,
|
||||
});
|
||||
|
||||
toast({ title: `Photography profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published.' });
|
||||
toast({ title: existingProfile ? t('labUi.photoForm.updated') : t('labUi.photoForm.created'), description: t('labUi.become.profilePublished') });
|
||||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -337,8 +339,8 @@ export default function BecomePhotographerPage() {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Camera className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to create a photography profile.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDesc', { role: t('labUi.role.photographer') })}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -355,19 +357,19 @@ export default function BecomePhotographerPage() {
|
|||
<div className="mb-6">
|
||||
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase text-indigo-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
Become a Photographer
|
||||
{t('labUi.photoForm.heading')}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Shoot the sessions — offer your skate photography</p>
|
||||
<p className="text-gray-400 mt-1">{t('labUi.photoForm.tagline')}</p>
|
||||
|
||||
{!hasNip05 && (
|
||||
<Nip05Required
|
||||
variant="banner"
|
||||
color="#eab308"
|
||||
role="photographers"
|
||||
reason="Add a NIP-05 identifier to verify your identity and build trust with clients."
|
||||
reason={t('labUi.photoForm.nip05Reason')}
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -378,42 +380,42 @@ export default function BecomePhotographerPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Wallet className="h-6 w-6 text-indigo-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-indigo-400 mb-2">⚡ Payment Setup Required</h3>
|
||||
<h3 className="font-bold text-indigo-400 mb-2">{t('labUi.become.paymentSetup')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-3">
|
||||
To get paid for shoots, connect a Lightning wallet. Clients zap you directly for your work.
|
||||
{t('labUi.photoForm.paymentDesc')}
|
||||
</p>
|
||||
{hasWallet ? (
|
||||
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<p className="text-sm font-bold text-green-400">Wallet Connected — Ready to Receive Payments!</p>
|
||||
<p className="text-sm font-bold text-green-400">{t('labUi.become.walletConnected')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}</p>
|
||||
<p className="text-xs text-gray-400">{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
|
||||
<p className="text-sm font-bold text-red-400">{t('labUi.become.noWallet')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<WalletModal>
|
||||
<Button className="gap-2 bg-indigo-600 hover:bg-indigo-700 flex-1">
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet with NWC
|
||||
{t('labUi.become.connectWalletNwc')}
|
||||
</Button>
|
||||
</WalletModal>
|
||||
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
|
||||
<Button variant="outline" className="gap-2 w-full border-indigo-500/50 text-indigo-400 hover:bg-indigo-500/10">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Learn How
|
||||
{t('labUi.become.learnHow')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 italic">
|
||||
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos).
|
||||
{t('labUi.become.tip')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -427,21 +429,21 @@ export default function BecomePhotographerPage() {
|
|||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Camera className="h-5 w-5 text-indigo-400" /> Photography Profile
|
||||
<Camera className="h-5 w-5 text-indigo-400" /> {t('labUi.photoForm.profileTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">Tell riders about your eye, your kit and your services</CardDescription>
|
||||
<CardDescription className="text-gray-400">{t('labUi.photoForm.profileDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Your Photographer Identity</Label>
|
||||
<Label>{t('labUi.photoForm.identity')}</Label>
|
||||
<div className="p-4 rounded-lg border-2 border-indigo-500/30 bg-indigo-500/5">
|
||||
<p className="font-black text-lg">{getDisplayName()}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Headline</Label>
|
||||
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={titleInput}
|
||||
|
|
@ -451,13 +453,13 @@ export default function BecomePhotographerPage() {
|
|||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Shown as the title on your Lab card — e.g. “Skate Photography”. Defaults to your name.
|
||||
{t('labUi.become.headlineHint', { example: t('labUi.photoForm.headlineExample') })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio / About Your Work</Label>
|
||||
<Textarea id="bio" placeholder="Your background, style, what you love shooting..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</Label>
|
||||
<Textarea id="bio" placeholder={t('labUi.photoForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
||||
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -477,7 +479,7 @@ export default function BecomePhotographerPage() {
|
|||
|
||||
{/* Services */}
|
||||
<div className="space-y-3">
|
||||
<Label>Services Offered * (select at least one)</Label>
|
||||
<Label>{t('labUi.photoForm.servicesLabel')}</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{SERVICE_ENTRIES.map(([service, label]) => (
|
||||
<button
|
||||
|
|
@ -502,87 +504,87 @@ export default function BecomePhotographerPage() {
|
|||
|
||||
{/* Camera */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2"><Camera className="h-4 w-4 text-indigo-400" /> Camera Bodies * (select at least one)</Label>
|
||||
<Label className="flex items-center gap-2"><Camera className="h-4 w-4 text-indigo-400" /> {t('labUi.photoForm.cameraLabel')}</Label>
|
||||
<ChipGrid entries={CAMERA_ENTRIES} selected={selectedCameras} onToggle={toggle(setSelectedCameras)} />
|
||||
</div>
|
||||
|
||||
{/* Lenses */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2"><Aperture className="h-4 w-4 text-indigo-400" /> Lenses</Label>
|
||||
<Label className="flex items-center gap-2"><Aperture className="h-4 w-4 text-indigo-400" /> {t('labUi.photoForm.lensesLabel')}</Label>
|
||||
<ChipGrid entries={LENS_ENTRIES} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
|
||||
</div>
|
||||
|
||||
{/* Lighting */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2">💡 Lighting Gear</Label>
|
||||
<Label className="flex items-center gap-2">💡 {t('labUi.photoForm.lightingLabel')}</Label>
|
||||
<ChipGrid entries={LIGHTING_ENTRIES} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2">🎒 Support & Rig</Label>
|
||||
<Label className="flex items-center gap-2">🎒 {t('labUi.photoForm.supportLabel')}</Label>
|
||||
<ChipGrid entries={SUPPORT_ENTRIES} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
|
||||
</div>
|
||||
|
||||
{/* Style */}
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2">🎨 Shooting Style</Label>
|
||||
<Label className="flex items-center gap-2">🎨 {t('labUi.photoForm.styleLabel')}</Label>
|
||||
<ChipGrid entries={STYLE_ENTRIES} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
|
||||
</div>
|
||||
|
||||
{/* Freeform gear list */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gear">What's in the Bag (optional)</Label>
|
||||
<Label htmlFor="gear">{t('labUi.filmerForm.gearLabel')}</Label>
|
||||
<Textarea
|
||||
id="gear"
|
||||
placeholder="e.g. Canon R6 II, 8-15mm fisheye, 70-200mm f/2.8, 2× Godox AD200, remote triggers, Manfrotto tripod..."
|
||||
placeholder={t('labUi.photoForm.gearPlaceholder')}
|
||||
value={gear}
|
||||
onChange={(e) => setGear(e.target.value)}
|
||||
rows={3}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">List exact bodies, lenses and lights — riders love knowing the setup.</p>
|
||||
<p className="text-xs text-gray-500">{t('labUi.photoForm.gearHelper')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="experience">Years Experience</Label>
|
||||
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
|
||||
<Input id="experience" type="number" placeholder="5" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Rate</Label>
|
||||
<Input id="rate" placeholder="20000 sats/shoot" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Label htmlFor="rate">{t('labUi.filmerForm.rate')}</Label>
|
||||
<Input id="rate" placeholder={t('labUi.photoForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">City / Area</Label>
|
||||
<Label htmlFor="location">{t('labUi.become.cityArea')}</Label>
|
||||
<Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Contact & Portfolio</Label>
|
||||
<p className="text-sm text-gray-400">How riders can see your work and reach you (all optional, but recommended)</p>
|
||||
<Label className="text-base font-bold">{t('labUi.builderForm.contactPortfolio')}</Label>
|
||||
<p className="text-sm text-gray-400">{t('labUi.photoForm.contactDesc')}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portfolio">Portfolio / Instagram</Label>
|
||||
<Label htmlFor="portfolio">{t('labUi.photoForm.portfolioLabel')}</Label>
|
||||
<Input id="portfolio" type="url" placeholder="https://instagram.com/you" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Label htmlFor="website">{t('labUi.become.website')}</Label>
|
||||
<Input id="website" type="url" placeholder="https://mywork.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Label htmlFor="phone">{t('labUi.become.phoneNumber')}</Label>
|
||||
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('labUi.become.email')}</Label>
|
||||
<Input id="email" type="email" placeholder="shooter@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="social">Other Social</Label>
|
||||
<Label htmlFor="social">{t('labUi.become.otherSocial')}</Label>
|
||||
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -590,7 +592,7 @@ export default function BecomePhotographerPage() {
|
|||
<PaymentReadinessNote />
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
|
||||
<p className="text-sm text-gray-300">💬 Riders can message you directly through SkateSpots' encrypted messaging</p>
|
||||
<p className="text-sm text-gray-300">{t('labUi.photoForm.dmNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -599,23 +601,23 @@ export default function BecomePhotographerPage() {
|
|||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
maxSpots={5}
|
||||
label="Spots Where You Shoot"
|
||||
description="Select spots where you offer photography"
|
||||
label={t('labUi.photoForm.spotsLabel')}
|
||||
description={t('labUi.photoForm.spotsDesc')}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Coordinates (optional)</Label>
|
||||
<Label>{t('labUi.become.coordinatesOptional')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Profile Photo</Label>
|
||||
<Label>{t('labUi.become.profilePhoto')}</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||||
|
|
@ -626,7 +628,7 @@ export default function BecomePhotographerPage() {
|
|||
) : (
|
||||
<label className="w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">{t('labUi.become.upload')}</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -640,9 +642,9 @@ export default function BecomePhotographerPage() {
|
|||
disabled={isPublishing || isUploading}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.publishing')}</>
|
||||
) : (
|
||||
<><Camera className="h-4 w-4 mr-2" />{existingProfile ? 'UPDATE PHOTOGRAPHY PROFILE' : isPro ? 'CREATE PRO PHOTOGRAPHY PROFILE' : 'CREATE PHOTOGRAPHY PROFILE'}</>
|
||||
<><Camera className="h-4 w-4 mr-2" />{existingProfile ? t('labUi.photoForm.update') : isPro ? t('labUi.photoForm.createPro') : t('labUi.photoForm.create')}</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@ import {
|
|||
type SkateSpot,
|
||||
} from '@/lib/types';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles, UserRound, ShieldCheck, Zap, FileText, Globe, Map as MapIcon, Search, Check } from 'lucide-react';
|
||||
|
||||
export default function BecomeSkaterPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Join the Skater Directory - SkateSpots',
|
||||
description: 'Create your skater profile so the community can find you.',
|
||||
|
|
@ -48,7 +50,7 @@ export default function BecomeSkaterPage() {
|
|||
const { data: allSpots, isLoading: loadingSpots } = useSkateSpots(500);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : 'Skater');
|
||||
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : t('labUi.role.skater'));
|
||||
const avatar = metadata?.picture;
|
||||
// kind-0 values we borrow from so we never re-ask for what the profile has.
|
||||
const kind0About = metadata?.about ?? '';
|
||||
|
|
@ -110,7 +112,7 @@ export default function BecomeSkaterPage() {
|
|||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
|
|
@ -119,11 +121,11 @@ export default function BecomeSkaterPage() {
|
|||
setLatitude(pos.coords.latitude.toFixed(6));
|
||||
setLongitude(pos.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
toast({ title: t('labUi.become.locationFound') });
|
||||
},
|
||||
(err) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: err.message, variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.locationError'), description: err.message, variant: 'destructive' });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -152,7 +154,7 @@ export default function BecomeSkaterPage() {
|
|||
if (!user) return;
|
||||
|
||||
if (styles.length === 0) {
|
||||
toast({ title: 'Pick your styles', description: 'Choose at least one thing you skate.', variant: 'destructive' });
|
||||
toast({ title: t('labUi.skaterForm.pickStyles'), description: t('labUi.skaterForm.pickStylesDesc'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -198,12 +200,12 @@ export default function BecomeSkaterPage() {
|
|||
await createEvent({ kind: SKATE_SKATER_KIND, content, tags });
|
||||
|
||||
toast({
|
||||
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
|
||||
description: 'Other skaters can now discover you.',
|
||||
title: existing ? t('labUi.skaterForm.updated') : t('labUi.skaterForm.created'),
|
||||
description: t('labUi.skaterForm.discoverable'),
|
||||
});
|
||||
navigate('/skaters');
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -216,8 +218,8 @@ export default function BecomeSkaterPage() {
|
|||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] mb-5">
|
||||
<PersonStanding className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black uppercase mb-2">Log in to join</h2>
|
||||
<p className="text-gray-400 mb-6">Create your skater profile so the community can find you.</p>
|
||||
<h2 className="text-2xl font-black uppercase mb-2">{t('labUi.skaterForm.loginToJoin')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('labUi.skaterForm.loginToJoinDesc')}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -235,7 +237,7 @@ export default function BecomeSkaterPage() {
|
|||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||||
<Link to="/skaters" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Skaters
|
||||
{t('labUi.skaterForm.backToSkaters')}
|
||||
</Link>
|
||||
|
||||
{/* Hero */}
|
||||
|
|
@ -250,10 +252,10 @@ export default function BecomeSkaterPage() {
|
|||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-black uppercase leading-none">
|
||||
{existing ? 'Edit your' : 'Join the'} <span className="text-[#ff0040]">Skater</span> Directory
|
||||
{existing ? t('labUi.skaterForm.heroEditPrefix') : t('labUi.skaterForm.heroJoinPrefix')} <span className="text-[#ff0040]">{t('labUi.role.skater')}</span> {t('labUi.skaterForm.heroSuffix')}
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm mt-1.5 truncate">
|
||||
Publishing as <span className="text-white font-bold">{displayName}</span>
|
||||
{t('labUi.skaterForm.publishingAs')} <span className="text-white font-bold">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -267,8 +269,7 @@ export default function BecomeSkaterPage() {
|
|||
>
|
||||
<UserRound className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
||||
<p className="text-xs text-gray-400 flex-1 leading-relaxed">
|
||||
This is your <span className="text-white font-bold">skate layer</span>, built on your Nostr profile.
|
||||
Your name, photo, bio, lightning & verification come from there — <span className="text-[#ccff00] font-bold group-hover:underline">edit your full profile</span> to change them everywhere.
|
||||
{t('labUi.skaterForm.layerIntro')} <span className="text-white font-bold">{t('labUi.skaterForm.layerWord')}</span>{t('labUi.skaterForm.layerMid')} <span className="text-[#ccff00] font-bold group-hover:underline">{t('labUi.skaterForm.editFullProfile')}</span> {t('labUi.skaterForm.layerEnd')}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
|
|
@ -276,7 +277,7 @@ export default function BecomeSkaterPage() {
|
|||
<div className="flex flex-wrap items-center gap-1.5 pl-8">
|
||||
{avatar && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
|
||||
<img src={avatar} alt="" className="h-3.5 w-3.5 rounded-full object-cover" /> Photo
|
||||
<img src={avatar} alt="" className="h-3.5 w-3.5 rounded-full object-cover" /> {t('labUi.skaterForm.chipPhoto')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
|
||||
|
|
@ -284,12 +285,12 @@ export default function BecomeSkaterPage() {
|
|||
</span>
|
||||
{kind0About.trim() && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
|
||||
<FileText className="h-3 w-3 text-gray-400" /> Bio
|
||||
<FileText className="h-3 w-3 text-gray-400" /> {t('labUi.skaterForm.chipBio')}
|
||||
</span>
|
||||
)}
|
||||
{kind0Website.trim() && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
|
||||
<Globe className="h-3 w-3 text-sky-400" /> Website
|
||||
<Globe className="h-3 w-3 text-sky-400" /> {t('labUi.become.website')}
|
||||
</span>
|
||||
)}
|
||||
{kind0Nip05 && (
|
||||
|
|
@ -299,7 +300,7 @@ export default function BecomeSkaterPage() {
|
|||
)}
|
||||
{kind0Lud16 && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
|
||||
<Zap className="h-3 w-3 text-yellow-400" /> Zaps on
|
||||
<Zap className="h-3 w-3 text-yellow-400" /> {t('labUi.skaterForm.zapsOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -307,7 +308,7 @@ export default function BecomeSkaterPage() {
|
|||
|
||||
{loadingExisting ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading your profile…
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> {t('labUi.skaterForm.loadingProfile')}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
|
@ -316,16 +317,16 @@ export default function BecomeSkaterPage() {
|
|||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="bio" className="text-sm font-bold uppercase tracking-wide text-gray-400">Your skate story</Label>
|
||||
<Label htmlFor="bio" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.skateStory')}</Label>
|
||||
{bio.trim() && bio.trim() === kind0About.trim() && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500 flex items-center gap-1">
|
||||
<UserRound className="h-3 w-3" /> From your profile
|
||||
<UserRound className="h-3 w-3" /> {t('labUi.skaterForm.fromProfile')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id="bio"
|
||||
placeholder="How long you've been skating, what you're into, what you're working on…"
|
||||
placeholder={t('labUi.skaterForm.bioPlaceholder')}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={4}
|
||||
|
|
@ -333,14 +334,14 @@ export default function BecomeSkaterPage() {
|
|||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{kind0About.trim()
|
||||
? 'Pulled from your profile — tweak it for your skater card, or leave it as is.'
|
||||
: 'A short intro for your skater card.'}
|
||||
? t('labUi.skaterForm.bioHelperPulled')
|
||||
: t('labUi.skaterForm.bioHelperShort')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Pic — always your Nostr profile photo, no separate override */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Profile Pic</Label>
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.profilePic')}</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0 ring-2 ring-white/10">
|
||||
{avatar ? (
|
||||
|
|
@ -351,19 +352,19 @@ export default function BecomeSkaterPage() {
|
|||
</div>
|
||||
)}
|
||||
<span className="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] font-bold uppercase tracking-wide text-center py-0.5 text-gray-300">
|
||||
Your profile pic
|
||||
{t('labUi.skaterForm.yourProfilePic')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Your skater card uses your Nostr profile photo — same picture everywhere.
|
||||
{t('labUi.skaterForm.profilePicHelper')}
|
||||
</p>
|
||||
<Link
|
||||
to="/settings?section=profile"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ff0040] hover:underline"
|
||||
>
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
Change profile photo
|
||||
{t('labUi.skaterForm.changePhoto')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -375,8 +376,8 @@ export default function BecomeSkaterPage() {
|
|||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">What you skate *</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Pick everything that fits your style.</p>
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.whatYouSkate')}</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t('labUi.skaterForm.pickEverything')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{styleEntries.map(([key, { label, emoji }]) => {
|
||||
|
|
@ -405,7 +406,7 @@ export default function BecomeSkaterPage() {
|
|||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Stance</Label>
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.stance')}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stanceEntries.map(([key, { label, emoji }]) => (
|
||||
<button
|
||||
|
|
@ -424,7 +425,7 @@ export default function BecomeSkaterPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Level</Label>
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.level')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{levelEntries.map(([key, { label, emoji, color }]) => {
|
||||
const active = levelMode === 'preset' && level === key;
|
||||
|
|
@ -466,42 +467,42 @@ export default function BecomeSkaterPage() {
|
|||
)}
|
||||
>
|
||||
<div className="text-lg leading-none mb-0.5">✏️</div>
|
||||
Custom
|
||||
{t('labUi.skaterForm.custom')}
|
||||
</button>
|
||||
</div>
|
||||
{levelMode === 'custom' && (
|
||||
<div className="pt-1">
|
||||
<Input
|
||||
id="custom-level"
|
||||
placeholder="e.g. OG, Weekend Warrior, Retired…"
|
||||
placeholder={t('labUi.skaterForm.customPlaceholder')}
|
||||
value={customLevel}
|
||||
maxLength={40}
|
||||
onChange={(e) => setCustomLevel(e.target.value)}
|
||||
className="border-[#ccff00]/30 focus-visible:ring-[#ccff00]/40"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Your own label — shown on your skater card instead of a preset.
|
||||
{t('labUi.skaterForm.customHelper')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">Years skating</Label>
|
||||
<Input id="years" placeholder="e.g. 8" value={years} onChange={(e) => setYears(e.target.value)} className="max-w-[160px]" />
|
||||
<Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.yearsSkating')}</Label>
|
||||
<Input id="years" placeholder={t('labUi.skaterForm.yearsPlaceholder')} value={years} onChange={(e) => setYears(e.target.value)} className="max-w-[160px]" />
|
||||
</div>
|
||||
|
||||
{/* Home spot — free text OR link a real mapped spot */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="home" className="text-sm font-bold uppercase tracking-wide text-gray-400">Home spot</Label>
|
||||
<Label htmlFor="home" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.homeSpot')}</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSpotPickerOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ccff00] hover:underline"
|
||||
>
|
||||
<MapIcon className="h-3.5 w-3.5" />
|
||||
Browse spots
|
||||
{t('labUi.skaterForm.browseSpots')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -512,16 +513,16 @@ export default function BecomeSkaterPage() {
|
|||
<MapPin className="h-4 w-4 text-[#ccff00]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white truncate">{homeSpot || 'Mapped spot'}</p>
|
||||
<p className="text-sm font-bold text-white truncate">{homeSpot || t('labUi.skaterForm.mappedSpot')}</p>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide text-[#ccff00] flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Linked to a spot on the map
|
||||
<Check className="h-3 w-3" /> {t('labUi.skaterForm.linked')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSpotLink}
|
||||
className="p-1.5 rounded-full hover:bg-white/10 transition-colors flex-shrink-0"
|
||||
aria-label="Unlink spot"
|
||||
aria-label={t('labUi.skaterForm.unlinkSpot')}
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
|
|
@ -529,13 +530,13 @@ export default function BecomeSkaterPage() {
|
|||
) : (
|
||||
<Input
|
||||
id="home"
|
||||
placeholder="Local park / plaza — or tap “Browse spots”"
|
||||
placeholder={t('labUi.skaterForm.homePlaceholder')}
|
||||
value={homeSpot}
|
||||
onChange={(e) => setHomeSpot(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type it freeform, or link a real spot from the map so people can go there.
|
||||
{t('labUi.skaterForm.homeHelper')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -545,25 +546,25 @@ export default function BecomeSkaterPage() {
|
|||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location" className="text-sm font-bold uppercase tracking-wide text-gray-400">City / area</Label>
|
||||
<Label htmlFor="location" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.cityArea')}</Label>
|
||||
<Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-1 border-white/15">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting…</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">Helps others find skaters near them. Only a coarse area is shared.</p>
|
||||
<p className="text-xs text-muted-foreground">{t('labUi.skaterForm.locationHelper')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ig" className="text-sm font-bold uppercase tracking-wide text-gray-400">Instagram</Label>
|
||||
<Label htmlFor="ig" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.become.instagram')}</Label>
|
||||
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="web" className="text-sm font-bold uppercase tracking-wide text-gray-400">Website</Label>
|
||||
<Label htmlFor="web" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.become.website')}</Label>
|
||||
{website.trim() && website.trim() === kind0Website.trim() && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500 flex items-center gap-1">
|
||||
<UserRound className="h-3 w-3" /> From profile
|
||||
<UserRound className="h-3 w-3" /> {t('labUi.skaterForm.fromProfile')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -575,9 +576,9 @@ export default function BecomeSkaterPage() {
|
|||
|
||||
<Button type="submit" className="w-full h-12 bg-[#ff0040] hover:bg-[#ff0040]/90 uppercase font-black text-base" disabled={isPublishing}>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-5 w-5 mr-2 animate-spin" />Publishing…</>
|
||||
<><Loader2 className="h-5 w-5 mr-2 animate-spin" />{t('labUi.become.publishing')}</>
|
||||
) : (
|
||||
<><Sparkles className="h-5 w-5 mr-2" />{existing ? 'Update my profile' : 'Put me on the map'}</>
|
||||
<><Sparkles className="h-5 w-5 mr-2" />{existing ? t('labUi.skaterForm.updateButton') : t('labUi.skaterForm.putOnMap')}</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
@ -590,7 +591,7 @@ export default function BecomeSkaterPage() {
|
|||
<DialogHeader className="p-5 pb-3 border-b border-white/10">
|
||||
<DialogTitle className="flex items-center gap-2 uppercase font-black">
|
||||
<MapIcon className="h-5 w-5 text-[#ccff00]" />
|
||||
Pick your home spot
|
||||
{t('labUi.skaterForm.pickHomeSpot')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -599,7 +600,7 @@ export default function BecomeSkaterPage() {
|
|||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search spots by name or city…"
|
||||
placeholder={t('labUi.skaterForm.searchSpots')}
|
||||
value={spotSearch}
|
||||
onChange={(e) => setSpotSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
|
|
@ -610,16 +611,16 @@ export default function BecomeSkaterPage() {
|
|||
<ScrollArea className="max-h-[50vh] px-4 pb-4">
|
||||
{loadingSpots ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading spots…
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> {t('labUi.skaterForm.loadingSpots')}
|
||||
</div>
|
||||
) : filteredSpots.length === 0 ? (
|
||||
<div className="text-center py-12 px-4">
|
||||
<MapPin className="h-8 w-8 mx-auto text-gray-600 mb-3" />
|
||||
<p className="text-sm text-gray-400">
|
||||
{spotSearch.trim() ? 'No spots match your search.' : 'No spots found yet.'}
|
||||
{spotSearch.trim() ? t('labUi.skaterForm.noSpotsMatch') : t('labUi.skaterForm.noSpots')}
|
||||
</p>
|
||||
<Link to="/map" className="text-xs font-bold uppercase tracking-wide text-[#ccff00] hover:underline mt-2 inline-block">
|
||||
Open the map
|
||||
{t('labUi.skaterForm.openMap')}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
|||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS, BARBER_SERVICE_LABELS, BARBER_SERVICE_DESCRIPTIONS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS, SKATE_BARBER_KIND } from '@/lib/types';
|
||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import NotFound from './NotFound';
|
||||
|
|
@ -82,6 +83,7 @@ function CoachDetailSkeleton() {
|
|||
}
|
||||
|
||||
export default function CoachDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { data: profile, isLoading, error, refetch } = useLabProfile(identifier || '', pubkey);
|
||||
const author = useAuthor(profile?.pubkey);
|
||||
|
|
@ -213,20 +215,20 @@ export default function CoachDetailPage() {
|
|||
const getSkillDescription = (skill: string): string | undefined => DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill];
|
||||
|
||||
const TYPE_NOUNS: Record<string, string> = {
|
||||
coach: 'coach', filmer: 'filmer', editor: 'video editor', photographer: 'photographer', builder: 'builder',
|
||||
artist: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician', judge: 'event judge',
|
||||
organizer: 'event organizer',
|
||||
barber: 'barber',
|
||||
coach: t('labUi.detail.noun.coach'), filmer: t('labUi.detail.noun.filmer'), editor: t('labUi.detail.noun.editor'), photographer: t('labUi.detail.noun.photographer'), builder: t('labUi.detail.noun.builder'),
|
||||
artist: t('labUi.detail.noun.artist'), graffiti: t('labUi.detail.noun.graffiti'), tattoo: t('labUi.detail.noun.tattoo'), trainer: t('labUi.detail.noun.trainer'), musician: t('labUi.detail.noun.musician'), judge: t('labUi.detail.noun.judge'),
|
||||
organizer: t('labUi.detail.noun.organizer'),
|
||||
barber: t('labUi.detail.noun.barber'),
|
||||
};
|
||||
const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile';
|
||||
const typeNoun = TYPE_NOUNS[profile.profileType] ?? t('labUi.detail.noun.profile');
|
||||
|
||||
const SKILLS_TITLES: Record<string, string> = {
|
||||
coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build',
|
||||
artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles',
|
||||
trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties',
|
||||
barber: 'Services & Styles', organizer: 'Event Types & Services',
|
||||
coach: t('labUi.detail.skillsTitle.skills'), photographer: t('labUi.detail.skillsTitle.servicesGear'), builder: t('labUi.detail.skillsTitle.whatWeBuild'),
|
||||
artist: t('labUi.detail.skillsTitle.whatTheyMake'), graffiti: t('labUi.detail.skillsTitle.stylesWork'), tattoo: t('labUi.detail.skillsTitle.styles'),
|
||||
trainer: t('labUi.detail.skillsTitle.focusSpecialties'), musician: t('labUi.detail.skillsTitle.genresInstruments'), judge: t('labUi.detail.skillsTitle.formatsSpecialties'),
|
||||
barber: t('labUi.detail.skillsTitle.servicesStyles'), organizer: t('labUi.detail.skillsTitle.eventTypesServices'),
|
||||
};
|
||||
const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills';
|
||||
const skillsTitle = SKILLS_TITLES[profile.profileType] ?? t('labUi.detail.servicesSkills');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -236,7 +238,7 @@ export default function CoachDetailPage() {
|
|||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to The Lab
|
||||
{t('labUi.become.backToLab')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
|
@ -274,7 +276,7 @@ export default function CoachDetailPage() {
|
|||
<TypeIcon className={`h-7 w-7 ${accent.text}`} />
|
||||
{profile.entityType === 'collective' && (
|
||||
<Badge variant="outline" className={`gap-1 ${accent.text} ${accent.border}`}>
|
||||
<Users className="h-3 w-3" /> Collective
|
||||
<Users className="h-3 w-3" /> {t('labUi.collective')}
|
||||
</Badge>
|
||||
)}
|
||||
<TrustBadge pubkey={profile.pubkey} size="md" />
|
||||
|
|
@ -286,7 +288,7 @@ export default function CoachDetailPage() {
|
|||
</div>
|
||||
<p className="text-gray-400 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Joined {new Date(profile.createdAt * 1000).toLocaleDateString()}
|
||||
{t('labUi.detail.joined')} {new Date(profile.createdAt * 1000).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -294,7 +296,7 @@ export default function CoachDetailPage() {
|
|||
{/* Bio */}
|
||||
{profile.bio && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2">About</h3>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2">{t('labUi.detail.about')}</h3>
|
||||
<p className="text-gray-300 whitespace-pre-wrap">{profile.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -329,7 +331,7 @@ export default function CoachDetailPage() {
|
|||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
{isBuilder ? <HardHat className={`h-4 w-4 ${accent.text}`} /> : <Aperture className={`h-4 w-4 ${accent.text}`} />}
|
||||
{isBuilder ? 'How We Work' : 'In the Bag'}
|
||||
{isBuilder ? t('labUi.detail.howWeWork') : t('labUi.detail.inTheBag')}
|
||||
</h3>
|
||||
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
{pget('gear')}
|
||||
|
|
@ -342,13 +344,13 @@ export default function CoachDetailPage() {
|
|||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{pget('hardware') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">Hardware</h4>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.hardware')}</h4>
|
||||
<p className="text-sm text-gray-300">{pget('hardware')}</p>
|
||||
</div>
|
||||
)}
|
||||
{pget('software') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">Software</h4>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.software')}</h4>
|
||||
<p className="text-sm text-gray-300">{pget('software')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -359,7 +361,7 @@ export default function CoachDetailPage() {
|
|||
{isJudge && pget('events_judged') && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<Gavel className={`h-4 w-4 ${accent.text}`} /> Events Judged
|
||||
<Gavel className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.eventsJudged')}
|
||||
</h3>
|
||||
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
{pget('events_judged')}
|
||||
|
|
@ -371,7 +373,7 @@ export default function CoachDetailPage() {
|
|||
{isOrganizer && pget('events_hosted') && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<Tent className={`h-4 w-4 ${accent.text}`} /> Events Organized
|
||||
<Tent className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.eventsOrganized')}
|
||||
</h3>
|
||||
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
{pget('events_hosted')}
|
||||
|
|
@ -383,7 +385,7 @@ export default function CoachDetailPage() {
|
|||
{'spotRefs' in profile && profile.spotRefs && profile.spotRefs.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<MapPin className={`h-4 w-4 ${accent.text}`} /> Spots
|
||||
<MapPin className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.spots')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.spotRefs.map((ref) => {
|
||||
|
|
@ -395,7 +397,7 @@ export default function CoachDetailPage() {
|
|||
to={`/spot/${pk}/${rest.join(':')}`}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200 hover:bg-white/5`}
|
||||
>
|
||||
<MapPin className="inline h-3 w-3 mr-1" /> View spot
|
||||
<MapPin className="inline h-3 w-3 mr-1" /> {t('labUi.detail.viewSpot')}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
|
@ -409,7 +411,7 @@ export default function CoachDetailPage() {
|
|||
{pget('serviceArea') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
|
||||
<MapPin className={`h-3 w-3 ${accent.text}`} /> Service Area
|
||||
<MapPin className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.serviceArea')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">{pget('serviceArea')}</p>
|
||||
</div>
|
||||
|
|
@ -417,7 +419,7 @@ export default function CoachDetailPage() {
|
|||
{pbool('insured') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
|
||||
<HardHat className={`h-4 w-4 ${accent.text}`} />
|
||||
<span className="text-sm font-bold text-gray-200">Licensed / Insured</span>
|
||||
<span className="text-sm font-bold text-gray-200">{t('labUi.detail.licensedInsured')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -429,7 +431,7 @@ export default function CoachDetailPage() {
|
|||
{pget('studio') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
|
||||
<MapPin className={`h-3 w-3 ${accent.text}`} /> Studio
|
||||
<MapPin className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.studio')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">{pget('studio')}</p>
|
||||
</div>
|
||||
|
|
@ -437,7 +439,7 @@ export default function CoachDetailPage() {
|
|||
{pbool('booksOpen') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
|
||||
<Brush className={`h-4 w-4 ${accent.text}`} />
|
||||
<span className="text-sm font-bold text-emerald-400">Books Open</span>
|
||||
<span className="text-sm font-bold text-emerald-400">{t('labUi.detail.booksOpen')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -449,7 +451,7 @@ export default function CoachDetailPage() {
|
|||
{pget('studio') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
|
||||
<MapPin className={`h-3 w-3 ${accent.text}`} /> Shop / Studio
|
||||
<MapPin className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.shopStudio')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">{pget('studio')}</p>
|
||||
</div>
|
||||
|
|
@ -457,7 +459,7 @@ export default function CoachDetailPage() {
|
|||
{pbool('booksOpen') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
|
||||
<Sparkles className={`h-4 w-4 ${accent.text}`} />
|
||||
<span className="text-sm font-bold text-emerald-400">Books Open</span>
|
||||
<span className="text-sm font-bold text-emerald-400">{t('labUi.detail.booksOpen')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -469,7 +471,7 @@ export default function CoachDetailPage() {
|
|||
{pget('credentials') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
|
||||
<Dumbbell className={`h-3 w-3 ${accent.text}`} /> Credentials
|
||||
<Dumbbell className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.credentials')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">{pget('credentials')}</p>
|
||||
</div>
|
||||
|
|
@ -477,7 +479,7 @@ export default function CoachDetailPage() {
|
|||
{pbool('remote') && (
|
||||
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
|
||||
<Globe className={`h-4 w-4 ${accent.text}`} />
|
||||
<span className="text-sm font-bold text-gray-200">Remote / Online available</span>
|
||||
<span className="text-sm font-bold text-gray-200">{t('labUi.detail.remoteOnline')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -487,7 +489,7 @@ export default function CoachDetailPage() {
|
|||
{isGraffiti && pget('crew') && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
|
||||
<SprayCan className={`h-3 w-3 ${accent.text}`} /> Graffiti Crew
|
||||
<SprayCan className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.graffitiCrew')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-300">{pget('crew')}</p>
|
||||
</div>
|
||||
|
|
@ -498,14 +500,14 @@ export default function CoachDetailPage() {
|
|||
<div className="mb-6 space-y-3">
|
||||
{'actType' in profile && profile.actType && (
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">Act</h4>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.act')}</h4>
|
||||
<p className="text-sm text-gray-300 capitalize">{profile.actType}</p>
|
||||
</div>
|
||||
)}
|
||||
{(('spotify' in profile && profile.spotify) || ('bandcamp' in profile && profile.bandcamp) || ('soundcloud' in profile && profile.soundcloud) || ('youtube' in profile && profile.youtube)) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-1">
|
||||
<Music className={`h-3 w-3 ${accent.text}`} /> Listen
|
||||
<Music className={`h-3 w-3 ${accent.text}`} /> {t('labUi.detail.listen')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{'spotify' in profile && sanitizeUrl(profile.spotify) && (
|
||||
|
|
@ -531,7 +533,7 @@ export default function CoachDetailPage() {
|
|||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||
<Calendar className={`h-4 w-4 ${accent.text}`} />
|
||||
Upcoming Gigs
|
||||
{t('labUi.detail.upcomingGigs')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{gigs.slice(0, 6).map((gig) => {
|
||||
|
|
@ -553,7 +555,7 @@ export default function CoachDetailPage() {
|
|||
</p>
|
||||
</div>
|
||||
{!isUpcoming && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">Past</Badge>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">{t('labUi.detail.past')}</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -566,13 +568,13 @@ export default function CoachDetailPage() {
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
{profile.experience && (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">Experience</h4>
|
||||
<p className="text-gray-300">{profile.experience} years</p>
|
||||
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.experience')}</h4>
|
||||
<p className="text-gray-300">{t('labUi.detail.yearsCount', { count: profile.experience })}</p>
|
||||
</div>
|
||||
)}
|
||||
{profile.rate && (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">Rate</h4>
|
||||
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.rate')}</h4>
|
||||
<p className="text-gray-300">{profile.rate}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -588,7 +590,7 @@ export default function CoachDetailPage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="text-lg text-[#ccff00] uppercase font-black flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
Contact
|
||||
{t('labUi.detail.contact')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
|
|
@ -599,7 +601,7 @@ export default function CoachDetailPage() {
|
|||
onClick={() => navigate(`/messages/${npub}`)}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Send Message
|
||||
{t('labUi.detail.sendMessage')}
|
||||
</Button>
|
||||
|
||||
{/* Book this act for an event — deep-links to event creation
|
||||
|
|
@ -614,7 +616,7 @@ export default function CoachDetailPage() {
|
|||
}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Book for a Gig
|
||||
{t('labUi.detail.bookGig')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -658,7 +660,7 @@ export default function CoachDetailPage() {
|
|||
{!isCoach && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
|
||||
<a href={sanitizeUrl(profile.portfolio)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.portfolio!)}`}>
|
||||
<Aperture className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="truncate">Portfolio · {urlDomain(profile.portfolio!)}</span>
|
||||
<span className="truncate">{t('labUi.detail.portfolioDomain', { domain: urlDomain(profile.portfolio!) })}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
|
@ -704,21 +706,21 @@ export default function CoachDetailPage() {
|
|||
<Card className="bg-black/90 border-2 border-red-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-red-400 uppercase font-black">
|
||||
{user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}
|
||||
{user.pubkey === profile.pubkey ? t('labUi.detail.manage') : t('labUi.detail.admin')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-gray-400">
|
||||
{user.pubkey === profile.pubkey
|
||||
? `Remove your ${typeNoun} profile from The Lab.`
|
||||
: `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`}
|
||||
? t('labUi.detail.manageRemove', { noun: typeNoun })
|
||||
: t('labUi.detail.adminTakeDown', { noun: typeNoun })}
|
||||
</p>
|
||||
<DeleteEntityDialog
|
||||
event={profile.event}
|
||||
entityLabel={`${typeNoun} profile`}
|
||||
entityLabel={t('labUi.detail.entityLabel', { noun: typeNoun })}
|
||||
entityName={profile.title}
|
||||
redirectTo="/lab"
|
||||
triggerText="Delete Profile"
|
||||
triggerText={t('labUi.detail.deleteProfile')}
|
||||
className="w-full justify-center"
|
||||
/>
|
||||
</CardContent>
|
||||
|
|
@ -760,7 +762,7 @@ export default function CoachDetailPage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
|
||||
<MapPin className={`h-5 w-5 ${accent.text}`} />
|
||||
Location
|
||||
{t('labUi.detail.location')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ import { Nip05Required } from '@/components/Nip05Required';
|
|||
import { SpamFeePayment } from '@/components/SpamFeePayment';
|
||||
import { MARKETPLACE_ITEM_KIND, GEAR_CATEGORIES, GEAR_CONDITIONS, PRICE_TYPES, SIZE_OPTIONS, TRUCK_HEIGHTS, WHEEL_HARDNESS, EXPIRATION_OPTIONS, CURRENCY_OPTIONS, CATEGORY_REQUIRED_FIELDS } from '@/lib/types';
|
||||
import { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
|
||||
export default function CreateListingPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'List Gear - SkateSpots',
|
||||
description: 'List skateboarding gear for sale or find gear you want to buy.',
|
||||
|
|
@ -144,12 +146,12 @@ export default function CreateListingPage() {
|
|||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImages((prev) => [...prev, url]);
|
||||
toast({ title: 'Image uploaded' });
|
||||
toast({ title: t('marketUi.create.imageUploaded') });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error instanceof Error ? error.message : 'Failed',
|
||||
title: t('marketUi.create.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : t('marketUi.create.failed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -172,16 +174,16 @@ export default function CreateListingPage() {
|
|||
if (!restrictions.canCreateListing && !feePaid) {
|
||||
if (restrictions.isInCooldown) {
|
||||
toast({
|
||||
title: 'Cooldown active',
|
||||
description: `Please wait ${formatCooldownTime(restrictions.timeUntilCooldownEnds)} before creating another listing`,
|
||||
title: t('marketUi.create.cooldownActive'),
|
||||
description: t('marketUi.create.cooldownWait', { time: formatCooldownTime(restrictions.timeUntilCooldownEnds) }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (restrictions.remainingListings <= 0) {
|
||||
toast({
|
||||
title: 'Listing limit reached',
|
||||
description: `You've reached your monthly limit of ${restrictions.listingLimit} listings`,
|
||||
title: t('marketUi.create.limitReached'),
|
||||
description: t('marketUi.create.limitReachedBody', { limit: restrictions.listingLimit }),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -190,8 +192,8 @@ export default function CreateListingPage() {
|
|||
|
||||
if (restrictions.requiresFee && !feePaid) {
|
||||
toast({
|
||||
title: 'Fee required',
|
||||
description: 'Please pay the anti-spam fee to continue',
|
||||
title: t('marketUi.create.feeRequired'),
|
||||
description: t('marketUi.create.feeRequiredBody'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -199,19 +201,19 @@ export default function CreateListingPage() {
|
|||
|
||||
// Validation
|
||||
if (!title.trim()) {
|
||||
toast({ title: 'Title required', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.create.titleRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
toast({ title: 'Category required', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.create.categoryRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (listingType === 'selling' && images.length === 0) {
|
||||
toast({
|
||||
title: 'Photo required',
|
||||
description: 'At least one photo is required when selling gear',
|
||||
title: t('marketUi.create.photoRequired'),
|
||||
description: t('marketUi.create.photoRequiredBody'),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
|
|
@ -219,23 +221,23 @@ export default function CreateListingPage() {
|
|||
|
||||
if (!hasNip05) {
|
||||
toast({
|
||||
title: 'NIP-05 verification required',
|
||||
description: 'You need a verified Nostr address to list gear',
|
||||
title: t('marketUi.create.nip05Required'),
|
||||
description: t('marketUi.create.nip05RequiredBody'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (priceType === 'fixed' && !price.trim()) {
|
||||
toast({ title: 'Price required for fixed price listings', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.create.priceRequired'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Category-specific validation
|
||||
if (requiresSize && !size) {
|
||||
toast({
|
||||
title: 'Size required',
|
||||
description: `Please select a size for ${GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES]}`,
|
||||
title: t('marketUi.create.sizeRequired'),
|
||||
description: t('marketUi.create.sizeRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
|
|
@ -243,8 +245,8 @@ export default function CreateListingPage() {
|
|||
|
||||
if (requiresCondition && !condition) {
|
||||
toast({
|
||||
title: 'Condition required',
|
||||
description: `Please select the condition of your ${GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES]}`,
|
||||
title: t('marketUi.create.conditionRequired'),
|
||||
description: t('marketUi.create.conditionRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
|
|
@ -325,8 +327,8 @@ export default function CreateListingPage() {
|
|||
}
|
||||
|
||||
toast({
|
||||
title: listingType === 'wtb' ? 'Want to Buy posted!' : 'Listing created!',
|
||||
description: listingType === 'wtb' ? 'Sellers will be able to find your request.' : 'Your gear is now listed.',
|
||||
title: listingType === 'wtb' ? t('marketUi.create.wtbPosted') : t('marketUi.create.listingCreated'),
|
||||
description: listingType === 'wtb' ? t('marketUi.create.wtbPostedBody') : t('marketUi.create.listingCreatedBody'),
|
||||
});
|
||||
|
||||
// Navigate back
|
||||
|
|
@ -337,8 +339,8 @@ export default function CreateListingPage() {
|
|||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to publish',
|
||||
description: error instanceof Error ? error.message : 'Error',
|
||||
title: t('marketUi.create.publishFailed'),
|
||||
description: error instanceof Error ? error.message : t('common.error'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -351,8 +353,8 @@ export default function CreateListingPage() {
|
|||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to list gear.</p>
|
||||
<h2 className="text-xl font-bold mb-2">{t('marketUi.create.loginRequired')}</h2>
|
||||
<p className="text-gray-400 mb-6">{t('marketUi.create.loginRequiredBody')}</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -370,15 +372,15 @@ export default function CreateListingPage() {
|
|||
className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{fromStash ? 'Back to Stash' : (selectedSpot ? `Back to ${selectedSpot.title}` : (returnTo === '/marketplace' ? 'Back to Marketplace' : 'Back to Map'))}
|
||||
{fromStash ? t('marketUi.create.backToStash') : (selectedSpot ? t('marketUi.create.backToSpot', { title: selectedSpot.title }) : (returnTo === '/marketplace' ? t('marketUi.create.backToMarketplace') : t('marketUi.create.backToMap')))}
|
||||
</Link>
|
||||
<PageHeader
|
||||
title={listingType === 'wtb' ? 'Want to Buy' : 'List Your Gear'}
|
||||
title={listingType === 'wtb' ? t('marketUi.create.wantToBuy') : t('marketUi.create.listYourGear')}
|
||||
accent="#ff6600"
|
||||
subtitle={
|
||||
listingType === 'wtb'
|
||||
? "Tell the community what gear you're looking for"
|
||||
: 'Sell skateboarding gear to the community'
|
||||
? t('marketUi.create.wtbSubtitle')
|
||||
: t('marketUi.create.sellSubtitle')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -392,9 +394,9 @@ export default function CreateListingPage() {
|
|||
<ShoppingBag className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-purple-400 mb-0.5">Listing from your Stash</h3>
|
||||
<h3 className="font-bold text-purple-400 mb-0.5">{t('marketUi.create.fromStashTitle')}</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
Pre-filled with your stash item details. Review and pick a meetup spot!
|
||||
{t('marketUi.create.fromStashBody')}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/stash" className="text-purple-400 hover:text-purple-300">
|
||||
|
|
@ -410,7 +412,7 @@ export default function CreateListingPage() {
|
|||
<Nip05Required
|
||||
variant="banner"
|
||||
role="sellers"
|
||||
reason="To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear."
|
||||
reason={t('marketUi.create.nip05Banner')}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -421,7 +423,7 @@ export default function CreateListingPage() {
|
|||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-400 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-green-400">Verified: </span>
|
||||
<span className="font-bold text-green-400">{t('marketUi.create.verifiedLabel')} </span>
|
||||
<span className="text-gray-300">{metadata?.nip05}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -436,7 +438,7 @@ export default function CreateListingPage() {
|
|||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
|
||||
<div>
|
||||
<span className="text-gray-400">Checking your trust level...</span>
|
||||
<span className="text-gray-400">{t('marketUi.create.checkingTrust')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -452,7 +454,7 @@ export default function CreateListingPage() {
|
|||
<Shield className="h-5 w-5 text-blue-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-gray-200">Trust Level: </span>
|
||||
<span className="font-bold text-gray-200">{t('marketUi.create.trustLevel')} </span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
|
|
@ -462,14 +464,14 @@ export default function CreateListingPage() {
|
|||
'border-gray-500 text-gray-400'
|
||||
}
|
||||
>
|
||||
{restrictions.trustLevel === 'trusted' ? '⭐ Trusted' :
|
||||
restrictions.trustLevel === 'active' ? '🔥 Active' :
|
||||
restrictions.trustLevel === 'newcomer' ? '🌱 Newcomer' :
|
||||
'👋 New User'}
|
||||
{restrictions.trustLevel === 'trusted' ? `⭐ ${t('marketUi.create.trusted')}` :
|
||||
restrictions.trustLevel === 'active' ? `🔥 ${t('marketUi.create.active')}` :
|
||||
restrictions.trustLevel === 'newcomer' ? `🌱 ${t('marketUi.create.newcomer')}` :
|
||||
`👋 ${t('marketUi.create.newUser')}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{restrictions.remainingListings} of {restrictions.listingLimit} listings remaining this month
|
||||
{t('marketUi.create.remaining', { remaining: restrictions.remainingListings, limit: restrictions.listingLimit })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -485,12 +487,12 @@ export default function CreateListingPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-bold text-orange-400 mb-1">Cooldown Active</h3>
|
||||
<h3 className="font-bold text-orange-400 mb-1">{t('marketUi.create.cooldownActive')}</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
New users have a 24-hour cooldown between listings.
|
||||
{t('marketUi.create.cooldownBody')}
|
||||
<br />
|
||||
<span className="font-semibold">
|
||||
Time remaining: {formatCooldownTime(restrictions.timeUntilCooldownEnds)}
|
||||
{t('marketUi.create.timeRemaining', { time: formatCooldownTime(restrictions.timeUntilCooldownEnds) })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -506,10 +508,9 @@ export default function CreateListingPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-bold text-red-400 mb-1">Monthly Limit Reached</h3>
|
||||
<h3 className="font-bold text-red-400 mb-1">{t('marketUi.create.monthlyLimitTitle')}</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
You've reached your limit of {restrictions.listingLimit} listings this month.
|
||||
Build your reputation by creating spots or completing sales to increase your limit.
|
||||
{t('marketUi.create.monthlyLimitBody', { limit: restrictions.listingLimit })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -540,9 +541,9 @@ export default function CreateListingPage() {
|
|||
<Zap className="h-5 w-5 text-green-400 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-bold text-green-400">
|
||||
{powBypass ? 'Proof of work accepted!' : 'Fee Paid!'}
|
||||
{powBypass ? t('marketUi.create.powAccepted') : t('marketUi.create.feePaid')}
|
||||
</span>
|
||||
<span className="text-gray-300 ml-2">You can now create your listing.</span>
|
||||
<span className="text-gray-300 ml-2">{t('marketUi.create.canCreateNow')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -553,7 +554,7 @@ export default function CreateListingPage() {
|
|||
{/* Listing Type Toggle */}
|
||||
<Card className="bg-gray-900 border-gray-700 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>What do you want to do?</CardTitle>
|
||||
<CardTitle>{t('marketUi.create.whatDoTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -568,7 +569,7 @@ export default function CreateListingPage() {
|
|||
onClick={() => setListingType('selling')}
|
||||
>
|
||||
<span className="text-2xl">💰</span>
|
||||
<span className="font-bold">SELL GEAR</span>
|
||||
<span className="font-bold">{t('marketUi.create.sellGear')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -581,7 +582,7 @@ export default function CreateListingPage() {
|
|||
onClick={() => setListingType('wtb')}
|
||||
>
|
||||
<span className="text-2xl">🔍</span>
|
||||
<span className="font-bold">WANT TO BUY</span>
|
||||
<span className="font-bold">{t('marketUi.create.wantToBuy')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -590,8 +591,8 @@ export default function CreateListingPage() {
|
|||
{/* Category Selection */}
|
||||
<Card className="bg-gray-900 border-gray-700 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Category *</CardTitle>
|
||||
<CardDescription className="text-gray-400">What type of gear?</CardDescription>
|
||||
<CardTitle>{t('marketUi.create.categoryTitle')}</CardTitle>
|
||||
<CardDescription className="text-gray-400">{t('marketUi.create.categoryHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
|
|
@ -623,17 +624,17 @@ export default function CreateListingPage() {
|
|||
{/* Gear Details */}
|
||||
<Card className="bg-gray-900 border-gray-700 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Gear Details</CardTitle>
|
||||
<CardTitle>{t('marketUi.create.detailsTitle')}</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
{listingType === 'wtb' ? 'Describe what you\'re looking for' : 'Tell buyers about your item'}
|
||||
{listingType === 'wtb' ? t('marketUi.create.detailsWtbHint') : t('marketUi.create.detailsSellHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Label htmlFor="title">{t('marketUi.create.titleLabel')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={listingType === 'wtb' ? 'Looking for 8.0" deck' : 'Almost New Palace Deck 8.25"'}
|
||||
placeholder={listingType === 'wtb' ? t('marketUi.create.titlePlaceholderWtb') : t('marketUi.create.titlePlaceholderSell')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
|
|
@ -642,10 +643,10 @@ export default function CreateListingPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="summary">Short Summary</Label>
|
||||
<Label htmlFor="summary">{t('marketUi.create.summaryLabel')}</Label>
|
||||
<Input
|
||||
id="summary"
|
||||
placeholder={listingType === 'wtb' ? 'Any brand, prefer minimal graphics' : 'Great condition, barely used'}
|
||||
placeholder={listingType === 'wtb' ? t('marketUi.create.summaryPlaceholderWtb') : t('marketUi.create.summaryPlaceholderSell')}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
|
|
@ -653,13 +654,13 @@ export default function CreateListingPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Label htmlFor="description">{t('marketUi.create.descriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={
|
||||
listingType === 'wtb'
|
||||
? 'Describe exactly what you\'re looking for - brand preferences, condition requirements, etc.'
|
||||
: 'Got this deck for Christmas but prefer a smaller size. Happy to let you try it before buying!'
|
||||
? t('marketUi.create.descriptionPlaceholderWtb')
|
||||
: t('marketUi.create.descriptionPlaceholderSell')
|
||||
}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
|
|
@ -671,7 +672,7 @@ export default function CreateListingPage() {
|
|||
{/* Condition (only for selling) */}
|
||||
{listingType === 'selling' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Condition {requiresCondition && <span className="text-red-400">*</span>}</Label>
|
||||
<Label>{t('marketplace.condition')} {requiresCondition && <span className="text-red-400">*</span>}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(GEAR_CONDITIONS).map(([key, { label, emoji, description }]) => (
|
||||
<Button
|
||||
|
|
@ -697,12 +698,12 @@ export default function CreateListingPage() {
|
|||
|
||||
{listingType === 'selling' && (
|
||||
<div className="space-y-2">
|
||||
<Label>How is it fulfilled?</Label>
|
||||
<Label>{t('marketUi.create.fulfilmentLabel')}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['pickup', '📍 Pickup', 'Meet locally'],
|
||||
['ships', '📦 Ships', 'Post it'],
|
||||
['both', '📍📦 Both', 'Either'],
|
||||
['pickup', `📍 ${t('marketUi.create.pickup')}`, t('marketUi.create.pickupHint')],
|
||||
['ships', `📦 ${t('marketUi.create.ships')}`, t('marketUi.create.shipsHint')],
|
||||
['both', `📍📦 ${t('marketUi.create.both')}`, t('marketUi.create.bothHint')],
|
||||
] as const).map(([key, label, hint]) => (
|
||||
<Button
|
||||
key={key}
|
||||
|
|
@ -721,19 +722,19 @@ export default function CreateListingPage() {
|
|||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
We never collect your address — for shipping, share it privately in the trade chat.
|
||||
{t('marketUi.create.addressNote')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listingType === 'selling' && fulfilment !== 'pickup' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Who pays postage?</Label>
|
||||
<Label>{t('marketUi.create.postageLabel')}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['buyer', 'Buyer pays'],
|
||||
['seller', 'I pay'],
|
||||
['split', 'Split'],
|
||||
['buyer', t('marketUi.create.postageBuyer')],
|
||||
['seller', t('marketUi.create.postageSeller')],
|
||||
['split', t('marketUi.create.postageSplit')],
|
||||
] as const).map(([key, label]) => (
|
||||
<Button
|
||||
key={key}
|
||||
|
|
@ -756,10 +757,10 @@ export default function CreateListingPage() {
|
|||
{/* Size */}
|
||||
{currentSizeOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Size {requiresSize && <span className="text-red-400">*</span>}</Label>
|
||||
<Label>{t('marketUi.create.sizeLabel')} {requiresSize && <span className="text-red-400">*</span>}</Label>
|
||||
<Select value={size} onValueChange={setSize}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue placeholder="Select size" />
|
||||
<SelectValue placeholder={t('marketUi.create.selectSize')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentSizeOptions.map((s) => (
|
||||
|
|
@ -775,10 +776,10 @@ export default function CreateListingPage() {
|
|||
{/* Truck Height */}
|
||||
{showTruckHeight && (
|
||||
<div className="space-y-2">
|
||||
<Label>Truck Height</Label>
|
||||
<Label>{t('marketUi.create.truckHeight')}</Label>
|
||||
<Select value={truckHeight} onValueChange={setTruckHeight}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue placeholder="Select height" />
|
||||
<SelectValue placeholder={t('marketUi.create.selectHeight')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRUCK_HEIGHTS.map((h) => (
|
||||
|
|
@ -794,10 +795,10 @@ export default function CreateListingPage() {
|
|||
{/* Wheel Hardness */}
|
||||
{showWheelHardness && (
|
||||
<div className="space-y-2">
|
||||
<Label>Wheel Hardness</Label>
|
||||
<Label>{t('marketUi.create.wheelHardness')}</Label>
|
||||
<Select value={wheelHardness} onValueChange={setWheelHardness}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue placeholder="Select hardness" />
|
||||
<SelectValue placeholder={t('marketUi.create.selectHardness')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WHEEL_HARDNESS.map((h) => (
|
||||
|
|
@ -815,9 +816,9 @@ export default function CreateListingPage() {
|
|||
{/* Pricing */}
|
||||
<Card className="bg-gray-900 border-gray-700 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Pricing</CardTitle>
|
||||
<CardTitle>{t('marketUi.create.pricingTitle')}</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
{listingType === 'wtb' ? 'How do you want to pay?' : 'How do you want to sell?'}
|
||||
{listingType === 'wtb' ? t('marketUi.create.pricingWtbHint') : t('marketUi.create.pricingSellHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
|
@ -843,7 +844,7 @@ export default function CreateListingPage() {
|
|||
{priceType === 'fixed' && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="price">Price *</Label>
|
||||
<Label htmlFor="price">{t('marketUi.create.priceLabel')}</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
|
|
@ -857,7 +858,7 @@ export default function CreateListingPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Label htmlFor="currency">{t('marketUi.create.currencyLabel')}</Label>
|
||||
<Select value={currency} onValueChange={setCurrency}>
|
||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
||||
<SelectValue />
|
||||
|
|
@ -880,12 +881,12 @@ export default function CreateListingPage() {
|
|||
<Card className="bg-gray-900 border-gray-700 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Photos {listingType === 'selling' && '*'}
|
||||
{t('marketUi.create.photosTitle')} {listingType === 'selling' && '*'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
{listingType === 'selling'
|
||||
? 'At least one photo is required'
|
||||
: 'Optional - add reference images'}
|
||||
? t('marketUi.create.photosSellHint')
|
||||
: t('marketUi.create.photosWtbHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -897,7 +898,7 @@ export default function CreateListingPage() {
|
|||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Product ${index + 1}`}
|
||||
alt={t('marketUi.create.productPhoto', { n: index + 1 })}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
|
|
@ -923,14 +924,14 @@ export default function CreateListingPage() {
|
|||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 mb-1 text-gray-500" />
|
||||
<span className="text-xs text-gray-500">Upload</span>
|
||||
<span className="text-xs text-gray-500">{t('marketUi.create.upload')}</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Add up to 5 photos. First image will be the main photo.
|
||||
{t('marketUi.create.photosNote')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -940,10 +941,10 @@ export default function CreateListingPage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5 text-orange-500" />
|
||||
Pickup Location
|
||||
{t('marketUi.create.pickupLocationTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
Link to a skate spot for easy meetups
|
||||
{t('marketUi.create.pickupLocationHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -979,7 +980,7 @@ export default function CreateListingPage() {
|
|||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="Search spots..."
|
||||
placeholder={t('marketUi.create.searchSpots')}
|
||||
value={spotSearchQuery}
|
||||
onChange={(e) => setSpotSearchQuery(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700 pl-9"
|
||||
|
|
@ -1006,7 +1007,7 @@ export default function CreateListingPage() {
|
|||
))}
|
||||
{filteredSpots.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
No spots found. Try a different search.
|
||||
{t('marketUi.create.noSpotsFound')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1014,16 +1015,16 @@ export default function CreateListingPage() {
|
|||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="availability">When are you usually available?</Label>
|
||||
<Label htmlFor="availability">{t('marketUi.create.availabilityLabel')}</Label>
|
||||
<Input
|
||||
id="availability"
|
||||
placeholder="Weekends 10am-2pm, Tue/Thu evenings"
|
||||
placeholder={t('marketUi.create.availabilityPlaceholder')}
|
||||
value={availability}
|
||||
onChange={(e) => setAvailability(e.target.value)}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">
|
||||
💡 Helps buyers coordinate meetups with you
|
||||
💡 {t('marketUi.create.availabilityHint')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -1032,9 +1033,9 @@ export default function CreateListingPage() {
|
|||
{/* Expiration */}
|
||||
<Card className="bg-gray-900 border-gray-700 mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Listing Duration</CardTitle>
|
||||
<CardTitle>{t('marketUi.create.durationTitle')}</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
How long should this listing stay active?
|
||||
{t('marketUi.create.durationHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -1075,32 +1076,32 @@ export default function CreateListingPage() {
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Publishing...
|
||||
{t('marketUi.create.publishing')}
|
||||
</>
|
||||
) : restrictions.isLoading && !restrictions.isPlatformOwner ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Checking eligibility...
|
||||
{t('marketUi.create.checkingEligibility')}
|
||||
</>
|
||||
) : restrictions.isInCooldown && !restrictions.isPlatformOwner ? (
|
||||
<>
|
||||
<Clock className="h-5 w-5 mr-2" />
|
||||
COOLDOWN: {formatCooldownTime(restrictions.timeUntilCooldownEnds)}
|
||||
{t('marketUi.create.cooldownButton', { time: formatCooldownTime(restrictions.timeUntilCooldownEnds) })}
|
||||
</>
|
||||
) : restrictions.remainingListings <= 0 && !restrictions.isPlatformOwner ? (
|
||||
<>
|
||||
<AlertTriangle className="h-5 w-5 mr-2" />
|
||||
LIMIT REACHED
|
||||
{t('marketUi.create.limitReachedButton')}
|
||||
</>
|
||||
) : restrictions.requiresFee && !feePaid && !restrictions.isPlatformOwner ? (
|
||||
<>
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
PAY FEE TO CONTINUE
|
||||
{t('marketUi.create.payFeeButton')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShoppingBag className="h-5 w-5 mr-2" />
|
||||
{listingType === 'wtb' ? 'POST WANT TO BUY' : 'LIST GEAR FOR SALE'}
|
||||
{listingType === 'wtb' ? t('marketUi.create.postWtbButton') : t('marketUi.create.listForSaleButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ export default function CreateSpotPage() {
|
|||
const { t } = useTranslation();
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Add a Skate Spot - SkateSpots',
|
||||
description: 'Share a new skateboarding spot with the community.',
|
||||
title: `${t('createSpot.title')} - SkateSpots`,
|
||||
description: t('createSpot.subtitle'),
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -113,9 +113,9 @@ export default function CreateSpotPage() {
|
|||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Photo upload failed',
|
||||
title: t('spotUi.photoUploadFailed'),
|
||||
description:
|
||||
error instanceof Error ? error.message : 'Could not upload the photo. You can add one below.',
|
||||
error instanceof Error ? error.message : t('spotUi.couldNotUploadPhoto'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -134,8 +134,8 @@ export default function CreateSpotPage() {
|
|||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({
|
||||
title: 'Geolocation not supported',
|
||||
description: 'Your browser does not support geolocation.',
|
||||
title: t('spotUi.geolocationNotSupported'),
|
||||
description: t('spotUi.geolocationNotSupportedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -148,14 +148,14 @@ export default function CreateSpotPage() {
|
|||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({
|
||||
title: 'Location found',
|
||||
description: 'Your current location has been set.',
|
||||
title: t('spotUi.locationFound'),
|
||||
description: t('spotUi.locationFoundDesc'),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({
|
||||
title: 'Location error',
|
||||
title: t('spotUi.locationError'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
|
@ -179,15 +179,15 @@ export default function CreateSpotPage() {
|
|||
}
|
||||
if (added > 0) {
|
||||
toast({
|
||||
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded',
|
||||
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
|
||||
description:
|
||||
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.',
|
||||
added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to upload image',
|
||||
title: t('spotUi.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -202,14 +202,14 @@ export default function CreateSpotPage() {
|
|||
setImages((prev) => [...prev, url]);
|
||||
setShowCamera(false);
|
||||
toast({
|
||||
title: 'Photo captured!',
|
||||
description: 'Your photo has been uploaded successfully.',
|
||||
title: t('spotUi.photoCaptured'),
|
||||
description: t('spotUi.photoCapturedDesc'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to upload photo',
|
||||
title: t('spotUi.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -224,8 +224,8 @@ export default function CreateSpotPage() {
|
|||
|
||||
if (!title.trim()) {
|
||||
toast({
|
||||
title: 'Title required',
|
||||
description: 'Please enter a name for this spot.',
|
||||
title: t('spotUi.titleRequired'),
|
||||
description: t('spotUi.titleRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -233,8 +233,8 @@ export default function CreateSpotPage() {
|
|||
|
||||
if (!latitude || !longitude) {
|
||||
toast({
|
||||
title: 'Location required',
|
||||
description: 'Please enter coordinates or use your current location.',
|
||||
title: t('spotUi.locationRequired'),
|
||||
description: t('spotUi.locationRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -245,8 +245,8 @@ export default function CreateSpotPage() {
|
|||
|
||||
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
toast({
|
||||
title: 'Invalid coordinates',
|
||||
description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).',
|
||||
title: t('spotUi.invalidCoordinates'),
|
||||
description: t('spotUi.invalidCoordinatesDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -254,8 +254,8 @@ export default function CreateSpotPage() {
|
|||
|
||||
if (!selectedCategory) {
|
||||
toast({
|
||||
title: 'Spot type required',
|
||||
description: 'Please pick a spot type so skaters know what they’re rolling up to.',
|
||||
title: t('spotUi.spotTypeRequired'),
|
||||
description: t('spotUi.spotTypeRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -265,8 +265,8 @@ export default function CreateSpotPage() {
|
|||
// spot would go out without the photo the user just took.
|
||||
if (isUploadingQuickPhoto) {
|
||||
toast({
|
||||
title: 'Hang tight',
|
||||
description: 'Your photo is still uploading. It’ll be attached in a second.',
|
||||
title: t('spotUi.hangTight'),
|
||||
description: t('spotUi.hangTightDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -277,8 +277,8 @@ export default function CreateSpotPage() {
|
|||
// mapper who has no picture on hand but can describe the place.
|
||||
if (images.length === 0 && !description.trim()) {
|
||||
toast({
|
||||
title: 'Add a photo or a description',
|
||||
description: 'Give skaters something to go on — a picture, or a line about the place.',
|
||||
title: t('spotUi.addPhotoOrDescription'),
|
||||
description: t('spotUi.addPhotoOrDescriptionDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -300,8 +300,8 @@ export default function CreateSpotPage() {
|
|||
if (nearby.length > 0) {
|
||||
setDupWarning(nearby);
|
||||
toast({
|
||||
title: 'That spot may already be on the map',
|
||||
description: 'Check the pin below before adding a duplicate.',
|
||||
title: t('spotUi.spotMayAlreadyBeOnMap'),
|
||||
description: t('spotUi.checkPinBelow'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -380,7 +380,7 @@ export default function CreateSpotPage() {
|
|||
}
|
||||
|
||||
// Add alt tag for NIP-31 (human-readable description)
|
||||
eventTags.push(['alt', `Skate spot: ${title.trim()}`]);
|
||||
eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]);
|
||||
|
||||
await createEvent({
|
||||
kind: SKATE_SPOT_KIND,
|
||||
|
|
@ -389,10 +389,10 @@ export default function CreateSpotPage() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Spot added!',
|
||||
title: t('spotUi.spotAdded'),
|
||||
description: images.length === 0
|
||||
? 'Published — and marked Unverified until someone adds a photo. A picture of the spot goes a long way.'
|
||||
: 'Your skate spot has been published to the network.',
|
||||
? t('spotUi.spotAddedNoPhoto')
|
||||
: t('spotUi.spotAddedDesc'),
|
||||
});
|
||||
|
||||
// Navigate to spot detail using pubkey and identifier
|
||||
|
|
@ -400,8 +400,8 @@ export default function CreateSpotPage() {
|
|||
} catch (error) {
|
||||
console.error('Failed to create spot:', error);
|
||||
toast({
|
||||
title: 'Failed to publish',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong. Check console for details.',
|
||||
title: t('spotUi.failedToPublish'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -663,9 +663,9 @@ export default function CreateSpotPage() {
|
|||
|
||||
{/* Shelter / weather protection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Shelter</Label>
|
||||
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
Is this spot exposed to the weather? Helps skaters find dry spots when it rains.
|
||||
{t('spotUi.shelterHint')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shelterOptions.map((option) => (
|
||||
|
|
@ -700,14 +700,14 @@ export default function CreateSpotPage() {
|
|||
|
||||
{/* Freeform Tags */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Tags</Label>
|
||||
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
Add tags for features, surfaces, vibes (max {MAX_TAGS})
|
||||
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
||||
</p>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder="Type to search or add tags..."
|
||||
placeholder={t('spotUi.typeToSearchTags')}
|
||||
maxTags={MAX_TAGS}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -717,13 +717,13 @@ export default function CreateSpotPage() {
|
|||
<Label>
|
||||
{t('spots.photos')}
|
||||
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
||||
strongly recommended — spots without a photo stay marked Unverified
|
||||
{t('spotUi.photosRecommended')}
|
||||
</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{images.map((url) => (
|
||||
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
||||
<img src={url} alt="Spot" className="w-full h-full object-cover" />
|
||||
<img src={url} alt={t('spotUi.spotPhotoAlt')} className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(url)}
|
||||
|
|
@ -737,7 +737,7 @@ export default function CreateSpotPage() {
|
|||
{isUploadingQuickPhoto && (
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted flex flex-col items-center justify-center border-2 border-dashed border-[#ccff00]/50">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#ccff00] mb-1" />
|
||||
<span className="text-xs text-[#ccff00] font-bold">Uploading photo…</span>
|
||||
<span className="text-xs text-[#ccff00] font-bold">{t('spotUi.uploadingPhoto')}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Camera capture button */}
|
||||
|
|
@ -748,7 +748,7 @@ export default function CreateSpotPage() {
|
|||
className="aspect-video rounded-lg border-2 border-dashed border-[#ff0040]/50 flex flex-col items-center justify-center cursor-pointer hover:bg-[#ff0040]/10 hover:border-[#ff0040] transition-colors"
|
||||
>
|
||||
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
|
||||
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
|
||||
<span className="text-xs text-[#ff0040] font-bold">{t('spotUi.takePhoto')}</span>
|
||||
</button>
|
||||
{/* File upload / drop zone — accepts several photos at once */}
|
||||
<PhotoDropzone
|
||||
|
|
@ -783,12 +783,12 @@ export default function CreateSpotPage() {
|
|||
{dupWarning && (
|
||||
<div className="rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
|
||||
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
|
||||
This spot may already exist
|
||||
{t('spotUi.spotMayAlreadyExist')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-300">
|
||||
{dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of
|
||||
your pin. If it's the same place, add your photo or a status report there
|
||||
instead of creating a duplicate.
|
||||
{dupWarning.length === 1
|
||||
? t('spotUi.duplicateOne')
|
||||
: t('spotUi.duplicateOther')}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{dupWarning.map(({ spot, km }) => (
|
||||
|
|
@ -797,7 +797,7 @@ export default function CreateSpotPage() {
|
|||
to={`/spot/${spot.pubkey}/${spot.identifier}`}
|
||||
className="text-[#ccff00] hover:opacity-80"
|
||||
>
|
||||
{spot.title || 'Untitled spot'} · {Math.round(km * 1000)} m away
|
||||
{spot.title || t('spotUi.untitledSpot')} · {t('spotUi.metresAway', { distance: Math.round(km * 1000) })}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -813,7 +813,7 @@ export default function CreateSpotPage() {
|
|||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
It's a different spot — publish anyway
|
||||
{t('spotUi.differentSpotPublishAnyway')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -821,7 +821,7 @@ export default function CreateSpotPage() {
|
|||
className="text-gray-400"
|
||||
onClick={() => setDupWarning(null)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -841,7 +841,7 @@ export default function CreateSpotPage() {
|
|||
) : isUploadingQuickPhoto ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Uploading photo…
|
||||
{t('spotUi.uploadingPhoto')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
||||
import { TagInput } from '@/components/TagInput';
|
||||
|
|
@ -60,6 +61,7 @@ function EditSpotSkeleton() {
|
|||
}
|
||||
|
||||
export default function EditSpotPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -84,8 +86,8 @@ export default function EditSpotPage() {
|
|||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
useSeoMeta({
|
||||
title: spot ? `Edit ${spot.title} - SkateSpots` : 'Edit Spot - SkateSpots',
|
||||
description: 'Edit your skateboarding spot.',
|
||||
title: spot ? `${t('spots.editSpot')} ${spot.title} - SkateSpots` : `${t('spots.editSpot')} - SkateSpots`,
|
||||
description: t('spotUi.editSpotSeoDesc'),
|
||||
});
|
||||
|
||||
// Initialize form with existing data
|
||||
|
|
@ -123,8 +125,8 @@ export default function EditSpotPage() {
|
|||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({
|
||||
title: 'Geolocation not supported',
|
||||
description: 'Your browser does not support geolocation.',
|
||||
title: t('spotUi.geolocationNotSupported'),
|
||||
description: t('spotUi.geolocationNotSupportedDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -137,14 +139,14 @@ export default function EditSpotPage() {
|
|||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({
|
||||
title: 'Location found',
|
||||
description: 'Your current location has been set.',
|
||||
title: t('spotUi.locationFound'),
|
||||
description: t('spotUi.locationFoundDesc'),
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({
|
||||
title: 'Location error',
|
||||
title: t('spotUi.locationError'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
|
@ -167,15 +169,15 @@ export default function EditSpotPage() {
|
|||
}
|
||||
if (added > 0) {
|
||||
toast({
|
||||
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded',
|
||||
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
|
||||
description:
|
||||
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.',
|
||||
added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to upload image',
|
||||
title: t('spotUi.uploadFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -192,8 +194,8 @@ export default function EditSpotPage() {
|
|||
|
||||
if (!title.trim()) {
|
||||
toast({
|
||||
title: 'Title required',
|
||||
description: 'Please enter a name for this spot.',
|
||||
title: t('spotUi.titleRequired'),
|
||||
description: t('spotUi.titleRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -201,8 +203,8 @@ export default function EditSpotPage() {
|
|||
|
||||
if (!latitude || !longitude) {
|
||||
toast({
|
||||
title: 'Location required',
|
||||
description: 'Please enter coordinates or use your current location.',
|
||||
title: t('spotUi.locationRequired'),
|
||||
description: t('spotUi.locationRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -213,8 +215,8 @@ export default function EditSpotPage() {
|
|||
|
||||
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
toast({
|
||||
title: 'Invalid coordinates',
|
||||
description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).',
|
||||
title: t('spotUi.invalidCoordinates'),
|
||||
description: t('spotUi.invalidCoordinatesDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -222,8 +224,8 @@ export default function EditSpotPage() {
|
|||
|
||||
if (!selectedCategory) {
|
||||
toast({
|
||||
title: 'Spot type required',
|
||||
description: 'Please pick a spot type so skaters know what they’re rolling up to.',
|
||||
title: t('spotUi.spotTypeRequired'),
|
||||
description: t('spotUi.spotTypeRequiredDesc'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
|
|
@ -248,7 +250,7 @@ export default function EditSpotPage() {
|
|||
['access', accessType],
|
||||
['lat', lat.toString()],
|
||||
['lon', lng.toString()],
|
||||
['alt', `Skate spot: ${title.trim()}`],
|
||||
['alt', t('spotUi.altSkateSpot', { title: title.trim() })],
|
||||
];
|
||||
pushRegionTags(eventTags, geo);
|
||||
|
||||
|
|
@ -290,15 +292,15 @@ export default function EditSpotPage() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: 'Spot updated!',
|
||||
description: 'Your changes have been published to the network.',
|
||||
title: t('spotUi.spotUpdated'),
|
||||
description: t('spotUi.spotUpdatedDesc'),
|
||||
});
|
||||
|
||||
navigate(`/spot/${user?.pubkey}/${spot.identifier}`);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to update',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('spotUi.failedToUpdate'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -320,9 +322,9 @@ export default function EditSpotPage() {
|
|||
<Card className="max-w-xl mx-auto border-dashed">
|
||||
<CardContent className="py-12 text-center">
|
||||
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<h2 className="text-xl font-bold mb-2">{t('spotUi.loginRequiredTitle')}</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You need to log in to edit this spot.
|
||||
{t('spotUi.loginToEditSpot')}
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</CardContent>
|
||||
|
|
@ -339,12 +341,12 @@ export default function EditSpotPage() {
|
|||
<Card className="max-w-xl mx-auto border-destructive">
|
||||
<CardContent className="py-12 text-center">
|
||||
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-destructive" />
|
||||
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
|
||||
<h2 className="text-xl font-bold mb-2">{t('spotUi.accessDeniedTitle')}</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You can only edit spots that you created.
|
||||
{t('spotUi.accessDeniedDesc')}
|
||||
</p>
|
||||
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
||||
<Button variant="outline">Back to Spot</Button>
|
||||
<Button variant="outline">{t('spotUi.backToSpot')}</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -362,29 +364,29 @@ export default function EditSpotPage() {
|
|||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Spot
|
||||
{t('spotUi.backToSpot')}
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold">Edit Spot</h1>
|
||||
<h1 className="text-3xl font-bold">{t('spots.editSpot')}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Update the details for {spot.title}
|
||||
{t('spotUi.updateDetailsFor', { title: spot.title })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Spot Details</CardTitle>
|
||||
<CardTitle>{t('spots.spotDetails')}</CardTitle>
|
||||
<CardDescription>
|
||||
Update the information about this spot. All fields marked with * are required.
|
||||
{t('spotUi.updateSpotDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Spot Name *</Label>
|
||||
<Label htmlFor="title">{t('spots.spotName')} *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g., Hollywood High School"
|
||||
placeholder={t('createSpot.namePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
|
|
@ -393,10 +395,10 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Label htmlFor="description">{t('spots.description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe the spot, obstacles, best times to skate, security info, etc."
|
||||
placeholder={t('createSpot.descriptionPlaceholder')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
|
|
@ -405,10 +407,10 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Location Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Address / Location</Label>
|
||||
<Label htmlFor="location">{t('spots.address')}</Label>
|
||||
<Input
|
||||
id="location"
|
||||
placeholder="e.g., 1521 N Highland Ave, Los Angeles, CA"
|
||||
placeholder={t('createSpot.addressPlaceholder')}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
/>
|
||||
|
|
@ -416,11 +418,11 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Coordinates */}
|
||||
<div className="space-y-2">
|
||||
<Label>Coordinates *</Label>
|
||||
<Label>{t('createSpot.coordinates')} *</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Latitude (e.g., 34.1012)"
|
||||
placeholder={t('createSpot.latitudePlaceholder')}
|
||||
value={latitude}
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
required
|
||||
|
|
@ -428,7 +430,7 @@ export default function EditSpotPage() {
|
|||
</div>
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Longitude (e.g., -118.3389)"
|
||||
placeholder={t('createSpot.longitudePlaceholder')}
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
required
|
||||
|
|
@ -446,12 +448,12 @@ export default function EditSpotPage() {
|
|||
{isGettingLocation ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Getting location...
|
||||
{t('createSpot.gettingLocation')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
Use my current location
|
||||
{t('createSpot.useCurrentLocation')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -459,7 +461,7 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Access Type */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Access Type *</Label>
|
||||
<Label className="text-base font-bold">{t('createSpot.accessType')} *</Label>
|
||||
<div className="space-y-2">
|
||||
{/* Public */}
|
||||
<Card
|
||||
|
|
@ -475,8 +477,8 @@ export default function EditSpotPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🌍</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">Public - Open to Everyone</h4>
|
||||
<p className="text-sm text-gray-400">Skate parks, public plazas, street spots</p>
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'public' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
|
||||
|
|
@ -501,8 +503,8 @@ export default function EditSpotPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🔒</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">Private Property - Permission Needed</h4>
|
||||
<p className="text-sm text-gray-400">Backyard ramps, private land, gated spots</p>
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'private' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
|
||||
|
|
@ -527,8 +529,8 @@ export default function EditSpotPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">👥</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-bold text-white mb-1">Ask Locals First</h4>
|
||||
<p className="text-sm text-gray-400">Respect the spot, don't blow it out</p>
|
||||
<h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
|
||||
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
|
||||
</div>
|
||||
{accessType === 'locals' && (
|
||||
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
|
||||
|
|
@ -544,25 +546,25 @@ export default function EditSpotPage() {
|
|||
{/* Contact Info for Private/Locals */}
|
||||
{(accessType === 'private' || accessType === 'locals') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact">Contact Information</Label>
|
||||
<Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
placeholder="e.g., Phone number, Instagram, email..."
|
||||
placeholder={t('createSpot.contactPlaceholder')}
|
||||
value={contactInfo}
|
||||
onChange={(e) => setContactInfo(e.target.value)}
|
||||
className="bg-black border-[#ff6600]/30"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">
|
||||
{accessType === 'private'
|
||||
? 'How should people contact you for permission?'
|
||||
: 'How should people reach out to ask about the spot?'}
|
||||
? t('createSpot.contactHintPrivate')
|
||||
: t('createSpot.contactHintLocals')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spot Category (required, mutually exclusive) */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Spot Type *</Label>
|
||||
<Label className="text-base font-bold">{t('spots.spotType')} *</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{spotCategories.map((category) => (
|
||||
<Card
|
||||
|
|
@ -591,9 +593,9 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Shelter / weather protection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Shelter</Label>
|
||||
<Label className="text-base font-bold">{t('spotUi.shelter')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
Is this spot exposed to the weather? Helps skaters find dry spots when it rains.
|
||||
{t('spotUi.shelterHint')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shelterOptions.map((option) => (
|
||||
|
|
@ -628,14 +630,14 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Freeform Tags */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Tags</Label>
|
||||
<Label className="text-base font-bold">{t('spotUi.tags')}</Label>
|
||||
<p className="text-xs text-gray-400 -mt-1">
|
||||
Add tags for features, surfaces, vibes (max {MAX_TAGS})
|
||||
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
||||
</p>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder="Type to search or add tags..."
|
||||
placeholder={t('spotUi.typeToSearchTags')}
|
||||
maxTags={MAX_TAGS}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -644,11 +646,11 @@ export default function EditSpotPage() {
|
|||
|
||||
{/* Images */}
|
||||
<div className="space-y-2">
|
||||
<Label>Photos</Label>
|
||||
<Label>{t('spots.photos')}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{images.map((url) => (
|
||||
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
||||
<img src={url} alt="Spot" className="w-full h-full object-cover" />
|
||||
<img src={url} alt={t('spotUi.spotPhotoAlt')} className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(url)}
|
||||
|
|
@ -661,8 +663,8 @@ export default function EditSpotPage() {
|
|||
<PhotoDropzone
|
||||
onFiles={uploadImageFiles}
|
||||
uploading={isUploading}
|
||||
label="Upload"
|
||||
hint="Drag & drop or click"
|
||||
label={t('createSpot.uploadPhotos')}
|
||||
hint={t('spotUi.dragDropOrClick')}
|
||||
className="aspect-video"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -677,12 +679,12 @@ export default function EditSpotPage() {
|
|||
{isPublishing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{t('spotUi.saving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
{t('spotUi.saveChanges')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ import { GEAR_CATEGORIES } from '@/lib/types';
|
|||
import { getLastLocation } from '@/lib/lastLocation';
|
||||
import { itemDistanceKm } from '@/lib/sortOptions';
|
||||
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
type SortOption = 'newest' | 'price_low' | 'price_high' | 'trust' | 'distance';
|
||||
type TrustFilter = 'all' | 'verified' | 'trusted' | 'following';
|
||||
|
||||
export default function GearSwapPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Gear Swap - SkateSpots',
|
||||
description: 'Buy & sell used skate gear with other skaters. Decks, trucks, wheels, shoes, and more.',
|
||||
|
|
@ -180,7 +182,7 @@ export default function GearSwapPage() {
|
|||
}, [items, searchQuery, selectedCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, fulfilmentFilter, trustScores, followingSet]);
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'all', label: 'All Categories' },
|
||||
{ value: 'all', label: t('marketUi.gearSwap.allCategories') },
|
||||
...Object.entries(GEAR_CATEGORIES).map(([key, label]) => ({
|
||||
value: key,
|
||||
label,
|
||||
|
|
@ -191,10 +193,10 @@ export default function GearSwapPage() {
|
|||
<>
|
||||
<div className="container px-4 py-8 max-w-7xl">
|
||||
<PageHeader
|
||||
title="Gear Swap"
|
||||
title={t('marketUi.gearSwap.title')}
|
||||
icon={ShoppingCart}
|
||||
accent="#ff6600"
|
||||
subtitle="Buy & sell used skate gear with other skaters"
|
||||
subtitle={t('marketUi.gearSwap.subtitle')}
|
||||
className="mb-8"
|
||||
actions={
|
||||
<Link to="/create-listing?returnTo=/marketplace">
|
||||
|
|
@ -203,7 +205,7 @@ export default function GearSwapPage() {
|
|||
className="gap-2 bg-orange-500 hover:bg-orange-600 text-white font-bold uppercase shadow-[0_0_20px_rgba(255,102,0,0.3)]"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
List Gear
|
||||
{t('marketUi.gearSwap.listGear')}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
|
|
@ -216,7 +218,7 @@ export default function GearSwapPage() {
|
|||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" />
|
||||
<Input
|
||||
placeholder="Search gear..."
|
||||
placeholder={t('marketUi.gearSwap.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-gray-900 border-gray-700 h-12 text-lg"
|
||||
|
|
@ -259,7 +261,7 @@ export default function GearSwapPage() {
|
|||
: 'border-gray-600 text-gray-400 hover:bg-gray-800'
|
||||
}
|
||||
>
|
||||
For Sale
|
||||
{t('marketUi.gearSwap.forSale')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showOnlyWTB ? 'default' : 'outline'}
|
||||
|
|
@ -275,7 +277,7 @@ export default function GearSwapPage() {
|
|||
}
|
||||
>
|
||||
<Search className="h-3 w-3 mr-1" />
|
||||
WTB
|
||||
{t('marketUi.gearSwap.wtb')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -290,7 +292,7 @@ export default function GearSwapPage() {
|
|||
className="gap-2 border-gray-600 text-gray-300 hover:bg-gray-800"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Trust
|
||||
{t('marketUi.gearSwap.trust')}
|
||||
{trustFilter !== 'all' && (
|
||||
<Badge className="ml-1 bg-orange-500/20 text-orange-400 text-xs">
|
||||
{trustFilter}
|
||||
|
|
@ -299,28 +301,28 @@ export default function GearSwapPage() {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
<DropdownMenuLabel>Filter by Trust</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t('marketUi.gearSwap.filterByTrust')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={trustFilter === 'all'}
|
||||
onCheckedChange={() => setTrustFilter('all')}
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
All Sellers
|
||||
{t('marketUi.gearSwap.allSellers')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={trustFilter === 'verified'}
|
||||
onCheckedChange={() => setTrustFilter('verified')}
|
||||
>
|
||||
<UserCheck className="h-4 w-4 mr-2 text-green-400" />
|
||||
Verified Only
|
||||
{t('marketUi.gearSwap.verifiedOnly')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={trustFilter === 'trusted'}
|
||||
onCheckedChange={() => setTrustFilter('trusted')}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-2 text-blue-400" />
|
||||
Trusted+
|
||||
{t('marketUi.gearSwap.trustedPlus')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={trustFilter === 'following'}
|
||||
|
|
@ -328,7 +330,7 @@ export default function GearSwapPage() {
|
|||
disabled={!user}
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2 text-purple-400" />
|
||||
Following Only
|
||||
{t('marketUi.gearSwap.followingOnly')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -342,12 +344,12 @@ export default function GearSwapPage() {
|
|||
>
|
||||
<SelectTrigger className="w-40 bg-gray-900 border-gray-700">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Pickup or ship" />
|
||||
<SelectValue placeholder={t('marketUi.gearSwap.pickupOrShip')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Pickup or ship</SelectItem>
|
||||
<SelectItem value="pickup">📍 Local pickup</SelectItem>
|
||||
<SelectItem value="ships">📦 Ships</SelectItem>
|
||||
<SelectItem value="all">{t('marketUi.gearSwap.pickupOrShip')}</SelectItem>
|
||||
<SelectItem value="pickup">📍 {t('marketUi.gearSwap.localPickup')}</SelectItem>
|
||||
<SelectItem value="ships">📦 {t('marketUi.gearSwap.ships')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
|
@ -358,14 +360,14 @@ export default function GearSwapPage() {
|
|||
>
|
||||
<SelectTrigger className="w-40 bg-gray-900 border-gray-700">
|
||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
<SelectValue placeholder={t('marketUi.gearSwap.sortBy')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newest">Newest</SelectItem>
|
||||
<SelectItem value="price_low">Price: Low → High</SelectItem>
|
||||
<SelectItem value="price_high">Price: High → Low</SelectItem>
|
||||
<SelectItem value="trust">Trust Score</SelectItem>
|
||||
<SelectItem value="distance">Nearest (local)</SelectItem>
|
||||
<SelectItem value="newest">{t('marketUi.gearSwap.newest')}</SelectItem>
|
||||
<SelectItem value="price_low">{t('marketUi.gearSwap.priceLowHigh')}</SelectItem>
|
||||
<SelectItem value="price_high">{t('marketUi.gearSwap.priceHighLow')}</SelectItem>
|
||||
<SelectItem value="trust">{t('marketUi.gearSwap.trustScore')}</SelectItem>
|
||||
<SelectItem value="distance">{t('marketUi.gearSwap.nearest')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -376,7 +378,9 @@ export default function GearSwapPage() {
|
|||
{!isLoading && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
{filteredItems.length} {filteredItems.length === 1 ? 'listing' : 'listings'} found
|
||||
{filteredItems.length === 1
|
||||
? t('marketUi.gearSwap.listingFound', { count: filteredItems.length })
|
||||
: t('marketUi.gearSwap.listingsFound', { count: filteredItems.length })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -405,8 +409,8 @@ export default function GearSwapPage() {
|
|||
<Card className="bg-red-950/30 border-2 border-red-500/50">
|
||||
<CardContent className="py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-red-400" />
|
||||
<h3 className="text-lg font-bold text-red-400 mb-2">Failed to load listings</h3>
|
||||
<p className="text-gray-400">Please try again later.</p>
|
||||
<h3 className="text-lg font-bold text-red-400 mb-2">{t('marketUi.gearSwap.loadFailed')}</h3>
|
||||
<p className="text-gray-400">{t('marketUi.gearSwap.tryLater')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -416,11 +420,11 @@ export default function GearSwapPage() {
|
|||
<Card className="bg-black/90 border-2 border-dashed border-orange-500/30">
|
||||
<CardContent className="py-16 text-center">
|
||||
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-600" />
|
||||
<h3 className="text-xl font-bold text-white mb-2">No listings found</h3>
|
||||
<h3 className="text-xl font-bold text-white mb-2">{t('marketUi.gearSwap.noneTitle')}</h3>
|
||||
<p className="text-gray-400 mb-6 max-w-md mx-auto">
|
||||
{searchQuery || selectedCategory !== 'all' || trustFilter !== 'all'
|
||||
? 'No gear matches your current filters. Try clearing them or searching something else.'
|
||||
: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!'}
|
||||
? t('marketUi.gearSwap.noneFiltered')
|
||||
: t('marketUi.gearSwap.noneFirst')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
{(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && (
|
||||
|
|
@ -434,18 +438,18 @@ export default function GearSwapPage() {
|
|||
}}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Clear Filters
|
||||
{t('common.clearFilters')}
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/create-listing">
|
||||
<Button className="gap-2 bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="h-4 w-4" />
|
||||
List Your Gear
|
||||
{t('marketUi.gearSwap.listYourGear')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Link to="/faq" className="mt-5 inline-block text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
|
||||
Have questions? Check the FAQ →
|
||||
{t('marketUi.gearSwap.faqLink')}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ const ACCENT = {
|
|||
// ─── FAB — offer services ─────────────────────────────────────────────────────
|
||||
|
||||
function OfferFAB() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -358,20 +359,20 @@ function OfferFAB() {
|
|||
}, [open]);
|
||||
|
||||
const items = [
|
||||
{ to: '/become/coach', label: 'Offer Coaching', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap },
|
||||
{ to: '/become/filmer', label: 'Offer Filming', color: 'bg-orange-500 hover:bg-orange-600', icon: Video },
|
||||
{ to: '/become/photographer', label: 'Offer Photography', color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera },
|
||||
{ to: '/become/editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors },
|
||||
{ to: '/become/builder', label: 'Offer Building', color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat },
|
||||
{ to: '/become/artist', label: 'Offer Design / Art', color: 'bg-fuchsia-600 hover:bg-fuchsia-700', icon: Palette },
|
||||
{ to: '/become/graffiti', label: 'Offer Graffiti', color: 'bg-lime-500 hover:bg-lime-600 text-black', icon: SprayCan },
|
||||
{ to: '/become/tattoo', label: 'Offer Tattoo', color: 'bg-rose-600 hover:bg-rose-700', icon: Brush },
|
||||
{ to: '/become/trainer', label: 'Offer Training / Physio', color: 'bg-emerald-600 hover:bg-emerald-700', icon: Dumbbell },
|
||||
{ to: '/become/musician', label: 'Add Music Profile', color: 'bg-sky-500 hover:bg-sky-600', icon: Music },
|
||||
{ to: '/become/judge', label: 'Judge Events', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel },
|
||||
{ to: '/become/organizer', label: 'Organize Events', color: 'bg-teal-600 hover:bg-teal-700', icon: Tent },
|
||||
{ to: '/become/barber', label: 'Offer Cuts', color: 'bg-cyan-600 hover:bg-cyan-700', icon: Sparkles },
|
||||
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
|
||||
{ to: '/become/coach', label: t('labUi.offerCoaching'), color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap },
|
||||
{ to: '/become/filmer', label: t('labUi.offerFilming'), color: 'bg-orange-500 hover:bg-orange-600', icon: Video },
|
||||
{ to: '/become/photographer', label: t('labUi.offerPhotography'), color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera },
|
||||
{ to: '/become/editor', label: t('labUi.offerEditing'), color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors },
|
||||
{ to: '/become/builder', label: t('labUi.offerBuilding'), color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat },
|
||||
{ to: '/become/artist', label: t('labUi.offerDesignArt'), color: 'bg-fuchsia-600 hover:bg-fuchsia-700', icon: Palette },
|
||||
{ to: '/become/graffiti', label: t('labUi.offerGraffiti'), color: 'bg-lime-500 hover:bg-lime-600 text-black', icon: SprayCan },
|
||||
{ to: '/become/tattoo', label: t('labUi.offerTattoo'), color: 'bg-rose-600 hover:bg-rose-700', icon: Brush },
|
||||
{ to: '/become/trainer', label: t('labUi.offerTraining'), color: 'bg-emerald-600 hover:bg-emerald-700', icon: Dumbbell },
|
||||
{ to: '/become/musician', label: t('labUi.addMusicProfile'), color: 'bg-sky-500 hover:bg-sky-600', icon: Music },
|
||||
{ to: '/become/judge', label: t('labUi.judgeEvents'), color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel },
|
||||
{ to: '/become/organizer', label: t('labUi.organizeEvents'), color: 'bg-teal-600 hover:bg-teal-700', icon: Tent },
|
||||
{ to: '/become/barber', label: t('labUi.offerCuts'), color: 'bg-cyan-600 hover:bg-cyan-700', icon: Sparkles },
|
||||
{ to: '/card', label: t('labUi.getRiderCard'), color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -386,8 +387,8 @@ function OfferFAB() {
|
|||
)}
|
||||
>
|
||||
{open ? <X className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">Offer Services</span>
|
||||
<span className="sm:hidden">Offer</span>
|
||||
<span className="hidden sm:inline">{t('labUi.offerServices')}</span>
|
||||
<span className="sm:hidden">{t('labUi.offer')}</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
|
|
@ -471,20 +472,21 @@ function LabToolbar({
|
|||
trustFilter,
|
||||
onClearAll,
|
||||
}: ToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ key: 'coaching', label: 'Coaches', icon: GraduationCap },
|
||||
{ key: 'filming', label: 'Filmers', icon: Video },
|
||||
{ key: 'photography', label: 'Photo', icon: Camera },
|
||||
{ key: 'editing', label: 'Editors', icon: Scissors },
|
||||
{ key: 'building', label: 'Builders', icon: HardHat },
|
||||
{ key: 'art', label: 'Artists', icon: Palette },
|
||||
{ key: 'graffiti', label: 'Graffiti', icon: SprayCan },
|
||||
{ key: 'tattoo', label: 'Tattoo', icon: Brush },
|
||||
{ key: 'training', label: 'Trainers', icon: Dumbbell },
|
||||
{ key: 'music', label: 'Music', icon: Music },
|
||||
{ key: 'judging', label: 'Judges', icon: Gavel },
|
||||
{ key: 'organizing', label: 'Organizers', icon: Tent },
|
||||
{ key: 'barbering', label: 'Barbers', icon: Sparkles },
|
||||
{ key: 'coaching', label: t('labUi.tabCoaches'), icon: GraduationCap },
|
||||
{ key: 'filming', label: t('labUi.tabFilmers'), icon: Video },
|
||||
{ key: 'photography', label: t('labUi.tabPhoto'), icon: Camera },
|
||||
{ key: 'editing', label: t('labUi.tabEditors'), icon: Scissors },
|
||||
{ key: 'building', label: t('labUi.tabBuilders'), icon: HardHat },
|
||||
{ key: 'art', label: t('labUi.tabArtists'), icon: Palette },
|
||||
{ key: 'graffiti', label: t('labUi.tabGraffiti'), icon: SprayCan },
|
||||
{ key: 'tattoo', label: t('labUi.tabTattoo'), icon: Brush },
|
||||
{ key: 'training', label: t('labUi.tabTrainers'), icon: Dumbbell },
|
||||
{ key: 'music', label: t('labUi.tabMusic'), icon: Music },
|
||||
{ key: 'judging', label: t('labUi.tabJudges'), icon: Gavel },
|
||||
{ key: 'organizing', label: t('labUi.tabOrganizers'), icon: Tent },
|
||||
{ key: 'barbering', label: t('labUi.tabBarbers'), icon: Sparkles },
|
||||
];
|
||||
|
||||
const accent = ACCENT[serviceType];
|
||||
|
|
@ -543,7 +545,7 @@ function LabToolbar({
|
|||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Name, bio…"
|
||||
placeholder={t('labUi.searchNamePlaceholder')}
|
||||
className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all"
|
||||
/>
|
||||
{searchQuery && (
|
||||
|
|
@ -563,7 +565,7 @@ function LabToolbar({
|
|||
type="text"
|
||||
value={locationQuery}
|
||||
onChange={(e) => onLocationChange(e.target.value)}
|
||||
placeholder="City…"
|
||||
placeholder={t('labUi.searchCityPlaceholder')}
|
||||
className="w-full h-9 pl-8 pr-3 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-white/30 transition-all"
|
||||
/>
|
||||
{locationQuery && (
|
||||
|
|
@ -587,7 +589,7 @@ function LabToolbar({
|
|||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
<span className="hidden sm:inline">{t('labUi.filters')}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex items-center justify-center w-4 h-4 text-[9px] font-black rounded-full bg-current/30 leading-none">
|
||||
{activeFilterCount}
|
||||
|
|
@ -618,7 +620,7 @@ function LabToolbar({
|
|||
{sortBy !== 'recent' && (
|
||||
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
|
||||
<Star className="h-2.5 w-2.5" />
|
||||
Top Rated
|
||||
{t('labUi.topRated')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -626,7 +628,7 @@ function LabToolbar({
|
|||
{trustFilter !== 'all' && (
|
||||
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}>
|
||||
<Shield className="h-2.5 w-2.5" />
|
||||
{trustFilter === 'verified' ? 'Verified' : trustFilter === 'trusted' ? 'Trusted' : 'Following'}
|
||||
{trustFilter === 'verified' ? t('common.verified') : trustFilter === 'trusted' ? t('labUi.trusted') : t('labUi.following')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -635,7 +637,7 @@ function LabToolbar({
|
|||
onClick={onClearAll}
|
||||
className="px-2.5 py-1 rounded-full text-[10px] font-bold text-gray-500 hover:text-red-400 border border-white/10 hover:border-red-500/30 transition-all"
|
||||
>
|
||||
Clear all
|
||||
{t('labUi.clearAll')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -644,8 +646,8 @@ function LabToolbar({
|
|||
{!isLoading && (
|
||||
<p className="text-[11px] text-gray-500 font-semibold leading-none">
|
||||
{totalCount === 0
|
||||
? 'No results'
|
||||
: `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
|
||||
? t('labUi.noResults')
|
||||
: t(totalCount === 1 ? 'labUi.resultsOne' : 'labUi.resultsMany', { count: totalCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -681,87 +683,88 @@ function FilterDrawer({
|
|||
onClearAll,
|
||||
activeCount,
|
||||
}: FilterDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const accent = ACCENT[serviceType];
|
||||
|
||||
const skillSections: { title: string; entries: [string, string][]; activeColor: string }[] =
|
||||
serviceType === 'coaching'
|
||||
? [{ title: 'Skills to Learn', entries: COACH_SKILL_ENTRIES, activeColor: accent.chip }]
|
||||
? [{ title: t('labUi.filterSkillsToLearn'), entries: COACH_SKILL_ENTRIES, activeColor: accent.chip }]
|
||||
: serviceType === 'filming'
|
||||
? [
|
||||
{ title: 'Content Type', entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Equipment & Style', entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' },
|
||||
{ title: t('labUi.filterContentType'), entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterEquipmentStyle'), entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' },
|
||||
]
|
||||
: serviceType === 'photography'
|
||||
? [
|
||||
{ title: 'Services', entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Camera', entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: 'Lenses', entries: PHOTO_LENS_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: 'Lighting', entries: PHOTO_LIGHTING_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: 'Support & Rig', entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: 'Style', entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: t('labUi.filterServices'), entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterCamera'), entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: t('labUi.filterLenses'), entries: PHOTO_LENS_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: t('labUi.filterLighting'), entries: PHOTO_LIGHTING_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: t('labUi.filterSupportRig'), entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
{ title: t('labUi.filterStyle'), entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
|
||||
]
|
||||
: serviceType === 'building'
|
||||
? [
|
||||
{ title: 'What They Build', entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Materials & Trade', entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
|
||||
{ title: 'Project Scale', entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
|
||||
{ title: t('labUi.filterWhatTheyBuild'), entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterMaterialsTrade'), entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
|
||||
{ title: t('labUi.filterProjectScale'), entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
|
||||
]
|
||||
: serviceType === 'art'
|
||||
? [
|
||||
{ title: 'What They Make', entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Medium', entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
|
||||
{ title: 'Software & Tools', entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
|
||||
{ title: t('labUi.filterWhatTheyMake'), entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterMedium'), entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
|
||||
{ title: t('labUi.filterSoftwareTools'), entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
|
||||
]
|
||||
: serviceType === 'graffiti'
|
||||
? [
|
||||
{ title: 'Style', entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Medium', entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
|
||||
{ title: 'Work Context', entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
|
||||
{ title: t('labUi.filterStyle'), entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterMedium'), entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
|
||||
{ title: t('labUi.filterWorkContext'), entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
|
||||
]
|
||||
: serviceType === 'tattoo'
|
||||
? [
|
||||
{ title: 'Style', entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Method', entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' },
|
||||
{ title: t('labUi.filterStyle'), entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterMethod'), entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' },
|
||||
]
|
||||
: serviceType === 'training'
|
||||
? [
|
||||
{ title: 'Focus', entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Injuries Treated', entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
|
||||
{ title: 'Credentials & Setting', entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
|
||||
{ title: t('labUi.filterFocus'), entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterInjuriesTreated'), entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
|
||||
{ title: t('labUi.filterCredentialsSetting'), entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
|
||||
]
|
||||
: serviceType === 'music'
|
||||
? [
|
||||
{ title: 'Genre', entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Instruments & Role', entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' },
|
||||
{ title: t('labUi.filterGenre'), entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterInstrumentsRole'), entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' },
|
||||
]
|
||||
: serviceType === 'judging'
|
||||
? [
|
||||
{ title: 'Event Formats', entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Specialty', entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
|
||||
{ title: 'Level / Circuit', entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
|
||||
{ title: t('labUi.filterEventFormats'), entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterSpecialty'), entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
|
||||
{ title: t('labUi.filterLevelCircuit'), entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
|
||||
]
|
||||
: serviceType === 'organizing'
|
||||
? [
|
||||
{ title: 'Event Types', entries: ORGANIZER_EVENT_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'What You Handle', entries: ORGANIZER_SERVICE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
|
||||
{ title: 'Scale', entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
|
||||
{ title: t('labUi.filterEventTypes'), entries: ORGANIZER_EVENT_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterWhatYouHandle'), entries: ORGANIZER_SERVICE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
|
||||
{ title: t('labUi.filterScale'), entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
|
||||
]
|
||||
: serviceType === 'barbering'
|
||||
? [
|
||||
{ title: 'What They Offer', entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Signature Styles', entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
|
||||
{ title: 'Setting', entries: BARBER_SETTING_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
|
||||
{ title: t('labUi.filterWhatTheyOffer'), entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterSignatureStyles'), entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
|
||||
{ title: t('labUi.filterSetting'), entries: BARBER_SETTING_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
|
||||
]
|
||||
: [
|
||||
{ title: 'Edit Type', entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: 'Software & Tools', entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
|
||||
{ title: t('labUi.filterEditType'), entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip },
|
||||
{ title: t('labUi.filterSoftwareTools'), entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
|
||||
];
|
||||
|
||||
const trustOptions: { value: TrustFilterLevel; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ value: 'all', label: 'All', icon: Users },
|
||||
{ value: 'verified', label: 'Verified', icon: CheckCircle2 },
|
||||
{ value: 'trusted', label: 'Trusted', icon: Shield },
|
||||
{ value: 'following', label: 'Following', icon: CheckCircle2 },
|
||||
{ value: 'all', label: t('labUi.all'), icon: Users },
|
||||
{ value: 'verified', label: t('common.verified'), icon: CheckCircle2 },
|
||||
{ value: 'trusted', label: t('labUi.trusted'), icon: Shield },
|
||||
{ value: 'following', label: t('labUi.following'), icon: CheckCircle2 },
|
||||
];
|
||||
|
||||
const drawerContent = (
|
||||
|
|
@ -770,10 +773,10 @@ function FilterDrawer({
|
|||
<div className="flex items-center justify-between px-4 pt-4 pb-3 flex-shrink-0 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className={cn('h-4 w-4', accent.icon)} />
|
||||
<span className="font-black text-base text-white">Filters</span>
|
||||
<span className="font-black text-base text-white">{t('labUi.filters')}</span>
|
||||
{activeCount > 0 && (
|
||||
<Badge className={cn('text-xs px-1.5 py-0', accent.badge)}>
|
||||
{activeCount} active
|
||||
{t('labUi.activeCount', { count: activeCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -783,7 +786,7 @@ function FilterDrawer({
|
|||
onClick={onClearAll}
|
||||
className="text-xs text-gray-400 hover:text-red-400 transition-colors font-semibold"
|
||||
>
|
||||
Clear all
|
||||
{t('labUi.clearAll')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -799,11 +802,11 @@ function FilterDrawer({
|
|||
<div className="flex-1 min-h-0 overflow-y-auto px-4 space-y-5 py-4 scroll-container">
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Sort By</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">{t('labUi.sortBy')}</p>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ value: 'recent' as SortOption, label: 'Newest', icon: Clock },
|
||||
{ value: 'trust' as SortOption, label: 'Top Rated', icon: Star },
|
||||
{ value: 'recent' as SortOption, label: t('labUi.newest'), icon: Clock },
|
||||
{ value: 'trust' as SortOption, label: t('labUi.topRated'), icon: Star },
|
||||
].map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
|
|
@ -824,7 +827,7 @@ function FilterDrawer({
|
|||
|
||||
{/* Trust — inline pill buttons, no dropdown */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Trust Level</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">{t('labUi.trustLevel')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trustOptions.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
|
|
@ -880,7 +883,7 @@ function FilterDrawer({
|
|||
accent.active
|
||||
)}
|
||||
>
|
||||
Show Results{activeCount > 0 ? ` · ${activeCount} active` : ''}
|
||||
{activeCount > 0 ? t('labUi.showResultsActive', { count: activeCount }) : t('labUi.showResults')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -893,7 +896,7 @@ function FilterDrawer({
|
|||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent side="bottom" className="h-[82vh] max-h-[700px] rounded-t-2xl p-0 flex flex-col bg-[#0a0a0a]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
<SheetTitle>{t('labUi.filters')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{drawerContent}
|
||||
</SheetContent>
|
||||
|
|
@ -905,7 +908,7 @@ function FilterDrawer({
|
|||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent side="right" className="w-80 p-0 flex flex-col bg-[#0a0a0a] border-l border-white/8">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
<SheetTitle>{t('labUi.filters')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{drawerContent}
|
||||
</SheetContent>
|
||||
|
|
@ -918,6 +921,7 @@ function FilterDrawer({
|
|||
// ─── Profile Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileCard({ profile }: { profile: LabProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(profile.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(profile.pubkey);
|
||||
|
|
@ -984,7 +988,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
|
|||
{profile.entityType === 'collective' && (
|
||||
<span
|
||||
className={cn('inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold flex-shrink-0', accent.badgeSkill)}
|
||||
title="Collective"
|
||||
title={t('labUi.collective')}
|
||||
>
|
||||
<Users className="h-2.5 w-2.5" />
|
||||
{profile.members.length > 0 ? profile.members.length : ''}
|
||||
|
|
@ -1014,7 +1018,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
|
|||
</span>
|
||||
)}
|
||||
{profile.experience && (
|
||||
<span className="flex-shrink-0">{profile.experience}yr exp</span>
|
||||
<span className="flex-shrink-0">{t('labUi.yearsExp', { count: profile.experience })}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1072,108 +1076,109 @@ function SkeletonCard() {
|
|||
// ─── Empty state per-category ─────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const configs = {
|
||||
coaching: {
|
||||
icon: GraduationCap,
|
||||
title: 'No coaches yet',
|
||||
body: 'Be the first to offer skateboarding coaching in your area.',
|
||||
cta: 'Offer Coaching',
|
||||
title: t('labUi.empty.coachesTitle'),
|
||||
body: t('labUi.empty.coachesBody'),
|
||||
cta: t('labUi.offerCoaching'),
|
||||
to: '/become/coach',
|
||||
color: 'bg-[#ff0040] hover:bg-[#ff0040]/90',
|
||||
},
|
||||
filming: {
|
||||
icon: Video,
|
||||
title: 'No filmers yet',
|
||||
body: 'Be the first to offer skate filming — clips, full parts, contest coverage and more.',
|
||||
cta: 'Offer Filming',
|
||||
title: t('labUi.empty.filmersTitle'),
|
||||
body: t('labUi.empty.filmersBody'),
|
||||
cta: t('labUi.offerFilming'),
|
||||
to: '/become/filmer',
|
||||
color: 'bg-orange-500 hover:bg-orange-600',
|
||||
},
|
||||
photography: {
|
||||
icon: Camera,
|
||||
title: 'No photographers yet',
|
||||
body: 'Be the first to offer skate photography in your area — action shots, sequences, editorial and more.',
|
||||
cta: 'Offer Photography',
|
||||
title: t('labUi.empty.photographersTitle'),
|
||||
body: t('labUi.empty.photographersBody'),
|
||||
cta: t('labUi.offerPhotography'),
|
||||
to: '/become/photographer',
|
||||
color: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
},
|
||||
editing: {
|
||||
icon: Scissors,
|
||||
title: 'No video editors yet',
|
||||
body: 'Be the first to offer skate video editing services.',
|
||||
cta: 'Offer Editing',
|
||||
title: t('labUi.empty.editorsTitle'),
|
||||
body: t('labUi.empty.editorsBody'),
|
||||
cta: t('labUi.offerEditing'),
|
||||
to: '/become/editor',
|
||||
color: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
building: {
|
||||
icon: HardHat,
|
||||
title: 'No builders yet',
|
||||
body: 'Be the first to list your ramp, park or DIY building — concrete, wood, rails and more.',
|
||||
cta: 'Offer Building',
|
||||
title: t('labUi.empty.buildersTitle'),
|
||||
body: t('labUi.empty.buildersBody'),
|
||||
cta: t('labUi.offerBuilding'),
|
||||
to: '/become/builder',
|
||||
color: 'bg-amber-500 hover:bg-amber-600 text-black',
|
||||
},
|
||||
art: {
|
||||
icon: Palette,
|
||||
title: 'No artists yet',
|
||||
body: 'Be the first to offer board graphics, logos, posters and merch for the scene.',
|
||||
cta: 'Offer Design / Art',
|
||||
title: t('labUi.empty.artistsTitle'),
|
||||
body: t('labUi.empty.artistsBody'),
|
||||
cta: t('labUi.offerDesignArt'),
|
||||
to: '/become/artist',
|
||||
color: 'bg-fuchsia-600 hover:bg-fuchsia-700',
|
||||
},
|
||||
graffiti: {
|
||||
icon: SprayCan,
|
||||
title: 'No graffiti artists yet',
|
||||
body: 'Be the first to offer pieces, murals and skatepark art in your area.',
|
||||
cta: 'Offer Graffiti',
|
||||
title: t('labUi.empty.graffitiTitle'),
|
||||
body: t('labUi.empty.graffitiBody'),
|
||||
cta: t('labUi.offerGraffiti'),
|
||||
to: '/become/graffiti',
|
||||
color: 'bg-lime-500 hover:bg-lime-600 text-black',
|
||||
},
|
||||
tattoo: {
|
||||
icon: Brush,
|
||||
title: 'No tattoo artists yet',
|
||||
body: 'Be the first skate-culture tattoo artist to list — flash, custom, fine line and more.',
|
||||
cta: 'Offer Tattoo',
|
||||
title: t('labUi.empty.tattooTitle'),
|
||||
body: t('labUi.empty.tattooBody'),
|
||||
cta: t('labUi.offerTattoo'),
|
||||
to: '/become/tattoo',
|
||||
color: 'bg-rose-600 hover:bg-rose-700',
|
||||
},
|
||||
training: {
|
||||
icon: Dumbbell,
|
||||
title: 'No trainers yet',
|
||||
body: 'Be the first trainer or physio to help skaters get strong and stay healthy.',
|
||||
cta: 'Offer Training / Physio',
|
||||
title: t('labUi.empty.trainersTitle'),
|
||||
body: t('labUi.empty.trainersBody'),
|
||||
cta: t('labUi.offerTraining'),
|
||||
to: '/become/trainer',
|
||||
color: 'bg-emerald-600 hover:bg-emerald-700',
|
||||
},
|
||||
music: {
|
||||
icon: Music,
|
||||
title: 'No musicians yet',
|
||||
body: 'Skate and make music? Add your band or solo project — genres, instruments and links.',
|
||||
cta: 'Add Music Profile',
|
||||
title: t('labUi.empty.musiciansTitle'),
|
||||
body: t('labUi.empty.musiciansBody'),
|
||||
cta: t('labUi.addMusicProfile'),
|
||||
to: '/become/musician',
|
||||
color: 'bg-sky-500 hover:bg-sky-600',
|
||||
},
|
||||
judging: {
|
||||
icon: Gavel,
|
||||
title: 'No judges yet',
|
||||
body: 'Run a fair contest? List yourself to judge Game of S.K.A.T.E., best trick jams, contests and touring stops.',
|
||||
cta: 'Judge Events',
|
||||
title: t('labUi.empty.judgesTitle'),
|
||||
body: t('labUi.empty.judgesBody'),
|
||||
cta: t('labUi.judgeEvents'),
|
||||
to: '/become/judge',
|
||||
color: 'bg-yellow-500 hover:bg-yellow-600 text-black',
|
||||
},
|
||||
organizing: {
|
||||
icon: Tent,
|
||||
title: 'No organizers yet',
|
||||
body: 'Put on jams, comps, meetups or demos? List yourself so skaters can find and book you.',
|
||||
cta: 'Organize Events',
|
||||
title: t('labUi.empty.organizersTitle'),
|
||||
body: t('labUi.empty.organizersBody'),
|
||||
cta: t('labUi.organizeEvents'),
|
||||
to: '/become/organizer',
|
||||
color: 'bg-teal-600 hover:bg-teal-700',
|
||||
},
|
||||
barbering: {
|
||||
icon: Sparkles,
|
||||
title: 'No barbers yet',
|
||||
body: 'Cut hair for skaters? List your chair or mobile service so the scene can get a fresh trim.',
|
||||
cta: 'Offer Cuts',
|
||||
title: t('labUi.empty.barbersTitle'),
|
||||
body: t('labUi.empty.barbersBody'),
|
||||
cta: t('labUi.offerCuts'),
|
||||
to: '/become/barber',
|
||||
color: 'bg-cyan-600 hover:bg-cyan-700',
|
||||
},
|
||||
|
|
@ -1193,9 +1198,9 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
|
|||
<>
|
||||
{c.body}{' '}
|
||||
<Link to="/settings" className="text-[#ccff00] underline underline-offset-2">
|
||||
Log in
|
||||
{t('auth.login')}
|
||||
</Link>{' '}
|
||||
to create a profile.
|
||||
{t('labUi.empty.loginSuffix')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1204,7 +1209,7 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
|
|||
? [{ label: c.cta, to: c.to, icon: Plus, className: c.color }]
|
||||
: []
|
||||
}
|
||||
footer={{ label: 'Have questions? Check the FAQ →', to: '/faq' }}
|
||||
footer={{ label: t('labUi.empty.footer'), to: '/faq' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1379,7 +1384,7 @@ export default function LabPage() {
|
|||
<OfferFAB />
|
||||
) : (
|
||||
<Badge variant="outline" className="border-yellow-500/40 text-yellow-500/80 text-xs px-2.5 py-1 flex-shrink-0">
|
||||
🔒 Login to offer services
|
||||
🔒 {t('labUi.loginToOffer')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1401,13 +1406,13 @@ export default function LabPage() {
|
|||
<div className="min-w-0 flex-1">
|
||||
<p className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-0.5">
|
||||
<PersonStanding className="h-3.5 w-3.5" />
|
||||
The Crew
|
||||
{t('labUi.theCrew')}
|
||||
</p>
|
||||
<p className="font-black uppercase text-sm leading-tight">
|
||||
Just a <span className="text-[#ff0040]" style={{ textShadow: '0 0 12px rgba(255,0,64,0.6)' }}>skater</span>?
|
||||
{t('labUi.justASkater')} <span className="text-[#ff0040]" style={{ textShadow: '0 0 12px rgba(255,0,64,0.6)' }}>{t('labUi.skaterWord')}</span>?
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5 leading-snug">
|
||||
The Lab's for services. Browse & join the whole skater community
|
||||
{t('labUi.labForServices')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-full bg-[#ff0040]/15 flex items-center justify-center group-hover:bg-[#ff0040]/30 transition-colors">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
Handshake,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
/**
|
||||
* Full marketplace listing page (`/listing/:pubkey/:identifier`).
|
||||
|
|
@ -45,6 +46,7 @@ import {
|
|||
* actions (`ListingTradeActions`, also used by the grid card).
|
||||
*/
|
||||
export default function ListingDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -84,10 +86,10 @@ export default function ListingDetailPage() {
|
|||
<>
|
||||
<div className="container mx-auto max-w-md px-4 py-16 text-center space-y-4">
|
||||
<Package className="mx-auto h-10 w-10 text-gray-600" />
|
||||
<h1 className="text-xl font-black uppercase text-white">Listing not found</h1>
|
||||
<p className="text-sm text-gray-400">It may have been deleted, or the link is wrong.</p>
|
||||
<h1 className="text-xl font-black uppercase text-white">{t('marketUi.listingDetail.notFound')}</h1>
|
||||
<p className="text-sm text-gray-400">{t('marketUi.listingDetail.notFoundBody')}</p>
|
||||
<Button asChild className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||
<Link to="/marketplace">Back to Gear Swap</Link>
|
||||
<Link to="/marketplace">{t('marketUi.listingDetail.backToGearSwap')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -102,7 +104,7 @@ export default function ListingDetailPage() {
|
|||
: formatPrice(undefined, item.priceType);
|
||||
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
|
||||
const categoryLabel = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]);
|
||||
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : 'Unknown');
|
||||
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : t('marketUi.listingDetail.unknown'));
|
||||
const npub = pubkey ? nip19.npubEncode(pubkey) : '';
|
||||
|
||||
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
|
||||
|
|
@ -110,20 +112,20 @@ export default function ListingDetailPage() {
|
|||
const handleShare = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
toast({ title: 'Link copied', description: 'Share it with a skater.' });
|
||||
toast({ title: t('marketUi.listingDetail.linkCopied'), description: t('marketUi.listingDetail.linkCopiedBody') });
|
||||
} catch {
|
||||
toast({ title: 'Could not copy the link', variant: 'destructive' });
|
||||
toast({ title: t('marketUi.listingDetail.copyFailed'), variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelist = async () => {
|
||||
try {
|
||||
await relist(item.event);
|
||||
toast({ title: 'Relisted', description: 'Your listing is active again.' });
|
||||
toast({ title: t('marketUi.listingDetail.relisted'), description: t('marketUi.listingDetail.relistedBody') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('marketUi.listingDetail.couldNotUpdate'),
|
||||
description: error instanceof Error ? error.message : t('marketUi.listingDetail.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -136,7 +138,7 @@ export default function ListingDetailPage() {
|
|||
onClick={() => navigate('/marketplace')}
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Gear Swap
|
||||
<ArrowLeft className="h-4 w-4" /> {t('marketUi.listingDetail.gearSwap')}
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
|
|
@ -154,11 +156,11 @@ export default function ListingDetailPage() {
|
|||
'group relative overflow-hidden rounded-2xl border border-white/10 bg-black/40',
|
||||
i === 0 && item.images.length > 1 ? 'sm:col-span-2 aspect-video' : 'aspect-square',
|
||||
)}
|
||||
aria-label={`View photo ${i + 1}`}
|
||||
aria-label={t('marketUi.listingDetail.viewPhoto', { n: i + 1 })}
|
||||
>
|
||||
<ImageWithFallback
|
||||
src={url}
|
||||
alt={`${item.title} — photo ${i + 1}`}
|
||||
alt={t('marketUi.listingDetail.photoAlt', { title: item.title, n: i + 1 })}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
|
||||
/>
|
||||
</button>
|
||||
|
|
@ -171,12 +173,12 @@ export default function ListingDetailPage() {
|
|||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{isWTB && (
|
||||
<Badge className="border-blue-500/50 bg-blue-500/20 text-blue-400">
|
||||
<Package className="mr-1 h-3 w-3" /> WANT TO BUY
|
||||
<Package className="mr-1 h-3 w-3" /> {t('marketUi.listingDetail.wantToBuy')}
|
||||
</Badge>
|
||||
)}
|
||||
{isSold && (
|
||||
<Badge variant="secondary" className="bg-gray-700 text-gray-300">
|
||||
SOLD
|
||||
{t('marketUi.listingDetail.sold')}
|
||||
</Badge>
|
||||
)}
|
||||
{item.status && item.status !== 'active' && !isSold && (
|
||||
|
|
@ -215,7 +217,7 @@ export default function ListingDetailPage() {
|
|||
<Card className="bg-black/90 border-white/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm uppercase tracking-wide text-gray-400">
|
||||
Description
|
||||
{t('marketUi.listingDetail.description')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -236,12 +238,12 @@ export default function ListingDetailPage() {
|
|||
)}
|
||||
{item.availability && (
|
||||
<div className="flex items-center gap-2 text-gray-300">
|
||||
<Calendar className="h-4 w-4 text-gray-500" /> Usually here {item.availability}
|
||||
<Calendar className="h-4 w-4 text-gray-500" /> {t('marketUi.listingDetail.usuallyHere', { availability: item.availability })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Calendar className="h-4 w-4" /> Listed{' '}
|
||||
{new Date(item.createdAt * 1000).toLocaleDateString()}
|
||||
<Calendar className="h-4 w-4" />{' '}
|
||||
{t('marketUi.listingDetail.listed', { date: new Date(item.createdAt * 1000).toLocaleDateString() })}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -272,8 +274,9 @@ export default function ListingDetailPage() {
|
|||
</Link>
|
||||
{sellerInfo && sellerInfo.listingCount > 0 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{sellerInfo.listingCount} listing{sellerInfo.listingCount === 1 ? '' : 's'} ·{' '}
|
||||
{sellerInfo.accountAge}d old
|
||||
{sellerInfo.listingCount === 1
|
||||
? t('marketUi.listingDetail.listingCountOne', { count: sellerInfo.listingCount, age: sellerInfo.accountAge })
|
||||
: t('marketUi.listingDetail.listingCountMany', { count: sellerInfo.listingCount, age: sellerInfo.accountAge })}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -291,7 +294,7 @@ export default function ListingDetailPage() {
|
|||
disabled={isUpdatingStatus}
|
||||
className="w-full gap-2 border-white/15"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Relist as active
|
||||
<RotateCcw className="h-4 w-4" /> {t('marketUi.listingDetail.relistActive')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -299,7 +302,7 @@ export default function ListingDetailPage() {
|
|||
onClick={() => setShowMarkSold(true)}
|
||||
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" /> Mark as sold
|
||||
<CheckCircle className="h-4 w-4" /> {t('marketUi.listingDetail.markSold')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
@ -308,7 +311,7 @@ export default function ListingDetailPage() {
|
|||
onClick={() => setShowDelete(true)}
|
||||
className="w-full gap-2 border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Delete listing
|
||||
<Trash2 className="h-4 w-4" /> {t('marketUi.listingDetail.deleteListing')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -322,7 +325,7 @@ export default function ListingDetailPage() {
|
|||
onClick={() => setShowOffer(true)}
|
||||
className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
|
||||
>
|
||||
<Handshake className="h-4 w-4" /> Make an offer
|
||||
<Handshake className="h-4 w-4" /> {t('marketUi.listingDetail.makeOffer')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -330,7 +333,7 @@ export default function ListingDetailPage() {
|
|||
onClick={handleShare}
|
||||
className="mt-1 w-full gap-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
<Share2 className="h-4 w-4" /> Share listing
|
||||
<Share2 className="h-4 w-4" /> {t('marketUi.listingDetail.shareListing')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ type ViewMode = 'map' | 'list';
|
|||
|
||||
// Compact stash item card for the drawer
|
||||
function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const category = STASH_CATEGORIES[item.category];
|
||||
const condition = CONDITION_LABELS[item.condition];
|
||||
const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_TIERS;
|
||||
|
|
@ -116,7 +117,7 @@ function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => vo
|
|||
{isWishlist && (
|
||||
<Badge className="absolute -top-2 -left-1 bg-cyan-500 text-black text-[9px] px-1.5 py-0">
|
||||
<Target className="h-2.5 w-2.5 mr-0.5" />
|
||||
Wishlist
|
||||
{t('mapUi.wishlist')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
|
@ -179,6 +180,7 @@ function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => vo
|
|||
|
||||
// Stash drawer component
|
||||
function StashDrawer() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { items, stats, isLoading, removeItem, isEncryptionAvailable } = useStash();
|
||||
const author = useAuthor(user?.pubkey || '');
|
||||
|
|
@ -195,9 +197,9 @@ function StashDrawer() {
|
|||
return (
|
||||
<div className="p-6 text-center">
|
||||
<Backpack className="h-12 w-12 mx-auto mb-4 text-[#ff6600]/50" />
|
||||
<h3 className="font-bold text-lg mb-2">Your Private Stash</h3>
|
||||
<h3 className="font-bold text-lg mb-2">{t('mapUi.yourPrivateStash')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Track your gear, set prices, and find trades with other skaters.
|
||||
{t('mapUi.stashIntro')}
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
|
|
@ -209,12 +211,12 @@ function StashDrawer() {
|
|||
return (
|
||||
<div className="p-6 text-center">
|
||||
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
|
||||
<h3 className="font-bold text-lg mb-2">Encryption Required</h3>
|
||||
<h3 className="font-bold text-lg mb-2">{t('mapUi.encryptionRequired')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use a signer with NIP-44 support (Alby, nos2x, Amber).
|
||||
{t('mapUi.encryptionRequiredDesc')}
|
||||
</p>
|
||||
<Link to="/signers">
|
||||
<Button variant="outline" size="sm">Learn More</Button>
|
||||
<Button variant="outline" size="sm">{t('common.learnMore')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -233,7 +235,7 @@ function StashDrawer() {
|
|||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-white truncate">{displayName}</h3>
|
||||
<p className="text-xs text-muted-foreground">{stats.total} items</p>
|
||||
<p className="text-xs text-muted-foreground">{t('mapUi.itemsCount', { count: stats.total })}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 text-amber-400">
|
||||
|
|
@ -243,7 +245,7 @@ function StashDrawer() {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{usdValue ? `~$${usdValue.toFixed(0)}` : 'sats'}
|
||||
{usdValue ? `~$${usdValue.toFixed(0)}` : t('common.sats')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -253,15 +255,15 @@ function StashDrawer() {
|
|||
<div className="grid grid-cols-3 gap-2 p-3 border-b border-white/10">
|
||||
<div className="text-center p-2 rounded bg-black/40">
|
||||
<div className="text-lg font-black text-[#ff0040]">{stashItems.length}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">Items</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">{t('mapUi.items')}</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded bg-black/40">
|
||||
<div className="text-lg font-black text-cyan-400">{wishlistItems.length}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">Wishlist</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">{t('mapUi.wishlist')}</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded bg-black/40">
|
||||
<div className="text-lg font-black text-purple-400">{Object.keys(stats.byCategory).length}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">Types</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase">{t('mapUi.types')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -274,11 +276,11 @@ function StashDrawer() {
|
|||
) : items.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground mb-3">Your stash is empty</p>
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('mapUi.stashEmpty')}</p>
|
||||
<Link to="/stash">
|
||||
<Button size="sm" className="bg-[#ff6600] hover:bg-[#ff6600]/90">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add First Item
|
||||
{t('mapUi.addFirstItem')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -289,7 +291,7 @@ function StashDrawer() {
|
|||
<div className="mb-3">
|
||||
<h4 className="text-[10px] font-bold uppercase text-cyan-400 mb-2 flex items-center gap-1">
|
||||
<Target className="h-3 w-3" />
|
||||
Wishlist ({wishlistItems.length})
|
||||
{t('mapUi.wishlistCount', { count: wishlistItems.length })}
|
||||
</h4>
|
||||
{wishlistItems.slice(0, 3).map(item => (
|
||||
<MiniStashCard
|
||||
|
|
@ -306,7 +308,7 @@ function StashDrawer() {
|
|||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase text-[#ff6600] mb-2 flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
My Gear ({stashItems.length})
|
||||
{t('mapUi.myGearCount', { count: stashItems.length })}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{stashItems.slice(0, 5).map(item => (
|
||||
|
|
@ -328,20 +330,20 @@ function StashDrawer() {
|
|||
<Link to="/stash" className="block">
|
||||
<Button className="w-full bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 font-bold">
|
||||
<Backpack className="h-4 w-4 mr-2" />
|
||||
Open Full Stash
|
||||
{t('mapUi.openFullStash')}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/stash" className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full border-[#ff6600]/50 text-[#ff6600] hover:bg-[#ff6600]/10">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Item
|
||||
{t('mapUi.addItem')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/stash" className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full border-cyan-500/50 text-cyan-400 hover:bg-cyan-500/10">
|
||||
<Target className="h-3.5 w-3.5 mr-1" />
|
||||
Wishlist
|
||||
{t('mapUi.wishlist')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -369,6 +371,7 @@ function DirectionsButton({
|
|||
*/
|
||||
onDirections?: (target: { lat: number; lng: number; label: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const destination = useMemo(() => {
|
||||
const la = parseFloat(lat);
|
||||
|
|
@ -389,7 +392,7 @@ function DirectionsButton({
|
|||
disabled={!destination}
|
||||
>
|
||||
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
|
||||
Directions
|
||||
{t('mapUi.directions')}
|
||||
</Button>
|
||||
{!onDirections && (
|
||||
<RouteDialog
|
||||
|
|
@ -413,6 +416,7 @@ function SpotBottomSheet({
|
|||
onClose: () => void;
|
||||
onDirections: (target: { lat: number; lng: number; label: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(spot.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -496,7 +500,7 @@ function SpotBottomSheet({
|
|||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="text-[8px] bg-white/10">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">Added by <span className="text-white/70">{displayName}</span></span>
|
||||
<span className="text-xs text-muted-foreground">{t('mapUi.addedBy')} <span className="text-white/70">{displayName}</span></span>
|
||||
</div>
|
||||
|
||||
{/* CTA buttons */}
|
||||
|
|
@ -506,7 +510,7 @@ function SpotBottomSheet({
|
|||
className="h-12 font-bold text-sm bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 rounded-xl"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
{t('mapUi.viewDetails')}
|
||||
</Button>
|
||||
{spot.geohash && (() => {
|
||||
const { latitude, longitude } = ngeohash.decode(spot.geohash);
|
||||
|
|
@ -700,10 +704,10 @@ export default function MapPage() {
|
|||
// Getting a GPS lock (especially first-time / on ROMs without Google's
|
||||
// fused provider) can take a while — let the user know it's working.
|
||||
const locatingToast = toast({
|
||||
title: cached ? 'Refining your location…' : 'Locating you…',
|
||||
title: cached ? t('mapUi.refiningLocation') : t('mapUi.locatingYou'),
|
||||
description: cached
|
||||
? 'Centered on your last spot — dialing in a fresh GPS fix.'
|
||||
: 'Getting a GPS fix. This can take up to a minute the first time.',
|
||||
? t('mapUi.refiningLocationDesc')
|
||||
: t('mapUi.locatingDesc'),
|
||||
duration: 60000,
|
||||
});
|
||||
|
||||
|
|
@ -715,8 +719,8 @@ export default function MapPage() {
|
|||
setNearMeLoading(false);
|
||||
setNearMeError(null);
|
||||
toast({
|
||||
title: 'Found you',
|
||||
description: 'Centered the map on your location.',
|
||||
title: t('mapUi.foundYou'),
|
||||
description: t('mapUi.foundYouDesc'),
|
||||
duration: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -735,9 +739,9 @@ export default function MapPage() {
|
|||
if (isNativeGeolocation()) {
|
||||
// Native app: it's an OS permission, not a browser setting.
|
||||
toast({
|
||||
title: 'Location permission needed',
|
||||
title: t('mapUi.locationPermissionNeeded'),
|
||||
description:
|
||||
'Allow location for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then tap Near Me again.' +
|
||||
t('mapUi.permissionNeededDesc') +
|
||||
diag,
|
||||
variant: 'destructive',
|
||||
duration: 12000,
|
||||
|
|
@ -749,22 +753,22 @@ export default function MapPage() {
|
|||
} else if (kind === 'timeout') {
|
||||
setNearMeError('timeout');
|
||||
toast({
|
||||
title: 'Location timed out',
|
||||
title: t('mapUi.locationTimedOut'),
|
||||
description:
|
||||
(isNativeGeolocation()
|
||||
? "Couldn't get a location fix in time. If you're indoors, GPS can't see satellites — enable Network Location (Settings → Location) or step outside, then try again."
|
||||
: 'Could not get your location in time. Check your signal and try again.') + diag,
|
||||
? t('mapUi.timeoutNativeDesc')
|
||||
: t('mapUi.timeoutWebDesc')) + diag,
|
||||
variant: 'destructive',
|
||||
duration: 12000,
|
||||
});
|
||||
} else if (kind === 'no_provider') {
|
||||
setNearMeError('unavailable');
|
||||
toast({
|
||||
title: 'No location provider',
|
||||
title: t('mapUi.noLocationProvider'),
|
||||
description:
|
||||
(isNativeGeolocation()
|
||||
? "Your phone isn't providing a location. On privacy-focused ROMs (e.g. GrapheneOS) enable Network Location under Settings → Location, or make sure GPS is on and try outdoors."
|
||||
: 'No location provider is available. Enable location services and try again.') +
|
||||
? t('mapUi.noProviderNativeDesc')
|
||||
: t('mapUi.noProviderWebDesc')) +
|
||||
diag,
|
||||
variant: 'destructive',
|
||||
duration: 14000,
|
||||
|
|
@ -772,11 +776,11 @@ export default function MapPage() {
|
|||
} else {
|
||||
setNearMeError('unavailable');
|
||||
toast({
|
||||
title: 'Location unavailable',
|
||||
title: t('mapUi.locationUnavailable'),
|
||||
description:
|
||||
(isNativeGeolocation()
|
||||
? 'Turn on Location/GPS in your phone settings, allow location for SkateSpots (Settings → Apps → SkateSpots → Permissions), then try again.'
|
||||
: 'Your location could not be determined. Make sure location services are enabled.') +
|
||||
? t('mapUi.unavailableNativeDesc')
|
||||
: t('mapUi.unavailableWebDesc')) +
|
||||
diag,
|
||||
variant: 'destructive',
|
||||
duration: 12000,
|
||||
|
|
@ -1060,8 +1064,8 @@ export default function MapPage() {
|
|||
<div className="container px-4 py-2.5 flex items-center gap-3">
|
||||
<span className="text-xl flex-shrink-0">🛹</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">New here?</p>
|
||||
<p className="text-xs text-white/70 leading-tight">Discover skate spots, events & the community.</p>
|
||||
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">{t('mapUi.newHere')}</p>
|
||||
<p className="text-xs text-white/70 leading-tight">{t('mapUi.welcomeDesc')}</p>
|
||||
</div>
|
||||
{/* Desktop-only: grab the mobile app via Zapstore */}
|
||||
<a
|
||||
|
|
@ -1071,18 +1075,18 @@ export default function MapPage() {
|
|||
className="hidden md:inline-flex flex-shrink-0 items-center gap-1.5 px-3 py-1.5 rounded-full border-2 border-[#ccff00]/50 text-[#ccff00] text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/10 active:scale-95 transition-all"
|
||||
>
|
||||
<Smartphone className="h-3.5 w-3.5" />
|
||||
Get on Zapstore
|
||||
{t('mapUi.getOnZapstore')}
|
||||
</a>
|
||||
<Link
|
||||
to="/onboarding"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-[#ccff00] text-black text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/90 active:scale-95 transition-transform"
|
||||
>
|
||||
Get Started
|
||||
{t('common.getStarted')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setWelcomeDismissed(true)}
|
||||
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-colors"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('mapUi.dismiss')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1127,7 +1131,7 @@ export default function MapPage() {
|
|||
className={cn('h-8 w-8 rounded-full flex items-center justify-center transition-all touch-manipulation active:scale-90',
|
||||
viewMode === 'map' ? 'bg-[#ff0040] shadow-lg shadow-[#ff0040]/30' : 'text-gray-500 hover:text-gray-300')}
|
||||
onClick={() => setViewMode('map')}
|
||||
aria-label="Map view"
|
||||
aria-label={t('mapUi.mapViewAria')}
|
||||
>
|
||||
<Map className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1135,7 +1139,7 @@ export default function MapPage() {
|
|||
className={cn('h-8 w-8 rounded-full flex items-center justify-center transition-all touch-manipulation active:scale-90',
|
||||
viewMode === 'list' ? 'bg-[#ff0040] shadow-lg shadow-[#ff0040]/30' : 'text-gray-500 hover:text-gray-300')}
|
||||
onClick={() => setViewMode('list')}
|
||||
aria-label="List view"
|
||||
aria-label={t('mapUi.listViewAria')}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1151,7 +1155,7 @@ export default function MapPage() {
|
|||
? 'bg-[#ff0040]/25 border-[#ff0040]/60 text-[#ff0040]'
|
||||
: 'bg-black/50 backdrop-blur-md border-white/15 text-gray-300 md:bg-white/5 md:border-white/10 md:text-gray-400 hover:text-gray-200 hover:border-white/20'
|
||||
)}
|
||||
aria-label="Filters"
|
||||
aria-label={t('mapPage.filters')}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
{totalActiveFilters > 0 && (
|
||||
|
|
@ -1164,7 +1168,7 @@ export default function MapPage() {
|
|||
<SheetContent side="bottom" className="h-[82vh] max-h-[680px] rounded-t-2xl px-4 flex flex-col">
|
||||
<SheetHeader className="pb-2 flex-shrink-0">
|
||||
<SheetTitle className="flex items-center justify-between text-base">
|
||||
<span>Filters</span>
|
||||
<span>{t('mapPage.filters')}</span>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -1175,7 +1179,7 @@ export default function MapPage() {
|
|||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
{t('common.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</SheetTitle>
|
||||
|
|
@ -1184,7 +1188,7 @@ export default function MapPage() {
|
|||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 -mx-4 px-4 pb-2 scroll-container">
|
||||
{/* Layer toggles */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Show on Map</h3>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">{t('mapUi.showOnMap')}</h3>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<Button
|
||||
variant={showSpots ? 'default' : 'outline'}
|
||||
|
|
@ -1195,7 +1199,7 @@ export default function MapPage() {
|
|||
)}
|
||||
onClick={() => setShowSpots(!showSpots)}
|
||||
>
|
||||
🛹 Spots
|
||||
🛹 {t('mapUi.spots')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showShops ? 'default' : 'outline'}
|
||||
|
|
@ -1206,7 +1210,7 @@ export default function MapPage() {
|
|||
)}
|
||||
onClick={() => setShowShops(!showShops)}
|
||||
>
|
||||
🏪 Shops
|
||||
🏪 {t('mapUi.shops')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showGear ? 'default' : 'outline'}
|
||||
|
|
@ -1217,7 +1221,7 @@ export default function MapPage() {
|
|||
)}
|
||||
onClick={() => setShowGear(!showGear)}
|
||||
>
|
||||
🛒 Gear
|
||||
🛒 {t('mapUi.gear')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTours ? 'default' : 'outline'}
|
||||
|
|
@ -1228,7 +1232,7 @@ export default function MapPage() {
|
|||
)}
|
||||
onClick={() => setShowTours(!showTours)}
|
||||
>
|
||||
🗺️ Tours
|
||||
🗺️ {t('mapUi.tours')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showEvents ? 'default' : 'outline'}
|
||||
|
|
@ -1239,7 +1243,7 @@ export default function MapPage() {
|
|||
)}
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
>
|
||||
📅 Events
|
||||
📅 {t('mapUi.events')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showCrews ? 'default' : 'outline'}
|
||||
|
|
@ -1250,7 +1254,7 @@ export default function MapPage() {
|
|||
)}
|
||||
onClick={() => setShowCrews(!showCrews)}
|
||||
>
|
||||
🤘 Crews
|
||||
🤘 {t('mapUi.crews')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showBtcMap ? 'default' : 'outline'}
|
||||
|
|
@ -1268,7 +1272,7 @@ export default function MapPage() {
|
|||
|
||||
{/* Spot categories */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Spot Type</h3>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">{t('mapUi.spotType')}</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => (
|
||||
<Button
|
||||
|
|
@ -1292,7 +1296,7 @@ export default function MapPage() {
|
|||
{/* Sort & Trust */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Sort By</h3>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">{t('mapUi.sortBy')}</h3>
|
||||
<SortDropdown
|
||||
value={sortBy}
|
||||
onChange={setSortBy}
|
||||
|
|
@ -1301,7 +1305,7 @@ export default function MapPage() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Trust Level</h3>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">{t('mapUi.trustLevel')}</h3>
|
||||
<TrustFilter
|
||||
value={trustFilter}
|
||||
onChange={setTrustFilter}
|
||||
|
|
@ -1312,7 +1316,7 @@ export default function MapPage() {
|
|||
|
||||
{/* Weather toggle */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Weather</h3>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">{t('mapUi.weather')}</h3>
|
||||
<WeatherToggle
|
||||
enabled={weatherEnabled}
|
||||
onToggle={() => {
|
||||
|
|
@ -1332,18 +1336,18 @@ export default function MapPage() {
|
|||
Stash) to avoid duplication. */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">Quick Actions</h3>
|
||||
<h3 className="text-xs font-bold text-muted-foreground mb-2 uppercase tracking-wide">{t('mapUi.quickActions')}</h3>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Link to="/marketplace" onClick={() => setMobileFiltersOpen(false)}>
|
||||
<Button size="sm" variant="outline" className="w-full gap-1 border-orange-500/50 text-orange-400 font-bold h-9 text-xs">
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
Buy/Sell Gear
|
||||
{t('mapUi.buySellGear')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/stash" onClick={() => setMobileFiltersOpen(false)}>
|
||||
<Button size="sm" variant="outline" className="w-full gap-1 border-[#ff6600]/50 text-[#ff6600] font-bold h-9 text-xs">
|
||||
<Backpack className="h-3 w-3" />
|
||||
Stash
|
||||
{t('mapUi.stash')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -1357,7 +1361,7 @@ export default function MapPage() {
|
|||
className="w-full h-12 font-bold text-base"
|
||||
onClick={() => setMobileFiltersOpen(false)}
|
||||
>
|
||||
Show Results
|
||||
{t('mapUi.showResults')}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
|
@ -1391,22 +1395,22 @@ export default function MapPage() {
|
|||
className="gap-1.5 font-bold text-xs px-2.5 h-8 flex-shrink-0 border-gray-700 hover:border-white/30 touch-manipulation active:scale-95"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>Layers</span>
|
||||
<span>{t('mapUi.layers')}</span>
|
||||
<span className="ml-0.5 min-w-[18px] h-[18px] px-1 text-[10px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none">
|
||||
{mobileLayerCount}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl">
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">{t('mapUi.showOnMap')}</p>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||||
{ key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
|
||||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||||
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||||
{ key: 'spots', label: t('mapUi.spots'), dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||||
{ key: 'shops', label: t('mapUi.shops'), dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
|
||||
{ key: 'gear', label: t('mapUi.gear'), dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: t('mapUi.tours'), dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
{ key: 'events', label: t('mapUi.events'), dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||||
{ key: 'crews', label: t('mapUi.crews'), dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||||
].map(({ key, label, dot, active, toggle }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
@ -1457,8 +1461,8 @@ export default function MapPage() {
|
|||
<TooltipContent side="bottom" className="max-w-[200px]">
|
||||
<p className="text-xs">
|
||||
{showBtcMap
|
||||
? `Showing ${btcMapPlaces?.length || 0} places where you can Spedn your sats nearby`
|
||||
: 'Show places where you can Spedn your sats (via BTC Map)'
|
||||
? t('mapUi.btcMapShowing', { count: btcMapPlaces?.length || 0 })
|
||||
: t('mapUi.btcMapShow')
|
||||
}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
|
|
@ -1473,7 +1477,7 @@ export default function MapPage() {
|
|||
className="gap-1 font-bold text-xs px-2.5 h-8 flex-shrink-0 touch-manipulation active:scale-95 border-[#ff6600]/50 text-[#ff6600] hover:bg-[#ff6600]/10 hover:border-[#ff6600]"
|
||||
>
|
||||
<Backpack className="h-3.5 w-3.5" />
|
||||
<span className="hidden xs:inline">Stash</span>
|
||||
<span className="hidden xs:inline">{t('mapUi.stash')}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[340px] sm:w-[400px] p-0 bg-black/95 border-l-2 border-[#ff6600]/30">
|
||||
|
|
@ -1482,10 +1486,10 @@ export default function MapPage() {
|
|||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#ff6600] to-[#ff0040] flex items-center justify-center">
|
||||
<Backpack className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
My Stash
|
||||
{t('mapUi.myStash')}
|
||||
<Badge variant="outline" className="ml-auto border-green-500/50 text-green-400 text-[10px]">
|
||||
<Shield className="h-2.5 w-2.5 mr-1" />
|
||||
Encrypted
|
||||
{t('mapUi.encrypted')}
|
||||
</Badge>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
|
@ -1544,10 +1548,10 @@ export default function MapPage() {
|
|||
<SheetHeader className="p-4 pb-2">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5 text-purple-400" />
|
||||
Local Chat
|
||||
{t('mapUi.localChat')}
|
||||
</SheetTitle>
|
||||
<p className="text-xs text-muted-foreground text-left">
|
||||
Live chat for wherever the map is centered. Pick how wide the channel reaches.
|
||||
{t('mapUi.localChatDesc')}
|
||||
</p>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 min-h-0 p-4 pt-0">
|
||||
|
|
@ -1581,7 +1585,7 @@ export default function MapPage() {
|
|||
className="flex-shrink-0 gap-1 border-2 border-gray-700 hover:border-[#ccff00]/50 font-bold h-8 px-2 sm:px-3"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden md:inline">More</span>
|
||||
<span className="hidden md:inline">{t('mapUi.more')}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -1590,10 +1594,10 @@ export default function MapPage() {
|
|||
<Store className="h-4 w-4" /> {t('shops.addShop')}
|
||||
</Link>
|
||||
<Link to="/marketplace" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-orange-500/10 text-orange-400 transition-colors">
|
||||
<ShoppingBag className="h-4 w-4" /> Buy/Sell Gear
|
||||
<ShoppingBag className="h-4 w-4" /> {t('mapUi.buySellGear')}
|
||||
</Link>
|
||||
<Link to="/create-tour" className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium hover:bg-cyan-500/10 text-cyan-400 transition-colors">
|
||||
<Route className="h-4 w-4" /> Offer Tour
|
||||
<Route className="h-4 w-4" /> {t('mapUi.offerTour')}
|
||||
</Link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -1639,7 +1643,7 @@ export default function MapPage() {
|
|||
<div className="h-full bg-black/20 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-white font-bold">Loading map...</span>
|
||||
<span className="text-sm text-white font-bold">{t('mapUi.loadingMap')}</span>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
|
|
@ -1687,7 +1691,7 @@ export default function MapPage() {
|
|||
<div className="flex items-center gap-2 rounded-full bg-black/60 px-3.5 py-1.5 backdrop-blur-md ring-1 ring-white/10 shadow-lg">
|
||||
<span className="h-3.5 w-3.5 rounded-full border-2 border-[#ccff00]/30 border-t-[#ccff00] animate-spin" />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
|
||||
Loading spots
|
||||
{t('mapUi.loadingSpots')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1706,7 +1710,7 @@ export default function MapPage() {
|
|||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-400/30 bg-black/75 px-3 py-1.5 shadow-lg backdrop-blur-md">
|
||||
<WifiOff className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-amber-200/90">
|
||||
Offline · showing saved spots
|
||||
{t('mapUi.offlineSavedSpots')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1715,7 +1719,7 @@ export default function MapPage() {
|
|||
relay.recheck();
|
||||
}}
|
||||
className="ml-0.5 rounded-full p-1 text-amber-200/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Try again"
|
||||
aria-label={t('common.tryAgain')}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1723,7 +1727,7 @@ export default function MapPage() {
|
|||
type="button"
|
||||
onClick={() => setOfflineBannerDismissed(true)}
|
||||
className="rounded-full p-1 text-amber-200/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('mapUi.dismiss')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1737,10 +1741,10 @@ export default function MapPage() {
|
|||
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && allLocations.length > 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto max-w-xs rounded-2xl border border-white/10 bg-black/85 p-5 text-center shadow-2xl backdrop-blur-md">
|
||||
<p className="font-black uppercase tracking-wide text-white">No spots match</p>
|
||||
<p className="mt-1 text-xs text-gray-400">Try a different search or clear your filters.</p>
|
||||
<p className="font-black uppercase tracking-wide text-white">{t('mapUi.noSpotsMatchTitle')}</p>
|
||||
<p className="mt-1 text-xs text-gray-400">{t('mapUi.noSpotsMatchDesc')}</p>
|
||||
<Button size="sm" className="mt-3" onClick={clearFilters}>
|
||||
Clear filters
|
||||
{t('common.clearFilters')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1754,13 +1758,13 @@ export default function MapPage() {
|
|||
<div className="pointer-events-auto max-w-sm rounded-2xl border border-amber-400/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
|
||||
<WifiOff className="mx-auto h-7 w-7 text-amber-400" />
|
||||
<p className="mt-2 font-black uppercase tracking-wide text-white">
|
||||
Can't reach the relays
|
||||
{t('mapUi.cantReachRelays')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-400">
|
||||
{online
|
||||
? "You're online, but the community relay isn't answering right now."
|
||||
: "You're offline."}{' '}
|
||||
Spots you've already seen are saved on this device and will show here.
|
||||
? t('mapUi.relayOnlineNoAnswer')
|
||||
: t('mapUi.youreOffline')}{' '}
|
||||
{t('mapUi.savedOnDevice')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -1772,7 +1776,7 @@ export default function MapPage() {
|
|||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Try again
|
||||
{t('common.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1786,11 +1790,10 @@ export default function MapPage() {
|
|||
<div className="pointer-events-auto max-w-sm rounded-2xl border-2 border-[#ccff00]/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
|
||||
<p className="text-2xl">🛹</p>
|
||||
<p className="mt-2 font-black uppercase tracking-wide text-white">
|
||||
No spots mapped yet
|
||||
{t('mapUi.noSpotsMappedYet')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-400">
|
||||
Be the first to map your park — it takes a minute, it's yours, and the next
|
||||
skater who rolls through will thank you.
|
||||
{t('mapUi.noSpotsMappedDesc')}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
|
||||
<Link to="/create-spot">
|
||||
|
|
@ -1799,12 +1802,12 @@ export default function MapPage() {
|
|||
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add the first spot
|
||||
{t('mapUi.addFirstSpot')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline" className="gap-2 border-white/20" onClick={handleNearMe} disabled={nearMeLoading}>
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
{nearMeLoading ? 'Locating…' : 'Find spots near me'}
|
||||
{nearMeLoading ? t('mapUi.locating') : t('mapUi.findSpotsNearMe')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1837,18 +1840,18 @@ export default function MapPage() {
|
|||
<LocateOff className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-white uppercase tracking-wide">Location Blocked</p>
|
||||
<p className="text-sm font-black text-white uppercase tracking-wide">{t('mapUi.locationBlocked')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Your browser has blocked location access for this site. Here's how to re-enable it:
|
||||
{t('mapUi.locationBlockedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-white/[0.03] divide-y divide-white/5 overflow-hidden">
|
||||
{[
|
||||
{ platform: '📱 Android Chrome', steps: 'Tap the lock icon in the address bar → Site settings → Location → Allow' },
|
||||
{ platform: '🍎 iOS Safari', steps: 'Settings app → Safari → Location → Allow (or tap the "AA" button in Safari\'s address bar)' },
|
||||
{ platform: '💻 Desktop Chrome', steps: 'Click the lock icon → Site settings → Location → Allow' },
|
||||
{ platform: '🦊 Firefox', steps: 'Click the lock icon → Connection secure → More info → Permissions → Access your location → Allow' },
|
||||
{ platform: `📱 ${t('mapUi.locationPlatformAndroid')}`, steps: t('mapUi.locationStepsAndroid') },
|
||||
{ platform: `🍎 ${t('mapUi.locationPlatformIos')}`, steps: t('mapUi.locationStepsIos') },
|
||||
{ platform: `💻 ${t('mapUi.locationPlatformDesktop')}`, steps: t('mapUi.locationStepsDesktop') },
|
||||
{ platform: `🦊 ${t('mapUi.locationPlatformFirefox')}`, steps: t('mapUi.locationStepsFirefox') },
|
||||
].map(({ platform, steps }) => (
|
||||
<div key={platform} className="px-3 py-2.5">
|
||||
<p className="text-xs font-bold text-white mb-0.5">{platform}</p>
|
||||
|
|
@ -1863,7 +1866,7 @@ export default function MapPage() {
|
|||
}}
|
||||
className="w-full py-3 rounded-xl bg-white/8 hover:bg-white/12 border border-white/10 text-sm font-bold text-white transition-all active:scale-[0.98]"
|
||||
>
|
||||
Got it
|
||||
{t('mapUi.gotIt')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1898,7 +1901,7 @@ export default function MapPage() {
|
|||
? 'bg-red-900/80 border-red-500/30'
|
||||
: 'bg-black/70 border-white/15 hover:border-white/30'
|
||||
)}
|
||||
aria-label="Find spots near me"
|
||||
aria-label={t('mapUi.findSpotsNearMe')}
|
||||
>
|
||||
{nearMeLoading ? (
|
||||
<span className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white animate-spin" />
|
||||
|
|
@ -1919,7 +1922,7 @@ export default function MapPage() {
|
|||
? 'bg-[#ff0040]/20 border-[#ff0040]/60'
|
||||
: 'bg-black/70 border-white/15 hover:border-white/30'
|
||||
)}
|
||||
aria-label="Map layers"
|
||||
aria-label={t('mapUi.mapLayers')}
|
||||
>
|
||||
<Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} />
|
||||
{mobileLayerCount < totalPrimaryLayers && (
|
||||
|
|
@ -1934,15 +1937,15 @@ export default function MapPage() {
|
|||
align="end"
|
||||
className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl"
|
||||
>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">{t('mapUi.showOnMap')}</p>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||||
{ key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
|
||||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||||
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||||
{ key: 'spots', label: t('mapUi.spots'), dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
|
||||
{ key: 'shops', label: t('mapUi.shops'), dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
|
||||
{ key: 'gear', label: t('mapUi.gear'), dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: t('mapUi.tours'), dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
{ key: 'events', label: t('mapUi.events'), dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||||
{ key: 'crews', label: t('mapUi.crews'), dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||||
].map(({ key, label, dot, active, toggle }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
@ -1985,11 +1988,11 @@ export default function MapPage() {
|
|||
{addDialOpen && (
|
||||
<>
|
||||
{[
|
||||
{ label: 'Add Spot', to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
|
||||
{ label: 'Add Shop', to: '/create-shop', bg: 'bg-[#9333ea]', icon: <Store className="h-4 w-4 text-white" /> },
|
||||
{ label: 'Add Event', to: '/create-event', bg: 'bg-[#ccff00]', icon: <Calendar className="h-4 w-4 text-black" /> },
|
||||
{ label: 'Sell Gear', to: '/create-listing', bg: 'bg-[#f97316]', icon: <ShoppingBag className="h-4 w-4 text-white" /> },
|
||||
{ label: 'Offer Tour', to: '/create-tour', bg: 'bg-[#06b6d4]', icon: <Route className="h-4 w-4 text-white" /> },
|
||||
{ label: t('mapUi.addSpot'), to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
|
||||
{ label: t('mapUi.addShop'), to: '/create-shop', bg: 'bg-[#9333ea]', icon: <Store className="h-4 w-4 text-white" /> },
|
||||
{ label: t('mapUi.addEvent'), to: '/create-event', bg: 'bg-[#ccff00]', icon: <Calendar className="h-4 w-4 text-black" /> },
|
||||
{ label: t('mapUi.sellGear'), to: '/create-listing', bg: 'bg-[#f97316]', icon: <ShoppingBag className="h-4 w-4 text-white" /> },
|
||||
{ label: t('mapUi.offerTour'), to: '/create-tour', bg: 'bg-[#06b6d4]', icon: <Route className="h-4 w-4 text-white" /> },
|
||||
].map((a, i) => (
|
||||
<Link
|
||||
key={a.to}
|
||||
|
|
@ -2018,7 +2021,7 @@ export default function MapPage() {
|
|||
? 'bg-white rotate-45'
|
||||
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90'
|
||||
)}
|
||||
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, event, gear, or tour'}
|
||||
aria-label={addDialOpen ? t('mapUi.closeAddMenu') : t('mapUi.addMenuAria')}
|
||||
>
|
||||
<Plus className={cn('h-7 w-7 transition-colors', addDialOpen ? 'text-black' : 'text-white')} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
|
@ -2097,7 +2100,7 @@ export default function MapPage() {
|
|||
hideClose
|
||||
className="h-auto max-h-[55vh] rounded-t-2xl px-0 pb-0 bg-black/95 border-t border-white/10"
|
||||
>
|
||||
<SheetTitle className="sr-only">{selectedSpot?.title ?? 'Spot details'}</SheetTitle>
|
||||
<SheetTitle className="sr-only">{selectedSpot?.title ?? t('mapUi.spotDetails')}</SheetTitle>
|
||||
{selectedSpot && (
|
||||
<SpotBottomSheet
|
||||
spot={selectedSpot}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import { SellerDashboard } from '@/components/SellerDashboard';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'browse', label: 'Browse', icon: Store },
|
||||
{ id: 'selling', label: 'Selling', icon: Package },
|
||||
{ id: 'trades', label: 'My Trades', icon: Handshake },
|
||||
{ id: 'reputation', label: 'Reputation', icon: Star },
|
||||
{ id: 'browse', labelKey: 'marketUi.hub.browse', icon: Store },
|
||||
{ id: 'selling', labelKey: 'marketUi.hub.selling', icon: Package },
|
||||
{ id: 'trades', labelKey: 'marketUi.hub.myTrades', icon: Handshake },
|
||||
{ id: 'reputation', labelKey: 'marketUi.hub.reputation', icon: Star },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
|
@ -23,6 +24,7 @@ type TabId = (typeof TABS)[number]['id'];
|
|||
* working; this is just the front door.
|
||||
*/
|
||||
export default function MarketplaceHubPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const [tab, setTab] = useState<TabId>('browse');
|
||||
|
||||
|
|
@ -32,7 +34,7 @@ export default function MarketplaceHubPage() {
|
|||
<div className="sticky top-14 z-30 border-b border-white/5 bg-black/95 backdrop-blur md:top-16">
|
||||
<div className="container max-w-7xl px-4">
|
||||
<div className="scroll-container flex gap-1 overflow-x-auto py-2">
|
||||
{TABS.map(({ id, label, icon: Icon }) => {
|
||||
{TABS.map(({ id, labelKey, icon: Icon }) => {
|
||||
const active = tab === id;
|
||||
return (
|
||||
<button
|
||||
|
|
@ -46,7 +48,7 @@ export default function MarketplaceHubPage() {
|
|||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -61,7 +63,7 @@ export default function MarketplaceHubPage() {
|
|||
<SellerDashboard />
|
||||
) : (
|
||||
<div className="space-y-4 py-16 text-center">
|
||||
<p className="text-gray-400">Log in to manage your listings.</p>
|
||||
<p className="text-gray-400">{t('marketUi.hub.loginListings')}</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -74,7 +76,7 @@ export default function MarketplaceHubPage() {
|
|||
<TradeReputationPanel pubkey={user.pubkey} />
|
||||
) : (
|
||||
<div className="space-y-4 py-16 text-center">
|
||||
<p className="text-gray-400">Log in to see your reputation and reviews.</p>
|
||||
<p className="text-gray-400">{t('marketUi.hub.loginReputation')}</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { Card, CardContent } from '@/components/ui/card';
|
|||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { ShoppingBag, ExternalLink, Package, DollarSign, Shield, Zap, Store } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Marketplace - SkateSpots',
|
||||
description: 'Buy and sell skateboarding gear on the decentralized Plebeian Market.',
|
||||
|
|
@ -24,11 +26,10 @@ export default function MarketplacePage() {
|
|||
fontFamily: 'Impact, "Arial Black", sans-serif',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
Shops & Marketplace
|
||||
{t('marketUi.marketplace.heroTitle')}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Our marketplace is powered by <strong className="text-[#ff6600]">Plebeian Market</strong> - a decentralized marketplace on Nostr.
|
||||
Skate shops can list their products, and you can buy/sell used gear directly with Bitcoin.
|
||||
{t('marketUi.marketplace.heroBefore')} <strong className="text-[#ff6600]">Plebeian Market</strong> {t('marketUi.marketplace.heroAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -40,10 +41,9 @@ export default function MarketplacePage() {
|
|||
<Store className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-black uppercase text-[#ff6600] mb-3">What is Plebeian Market?</h2>
|
||||
<h2 className="text-2xl font-black uppercase text-[#ff6600] mb-3">{t('marketUi.marketplace.whatIsTitle')}</h2>
|
||||
<p className="text-gray-300 mb-4">
|
||||
Plebeian Market is a decentralized marketplace built on Nostr. Buy and sell skateboarding gear
|
||||
directly with other skaters using Bitcoin. No middlemen, no fees, total freedom.
|
||||
{t('marketUi.marketplace.whatIsBody')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -51,18 +51,18 @@ export default function MarketplacePage() {
|
|||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ff0040]/20">
|
||||
<Shield className="h-6 w-6 text-[#ff0040] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-1">No Fees</h3>
|
||||
<p className="text-xs text-gray-400">No platform taking 30%. Direct peer-to-peer sales.</p>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-1">{t('marketUi.marketplace.noFeesTitle')}</h3>
|
||||
<p className="text-xs text-gray-400">{t('marketUi.marketplace.noFeesBody')}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
|
||||
<DollarSign className="h-6 w-6 text-[#ccff00] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-1">Bitcoin Payments</h3>
|
||||
<p className="text-xs text-gray-400">Pay with Bitcoin. Fast, secure, global.</p>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-1">{t('marketUi.marketplace.bitcoinTitle')}</h3>
|
||||
<p className="text-xs text-gray-400">{t('marketUi.marketplace.bitcoinBody')}</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-black/50 border border-[#ff6600]/20">
|
||||
<Package className="h-6 w-6 text-[#ff6600] mb-2" />
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-1">Decentralized</h3>
|
||||
<p className="text-xs text-gray-400">Built on Nostr. No company controls it.</p>
|
||||
<h3 className="font-bold text-white uppercase text-sm mb-1">{t('marketUi.marketplace.decentralizedTitle')}</h3>
|
||||
<p className="text-xs text-gray-400">{t('marketUi.marketplace.decentralizedBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export default function MarketplacePage() {
|
|||
<a href="https://plebeian.market" target="_blank" rel="noopener noreferrer">
|
||||
<Button size="lg" className="gap-2 bg-[#ff6600] hover:bg-[#ff6600]/90 text-white border-2 border-[#ff6600] shadow-[0_0_20px_rgba(255,102,0,0.5)] font-bold uppercase px-12 py-6 text-lg">
|
||||
<ExternalLink className="h-6 w-6" />
|
||||
Visit Plebeian Market
|
||||
{t('marketUi.marketplace.visit')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -79,27 +79,51 @@ export default function MarketplacePage() {
|
|||
|
||||
{/* What You Can Buy/Sell */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-6">What You Can Buy & Sell</h2>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-6">{t('marketUi.marketplace.buySellTitle')}</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{
|
||||
title: 'Complete Decks',
|
||||
items: ['Pre-built completes', 'Custom setups', 'Beginner boards'],
|
||||
title: t('marketUi.marketplace.catCompletes'),
|
||||
items: [
|
||||
t('marketUi.marketplace.preBuilt'),
|
||||
t('marketUi.marketplace.customSetups'),
|
||||
t('marketUi.marketplace.beginnerBoards'),
|
||||
],
|
||||
color: '#ff0040'
|
||||
},
|
||||
{
|
||||
title: 'Deck Parts',
|
||||
items: ['Decks', 'Trucks', 'Wheels', 'Bearings', 'Grip tape', 'Hardware'],
|
||||
title: t('marketUi.marketplace.catParts'),
|
||||
items: [
|
||||
t('marketUi.marketplace.decks'),
|
||||
t('marketUi.marketplace.trucks'),
|
||||
t('marketUi.marketplace.wheels'),
|
||||
t('marketUi.marketplace.bearings'),
|
||||
t('marketUi.marketplace.gripTape'),
|
||||
t('marketUi.marketplace.hardware'),
|
||||
],
|
||||
color: '#ff6600'
|
||||
},
|
||||
{
|
||||
title: 'Apparel & Protection',
|
||||
items: ['Skate shoes', 'Clothing', 'Helmets', 'Pads', 'Backpacks'],
|
||||
title: t('marketUi.marketplace.catApparel'),
|
||||
items: [
|
||||
t('marketUi.marketplace.skateShoes'),
|
||||
t('marketUi.marketplace.clothing'),
|
||||
t('marketUi.marketplace.helmets'),
|
||||
t('marketUi.marketplace.pads'),
|
||||
t('marketUi.marketplace.backpacks'),
|
||||
],
|
||||
color: '#ccff00'
|
||||
},
|
||||
{
|
||||
title: 'Accessories',
|
||||
items: ['Tools', 'Wax', 'Rails', 'Stickers', 'Videos', 'Magazines'],
|
||||
title: t('marketUi.marketplace.catAccessories'),
|
||||
items: [
|
||||
t('marketUi.marketplace.tools'),
|
||||
t('marketUi.marketplace.wax'),
|
||||
t('marketUi.marketplace.rails'),
|
||||
t('marketUi.marketplace.stickers'),
|
||||
t('marketUi.marketplace.videos'),
|
||||
t('marketUi.marketplace.magazines'),
|
||||
],
|
||||
color: '#ff0040'
|
||||
},
|
||||
].map((category, i) => (
|
||||
|
|
@ -125,29 +149,29 @@ export default function MarketplacePage() {
|
|||
{/* How It Works */}
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/50">
|
||||
<CardContent className="p-8">
|
||||
<h2 className="text-2xl font-black uppercase text-[#ccff00] mb-6">How It Works</h2>
|
||||
<h2 className="text-2xl font-black uppercase text-[#ccff00] mb-6">{t('marketUi.marketplace.howItWorks')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
num: 1,
|
||||
title: 'Browse Products',
|
||||
desc: 'Visit Plebeian Market and browse skateboarding gear from shops and individuals worldwide.',
|
||||
title: t('marketUi.marketplace.step1Title'),
|
||||
desc: t('marketUi.marketplace.step1Body'),
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: 'Contact Sellers',
|
||||
desc: 'Message sellers directly via Nostr. Negotiate prices, ask questions, arrange shipping.',
|
||||
title: t('marketUi.marketplace.step2Title'),
|
||||
desc: t('marketUi.marketplace.step2Body'),
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: 'Pay with Bitcoin',
|
||||
desc: 'Complete your purchase using Bitcoin Lightning for instant, low-fee payments.',
|
||||
title: t('marketUi.marketplace.step3Title'),
|
||||
desc: t('marketUi.marketplace.step3Body'),
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
title: 'Leave Feedback',
|
||||
desc: 'Rate your experience and build seller reputation on Nostr. Your reviews help the community.',
|
||||
title: t('marketUi.marketplace.step4Title'),
|
||||
desc: t('marketUi.marketplace.step4Body'),
|
||||
},
|
||||
].map((step) => (
|
||||
<div key={step.num} className="flex gap-4 p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
|
||||
|
|
@ -166,10 +190,9 @@ export default function MarketplacePage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<Zap className="h-5 w-5 text-[#ff0040] flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-bold text-[#ff0040] mb-1">Need Bitcoin?</h4>
|
||||
<h4 className="font-bold text-[#ff0040] mb-1">{t('marketUi.marketplace.needBitcoinTitle')}</h4>
|
||||
<p className="text-sm text-gray-300">
|
||||
You'll need a Lightning wallet with some sats to make purchases.
|
||||
Check out the <a href="/onboarding" className="text-[#ccff00] underline font-bold">onboarding guide</a> for wallet recommendations.
|
||||
{t('marketUi.marketplace.needBitcoinBefore')} <a href="/onboarding" className="text-[#ccff00] underline font-bold">{t('marketUi.marketplace.onboardingGuide')}</a> {t('marketUi.marketplace.needBitcoinAfter')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -181,22 +204,21 @@ export default function MarketplacePage() {
|
|||
<Card className="bg-black/90 border-2 border-[#ff0040]/50 mt-8">
|
||||
<CardContent className="p-8 text-center">
|
||||
<Store className="h-12 w-12 mx-auto mb-4 text-[#ff0040]" />
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-3">Own a Skate Shop?</h2>
|
||||
<h2 className="text-2xl font-black uppercase text-white mb-3">{t('marketUi.marketplace.ownShopTitle')}</h2>
|
||||
<p className="text-gray-300 mb-6 max-w-xl mx-auto">
|
||||
List your shop on SkateSpots and sell your products on Plebeian Market.
|
||||
Reach customers worldwide with zero platform fees.
|
||||
{t('marketUi.marketplace.ownShopBody')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/create-shop">
|
||||
<Button className="gap-2 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white border-2 border-[#ff0040] shadow-[0_0_20px_rgba(255,0,64,0.5)] font-bold uppercase px-8">
|
||||
<Store className="h-5 w-5" />
|
||||
Add Your Shop
|
||||
{t('marketUi.marketplace.addShop')}
|
||||
</Button>
|
||||
</a>
|
||||
<a href="https://plebeian.market/sell" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" className="gap-2 border-2 border-[#ccff00]/50 hover:bg-[#ccff00]/10 text-[#ccff00] font-bold uppercase px-8">
|
||||
<Package className="h-5 w-5" />
|
||||
Start Selling
|
||||
{t('marketUi.marketplace.startSelling')}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import { useAuthor } from '@/hooks/useAuthor';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { Trophy, Medal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots: number; events: number; rank: number }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(pubkey);
|
||||
const { data: trustScore } = useTrustScore(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -51,21 +53,21 @@ function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots
|
|||
<div className="flex gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-[#ff0040] font-black">{spots}</div>
|
||||
<div className="text-gray-500 text-xs uppercase">Spots</div>
|
||||
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.spots')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[#ccff00] font-black">{events}</div>
|
||||
<div className="text-gray-500 text-xs uppercase">Events</div>
|
||||
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.events')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[#ff6600] font-black">{total}</div>
|
||||
<div className="text-gray-500 text-xs uppercase">Total</div>
|
||||
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.total')}</div>
|
||||
</div>
|
||||
<div className="text-center min-w-[48px]">
|
||||
<div className={cn("font-black", trustScoreColor)}>
|
||||
{trustScore ?? '—'}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs uppercase">Trust</div>
|
||||
<div className="text-gray-500 text-xs uppercase">{t('labUi.pyramid.trust')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,6 +75,7 @@ function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots
|
|||
}
|
||||
|
||||
export default function PyramidPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Pyramid Rankings - SkateSpots',
|
||||
description: 'Top contributors and community leaders.',
|
||||
|
|
@ -121,9 +124,9 @@ export default function PyramidPage() {
|
|||
<div className="text-center mb-12">
|
||||
<Trophy className="h-16 w-16 mx-auto mb-4 text-orange-500" />
|
||||
<h1 className="text-4xl md:text-6xl font-black uppercase mb-4 neon-header">
|
||||
<span className="neon-red">PYRAMID</span> <span className="neon-lime">RANKINGS</span>
|
||||
<span className="neon-red">{t('labUi.pyramid.pyramidWord')}</span> <span className="neon-lime">{t('labUi.pyramid.rankingsWord')}</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 text-xl">Top contributors in your city</p>
|
||||
<p className="text-gray-400 text-xl">{t('labUi.pyramid.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Your Stats */}
|
||||
|
|
@ -134,22 +137,22 @@ export default function PyramidPage() {
|
|||
<div className="flex items-center gap-4">
|
||||
<div className="text-4xl font-black text-[#ff0040]">#{userRank}</div>
|
||||
<div>
|
||||
<div className="font-black text-xl text-white uppercase">Your Rank</div>
|
||||
<div className="text-sm text-gray-400">Keep contributing to climb higher!</div>
|
||||
<div className="font-black text-xl text-white uppercase">{t('labUi.pyramid.yourRank')}</div>
|
||||
<div className="text-sm text-gray-400">{t('labUi.pyramid.keepContributing')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black text-[#ff0040]">{userStats.spots}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Spots</div>
|
||||
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.spots')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black text-[#ccff00]">{userStats.events}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Events</div>
|
||||
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.events')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-black text-[#ff6600]">{userStats.total}</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Total</div>
|
||||
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.total')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={cn(
|
||||
|
|
@ -161,7 +164,7 @@ export default function PyramidPage() {
|
|||
)}>
|
||||
{userTrustScore ?? '—'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase">Trust</div>
|
||||
<div className="text-xs text-gray-400 uppercase">{t('labUi.pyramid.trust')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -174,7 +177,7 @@ export default function PyramidPage() {
|
|||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Medal className="h-8 w-8 text-[#ff0040]" />
|
||||
<h2 className="text-2xl font-black uppercase">Top Contributors</h2>
|
||||
<h2 className="text-2xl font-black uppercase">{t('labUi.pyramid.topContributors')}</h2>
|
||||
</div>
|
||||
|
||||
{rankings.length > 0 ? (
|
||||
|
|
@ -192,7 +195,7 @@ export default function PyramidPage() {
|
|||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Trophy className="h-16 w-16 mx-auto mb-4 text-gray-600" />
|
||||
<p className="text-gray-400">No contributors yet. Be the first to add a spot or create an event!</p>
|
||||
<p className="text-gray-400">{t('labUi.pyramid.noContributors')}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { useToast } from '@/hooks/useToast';
|
|||
import { useNostr } from '@/hooks/useNostr';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, Shield, ChevronUp, ChevronDown, User, Building2 } from 'lucide-react';
|
||||
|
|
@ -627,6 +628,7 @@ function useWitnessName(pubkey: string) {
|
|||
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
|
||||
// No flex-1 / flex-grow — every row has a fixed pixel height.
|
||||
function RiderFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
const witnessedBadges = d.receivedBadges.slice(0, 2);
|
||||
|
||||
return (
|
||||
|
|
@ -639,14 +641,14 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
|
||||
🛹 SkateSpots
|
||||
</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Skater License</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewLicense')}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
|
||||
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
|
||||
{shortNpub(d.pubkey)}
|
||||
</div>
|
||||
<div className="text-[5px] card-muted mt-[1px]">Since {d.memberYear}</div>
|
||||
<div className="text-[5px] card-muted mt-[1px]">{t('labUi.rider.previewSince', { year: d.memberYear })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -666,7 +668,7 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
<div className="space-y-[2px]">
|
||||
{d.showSpots && (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">Spots</span>
|
||||
<span className="text-[5px] card-muted">{t('labUi.pyramid.spots')}</span>
|
||||
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -678,7 +680,7 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
)}
|
||||
{d.showEvents && (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[5px] card-muted">Evts</span>
|
||||
<span className="text-[5px] card-muted">{t('labUi.rider.previewEvts')}</span>
|
||||
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -736,7 +738,7 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">{t('labUi.rider.previewScan')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -758,6 +760,7 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
|
||||
// ─── Card visual: Park Access front ──────────────────────────────────────────
|
||||
function ParkFront({ d }: { d: CardData }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||
|
|
@ -766,9 +769,9 @@ function ParkFront({ d }: { d: CardData }) {
|
|||
<div className="flex items-center justify-between" style={{ height: '26px' }}>
|
||||
<div>
|
||||
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
|
||||
🏟️ {d.issuerName || 'Skatepark'}
|
||||
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
|
||||
</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">Access Pass</div>
|
||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
|
||||
</div>
|
||||
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
|
||||
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
|
||||
|
|
@ -791,12 +794,12 @@ function ParkFront({ d }: { d: CardData }) {
|
|||
|
||||
{/* Identity */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || 'Member Name'}</div>
|
||||
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || t('labUi.rider.previewMemberName')}</div>
|
||||
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
|
||||
<div className="text-[5.5px] card-muted leading-none">Issued by park owner · Nostr-signed</div>
|
||||
<div className="text-[5.5px] card-muted leading-none">{t('labUi.rider.previewIssuedBy')}</div>
|
||||
{d.validThru && (
|
||||
<div className="flex items-center gap-1 mt-[3px]">
|
||||
<span className="text-[5.5px] card-muted uppercase">Valid thru</span>
|
||||
<span className="text-[5.5px] card-muted uppercase">{t('labUi.rider.previewValidThru')}</span>
|
||||
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -811,7 +814,7 @@ function ParkFront({ d }: { d: CardData }) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">Scan to verify</div>
|
||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">{t('labUi.rider.previewScan')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -825,7 +828,7 @@ function ParkFront({ d }: { d: CardData }) {
|
|||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield className="card-muted" style={{ width: '8px', height: '8px', opacity: 0.5 }} />
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">Nostr-verified</span>
|
||||
<span className="card-muted text-[5px] uppercase tracking-widest">{t('labUi.rider.previewNostrVerified')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -854,6 +857,7 @@ function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObje
|
|||
|
||||
// ─── Received badges panel ────────────────────────────────────────────────────
|
||||
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: badgeEvents = [] } = useReceivedBadges(pubkey);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
|
|
@ -861,7 +865,7 @@ function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
|||
return (
|
||||
<div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center">
|
||||
<div className="text-2xl mb-1">🏅</div>
|
||||
<p className="text-xs text-gray-500">No badges yet. Skate in front of someone and ask them to witness your tricks!</p>
|
||||
<p className="text-xs text-gray-500">{t('labUi.rider.noBadges')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -879,7 +883,7 @@ function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
|||
className="w-full text-xs text-gray-500 hover:text-gray-300 flex items-center justify-center gap-1 py-1"
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{expanded ? 'Show less' : `Show ${badgeEvents.length - 3} more`}
|
||||
{expanded ? t('common.showLess') : t('labUi.rider.showMore', { count: badgeEvents.length - 3 })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -887,6 +891,7 @@ function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
|||
}
|
||||
|
||||
function BadgeEventRow({ event }: { event: NostrEvent }) {
|
||||
const { t } = useTranslation();
|
||||
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
|
||||
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
|
||||
const witnessName = useWitnessName(event.pubkey);
|
||||
|
|
@ -913,8 +918,8 @@ function BadgeEventRow({ event }: { event: NostrEvent }) {
|
|||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
Witnessed by <span className="text-yellow-400 font-medium">{witnessName}</span>
|
||||
{spot && <> at <span className="text-gray-300">{spot}</span></>}
|
||||
{t('labUi.rider.witnessedBy')} <span className="text-yellow-400 font-medium">{witnessName}</span>
|
||||
{spot && <> {t('labUi.rider.at')} <span className="text-gray-300">{spot}</span></>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-[9px] text-gray-600 flex-shrink-0">
|
||||
|
|
@ -926,6 +931,7 @@ function BadgeEventRow({ event }: { event: NostrEvent }) {
|
|||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function RiderCardPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Skater Card — SkateSpots',
|
||||
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
|
||||
|
|
@ -1038,23 +1044,23 @@ export default function RiderCardPage() {
|
|||
|
||||
const handlePrint = useCallback(async () => {
|
||||
const dataUrl = await renderCardToImage();
|
||||
if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
|
||||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||||
const w = Math.round(85.6 * 2.835);
|
||||
const h = Math.round(54 * 2.835);
|
||||
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
|
||||
if (!win) { toast({ title: 'Popup blocked', description: 'Allow popups for this site.', variant: 'destructive' }); return; }
|
||||
if (!win) { toast({ title: t('labUi.rider.popupBlocked'), description: t('labUi.rider.popupBlockedDesc'), variant: 'destructive' }); return; }
|
||||
win.document.write(`<!DOCTYPE html><html><head><title>SkateSpots Rider Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm}</style></head><body><img src="${dataUrl}"/><scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
|
||||
win.document.close();
|
||||
}, [renderCardToImage, toast]);
|
||||
}, [renderCardToImage, toast, t]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
const dataUrl = await renderCardToImage();
|
||||
if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
|
||||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
|
||||
a.click();
|
||||
}, [renderCardToImage, cardData.npub, toast]);
|
||||
}, [renderCardToImage, cardData.npub, toast, t]);
|
||||
|
||||
const handleCopyNpub = useCallback(() => {
|
||||
navigator.clipboard.writeText(npub);
|
||||
|
|
@ -1064,7 +1070,7 @@ export default function RiderCardPage() {
|
|||
|
||||
const handleShare = useCallback(async () => {
|
||||
const dataUrl = await renderCardToImage();
|
||||
if (!dataUrl) { toast({ title: 'Could not render card', variant: 'destructive' }); return; }
|
||||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
|
||||
|
|
@ -1075,11 +1081,11 @@ export default function RiderCardPage() {
|
|||
} catch { /* fall through */ }
|
||||
}
|
||||
await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
|
||||
toast({ title: '🔗 Link copied!' });
|
||||
}, [renderCardToImage, cardData.name, cardData.npub, toast]);
|
||||
toast({ title: t('labUi.rider.linkCopied') });
|
||||
}, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
|
||||
|
||||
const handlePublishParkCard = useCallback(() => {
|
||||
if (!user || !recipientPubkey) { toast({ title: "Enter the rider's npub", variant: 'destructive' }); return; }
|
||||
if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; }
|
||||
const tags: string[][] = [
|
||||
['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`],
|
||||
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
|
||||
|
|
@ -1087,10 +1093,10 @@ export default function RiderCardPage() {
|
|||
];
|
||||
if (validThru) tags.push(['valid-thru', validThru]);
|
||||
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
||||
onSuccess: () => toast({ title: '✅ Park Access Card published!' }),
|
||||
onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }),
|
||||
onSuccess: () => toast({ title: t('labUi.rider.parkPublished') }),
|
||||
onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }),
|
||||
});
|
||||
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast]);
|
||||
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast, t]);
|
||||
|
||||
const toggleSkill = (id: string) =>
|
||||
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||||
|
|
@ -1110,10 +1116,10 @@ export default function RiderCardPage() {
|
|||
<span className="text-white"> CARD</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-sm mb-2">
|
||||
Your verifiable skater identity. Nostr-signed, print-ready, impossible to fake.
|
||||
{t('labUi.rider.heroDesc')}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-8 max-w-xs">
|
||||
Earn witnessed skill badges from other skaters. Get park access passes. Share your card anywhere.
|
||||
{t('labUi.rider.heroDesc2')}
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
|
|
@ -1131,15 +1137,15 @@ export default function RiderCardPage() {
|
|||
<span className="neon-red">RIDER</span>
|
||||
<span className="text-white"> CARD</span>
|
||||
</h1>
|
||||
<p className="text-gray-500 text-xs tracking-widest uppercase">Nostr-signed · Print-ready · QR-verified</p>
|
||||
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Top tabs: My Card / My Badges / Award Badge ── */}
|
||||
<div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40">
|
||||
{([
|
||||
{ id: 'card', label: '🪪 My Card' },
|
||||
{ id: 'badges', label: `🏅 Badges${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
|
||||
{ id: 'award', label: '⭐ Award' },
|
||||
{ id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` },
|
||||
{ id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
|
||||
{ id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` },
|
||||
] as const).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
|
|
@ -1184,7 +1190,7 @@ export default function RiderCardPage() {
|
|||
className="w-full h-14 bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 font-black uppercase text-base rounded-xl shadow-xl shadow-[#ff0040]/20 gap-2"
|
||||
>
|
||||
{rendering ? <Loader2 className="h-5 w-5 animate-spin" /> : <Share2 className="h-5 w-5" />}
|
||||
{rendering ? 'Rendering…' : 'Share Card'}
|
||||
{rendering ? t('labUi.rider.rendering') : t('labUi.rider.shareCard')}
|
||||
</Button>
|
||||
|
||||
{/* Secondary actions row */}
|
||||
|
|
@ -1196,7 +1202,7 @@ export default function RiderCardPage() {
|
|||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Save PNG
|
||||
{t('labUi.rider.savePng')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -1205,7 +1211,7 @@ export default function RiderCardPage() {
|
|||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
Print
|
||||
{t('labUi.rider.print')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -1213,18 +1219,18 @@ export default function RiderCardPage() {
|
|||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? 'Copied!' : 'Copy ID'}
|
||||
{copied ? t('common.copied') : t('labUi.rider.copyId')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Card type toggle ── */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">Card Type</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">{t('labUi.rider.cardType')}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ id: 'rider', label: 'Skater License', sub: 'Your personal rider identity', icon: User },
|
||||
{ id: 'park', label: 'Park Access', sub: 'Issue passes as a park owner', icon: Building2 },
|
||||
{ id: 'rider', label: t('labUi.rider.licenseLabel'), sub: t('labUi.rider.licenseSub'), icon: User },
|
||||
{ id: 'park', label: t('labUi.rider.parkLabel'), sub: t('labUi.rider.parkSub'), icon: Building2 },
|
||||
] as const).map(({ id, label, sub, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
|
|
@ -1247,23 +1253,23 @@ export default function RiderCardPage() {
|
|||
{/* ── Theme picker ── */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2 flex items-center gap-1.5">
|
||||
<Palette className="h-3 w-3" /> Theme
|
||||
<Palette className="h-3 w-3" /> {t('labUi.rider.theme')}
|
||||
</p>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{THEMES.map(t => (
|
||||
{THEMES.map((th) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTheme(t.id)}
|
||||
key={th.id}
|
||||
onClick={() => setTheme(th.id)}
|
||||
className={cn(
|
||||
'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
|
||||
`card-theme-${t.id}`,
|
||||
theme === t.id
|
||||
`card-theme-${th.id}`,
|
||||
theme === th.id
|
||||
? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black scale-105 border-white/30'
|
||||
: 'border-white/10 opacity-60 hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
<span className="text-xl">{t.emoji}</span>
|
||||
<span className="text-[8px] font-black uppercase opacity-80">{t.label}</span>
|
||||
<span className="text-xl">{th.emoji}</span>
|
||||
<span className="text-[8px] font-black uppercase opacity-80">{th.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1272,8 +1278,8 @@ export default function RiderCardPage() {
|
|||
{/* ── Holographic toggle ── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8">
|
||||
<div>
|
||||
<div className="text-sm font-bold">✨ Holographic Shimmer</div>
|
||||
<div className="text-xs text-gray-500">Animated foil effect</div>
|
||||
<div className="text-sm font-bold">✨ {t('labUi.rider.holoTitle')}</div>
|
||||
<div className="text-xs text-gray-500">{t('labUi.rider.holoDesc')}</div>
|
||||
</div>
|
||||
<Switch checked={holographic} onCheckedChange={setHolographic} />
|
||||
</div>
|
||||
|
|
@ -1281,11 +1287,11 @@ export default function RiderCardPage() {
|
|||
{/* ── RIDER fields ── */}
|
||||
{cardType === 'rider' && (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">Card Details</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.cardDetails')}</p>
|
||||
|
||||
{/* Style */}
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Skate Style</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.skateStyle')}</Label>
|
||||
<Select value={style} onValueChange={setStyle}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11">
|
||||
<SelectValue />
|
||||
|
|
@ -1299,18 +1305,18 @@ export default function RiderCardPage() {
|
|||
{/* Rides For + Sponsors — side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rides For</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.ridesFor')}</Label>
|
||||
<Input
|
||||
placeholder="Local shop…"
|
||||
placeholder={t('labUi.rider.ridesForPlaceholder')}
|
||||
value={ridesFor}
|
||||
onChange={e => setRidesFor(e.target.value)}
|
||||
className="bg-black/50 border-white/10 text-sm h-11"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Sponsors</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.sponsors')}</Label>
|
||||
<Input
|
||||
placeholder="Brands…"
|
||||
placeholder={t('labUi.rider.sponsorsPlaceholder')}
|
||||
value={sponsors}
|
||||
onChange={e => setSponsors(e.target.value)}
|
||||
className="bg-black/50 border-white/10 text-sm h-11"
|
||||
|
|
@ -1320,12 +1326,12 @@ export default function RiderCardPage() {
|
|||
|
||||
{/* Stats toggles */}
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">Show on Card</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.showOnCard')}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: 'Spots', value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
|
||||
{ label: 'Zaps ⚡', value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
|
||||
{ label: 'Events', value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
|
||||
{ label: t('labUi.pyramid.spots'), value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
|
||||
{ label: t('labUi.rider.zaps'), value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
|
||||
{ label: t('labUi.pyramid.events'), value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
|
||||
].map(({ label, value, state, set }) => (
|
||||
<button
|
||||
key={label}
|
||||
|
|
@ -1347,8 +1353,8 @@ export default function RiderCardPage() {
|
|||
{/* Skills */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500">Skills</Label>
|
||||
<span className="text-[10px] text-gray-600">{skills.length}/6 selected</span>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.skills')}</Label>
|
||||
<span className="text-[10px] text-gray-600">{t('labUi.rider.selected', { count: skills.length })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SKILL_OPTIONS.map(s => (
|
||||
|
|
@ -1368,7 +1374,7 @@ export default function RiderCardPage() {
|
|||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-2">
|
||||
⭐ Gold badges on your card come from other skaters witnessing you — see the <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">Badges tab</button>.
|
||||
⭐ {t('labUi.rider.goldBadgesHint1')} <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">{t('labUi.rider.tabBadges')}</button>{t('labUi.rider.goldBadgesHint2')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1380,28 +1386,28 @@ export default function RiderCardPage() {
|
|||
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs text-gray-400">
|
||||
<Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold text-blue-300">Park owners only.</span>{' '}
|
||||
Issue this using the same Nostr key that published your park listing. Anyone can scan the QR and verify the signature on-chain — no central authority needed.
|
||||
<span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</span>{' '}
|
||||
{t('labUi.rider.parkOwnersDesc')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">Park Details</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.parkDetails')}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Park Name</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.parkName')}</Label>
|
||||
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
|
||||
placeholder="The Berrics…" className="bg-black/50 border-white/10 h-11" />
|
||||
placeholder={t('labUi.rider.parkNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider Name</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderName')}</Label>
|
||||
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
|
||||
placeholder="Name on card" className="bg-black/50 border-white/10 h-11" />
|
||||
placeholder={t('labUi.rider.riderNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider's Nostr ID (npub)</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</Label>
|
||||
<Input
|
||||
value={recipientNpub}
|
||||
onChange={e => setRecipientNpub(e.target.value)}
|
||||
|
|
@ -1412,7 +1418,7 @@ export default function RiderCardPage() {
|
|||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Access Level</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.accessLevel')}</Label>
|
||||
<Select value={accessLevel} onValueChange={setAccessLevel}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1422,7 +1428,7 @@ export default function RiderCardPage() {
|
|||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Valid Thru</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.validThru')}</Label>
|
||||
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
|
||||
placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
|
||||
</div>
|
||||
|
|
@ -1433,7 +1439,7 @@ export default function RiderCardPage() {
|
|||
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
Publish to Nostr
|
||||
{t('labUi.rider.publishToNostr')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1447,19 +1453,19 @@ export default function RiderCardPage() {
|
|||
<div className="space-y-6">
|
||||
<div className="text-center py-2">
|
||||
<p className="text-gray-400 text-sm max-w-xs mx-auto">
|
||||
These badges were witnessed and signed by other skaters on Nostr. They appear as gold ⭐ chips on your card.
|
||||
{t('labUi.rider.badgesIntro')}
|
||||
</p>
|
||||
</div>
|
||||
<ReceivedBadgesPanel pubkey={user.pubkey} />
|
||||
<div className="pt-2 border-t border-white/8">
|
||||
<p className="text-xs text-gray-600 text-center mb-3">Want to earn more? Skate in front of someone and ask them to witness your trick.</p>
|
||||
<p className="text-xs text-gray-600 text-center mb-3">{t('labUi.rider.earnMore')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPageTab('award')}
|
||||
className="w-full border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 gap-2"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
Award a Badge to Someone Else
|
||||
{t('labUi.rider.awardSomeone')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1473,8 +1479,8 @@ export default function RiderCardPage() {
|
|||
<div className="flex items-center gap-3 p-4 rounded-xl bg-yellow-500/5 border border-yellow-500/20">
|
||||
<Award className="h-5 w-5 text-yellow-400 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-bold text-yellow-300">You saw it happen</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">Sign the proof on Nostr. It becomes part of their permanent record — cryptographically tied to your key.</div>
|
||||
<div className="text-sm font-bold text-yellow-300">{t('labUi.rider.youSawIt')}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{t('labUi.rider.youSawItDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1490,6 +1496,7 @@ export default function RiderCardPage() {
|
|||
|
||||
// ─── Inline award form (used on the Award tab) ────────────────────────────────
|
||||
function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: publishEvent, isPending } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const [recipientNpub, setRecipientNpub] = useState('');
|
||||
|
|
@ -1501,7 +1508,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
|
|||
|
||||
const handleAward = () => {
|
||||
if (!recipientNpub.trim() || selectedSkills.length === 0) {
|
||||
toast({ title: 'Add rider npub + at least one skill', variant: 'destructive' });
|
||||
toast({ title: t('labUi.rider.addRiderSkill'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
let recipientHex = '';
|
||||
|
|
@ -1510,7 +1517,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
|
|||
if (decoded.type !== 'npub') throw new Error();
|
||||
recipientHex = decoded.data;
|
||||
} catch {
|
||||
toast({ title: 'Invalid npub', variant: 'destructive' });
|
||||
toast({ title: t('labUi.rider.invalidNpub'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
const tags: string[][] = [
|
||||
|
|
@ -1521,18 +1528,18 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
|
|||
if (spotName.trim()) tags.push(['spot', spotName.trim()]);
|
||||
publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, {
|
||||
onSuccess: () => {
|
||||
toast({ title: '🏅 Badge awarded!', description: `${selectedSkills.join(', ')} sent.` });
|
||||
toast({ title: t('labUi.rider.badgeAwarded'), description: t('labUi.rider.badgeSent', { skills: selectedSkills.join(', ') }) });
|
||||
setRecipientNpub(''); setSelectedSkills([]); setSpotName('');
|
||||
onSuccess();
|
||||
},
|
||||
onError: (e) => toast({ title: 'Failed', description: String(e), variant: 'destructive' }),
|
||||
onError: (e) => toast({ title: t('labUi.rider.failed'), description: String(e), variant: 'destructive' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Rider's Nostr ID (npub)</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</Label>
|
||||
<Input
|
||||
value={recipientNpub}
|
||||
onChange={e => setRecipientNpub(e.target.value)}
|
||||
|
|
@ -1542,7 +1549,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">Skills you witnessed</Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.skillsWitnessed')}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SKILL_OPTIONS.map(s => (
|
||||
<button
|
||||
|
|
@ -1562,7 +1569,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">Where did you see it? <span className="normal-case text-gray-600">(optional)</span></Label>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.whereSeen')} <span className="normal-case text-gray-600">{t('labUi.rider.optional')}</span></Label>
|
||||
<Input
|
||||
value={spotName}
|
||||
onChange={e => setSpotName(e.target.value)}
|
||||
|
|
@ -1577,7 +1584,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
|
|||
className="w-full h-12 bg-yellow-500 hover:bg-yellow-400 text-black font-black uppercase gap-2"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
{isPending ? 'Signing on Nostr…' : 'Award Badge'}
|
||||
{isPending ? t('labUi.rider.signingOnNostr') : t('labUi.rider.awardBadge')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useSkaterVerifications } from '@/hooks/useSkaterVerifications';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import {
|
||||
SKATER_STYLES,
|
||||
SKATER_STANCES,
|
||||
|
|
@ -25,6 +26,7 @@ import { PersonStanding, Search, MapPin, Check, Sparkles, Users, ShieldCheck, X
|
|||
|
||||
// ─── Skater card ────────────────────────────────────────────────────────────
|
||||
function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const author = useAuthor(skater.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? skater.title ?? genUserName(skater.pubkey);
|
||||
|
|
@ -67,7 +69,7 @@ function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }
|
|||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-black text-white truncate group-hover:text-[#ff0040] transition-colors">{name}</h3>
|
||||
{verified && (
|
||||
<span title="NIP-05 verified" className="flex-shrink-0">
|
||||
<span title={t('labUi.skaters.nip05Verified')} className="flex-shrink-0">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" />
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -131,6 +133,7 @@ function SkaterCardSkeleton() {
|
|||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
export default function SkatersPage() {
|
||||
const { t } = useTranslation();
|
||||
useSeoMeta({
|
||||
title: 'Skaters - SkateSpots',
|
||||
description: 'Discover skaters in the SkateSpots community. Browse by style, stance, level and location.',
|
||||
|
|
@ -186,7 +189,7 @@ export default function SkatersPage() {
|
|||
<div className="container relative px-4 py-10 sm:py-14 max-w-6xl">
|
||||
<div className="flex items-center gap-2 text-[#ff0040] mb-3">
|
||||
<PersonStanding className="h-5 w-5" />
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">The Community</span>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('labUi.skaters.community')}</span>
|
||||
</div>
|
||||
<h1
|
||||
className="text-4xl sm:text-6xl font-black uppercase tracking-tight leading-none"
|
||||
|
|
@ -195,7 +198,7 @@ export default function SkatersPage() {
|
|||
Skaters
|
||||
</h1>
|
||||
<p className="text-gray-300 mt-3 max-w-xl text-base sm:text-lg">
|
||||
Find your people. Browse the crew of skaters repping SkateSpots — by style, stance, level and city.
|
||||
{t('labUi.skaters.heroDesc')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-6">
|
||||
|
|
@ -203,21 +206,21 @@ export default function SkatersPage() {
|
|||
<Link to="/become/skater">
|
||||
<Button className="h-11 bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Put me on the map
|
||||
{t('labUi.skaterForm.putOnMap')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/become/skater">
|
||||
<Button variant="outline" className="h-11 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10 font-bold gap-2">
|
||||
<Check className="h-4 w-4" />
|
||||
Edit my skater profile
|
||||
{t('labUi.skaters.editProfile')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="font-black text-white">{total}</span> skater{total !== 1 ? 's' : ''}
|
||||
<span className="font-black text-white">{total}</span> {t(total === 1 ? 'labUi.skaters.countOne' : 'labUi.skaters.countMany', { count: total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -230,7 +233,7 @@ export default function SkatersPage() {
|
|||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by name, city or npub…"
|
||||
placeholder={t('labUi.skaters.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 h-10 bg-white/5 border-white/10"
|
||||
|
|
@ -239,7 +242,7 @@ export default function SkatersPage() {
|
|||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
aria-label="Clear search"
|
||||
aria-label={t('labUi.skaters.clearSearch')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -256,7 +259,7 @@ export default function SkatersPage() {
|
|||
styleFilter === 'all' ? 'bg-white text-black border-white' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-white/25',
|
||||
)}
|
||||
>
|
||||
All
|
||||
{t('labUi.all')}
|
||||
</button>
|
||||
|
||||
{/* Verified only — soft trust filter, not a gate */}
|
||||
|
|
@ -269,10 +272,10 @@ export default function SkatersPage() {
|
|||
? 'bg-[#ccff00] border-[#ccff00] text-black'
|
||||
: 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ccff00]/40',
|
||||
)}
|
||||
title="Show only skaters with a verified NIP-05 identity"
|
||||
title={t('labUi.skaters.verifiedFilterTitle')}
|
||||
>
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Verified
|
||||
{t('common.verified')}
|
||||
{verifying ? (
|
||||
<span className="opacity-60">…</span>
|
||||
) : (
|
||||
|
|
@ -313,32 +316,32 @@ export default function SkatersPage() {
|
|||
</div>
|
||||
{total === 0 ? (
|
||||
<>
|
||||
<p className="text-xl font-black uppercase text-white mb-2">No skaters yet</p>
|
||||
<p className="text-xl font-black uppercase text-white mb-2">{t('labUi.skaters.noneTitle')}</p>
|
||||
<p className="text-gray-400 max-w-sm mx-auto mb-6">
|
||||
Be the first to join the directory and put SkateSpots on the map.
|
||||
{t('labUi.skaters.noneDesc')}
|
||||
</p>
|
||||
<Link to="/become/skater">
|
||||
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Put me on the map
|
||||
{t('labUi.skaterForm.putOnMap')}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-white mb-1">
|
||||
{verifiedOnly ? 'No verified skaters match' : 'No matches'}
|
||||
{verifiedOnly ? t('labUi.skaters.noVerifiedMatch') : t('labUi.skaters.noMatches')}
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
{verifiedOnly
|
||||
? 'Try turning off the “Verified” filter to see everyone.'
|
||||
: 'Try a different search or clear your filters.'}
|
||||
? t('labUi.skaters.tryTurnOffVerified')
|
||||
: t('labUi.skaters.tryDifferentSearch')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setQuery(''); setStyleFilter('all'); setVerifiedOnly(false); }}
|
||||
className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
{t('common.clearFilters')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useMySpotCollections, useCreateSpotCollection, useDeleteSpotCollection, useUpdateSpotCollection } from '@/hooks/useSpotCollections';
|
||||
import type { SpotCollection } from '@/lib/types';
|
||||
import { FolderPlus, FolderOpen, MapPin, Plus, Loader2, Pencil, Trash2, Calendar, ArrowRight, Bookmark } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function CollectionCard({
|
||||
|
|
@ -26,6 +27,7 @@ function CollectionCard({
|
|||
onEdit: (collection: SpotCollection) => void;
|
||||
onDelete: (collection: SpotCollection) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Link to={`/collections/${collection.pubkey}/${collection.identifier}`}>
|
||||
<Card className={cn(
|
||||
|
|
@ -49,7 +51,9 @@ function CollectionCard({
|
|||
<div className="absolute top-2 left-2 sm:top-3 sm:left-3">
|
||||
<Badge className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold text-xs">
|
||||
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3 mr-1" />
|
||||
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
|
||||
{collection.spotRefs.length === 1
|
||||
? t('mapUi.spotSingular')
|
||||
: t('mapUi.spotPlural', { count: collection.spotRefs.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Action buttons - always visible on mobile, hover on desktop */}
|
||||
|
|
@ -99,7 +103,7 @@ function CollectionCard({
|
|||
{new Date(collection.createdAt * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[#ccff00] sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
View
|
||||
{t('mapUi.view')}
|
||||
<ArrowRight className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -125,6 +129,7 @@ function CollectionCardSkeleton() {
|
|||
}
|
||||
|
||||
export default function SpotCollectionsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: collections, isLoading } = useMySpotCollections();
|
||||
const { createCollection, isPending: isCreating } = useCreateSpotCollection();
|
||||
|
|
@ -157,16 +162,16 @@ export default function SpotCollectionsPage() {
|
|||
description: newDescription.trim() || undefined,
|
||||
});
|
||||
toast({
|
||||
title: 'Collection created!',
|
||||
description: `"${newTitle}" is ready for spots`,
|
||||
title: t('mapUi.collectionCreated'),
|
||||
description: t('mapUi.collectionCreatedDesc', { title: newTitle }),
|
||||
});
|
||||
setNewTitle('');
|
||||
setNewDescription('');
|
||||
setShowCreateDialog(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to create collection',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('mapUi.collectionCreateFailed'),
|
||||
description: error instanceof Error ? error.message : t('mapUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -190,15 +195,15 @@ export default function SpotCollectionsPage() {
|
|||
description: editDescription.trim() || undefined,
|
||||
});
|
||||
toast({
|
||||
title: 'Collection updated!',
|
||||
description: 'Your changes have been saved',
|
||||
title: t('mapUi.collectionUpdated'),
|
||||
description: t('mapUi.changesSaved'),
|
||||
});
|
||||
setShowEditDialog(false);
|
||||
setSelectedCollection(null);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to update collection',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('mapUi.collectionUpdateFailed'),
|
||||
description: error instanceof Error ? error.message : t('mapUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -215,15 +220,15 @@ export default function SpotCollectionsPage() {
|
|||
try {
|
||||
await deleteCollection(selectedCollection);
|
||||
toast({
|
||||
title: 'Collection deleted',
|
||||
description: `"${selectedCollection.title}" has been removed`,
|
||||
title: t('mapUi.collectionDeleted'),
|
||||
description: t('mapUi.collectionDeletedDesc', { title: selectedCollection.title }),
|
||||
});
|
||||
setShowDeleteDialog(false);
|
||||
setSelectedCollection(null);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete collection',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('mapUi.collectionDeleteFailed'),
|
||||
description: error instanceof Error ? error.message : t('mapUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
|
@ -237,10 +242,10 @@ export default function SpotCollectionsPage() {
|
|||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-3xl font-black uppercase tracking-wider flex items-center gap-2 sm:gap-3">
|
||||
<Bookmark className="h-6 w-6 sm:h-8 sm:w-8 text-[#ccff00] flex-shrink-0" />
|
||||
<span className="neon-text truncate">MY COLLECTIONS</span>
|
||||
<span className="neon-text truncate">{t('mapUi.myCollections')}</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1 sm:mt-2 text-sm sm:text-base">
|
||||
Organize your favorite spots into personalized lists
|
||||
{t('mapUi.collectionsSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
{user && (
|
||||
|
|
@ -249,7 +254,7 @@ export default function SpotCollectionsPage() {
|
|||
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase gap-2 h-11 sm:h-auto w-full sm:w-auto flex-shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Collection
|
||||
{t('mapUi.newCollection')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -259,9 +264,9 @@ export default function SpotCollectionsPage() {
|
|||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-16 text-center">
|
||||
<FolderPlus className="h-16 w-16 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Log in to create collections</h2>
|
||||
<h2 className="text-xl font-bold mb-2">{t('mapUi.loginToCreate')}</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Save your favorite spots to personalized lists like "Barcelona Trip 2024" or "Best Rails in LA"
|
||||
{t('mapUi.loginToCreateDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -281,16 +286,16 @@ export default function SpotCollectionsPage() {
|
|||
<Card className="border-dashed border-2 border-gray-700">
|
||||
<CardContent className="py-16 text-center">
|
||||
<FolderOpen className="h-16 w-16 mx-auto mb-4 text-[#ccff00]/40" />
|
||||
<h2 className="text-xl font-bold mb-2">No collections yet</h2>
|
||||
<h2 className="text-xl font-bold mb-2">{t('mapUi.noCollections')}</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Create your first collection to start organizing spots
|
||||
{t('mapUi.noCollectionsDesc')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Collection
|
||||
{t('mapUi.createFirstCollection')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -317,31 +322,31 @@ export default function SpotCollectionsPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
|
||||
Create New Collection
|
||||
{t('mapUi.createNewCollection')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400 text-sm">
|
||||
Give your collection a name and optional description
|
||||
{t('mapUi.createCollectionDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreate} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-title" className="text-white text-sm">Collection Name</Label>
|
||||
<Label htmlFor="create-title" className="text-white text-sm">{t('mapUi.collectionName')}</Label>
|
||||
<Input
|
||||
id="create-title"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Barcelona Trip 2024"
|
||||
placeholder={t('mapUi.collectionNamePlaceholder')}
|
||||
className="bg-black border-gray-700 text-white h-11"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-description" className="text-white text-sm">Description (optional)</Label>
|
||||
<Label htmlFor="create-description" className="text-white text-sm">{t('mapUi.descriptionOptional')}</Label>
|
||||
<Textarea
|
||||
id="create-description"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder="My favorite spots from my Barcelona trip..."
|
||||
placeholder={t('mapUi.descriptionPlaceholder')}
|
||||
className="bg-black border-gray-700 text-white resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
|
|
@ -353,7 +358,7 @@ export default function SpotCollectionsPage() {
|
|||
onClick={() => setShowCreateDialog(false)}
|
||||
className="flex-1 border-gray-700 h-11"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
@ -363,13 +368,13 @@ export default function SpotCollectionsPage() {
|
|||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
{t('mapUi.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<span className="hidden sm:inline">Create Collection</span>
|
||||
<span className="sm:hidden">Create</span>
|
||||
<span className="hidden sm:inline">{t('mapUi.createCollection')}</span>
|
||||
<span className="sm:hidden">{t('common.create')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
@ -384,12 +389,12 @@ export default function SpotCollectionsPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<Pencil className="h-5 w-5 text-[#ccff00]" />
|
||||
Edit Collection
|
||||
{t('mapUi.editCollection')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-title" className="text-white text-sm">Collection Name</Label>
|
||||
<Label htmlFor="edit-title" className="text-white text-sm">{t('mapUi.collectionName')}</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={editTitle}
|
||||
|
|
@ -398,7 +403,7 @@ export default function SpotCollectionsPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description" className="text-white text-sm">Description (optional)</Label>
|
||||
<Label htmlFor="edit-description" className="text-white text-sm">{t('mapUi.descriptionOptional')}</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={editDescription}
|
||||
|
|
@ -414,7 +419,7 @@ export default function SpotCollectionsPage() {
|
|||
onClick={() => setShowEditDialog(false)}
|
||||
className="flex-1 border-gray-700 h-11"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
@ -424,10 +429,10 @@ export default function SpotCollectionsPage() {
|
|||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{t('mapUi.saving')}
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
t('mapUi.saveChanges')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -439,13 +444,13 @@ export default function SpotCollectionsPage() {
|
|||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent className="bg-black border-2 border-red-500/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">Delete Collection?</AlertDialogTitle>
|
||||
<AlertDialogTitle className="text-white">{t('mapUi.deleteCollectionTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-400 text-sm">
|
||||
This will permanently delete "{selectedCollection?.title}". The spots themselves won't be affected.
|
||||
{t('mapUi.deleteCollectionDesc', { title: selectedCollection?.title ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
|
|
@ -454,10 +459,10 @@ export default function SpotCollectionsPage() {
|
|||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
{t('mapUi.deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
t('common.delete')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useSpotReports } from '@/hooks/useSpotReports';
|
|||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSpotWeather } from '@/hooks/useSpotWeather';
|
||||
import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
|
||||
|
|
@ -124,6 +125,7 @@ function SpotDetailSkeleton() {
|
|||
}
|
||||
|
||||
export default function SpotDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { data: spot, isLoading, error, refetch } = useSkateSpot(identifier || '', pubkey);
|
||||
const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || '');
|
||||
|
|
@ -195,8 +197,8 @@ export default function SpotDetailPage() {
|
|||
});
|
||||
|
||||
useSeoMeta({
|
||||
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots',
|
||||
description: spot?.description || 'View this skateboarding spot on SkateSpots.',
|
||||
title: spot ? `${spot.title} - SkateSpots` : `${t('spotUi.skateSpot')} - SkateSpots`,
|
||||
description: spot?.description || t('spotUi.spotSeoDesc'),
|
||||
});
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
|
|
@ -224,7 +226,7 @@ export default function SpotDetailPage() {
|
|||
try {
|
||||
await navigator.share({
|
||||
title: spot?.title,
|
||||
text: spot?.description || `Check out this skate spot: ${spot?.title}`,
|
||||
text: spot?.description || t('spotUi.shareSpotText', { title: spot?.title ?? '' }),
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch {
|
||||
|
|
@ -249,15 +251,17 @@ export default function SpotDetailPage() {
|
|||
// delete + a moderation label so the spot is hidden across the app.
|
||||
await deleteEntity({
|
||||
event: spot.event,
|
||||
reason: adminTakedown ? `SkateSpots admin removed spot "${spot.title}"` : 'Deleted spot',
|
||||
reason: adminTakedown
|
||||
? t('spotUi.adminDeleteReason', { title: spot.title })
|
||||
: t('spotUi.deletedSpotReason'),
|
||||
});
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
toast({
|
||||
title: adminTakedown ? 'Spot removed' : 'Spot deleted',
|
||||
title: adminTakedown ? t('spotUi.spotRemoved') : t('spotUi.spotDeleted'),
|
||||
description: adminTakedown
|
||||
? 'This spot has been taken down and will be hidden across SkateSpots.'
|
||||
: 'Deletion request sent to relays. Most relays will remove this content.',
|
||||
? t('spotUi.spotRemovedDesc')
|
||||
: t('spotUi.spotDeletedDesc'),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
|
||||
|
|
@ -267,8 +271,8 @@ export default function SpotDetailPage() {
|
|||
setTimeout(() => navigate('/map'), 1000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
title: t('spotUi.deleteFailed'),
|
||||
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setShowDeleteDialog(false);
|
||||
|
|
@ -296,7 +300,7 @@ export default function SpotDetailPage() {
|
|||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Map
|
||||
{t('createSpot.backToMap')}
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
|
@ -309,7 +313,7 @@ export default function SpotDetailPage() {
|
|||
type="button"
|
||||
onClick={() => setLightboxIndex(0)}
|
||||
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted"
|
||||
aria-label="View photo full screen"
|
||||
aria-label={t('spotUi.viewPhotoFullScreen')}
|
||||
>
|
||||
<ImageWithFallback
|
||||
src={galleryImages[0].url}
|
||||
|
|
@ -319,7 +323,7 @@ export default function SpotDetailPage() {
|
|||
{galleryImages[0].isOwner === false && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1 rounded bg-black/70 px-2 py-1 text-xs text-[#ccff00]">
|
||||
<Users className="h-3 w-3" />
|
||||
Community
|
||||
{t('spotUi.community')}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -336,7 +340,7 @@ export default function SpotDetailPage() {
|
|||
type="button"
|
||||
onClick={() => setLightboxIndex(realIndex)}
|
||||
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted"
|
||||
aria-label={`View photo ${realIndex + 1} of ${galleryImages.length}`}
|
||||
aria-label={t('spotUi.viewPhotoOf', { n: realIndex + 1, total: galleryImages.length })}
|
||||
>
|
||||
<ImageWithFallback
|
||||
src={img.url}
|
||||
|
|
@ -395,7 +399,7 @@ export default function SpotDetailPage() {
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
|
||||
title={adminTakedown ? t('spotUi.removeAdmin') : t('common.delete')}
|
||||
className={cn(
|
||||
'hover:bg-destructive hover:text-destructive-foreground',
|
||||
adminTakedown && 'border-red-500/50 text-red-400',
|
||||
|
|
@ -422,7 +426,7 @@ export default function SpotDetailPage() {
|
|||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-white" />
|
||||
</span>
|
||||
I'm Here
|
||||
{t('spotUi.imHere')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -436,7 +440,7 @@ export default function SpotDetailPage() {
|
|||
<Button variant="outline" size="icon" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleDirections} aria-label="Get directions">
|
||||
<Button variant="outline" size="icon" onClick={handleDirections} aria-label={t('spotUi.getDirections')}>
|
||||
<Navigation className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* More options menu */}
|
||||
|
|
@ -453,7 +457,7 @@ export default function SpotDetailPage() {
|
|||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report {isShop ? 'Shop' : 'Spot'}
|
||||
{isShop ? t('spotUi.reportShop') : t('spotUi.reportSpot')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* One tap: this pin is off-topic (test entry, airport,
|
||||
|
|
@ -466,7 +470,7 @@ export default function SpotDetailPage() {
|
|||
className="text-amber-500 focus:text-amber-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
{alreadyReported ? 'Reported as not a skate spot' : 'Not a skate spot'}
|
||||
{alreadyReported ? t('spotUi.reportedNotASpot') : t('spotUi.notASkateSpot')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -504,10 +508,10 @@ export default function SpotDetailPage() {
|
|||
{staffVerified && (
|
||||
<Badge
|
||||
className="border border-[#ccff00]/60 bg-[#ccff00]/20 font-bold uppercase text-[#ccff00]"
|
||||
title="A SkateSpots admin reviewed and vouched for this spot."
|
||||
title={t('spotUi.staffVerifiedTitle')}
|
||||
>
|
||||
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||
Verified by SkateSpots
|
||||
{t('spotUi.verifiedBySkateSpots')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Community confidence — NOT a quality/weather score. */}
|
||||
|
|
@ -521,20 +525,20 @@ export default function SpotDetailPage() {
|
|||
)}
|
||||
title={
|
||||
communityVerified
|
||||
? 'Another skater has contributed here (photo or status report).'
|
||||
: 'No community photo or status report yet — nobody has confirmed this place.'
|
||||
? t('spotUi.communityVerifiedTitle')
|
||||
: t('spotUi.noCommunitySignalTitle')
|
||||
}
|
||||
>
|
||||
{communityVerified ? (
|
||||
<>
|
||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||
Community verified
|
||||
{t('spotUi.communityVerified')}
|
||||
{communitySignalCount > 1 && ` · ${communitySignalCount}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldQuestion className="mr-1 h-3 w-3" />
|
||||
Unverified
|
||||
{t('spotUi.unverified')}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
|
|
@ -549,7 +553,7 @@ export default function SpotDetailPage() {
|
|||
<Badge
|
||||
key={tag}
|
||||
className="bg-blue-500/10 text-blue-400 border border-blue-500/30 font-medium"
|
||||
title={`Suggested by ${count} user${count > 1 ? 's' : ''}`}
|
||||
title={count > 1 ? t('spotUi.suggestedByUsers', { count }) : t('spotUi.suggestedByUser', { count })}
|
||||
>
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{SPOT_TYPE_LABELS[tag] || tag}
|
||||
|
|
@ -563,9 +567,10 @@ export default function SpotDetailPage() {
|
|||
<div className="mt-3 flex items-start gap-2.5 rounded-xl border border-red-500/40 bg-red-500/10 p-3.5">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-red-400" />
|
||||
<p className="text-xs leading-relaxed text-red-200">
|
||||
<strong className="text-red-300">Reported as not a skate spot</strong>
|
||||
{notASpotReporters > 1 ? ` by ${notASpotReporters} skaters` : ''} — under review. If
|
||||
this is a real spot, add a photo or a status report to confirm it.
|
||||
<strong className="text-red-300">{t('spotUi.reportedNotASpot')}</strong>
|
||||
{notASpotReporters > 1
|
||||
? t('spotUi.reportedBySkaters', { count: notASpotReporters })
|
||||
: t('spotUi.underReview')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -578,12 +583,10 @@ export default function SpotDetailPage() {
|
|||
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
|
||||
Hidden by a moderator
|
||||
{t('spotUi.hiddenByModerator')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||
This spot is not shown on the map or in search. You can still see it because
|
||||
it's yours. Editing it will not un-hide it — it stays hidden until a moderator
|
||||
retracts the verdict.
|
||||
{t('spotUi.hiddenByModeratorDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -593,10 +596,10 @@ export default function SpotDetailPage() {
|
|||
<BadgeCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#ccff00]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||
Verified by SkateSpots
|
||||
{t('spotUi.verifiedBySkateSpots')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||
A moderator reviewed this spot and vouched for it. Nice one.
|
||||
{t('spotUi.moderatorVouchedDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -609,7 +612,7 @@ export default function SpotDetailPage() {
|
|||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Users className="h-5 w-5 text-blue-400 flex-shrink-0" />
|
||||
<span className="font-bold text-blue-400 uppercase text-sm whitespace-nowrap">Community</span>
|
||||
<span className="font-bold text-blue-400 uppercase text-sm whitespace-nowrap">{t('spotUi.community')}</span>
|
||||
{((communityPhotos?.length || 0) + aggregatedTags.length) > 0 && (
|
||||
<Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-blue-500/50">
|
||||
{(communityPhotos?.length || 0) + aggregatedTags.length}
|
||||
|
|
@ -631,7 +634,7 @@ export default function SpotDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Help improve this spot by adding photos or suggesting tags
|
||||
{t('spotUi.helpImproveSpot')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -647,28 +650,28 @@ export default function SpotDetailPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🏪</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-[#ccff00] mb-2 uppercase">Are You the Shop Owner?</h3>
|
||||
<h3 className="font-bold text-[#ccff00] mb-2 uppercase">{t('spotUi.areYouShopOwner')}</h3>
|
||||
<p className="text-sm text-gray-300 mb-4">
|
||||
If you own or manage this shop, you can claim it to verify ownership and manage the listing.
|
||||
{t('spotUi.shopClaimDesc')}
|
||||
</p>
|
||||
{hasNip05 ? (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
To claim this shop, add a NIP-05 identifier that matches the shop's website domain.
|
||||
{t('spotUi.shopClaimNip05Hint')}
|
||||
</p>
|
||||
<Link to="/settings">
|
||||
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
|
||||
<Shield className="h-4 w-4" />
|
||||
Manage NIP-05 to Claim
|
||||
{t('spotUi.manageNip05ToClaim')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-xs text-yellow-400 mb-2">⚠️ NIP-05 verification required to claim shops</p>
|
||||
<p className="text-xs text-yellow-400 mb-2">{t('spotUi.nip05RequiredToClaim')}</p>
|
||||
<Link to="/settings">
|
||||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||||
Add NIP-05 in Settings
|
||||
{t('spotUi.addNip05InSettings')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -686,10 +689,10 @@ export default function SpotDetailPage() {
|
|||
<div className="flex items-start gap-3">
|
||||
<div className="text-xl">{spot.accessType === 'private' ? '🔒' : '👥'}</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-[#ff6600] mb-1 uppercase text-sm">Contact Info</h4>
|
||||
<h4 className="font-bold text-[#ff6600] mb-1 uppercase text-sm">{t('spotUi.contactInfo')}</h4>
|
||||
<p className="text-sm text-gray-300">{spot.contactInfo}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{spot.accessType === 'private' ? 'Contact for permission' : 'Ask before skating'}
|
||||
{spot.accessType === 'private' ? t('spotUi.contactForPermission') : t('spotUi.askBeforeSkating')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -717,7 +720,7 @@ export default function SpotDetailPage() {
|
|||
<div className="min-w-0">
|
||||
<CardTitle className="text-lg sm:text-2xl font-black uppercase flex items-center gap-2 flex-wrap">
|
||||
<ShoppingBag className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500 flex-shrink-0" />
|
||||
<span className="neon-orange">GEAR SWAP</span>
|
||||
<span className="neon-orange">{t('spotUi.gearSwap')}</span>
|
||||
{marketplaceItems && marketplaceItems.length > 0 && (
|
||||
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 border-orange-500/50">
|
||||
{marketplaceItems.length}
|
||||
|
|
@ -725,7 +728,7 @@ export default function SpotDetailPage() {
|
|||
)}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Skaters at this spot are selling gear
|
||||
{t('spotUi.skatersSellingGear')}
|
||||
</p>
|
||||
</div>
|
||||
{user && (
|
||||
|
|
@ -735,7 +738,7 @@ export default function SpotDetailPage() {
|
|||
className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
List Gear
|
||||
{t('spotUi.listGear')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
|
@ -755,22 +758,22 @@ export default function SpotDetailPage() {
|
|||
))}
|
||||
<div className="pt-4 border-t border-orange-500/20 text-center">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
💡 Meet at this spot for safe pickup
|
||||
{t('spotUi.meetAtSpotForPickup')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-orange-500/50" />
|
||||
<h3 className="text-lg font-bold mb-2">NO GEAR LISTED YET</h3>
|
||||
<h3 className="text-lg font-bold mb-2">{t('spotUi.noGearListedYet')}</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Be the first to list gear at this spot!
|
||||
{t('spotUi.beFirstToListGear')}
|
||||
</p>
|
||||
{user && (
|
||||
<Link to={`/create-listing?spot=${pubkey}:${identifier}`}>
|
||||
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase">
|
||||
<Plus className="h-4 w-4" />
|
||||
List Your Gear Here
|
||||
{t('spotUi.listYourGearHere')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
|
@ -786,7 +789,7 @@ export default function SpotDetailPage() {
|
|||
<CardHeader className="border-b border-lime-500/20 px-4 sm:px-6">
|
||||
<CardTitle className="text-lg sm:text-2xl font-black uppercase flex items-center gap-2 flex-wrap">
|
||||
<GraduationCap className="h-5 w-5 sm:h-6 sm:w-6 text-lime-400 flex-shrink-0" />
|
||||
<span className="neon-lime">SERVICES</span>
|
||||
<span className="neon-lime">{t('spotUi.services')}</span>
|
||||
{((spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)) > 0 && (
|
||||
<Badge variant="secondary" className="bg-lime-500/20 text-lime-400 border-lime-500/50">
|
||||
{(spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)}
|
||||
|
|
@ -794,7 +797,7 @@ export default function SpotDetailPage() {
|
|||
)}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Coaches and filmers available at this spot
|
||||
{t('spotUi.coachesAndFilmersAvailable')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
|
|
@ -811,7 +814,7 @@ export default function SpotDetailPage() {
|
|||
<div>
|
||||
<h3 className="text-sm font-bold text-lime-400 uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Coaches ({spotServices.coaches.length})
|
||||
{t('spotUi.coachesCount', { count: spotServices.coaches.length })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{spotServices.coaches.map((coach) => (
|
||||
|
|
@ -854,7 +857,7 @@ export default function SpotDetailPage() {
|
|||
<div>
|
||||
<h3 className="text-sm font-bold text-purple-400 uppercase tracking-wide mb-3 flex items-center gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
Filmers ({spotServices.filmers.length})
|
||||
{t('spotUi.filmersCount', { count: spotServices.filmers.length })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{spotServices.filmers.map((filmer) => (
|
||||
|
|
@ -894,19 +897,19 @@ export default function SpotDetailPage() {
|
|||
|
||||
<div className="pt-4 border-t border-lime-500/20">
|
||||
<p className="text-sm text-gray-400 text-center mb-3">
|
||||
💡 Offer your services at this spot
|
||||
{t('spotUi.offerServicesHere')}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Link to="/become/coach">
|
||||
<Button size="sm" variant="outline" className="gap-2 border-lime-500/50 text-lime-400 hover:bg-lime-500/10">
|
||||
<GraduationCap className="h-4 w-4" />
|
||||
Coach Here
|
||||
{t('spotUi.coachHere')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/become/filmer">
|
||||
<Button size="sm" variant="outline" className="gap-2 border-purple-500/50 text-purple-400 hover:bg-purple-500/10">
|
||||
<Video className="h-4 w-4" />
|
||||
Film Here
|
||||
{t('spotUi.filmHere')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
@ -946,7 +949,7 @@ export default function SpotDetailPage() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
Weather Conditions
|
||||
{t('spotUi.weatherConditions')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -969,9 +972,9 @@ export default function SpotDetailPage() {
|
|||
<div className="p-3 rounded-lg" style={{ backgroundColor: `${weatherData.skateability.color}20`, border: `2px solid ${weatherData.skateability.color}` }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
|
||||
Conditions
|
||||
{t('spotUi.conditions')}
|
||||
<span className="ml-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
weather only
|
||||
{t('spotUi.weatherOnly')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-2xl font-bold" style={{ color: weatherData.skateability.color }}>
|
||||
|
|
@ -987,8 +990,7 @@ export default function SpotDetailPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-3 text-xs text-muted-foreground">
|
||||
Weather only. Nobody has confirmed this is a skate spot yet, so there's no
|
||||
conditions score — add a photo or a status report to confirm it.
|
||||
{t('spotUi.weatherOnlyUnconfirmed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -997,14 +999,14 @@ export default function SpotDetailPage() {
|
|||
<div className="flex items-center gap-2">
|
||||
<Wind className="h-4 w-4 text-blue-400" />
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Wind</div>
|
||||
<div className="text-xs text-muted-foreground">{t('spotUi.wind')}</div>
|
||||
<div className="font-medium">{weatherData.weather.windSpeed} km/h {weatherData.weather.windDirectionLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Droplets className="h-4 w-4 text-blue-400" />
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Humidity</div>
|
||||
<div className="text-xs text-muted-foreground">{t('spotUi.humidity')}</div>
|
||||
<div className="font-medium">{weatherData.weather.humidity}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1012,7 +1014,7 @@ export default function SpotDetailPage() {
|
|||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4 text-yellow-400" />
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">UV Index</div>
|
||||
<div className="text-xs text-muted-foreground">{t('spotUi.uvIndex')}</div>
|
||||
<div className="font-medium">{weatherData.weather.uvIndex}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1021,7 +1023,7 @@ export default function SpotDetailPage() {
|
|||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4" style={{ color: weatherData.airQuality.aqiColor }} />
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Air Quality</div>
|
||||
<div className="text-xs text-muted-foreground">{t('spotUi.airQuality')}</div>
|
||||
<div className="font-medium">{weatherData.airQuality.aqiLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1031,11 +1033,11 @@ export default function SpotDetailPage() {
|
|||
{/* 3-Day Forecast */}
|
||||
{weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
<div className="text-sm font-semibold mb-3">3-Day Forecast</div>
|
||||
<div className="text-sm font-semibold mb-3">{t('spotUi.threeDayForecast')}</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{weatherData.forecast.daily.map((day, idx) => {
|
||||
const date = new Date(day.date);
|
||||
const dayName = idx === 0 ? 'Today' : idx === 1 ? 'Tomorrow' : date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
const dayName = idx === 0 ? t('spotUi.today') : idx === 1 ? t('spotUi.tomorrow') : date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
return (
|
||||
<div key={day.date} className="text-center p-2 rounded-lg bg-background/50">
|
||||
<div className="text-xs font-semibold text-muted-foreground mb-1">{dayName}</div>
|
||||
|
|
@ -1058,7 +1060,7 @@ export default function SpotDetailPage() {
|
|||
{/* Feels Like */}
|
||||
{weatherData.weather.feelsLike !== weatherData.weather.temperature && (
|
||||
<div className="text-xs text-muted-foreground text-center pt-2 border-t border-border">
|
||||
Feels like {weatherData.weather.feelsLike}°C
|
||||
{t('spotUi.feelsLikeTemp', { temp: weatherData.weather.feelsLike })}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -1071,7 +1073,7 @@ export default function SpotDetailPage() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
Weather Conditions
|
||||
{t('spotUi.weatherConditions')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -1087,7 +1089,7 @@ export default function SpotDetailPage() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5 text-primary" />
|
||||
Location
|
||||
{t('spots.location')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -1106,7 +1108,7 @@ export default function SpotDetailPage() {
|
|||
onClick={handleDirections}
|
||||
>
|
||||
<Navigation className="h-4 w-4" />
|
||||
Get Directions
|
||||
{t('spotUi.getDirections')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -1117,7 +1119,7 @@ export default function SpotDetailPage() {
|
|||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-[#ccff00]">⚡</span>
|
||||
Tip the Mapper
|
||||
{t('spotUi.tipTheMapper')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -1143,7 +1145,7 @@ export default function SpotDetailPage() {
|
|||
</Link>
|
||||
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Found this spot useful? Send a zap to thank the mapper!
|
||||
{t('spotUi.tipTheMapperDesc')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1161,7 +1163,7 @@ export default function SpotDetailPage() {
|
|||
>
|
||||
<Link to={`/messages/${mapperNpub}`}>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Message Mapper
|
||||
{t('spotUi.messageMapper')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -1178,21 +1180,21 @@ export default function SpotDetailPage() {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
|
||||
{adminTakedown ? t('spotUi.removeSpotAdminTitle') : t('spotUi.deleteSpotTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-300">
|
||||
{adminTakedown
|
||||
? `As a SkateSpots admin you're taking down "${spot.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
|
||||
: `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`}
|
||||
? t('spotUi.adminDeleteDialogDesc', { title: spot.title })
|
||||
: t('spotUi.deleteDialogDesc', { title: spot.title })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-gray-600">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="border-gray-600">{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
{adminTakedown ? 'Remove Spot' : 'Delete Spot'}
|
||||
{adminTakedown ? t('spotUi.removeSpot') : t('spotUi.deleteSpot')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -1229,7 +1231,7 @@ export default function SpotDetailPage() {
|
|||
open={showRouteDialog}
|
||||
onOpenChange={setShowRouteDialog}
|
||||
destination={coordinates}
|
||||
destinationLabel={spot?.title ?? 'Spot'}
|
||||
destinationLabel={spot?.title ?? t('spotUi.spot')}
|
||||
/>
|
||||
|
||||
{/* Full-screen photo viewer */}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from '@/hooks/useTradeAttestations';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { Handshake, CheckCircle, Loader2, Package, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
|
||||
function PeerName({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
|
|
@ -49,6 +50,7 @@ function listingUrl(pubkey: string, identifier: string): string {
|
|||
* attestation records. Mirrors the DM banners, in a dedicated surface.
|
||||
*/
|
||||
export default function TradesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { publishTrade, isPending: publishing } = useTradeAttestation();
|
||||
|
|
@ -85,11 +87,11 @@ export default function TradesPage() {
|
|||
requestCoord,
|
||||
rating,
|
||||
});
|
||||
toast({ title: 'Sale confirmed', description: 'They can now confirm their side.' });
|
||||
toast({ title: t('marketUi.trades.saleConfirmed'), description: t('marketUi.trades.saleConfirmedBody') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the sale',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.trades.saleConfirmFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -106,11 +108,11 @@ export default function TradesPage() {
|
|||
setBusy(coord);
|
||||
try {
|
||||
await publishTrade({ listing, role: 'bought', counterparty: seller, rating });
|
||||
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
|
||||
toast({ title: t('marketUi.trades.tradeConfirmed'), description: t('marketUi.trades.tradeConfirmedBody') });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm the trade',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.trades.tradeConfirmFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -127,17 +129,17 @@ export default function TradesPage() {
|
|||
role: revokeTarget.role,
|
||||
counterparty: revokeTarget.counterparty,
|
||||
state: 'revoked',
|
||||
note: 'Retracted by the author.',
|
||||
note: t('marketUi.trades.revokeNote'),
|
||||
});
|
||||
toast({
|
||||
title: 'Trade record revoked',
|
||||
description: 'It no longer counts toward reputation.',
|
||||
title: t('marketUi.trades.revoked'),
|
||||
description: t('marketUi.trades.revokedBody'),
|
||||
});
|
||||
setRevokeTarget(null);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not revoke the trade',
|
||||
description: e instanceof Error ? e.message : 'Something went wrong.',
|
||||
title: t('marketUi.trades.revokeFailed'),
|
||||
description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -150,9 +152,9 @@ export default function TradesPage() {
|
|||
<>
|
||||
<div className="container mx-auto max-w-md px-4 py-16 text-center space-y-4">
|
||||
<Handshake className="mx-auto h-10 w-10 text-gray-600" />
|
||||
<h1 className="text-xl font-black uppercase text-white">My Trades</h1>
|
||||
<h1 className="text-xl font-black uppercase text-white">{t('marketUi.trades.title')}</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
Log in to manage your marketplace trades and reputation.
|
||||
{t('marketUi.trades.loginBody')}
|
||||
</p>
|
||||
<LoginArea className="justify-center" />
|
||||
</div>
|
||||
|
|
@ -171,11 +173,10 @@ export default function TradesPage() {
|
|||
<div className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-3xl font-black uppercase text-white">
|
||||
<Handshake className="h-8 w-8 text-[#ff6600]" /> My Trades
|
||||
<Handshake className="h-8 w-8 text-[#ff6600]" /> {t('marketUi.trades.title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Confirmations, requests, and the reputation they build. Payment is arranged
|
||||
with the other skater — the app only records the deal.
|
||||
{t('marketUi.trades.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -184,7 +185,7 @@ export default function TradesPage() {
|
|||
{/* Action needed */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
||||
Action needed {actionNeeded > 0 && `· ${actionNeeded}`}
|
||||
{t('marketUi.trades.actionNeeded')} {actionNeeded > 0 && `· ${actionNeeded}`}
|
||||
</h2>
|
||||
|
||||
{(loadingIncoming || loadingPending) && (
|
||||
|
|
@ -200,39 +201,39 @@ export default function TradesPage() {
|
|||
key={r.coord}
|
||||
tone="sale"
|
||||
icon={Handshake}
|
||||
text={<><PeerName pubkey={r.requestor} /> asked you to confirm a sale</>}
|
||||
text={<><PeerName pubkey={r.requestor} /> {t('marketUi.trades.askedToConfirmSale')}</>}
|
||||
listingUrl={listingUrl(r.listing.pubkey, r.listing.identifier)}
|
||||
busy={busy === r.coord}
|
||||
disabled={busy === r.coord || publishing}
|
||||
actionLabel="Confirm sale"
|
||||
actionLabel={t('marketUi.trades.confirmSale')}
|
||||
onConfirm={(rating) => handleConfirmSale(r.listing, r.requestor, r.coord, rating)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Pending: a seller recorded a sale you haven't confirmed */}
|
||||
{pending?.map((t) => (
|
||||
{pending?.map((p) => (
|
||||
<TradeConfirmRow
|
||||
key={t.coord}
|
||||
key={p.coord}
|
||||
tone="purchase"
|
||||
icon={CheckCircle}
|
||||
text={<><PeerName pubkey={t.seller} /> recorded a sale — confirm your side</>}
|
||||
listingUrl={listingUrl(t.listing.pubkey, t.listing.identifier)}
|
||||
busy={busy === t.coord}
|
||||
disabled={busy === t.coord || publishing}
|
||||
actionLabel="Confirm"
|
||||
onConfirm={(rating) => handleConfirmPurchase(t.listing, t.seller, t.coord, rating)}
|
||||
text={<><PeerName pubkey={p.seller} /> {t('marketUi.trades.recordedSale')}</>}
|
||||
listingUrl={listingUrl(p.listing.pubkey, p.listing.identifier)}
|
||||
busy={busy === p.coord}
|
||||
disabled={busy === p.coord || publishing}
|
||||
actionLabel={t('marketUi.trades.confirm')}
|
||||
onConfirm={(rating) => handleConfirmPurchase(p.listing, p.seller, p.coord, rating)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loadingIncoming && !loadingPending && actionNeeded === 0 && (
|
||||
<p className="text-sm text-gray-500">Nothing waiting on you right now. 🛹</p>
|
||||
<p className="text-sm text-gray-500">{t('marketUi.trades.nothingWaiting')}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Your trade records */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
|
||||
Your trade records
|
||||
{t('marketUi.trades.yourRecords')}
|
||||
</h2>
|
||||
|
||||
{loadingRep && <Skeleton className="h-16 w-full rounded-2xl" />}
|
||||
|
|
@ -242,10 +243,10 @@ export default function TradesPage() {
|
|||
<CardContent className="py-10 text-center">
|
||||
<Package className="mx-auto mb-3 h-8 w-8 text-gray-600" />
|
||||
<p className="text-sm text-gray-400">
|
||||
No trades yet. Close a deal and confirm it to start building reputation.
|
||||
{t('marketUi.trades.noTrades')}
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4 border-white/15">
|
||||
<Link to="/marketplace">Browse Gear Swap</Link>
|
||||
<Link to="/marketplace">{t('marketUi.trades.browseGearSwap')}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -260,7 +261,7 @@ export default function TradesPage() {
|
|||
: d.includes(':trade:bought:')
|
||||
? 'bought'
|
||||
: null;
|
||||
const role = roleTyped === 'sold' ? 'Sold' : roleTyped === 'bought' ? 'Bought' : 'Trade';
|
||||
const role = roleTyped === 'sold' ? t('marketUi.trades.roleSold') : roleTyped === 'bought' ? t('marketUi.trades.roleBought') : t('marketUi.trades.roleTrade');
|
||||
const state = ev.tags.find(([t]) => t === 's')?.[1] ?? 'valid';
|
||||
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1];
|
||||
return (
|
||||
|
|
@ -268,7 +269,7 @@ export default function TradesPage() {
|
|||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Badge
|
||||
className={
|
||||
role === 'Sold'
|
||||
roleTyped === 'sold'
|
||||
? 'bg-orange-500/15 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30'
|
||||
}
|
||||
|
|
@ -277,7 +278,7 @@ export default function TradesPage() {
|
|||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-200">
|
||||
{counterparty ? <>with <PeerName pubkey={counterparty} /></> : 'trade'}
|
||||
{counterparty ? <>{t('marketUi.trades.with')} <PeerName pubkey={counterparty} /></> : t('marketUi.trades.roleTrade').toLowerCase()}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{new Date(ev.created_at * 1000).toLocaleDateString()}
|
||||
|
|
@ -289,7 +290,7 @@ export default function TradesPage() {
|
|||
to={listingUrl(parsed.pubkey, parsed.identifier)}
|
||||
className="shrink-0 text-xs text-gray-400 hover:text-white"
|
||||
>
|
||||
View listing <ArrowRight className="inline h-3 w-3" />
|
||||
{t('marketUi.trades.viewListing')} <ArrowRight className="inline h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
{roleTyped && counterparty && parsed && (
|
||||
|
|
@ -304,7 +305,7 @@ export default function TradesPage() {
|
|||
}
|
||||
className="shrink-0 text-xs text-red-400/70 hover:text-red-400"
|
||||
>
|
||||
Revoke
|
||||
{t('marketUi.trades.revoke')}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -317,10 +318,9 @@ export default function TradesPage() {
|
|||
<Dialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
||||
<DialogContent className="border-2 border-red-500/30 bg-black sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Revoke this trade record?</DialogTitle>
|
||||
<DialogTitle className="text-white">{t('marketUi.trades.revokeTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-400">
|
||||
It will stop counting toward your reputation. The other party keeps their own
|
||||
attestation. Use this for a disputed or mistaken deal.
|
||||
{t('marketUi.trades.revokeBody')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 pt-1">
|
||||
|
|
@ -329,7 +329,7 @@ export default function TradesPage() {
|
|||
onClick={() => setRevokeTarget(null)}
|
||||
className="flex-1 border-white/15"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRevoke}
|
||||
|
|
@ -337,7 +337,7 @@ export default function TradesPage() {
|
|||
className="flex-1 gap-2 bg-red-500 font-bold text-white hover:bg-red-500/90"
|
||||
>
|
||||
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Revoke
|
||||
{t('marketUi.trades.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
Loading…
Reference in a new issue