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:
SkateSpots 2026-10-01 23:57:20 +07:00
parent 9155949fda
commit 09735375c2
70 changed files with 5892 additions and 1965 deletions

View file

@ -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.

View file

@ -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
View 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);
});

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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')}
>

View file

@ -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>

View file

@ -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&apos;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>

View file

@ -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>

View file

@ -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 &amp; 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>
);

View file

@ -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>

View file

@ -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>
);

View file

@ -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 &amp; 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>
)}

View file

@ -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>

View file

@ -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 &amp; 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'),
});
}}
/>

View file

@ -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>

View file

@ -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&apos;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>

View file

@ -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>

View file

@ -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>
)}

View file

@ -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&apos;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>

View file

@ -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>
)}

View file

@ -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>
)}

View file

@ -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>

View file

@ -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>

View file

@ -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>
)}

View file

@ -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) => (

View file

@ -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(() => {

View file

@ -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} />
))}

View file

@ -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>
);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 &amp; 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>
)}

View file

@ -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}>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
)
)}

View file

@ -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 &amp; 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>

View file

@ -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>

View file

@ -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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 &amp; 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>

View file

@ -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>

View file

@ -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 &amp; 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>
) : (

View file

@ -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>

View file

@ -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>

View file

@ -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')}
</>
) : (
<>

View file

@ -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>

View file

@ -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>

View file

@ -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 &amp; 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">

View file

@ -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>

View file

@ -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 &amp; 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}

View file

@ -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>
)}

View file

@ -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>

View file

@ -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>

View file

@ -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>
);

View file

@ -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>
</>
)}

View file

@ -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>

View file

@ -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&apos;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 */}

View file

@ -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>