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 ⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
defer it); `fast` needs a sticky notification. Never the only path. (Approach defer it); `fast` needs a sticky notification. Never the only path. (Approach
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.) + 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 - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
delivery coordinator** exposed as a **ContextVM server** over Nostr relays delivery coordinator** exposed as a **ContextVM server** over Nostr relays
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is (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 · **Zapstore** · **ContextVM**. (`useShakespeare.ts`, `proxy.shakespeare.diy` and
similar are internal endpoints/hook names, not credits — leave them.) 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 ## Nostr Protocol Integration
This project comes with custom hooks for querying and publishing events on the Nostr network. 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: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: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", "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", "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: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", "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 { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions';
import { SKATE_SPOT_KIND } from '@/lib/types'; import { SKATE_SPOT_KIND } from '@/lib/types';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { useTranslation } from '@/contexts/language';
interface AddPhotoToSpotProps { interface AddPhotoToSpotProps {
spotPubkey: string; spotPubkey: string;
@ -27,6 +28,7 @@ interface AddPhotoToSpotProps {
} }
export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) { export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@ -51,14 +53,14 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
if (url) { if (url) {
setUploadedImage({ url, mimeType }); setUploadedImage({ url, mimeType });
toast({ toast({
title: 'Photo uploaded', title: t('spotUi.photoUploaded'),
description: 'Add a caption and submit to contribute!', description: t('spotUi.photoUploadedDesc'),
}); });
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Upload failed', title: t('spotUi.uploadFailed'),
description: error instanceof Error ? error.message : 'Failed to upload photo', description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -67,8 +69,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
const handleSubmit = async () => { const handleSubmit = async () => {
if (!uploadedImage) { if (!uploadedImage) {
toast({ toast({
title: 'No photo', title: t('spotUi.noPhoto'),
description: 'Please upload a photo first.', description: t('spotUi.uploadPhotoFirst'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -93,8 +95,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
}); });
toast({ toast({
title: 'Photo contributed!', title: t('spotUi.photoContributed'),
description: 'Your photo has been added to this spot.', description: t('spotUi.photoContributedDesc'),
}); });
// Reset and close // Reset and close
@ -106,8 +108,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] }); queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] });
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to contribute', title: t('spotUi.failedToContribute'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -124,23 +126,23 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Camera className="h-4 w-4" /> <Camera className="h-4 w-4" />
Add Photo {t('spotUi.addPhoto')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-[#ff0040]" /> <Camera className="h-5 w-5 text-[#ff0040]" />
Add Photo to Spot {t('spotUi.addPhotoToSpot')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Contribute a photo to help others discover this spot. {t('spotUi.addPhotoDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!user ? ( {!user ? (
<div className="py-6 text-center"> <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" /> <LoginArea className="justify-center" />
</div> </div>
) : ( ) : (
@ -150,7 +152,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
<div className="relative"> <div className="relative">
<img <img
src={uploadedImage.url} src={uploadedImage.url}
alt="Uploaded" alt={t('spotUi.uploadedAlt')}
className="w-full aspect-video object-cover rounded-lg" className="w-full aspect-video object-cover rounded-lg"
/> />
<Button <Button
@ -167,8 +169,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
onFiles={handleFiles} onFiles={handleFiles}
uploading={isUploading} uploading={isUploading}
multiple={false} multiple={false}
label="Upload a photo" label={t('spotUi.uploadAPhoto')}
hint="Drag & drop or click · JPG, PNG, WebP" hint={t('spotUi.uploadAPhotoHint')}
className="py-10" className="py-10"
/> />
)} )}
@ -176,7 +178,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
{/* Caption */} {/* Caption */}
<div className="space-y-2"> <div className="space-y-2">
<Textarea <Textarea
placeholder="Add a caption (optional) - describe what's in the photo, when it was taken, etc." placeholder={t('spotUi.captionPlaceholder')}
value={caption} value={caption}
onChange={(e) => setCaption(e.target.value)} onChange={(e) => setCaption(e.target.value)}
rows={3} rows={3}
@ -186,7 +188,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
{/* Actions */} {/* Actions */}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@ -196,12 +198,12 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Contributing... {t('spotUi.contributing')}
</> </>
) : ( ) : (
<> <>
<Camera className="h-4 w-4 mr-2" /> <Camera className="h-4 w-4 mr-2" />
Contribute Photo {t('spotUi.contributePhoto')}
</> </>
)} )}
</Button> </Button>

View file

@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { SKATE_SPOT_KIND } from '@/lib/types'; import { SKATE_SPOT_KIND } from '@/lib/types';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { useTranslation } from '@/contexts/language';
interface AddTagsToSpotProps { interface AddTagsToSpotProps {
spotPubkey: string; spotPubkey: string;
@ -25,6 +26,7 @@ interface AddTagsToSpotProps {
} }
export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingTags = [] }: AddTagsToSpotProps) { export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingTags = [] }: AddTagsToSpotProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { toast } = useToast(); const { toast } = useToast();
@ -38,8 +40,8 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
const handleSubmit = async () => { const handleSubmit = async () => {
if (tags.length === 0) { if (tags.length === 0) {
toast({ toast({
title: 'No tags', title: t('spotUi.noTags'),
description: 'Please add at least one tag.', description: t('spotUi.addAtLeastOneTag'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -63,13 +65,13 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
await publishEvent({ await publishEvent({
kind: 1111, 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, tags: eventTags,
}); });
toast({ toast({
title: 'Tags suggested!', title: t('spotUi.tagsSuggested'),
description: 'Your tag suggestions have been submitted.', description: t('spotUi.tagsSuggestedDesc'),
}); });
// Reset and close // Reset and close
@ -80,8 +82,8 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
queryClient.invalidateQueries({ queryKey: ['spot-tag-suggestions', spotPubkey, spotIdentifier] }); queryClient.invalidateQueries({ queryKey: ['spot-tag-suggestions', spotPubkey, spotIdentifier] });
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to suggest tags', title: t('spotUi.failedToSuggestTags'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -104,23 +106,23 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Tags className="h-4 w-4" /> <Tags className="h-4 w-4" />
Suggest Tags {t('spotUi.suggestTags')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-[#ccff00]" /> <Tags className="h-5 w-5 text-[#ccff00]" />
Suggest Tags {t('spotUi.suggestTags')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Help categorize this spot by suggesting relevant tags. {t('spotUi.suggestTagsDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!user ? ( {!user ? (
<div className="py-6 text-center"> <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" /> <LoginArea className="justify-center" />
</div> </div>
) : ( ) : (
@ -128,7 +130,7 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
{/* Existing tags display */} {/* Existing tags display */}
{existingTags.length > 0 && ( {existingTags.length > 0 && (
<div> <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"> <div className="flex flex-wrap gap-1">
{existingTags.map(tag => ( {existingTags.map(tag => (
<span key={tag} className="px-2 py-0.5 text-xs bg-gray-800 rounded-full text-gray-400"> <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 */} {/* Tag input */}
<div> <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 <TagInput
tags={tags} tags={tags}
onChange={handleTagChange} onChange={handleTagChange}
placeholder="Type to search or add tags..." placeholder={t('spotUi.typeToSearchTags')}
maxTags={10} maxTags={10}
/> />
</div> </div>
@ -153,7 +155,7 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
{/* Actions */} {/* Actions */}
<div className="flex gap-2 justify-end pt-2"> <div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@ -163,12 +165,14 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting... {t('spotUi.submitting')}
</> </>
) : ( ) : (
<> <>
<Tags className="h-4 w-4 mr-2" /> <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> </Button>

View file

@ -17,6 +17,7 @@ import {
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { isValidNoffer } from '@/lib/types'; import { isValidNoffer } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface ClinkPayButtonProps { interface ClinkPayButtonProps {
/** The CLINK Offer code (noffer1...) */ /** The CLINK Offer code (noffer1...) */
@ -49,8 +50,10 @@ export function ClinkPayButton({
className, className,
variant = 'outline', variant = 'outline',
size = 'default', size = 'default',
label = 'Tip in Bitcoin', label,
}: ClinkPayButtonProps) { }: ClinkPayButtonProps) {
const { t } = useTranslation();
const resolvedLabel = label ?? t('marketUi.clink.tip');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
@ -88,11 +91,11 @@ export function ClinkPayButton({
await navigator.clipboard.writeText(noffer); await navigator.clipboard.writeText(noffer);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
toast({ title: 'Copied', description: 'CLINK code copied to clipboard.' }); toast({ title: t('common.copied'), description: t('marketUi.clink.copiedBody') });
} catch { } catch {
toast({ toast({
title: 'Copy failed', title: t('marketUi.clink.copyFailed'),
description: 'Could not access the clipboard.', description: t('marketUi.clink.copyFailedBody'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -111,7 +114,7 @@ export function ClinkPayButton({
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
{label} {resolvedLabel}
</Button> </Button>
<TooltipProvider delayDuration={150}> <TooltipProvider delayDuration={150}>
<Tooltip> <Tooltip>
@ -119,18 +122,16 @@ export function ClinkPayButton({
<button <button
type="button" type="button"
onClick={() => setOpen(true)} 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" 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" /> <HelpCircle className="h-4 w-4" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px] text-xs"> <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"> <p className="mt-1 text-muted-foreground">
CLINK is a self-custodial payment code — no app account, no middleman. {t('marketUi.clink.tooltipBody')}
Scan it with a compatible Lightning wallet and the tip goes straight to
them.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -142,12 +143,12 @@ export function ClinkPayButton({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-indigo-500" /> <Zap className="h-5 w-5 text-indigo-500" />
{label} {resolvedLabel}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{recipientName {recipientName
? `Send a self-custodial Bitcoin (Lightning) payment to ${recipientName} — no middleman.` ? t('marketUi.clink.descRecipient', { recipientName })
: 'Send a self-custodial Bitcoin (Lightning) payment over Nostr — no middleman.'} : t('marketUi.clink.descGeneric')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -156,7 +157,7 @@ export function ClinkPayButton({
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-black/5"> <div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-black/5">
<img <img
src={qrDataUrl} src={qrDataUrl}
alt="CLINK Offer QR code" alt={t('marketUi.clink.qrAlt')}
className="h-[220px] w-[220px]" className="h-[220px] w-[220px]"
/> />
</div> </div>
@ -176,7 +177,7 @@ export function ClinkPayButton({
size="icon" size="icon"
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
onClick={handleCopy} onClick={handleCopy}
aria-label="Copy CLINK code" aria-label={t('marketUi.clink.copyCode')}
> >
{copied ? ( {copied ? (
<Check className="h-4 w-4 text-green-500" /> <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"> <Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700">
<a href={nostrUri}> <a href={nostrUri}>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
Open in CLINK wallet {t('marketUi.clink.openWallet')}
</a> </a>
</Button> </Button>
<p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground"> <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" /> <ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-indigo-500" />
<span> <span>
Scan with a CLINK-compatible wallet (e.g. ShockWallet). If nothing opens, copy the {t('marketUi.clink.footnote')}
code above — you may not have a CLINK-compatible wallet installed. No custodian,
no web server: the invoice is requested directly over Nostr.
</span> </span>
</p> </p>
</div> </div>

View file

@ -23,6 +23,7 @@ import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { isValidNoffer, type EntityType } from '@/lib/types'; import { isValidNoffer, type EntityType } from '@/lib/types';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react'; import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
@ -90,6 +91,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
} = config; } = config;
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const { user, metadata } = useCurrentUser(); const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || ''); const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending } = useNostrPublish(); const { mutateAsync: createEvent, isPending } = useNostrPublish();
@ -168,7 +170,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
setIsGettingLocation(true); setIsGettingLocation(true);
@ -177,11 +179,11 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(error) => { (error) => {
setIsGettingLocation(false); 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]; const url = tags[0]?.[1];
if (url) { if (url) {
setImage(url); setImage(url);
toast({ title: 'Image uploaded' }); toast({ title: t('labUi.become.imageUploaded') });
} }
} catch (error) { } 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 = ''; 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 // Contact details are optional, but if provided they must be usable — an
// invalid email/URL would otherwise be stored and then silently hidden. // invalid email/URL would otherwise be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email) const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.' ? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website)) : website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.' ? t('labUi.become.invalidWebsite')
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.' ? t('labUi.become.invalidPortfolio')
: ''; : '';
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (contactError) { if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return; return;
} }
@ -226,7 +228,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
for (const section of requiredSections) { for (const section of requiredSections) {
const keys = section.entries.map(([k]) => k); const keys = section.entries.map(([k]) => k);
if (!selected.some((s) => keys.includes(s))) { 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; return;
} }
} }
@ -285,10 +287,10 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]); if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
await createEvent({ kind, content: bio.trim(), tags }); 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}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) { } 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"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} /> <Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">You need to log in to create a {noun} profile.</p> <p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDescCraft', { noun })}</p>
<LoginArea className="justify-center max-w-60 mx-auto" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -319,7 +321,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<div className="mb-6"> <div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </Link>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center', accentChip)}> <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="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div> <div className="text-yellow-500">⚠️</div>
<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"> <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> </p>
<Link to="/settings"> <Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold"> <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> </Button>
</Link> </Link>
</div> </div>
@ -357,7 +359,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{existingProfile && ( {existingProfile && (
<p className="mb-3 text-xs text-gray-500"> <p className="mb-3 text-xs text-gray-500">
Editing your existing {noun} profile — publishing replaces it. {t('labUi.craftForm.editingNote', { noun })}
</p> </p>
)} )}
@ -379,17 +381,17 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
)} )}
</div> </div>
<div className="flex-1"> <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')}> <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 ? <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} /> <input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
</label> </label>
</div> </div>
</div> </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 <Input
id="title" id="title"
value={titleInput} value={titleInput}
@ -399,17 +401,17 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
className="mt-1.5 bg-white/5 border-white/10" className="mt-1.5 bg-white/5 border-white/10"
/> />
<p className="mt-1 text-xs text-gray-500"> <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> </p>
</div> </div>
<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 <Textarea
id="bio" id="bio"
value={bio} value={bio}
onChange={(e) => setBio(e.target.value)} 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" className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
maxLength={3000} maxLength={3000}
/> />
@ -426,7 +428,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<CardContent className="p-5"> <CardContent className="p-5">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2"> <Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
{section.title} {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> </Label>
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{section.entries.map(([key, label]) => { {section.entries.map(([key, label]) => {
@ -489,24 +491,24 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4"> <CardContent className="p-5 space-y-4">
<div> <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"> <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"> <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" />} {isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
</Button> </Button>
</div> </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>
<div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}> <div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}>
<div> <div>
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">Experience (years)</Label> <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="e.g. 5" inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" /> <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> </div>
{showRate && ( {showRate && (
<div> <div>
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabel}</Label> <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>
)} )}
</div> </div>
@ -516,12 +518,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
{/* Contact + links */} {/* Contact + links */}
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4"> <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"> <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={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="Email (optional)" type="email" 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="Website" inputMode="url" 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="Social (Instagram, etc.)" 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" /> <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> </div>
{contactError && <p className="text-xs text-red-400">{contactError}</p>} {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"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4"> <CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400"> <Label className="text-xs font-bold uppercase tracking-widest text-gray-400">
Getting Paid {t('labUi.craftForm.gettingPaid')}
</Label> </Label>
<PaymentReadinessNote /> <PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
@ -567,7 +569,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
disabled={isPending} disabled={isPending}
className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)} 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> </Button>
</form> </form>
</div> </div>

View file

@ -4,6 +4,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useCrewsForSpot } from '@/hooks/useCrewsForSpot'; import { useCrewsForSpot } from '@/hooks/useCrewsForSpot';
import { crewAccent } from '@/lib/crewHelpers'; import { crewAccent } from '@/lib/crewHelpers';
import { useTranslation } from '@/contexts/language';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
/** /**
@ -17,6 +18,7 @@ export function CrewsForSpotCard({
spotPubkey?: string; spotPubkey?: string;
spotIdentifier?: string; spotIdentifier?: string;
}) { }) {
const { t } = useTranslation();
const { data: crews, isLoading } = useCrewsForSpot(spotPubkey, spotIdentifier); const { data: crews, isLoading } = useCrewsForSpot(spotPubkey, spotIdentifier);
// Nothing to show and not loading → render nothing (keeps the page clean). // 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"> <Card className="border-2 border-teal-500/20 bg-black/90">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white"> <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@ -61,8 +63,10 @@ export function CrewsForSpotCard({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{crew.name}</p> <p className="truncate text-sm font-bold text-white">{crew.name}</p>
<p className="flex items-center gap-1 text-xs text-gray-500"> <p className="flex items-center gap-1 text-xs text-gray-500">
<Users className="h-3 w-3" /> {crew.members.length} member <Users className="h-3 w-3" />{' '}
{crew.members.length === 1 ? '' : 's'} {crew.members.length === 1
? t('spotUi.memberOne', { count: crew.members.length })
: t('spotUi.memberOther', { count: crew.members.length })}
{crew.location && ` · ${crew.location}`} {crew.location && ` · ${crew.location}`}
</p> </p>
</div> </div>

View file

@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
/** A little skateboard-deck glyph for the rating. */ /** A little skateboard-deck glyph for the rating. */
function Deck({ className }: { className?: string }) { function Deck({ className }: { className?: string }) {
@ -28,12 +29,13 @@ interface DeckRatingProps {
* Interactive by default; read-only when no `onChange` is given. * Interactive by default; read-only when no `onChange` is given.
*/ */
export function DeckRating({ value, onChange, size = 'md', compact = false, className }: DeckRatingProps) { export function DeckRating({ value, onChange, size = 'md', compact = false, className }: DeckRatingProps) {
const { t } = useTranslation();
const readOnly = !onChange; const readOnly = !onChange;
const count = readOnly && compact ? Math.max(value, 0) : 10; const count = readOnly && compact ? Math.max(value, 0) : 10;
const deckCls = size === 'sm' ? 'h-3 w-6' : 'h-3.5 w-7'; const deckCls = size === 'sm' ? 'h-3 w-6' : 'h-3.5 w-7';
return ( 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) => { {Array.from({ length: count }).map((_, i) => {
const n = i + 1; const n = i + 1;
const filled = n <= value; const filled = n <= value;
@ -45,7 +47,7 @@ export function DeckRating({ value, onChange, size = 'md', compact = false, clas
key={n} key={n}
type="button" type="button"
onClick={() => onChange?.(n === value ? 0 : n)} onClick={() => onChange?.(n === value ? 0 : n)}
aria-label={`${n} of 10`} aria-label={t('marketUi.deckRating.aria', { n })}
aria-pressed={filled} aria-pressed={filled}
className={cn(deckCls, 'transition-transform hover:scale-110 active:scale-95', filled ? 'text-[#ccff00]' : 'text-gray-600')} 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 { Lock } from 'lucide-react';
import { parseEscrowToken, type EscrowTokenInfo } from '@/lib/cashuEscrow'; import { parseEscrowToken, type EscrowTokenInfo } from '@/lib/cashuEscrow';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
function hostOf(url: string): string { function hostOf(url: string): string {
try { try {
@ -25,6 +26,7 @@ function formatDate(seconds: number): string {
* mint-touching create/redeem actions are native-only. * mint-touching create/redeem actions are native-only.
*/ */
export function EscrowPanel({ token, className }: { token: string; className?: string }) { export function EscrowPanel({ token, className }: { token: string; className?: string }) {
const { t } = useTranslation();
const info = useMemo<EscrowTokenInfo | null>(() => { const info = useMemo<EscrowTokenInfo | null>(() => {
try { try {
return parseEscrowToken(token); 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]"> <div className="flex items-center gap-2 font-bold text-[#ff6600]">
<Lock className="h-3.5 w-3.5" /> <Lock className="h-3.5 w-3.5" />
Escrowed {info.amount.toLocaleString()} sats {t('marketUi.escrowPanel.escrowed', { amount: info.amount.toLocaleString() })}
</div> </div>
<div className="mt-1 space-y-0.5 text-gray-400"> <div className="mt-1 space-y-0.5 text-gray-400">
<p> <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> </p>
{locktime ? ( {locktime ? (
<p> <p>
{info.refundable ? ( {info.refundable ? (
<> <>{t('marketUi.escrowPanel.refundSinceBefore')}{' '}
Buyer can refund since{' '}
<span className="text-[#ccff00]">{formatDate(locktime)}</span> <span className="text-[#ccff00]">{formatDate(locktime)}</span>
</> </>
) : ( ) : (
<> <>{t('marketUi.escrowPanel.redeemBefore')}{' '}
Seller can redeem now · buyer refunds after{' '}
<span className="text-gray-300">{formatDate(locktime)}</span> <span className="text-gray-300">{formatDate(locktime)}</span>
</> </>
)} )}
</p> </p>
) : ( ) : (
<p>No expiry set on this token.</p> <p>{t('marketUi.escrowPanel.noExpiry')}</p>
)} )}
</div> </div>
</div> </div>

View file

@ -14,6 +14,7 @@ import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { ESCROW_DEFAULT_LOCK_DAYS, ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow'; import { ESCROW_DEFAULT_LOCK_DAYS, ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
import { Loader2, Lock } from 'lucide-react'; import { Loader2, Lock } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
/** /**
* Buyer: lock funds in Cashu escrow (P2PK to the seller, refundable after a * Buyer: lock funds in Cashu escrow (P2PK to the seller, refundable after a
@ -36,6 +37,7 @@ export function EscrowRequestDialog({
sellerPubkey: string; sellerPubkey: string;
sellerName: string; sellerName: string;
}) { }) {
const { t } = useTranslation();
const escrow = useCashuEscrow(); const escrow = useCashuEscrow();
const { publishRequest, isPending: publishing } = useTradeAttestationRequest(); const { publishRequest, isPending: publishing } = useTradeAttestationRequest();
const { toast } = useToast(); const { toast } = useToast();
@ -49,11 +51,11 @@ export function EscrowRequestDialog({
const lockDays = parseInt(days, 10); const lockDays = parseInt(days, 10);
if (!Number.isFinite(amountSats) || amountSats <= 0) { if (!Number.isFinite(amountSats) || amountSats <= 0) {
toast({ title: 'Enter an escrow amount', variant: 'destructive' }); toast({ title: t('marketUi.escrowRequest.enterAmount'), variant: 'destructive' });
return; return;
} }
if (!Number.isFinite(lockDays) || lockDays < ESCROW_MIN_LOCK_DAYS) { 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; return;
} }
@ -64,17 +66,17 @@ export function EscrowRequestDialog({
listing, listing,
attestor: sellerPubkey, attestor: sellerPubkey,
cashuToken: token, cashuToken: token,
note: `Escrowed ${amountSats} sats for our trade — please confirm the sale.`, note: t('marketUi.escrowRequest.requestNote', { amount: amountSats }),
}); });
toast({ toast({
title: 'Escrow created 🎉', title: t('marketUi.escrowRequest.created'),
description: `${amountSats} sats locked for ${lockDays} days. ${sellerName} can redeem on delivery.`, description: t('marketUi.escrowRequest.createdBody', { amount: amountSats, days: lockDays, sellerName }),
}); });
onOpenChange(false); onOpenChange(false);
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not create escrow', title: t('marketUi.escrowRequest.createFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } 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"> <DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ff6600]/40 bg-black sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white"> <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> </DialogTitle>
<DialogDescription className="text-sm text-gray-400"> <DialogDescription className="text-sm text-gray-400">
Funds are locked to {sellerName} and refundable by you if the deal falls {t('marketUi.escrowRequest.description', { sellerName })}
through after the deadline. Requires a connected Lightning wallet.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="escrow-amount" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="escrow-amount" className="text-xs uppercase tracking-wide text-gray-400">
Amount (sats) {t('marketUi.escrowRequest.amountLabel')}
</Label> </Label>
<Input <Input
id="escrow-amount" id="escrow-amount"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
inputMode="numeric" inputMode="numeric"
placeholder="e.g. 45000" placeholder={t('marketUi.offer.amountPlaceholder')}
className="h-11 border-gray-700 bg-gray-900 text-white" className="h-11 border-gray-700 bg-gray-900 text-white"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="escrow-days" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="escrow-days" className="text-xs uppercase tracking-wide text-gray-400">
Buyer refund after (days) {t('marketUi.escrowRequest.daysLabel')}
</Label> </Label>
<Input <Input
id="escrow-days" id="escrow-days"
@ -124,19 +125,19 @@ export function EscrowRequestDialog({
className="h-11 border-gray-700 bg-gray-900 text-white" className="h-11 border-gray-700 bg-gray-900 text-white"
/> />
<p className="text-[11px] text-gray-500"> <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> </p>
</div> </div>
{!escrow.available && ( {!escrow.available && (
<p className="text-xs text-amber-400"> <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> </p>
)} )}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15"> <Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={submit} onClick={submit}
@ -144,7 +145,7 @@ export function EscrowRequestDialog({
className="flex-1 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90" 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" />} {pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Lock className="h-4 w-4" />}
Lock & request {t('marketUi.escrowRequest.lockAndRequest')}
</Button> </Button>
</div> </div>
</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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { ReportDialog } from '@/components/ReportDialog'; import { ReportDialog } from '@/components/ReportDialog';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useTranslation } from '@/contexts/language';
interface GearSwapItemCardProps { interface GearSwapItemCardProps {
item: MarketplaceItem; item: MarketplaceItem;
@ -31,6 +32,7 @@ interface GearSwapItemCardProps {
} }
export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemCardProps) { export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemCardProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const navigate = useNavigate(); const navigate = useNavigate();
// Distance from the viewer's last known location (cache only — no GPS call). // 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}"`, : `SkateSpots admin removed listing "${item.title}"`,
}); });
toast({ toast({
title: ownerDeleting ? 'Listing deleted' : 'Listing removed', title: ownerDeleting ? t('marketUi.card.listingDeleted') : t('marketUi.card.listingRemoved'),
description: ownerDeleting description: ownerDeleting
? 'Your listing has been removed. Relays that honor deletions will stop serving it.' ? t('marketUi.card.listingDeletedBody')
: 'This listing has been taken down and will be hidden across SkateSpots.', : t('marketUi.card.listingRemovedBody'),
}); });
setShowRemoveDialog(false); setShowRemoveDialog(false);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Remove failed', title: t('marketUi.card.removeFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -107,11 +109,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
const handleMarkSold = async () => { const handleMarkSold = async () => {
try { try {
await markSold(item.event); 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) { } catch (error) {
toast({ toast({
title: 'Could not update', title: t('marketUi.card.couldNotUpdate'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -120,11 +122,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
const handleRelist = async () => { const handleRelist = async () => {
try { try {
await relist(item.event); 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) { } catch (error) {
toast({ toast({
title: 'Could not update', title: t('marketUi.card.couldNotUpdate'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -162,7 +164,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{isSold && ( {isSold && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60"> <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"> <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> </span>
</div> </div>
)} )}
@ -171,7 +173,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{isWTB && ( {isWTB && (
<Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg"> <Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg">
<Search className="h-3 w-3 mr-1" /> <Search className="h-3 w-3 mr-1" />
WTB {t('marketUi.gearSwap.wtb')}
</Badge> </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" className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
disabled={isUpdatingStatus} disabled={isUpdatingStatus}
aria-label="Listing options" aria-label={t('marketUi.card.listingOptions')}
> >
{isUpdatingStatus ? ( {isUpdatingStatus ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@ -234,7 +236,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
disabled={isUpdatingStatus} disabled={isUpdatingStatus}
> >
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
Relist as active {t('marketUi.listingDetail.relistActive')}
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
@ -245,7 +247,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
disabled={isUpdatingStatus} disabled={isUpdatingStatus}
> >
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
Mark as sold {t('marketUi.listingDetail.markSold')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -257,7 +259,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
className="text-red-500 focus:text-red-500" className="text-red-500 focus:text-red-500"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete listing {t('marketUi.listingDetail.deleteListing')}
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
@ -270,7 +272,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
className="text-red-500 focus:text-red-500" className="text-red-500 focus:text-red-500"
> >
<Flag className="h-4 w-4 mr-2" /> <Flag className="h-4 w-4 mr-2" />
Report {t('marketUi.card.report')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && !isOwnListingCheck && ( {isAdmin && !isOwnListingCheck && (
@ -282,7 +284,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
className="text-red-500 focus:text-red-500" className="text-red-500 focus:text-red-500"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Remove (admin) {t('marketUi.card.removeAdmin')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@ -317,15 +319,15 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{item.fulfilment && ( {item.fulfilment && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400"> <Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
{item.fulfilment === 'pickup' {item.fulfilment === 'pickup'
? '📍 Pickup' ? `📍 ${t('marketUi.create.pickup')}`
: item.fulfilment === 'ships' : item.fulfilment === 'ships'
? '📦 Ships' ? `📦 ${t('marketUi.create.ships')}`
: '📍📦 Both'} : `📍📦 ${t('marketUi.create.both')}`}
</Badge> </Badge>
)} )}
{item.shipping && ( {item.shipping && (
<Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400"> <Badge variant="outline" className="text-[10px] border-gray-700 text-gray-400">
postage: {item.shipping} {t('marketUi.card.postage')}: {item.shipping}
</Badge> </Badge>
)} )}
{distanceKm != null && ( {distanceKm != null && (
@ -387,13 +389,13 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
{sellerRating.rating > 0 ? ( {sellerRating.rating > 0 ? (
<span className="text-sm">{sellerRating.display}</span> <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> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
<div className="text-center"> <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> <p className="text-xs text-muted-foreground">{sellerRating.label}</p>
</div> </div>
</TooltipContent> </TooltipContent>
@ -419,7 +421,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
}} }}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
{isWTB ? 'I Have This!' : 'Contact Seller'} {isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -443,24 +445,24 @@ export function GearSwapItemCard({ item, trustScore, salesCount }: GearSwapItemC
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2"> <AlertDialogTitle className="text-white flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" /> <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> </AlertDialogTitle>
<AlertDialogDescription className="text-gray-300"> <AlertDialogDescription className="text-gray-300">
{ownerDeleting {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.` ? t('marketUi.card.deleteBody', { title: item.title })
: `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.adminRemoveBody', { title: item.title })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}> <AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
Cancel {t('common.cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleRemove} onClick={handleRemove}
disabled={isRemoving} disabled={isRemoving}
className="bg-red-500 hover:bg-red-600 text-white" 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> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -13,8 +13,10 @@ import { Button } from '@/components/ui/button';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
import { itemDistanceKm } from '@/lib/sortOptions'; import { itemDistanceKm } from '@/lib/sortOptions';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
export function HandoffHelper({ item }: { item: MarketplaceItem }) { export function HandoffHelper({ item }: { item: MarketplaceItem }) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const npub = nip19.npubEncode(item.pubkey); const npub = nip19.npubEncode(item.pubkey);
const dm = (message: string) => `/messages/${npub}?message=${encodeURIComponent(message)}`; const dm = (message: string) => `/messages/${npub}?message=${encodeURIComponent(message)}`;
@ -46,7 +48,7 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
return ( return (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3"> <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"> <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> </p>
{pickup && ( {pickup && (
@ -56,17 +58,17 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
className="w-full justify-start gap-2 border-white/15" className="w-full justify-start gap-2 border-white/15"
onClick={() => onClick={() =>
navigate( 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> </Button>
)} )}
{meetSpots.length > 0 && ( {meetSpots.length > 0 && (
<div className="space-y-1.5"> <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 }) => ( {meetSpots.map(({ spot, km }) => (
<Button <Button
key={spot.id} key={spot.id}
@ -76,7 +78,7 @@ export function HandoffHelper({ item }: { item: MarketplaceItem }) {
onClick={() => onClick={() =>
navigate( navigate(
dm( 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" className="w-full justify-start gap-2 border-white/15"
onClick={() => onClick={() =>
navigate( 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>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full justify-start gap-2 border-white/15" className="w-full justify-start gap-2 border-white/15"
onClick={() => 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> </Button>
</> </>
)} )}
<p className="text-[10px] leading-relaxed text-gray-600"> <p className="text-[10px] leading-relaxed text-gray-600">
These details stay in the private chat — SkateSpots never sees them. For shipped deals, {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')}
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.
</p> </p>
</div> </div>
); );

View file

@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Users, ChevronRight, MapPin } from 'lucide-react'; import { Users, ChevronRight, MapPin } from 'lucide-react';
import { useCrewsByFocus } from '@/hooks/useCrews'; import { useCrewsByFocus } from '@/hooks/useCrews';
import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew, type CrewFocus } from '@/lib/types'; import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew, type CrewFocus } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
/** The Lab page's own ServiceType union (kept in sync with LabPage). */ /** 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 }) { function CrewChip({ crew }: { crew: SkateCrew }) {
const { t } = useTranslation();
return ( return (
<Link <Link
to={`/crew/${crew.pubkey}/${crew.identifier}`} 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"> <p className="mt-0.5 flex items-center gap-1 text-xs text-gray-500">
<Users className="h-3 w-3 text-teal-400" /> <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> </p>
)} )}
</div> </div>
@ -73,6 +75,7 @@ function CrewChip({ crew }: { crew: SkateCrew }) {
* cross-reference each other. Renders nothing when there are no matching crews. * cross-reference each other. Renders nothing when there are no matching crews.
*/ */
export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) { export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
const { t } = useTranslation();
const focus = focusForLabService(serviceType); const focus = focusForLabService(serviceType);
const { data: crews, isLoading } = useCrewsByFocus(focus, 20); 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"> <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"> <h2 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-teal-400">
<Users className="h-4 w-4" /> <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"> <span className="rounded-full bg-teal-500/15 px-2 py-0.5 text-[10px] font-bold text-teal-300">
{CREW_FOCUS_LABELS[focus]} {CREW_FOCUS_LABELS[focus]}
</span> </span>
@ -96,7 +99,7 @@ export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
to="/crews" to="/crews"
className="flex items-center gap-0.5 text-xs font-bold text-gray-400 hover:text-teal-300" 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> </Link>
</div> </div>

View file

@ -3,23 +3,9 @@ import { Label } from '@/components/ui/label';
import { Link2, X, GraduationCap } from 'lucide-react'; import { Link2, X, GraduationCap } from 'lucide-react';
import { useLabProfiles } from '@/hooks/useLabProfiles'; import { useLabProfiles } from '@/hooks/useLabProfiles';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; 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 { interface LabLinkFieldProps {
/** Selected Lab profile coordinates (`kind:pubkey:d`). */ /** Selected Lab profile coordinates (`kind:pubkey:d`). */
value: string[]; value: string[];
@ -33,8 +19,14 @@ interface LabLinkFieldProps {
*/ */
export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProps) { export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProps) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { t } = useTranslation();
const { data: allProfiles } = useLabProfiles(200); 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. // Only your own Lab profiles — a crew can only vouch a link to listings it controls.
const myProfiles = useMemo( const myProfiles = useMemo(
() => (allProfiles ?? []).filter((p) => p.pubkey === user?.pubkey), () => (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 className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div> <div>
<Label className="flex items-center gap-2 text-teal-400"> <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> </Label>
<p className="mt-1 text-xs text-gray-500"> <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. {t('labUi.labLink.desc')}{' '}
When the Lab profile also links back to this crew, both show a green{' '} <span className="font-bold text-green-400">{t('common.verified')}</span>{' '}
<span className="font-bold text-green-400">Verified</span> badge. {t('labUi.labLink.descEnd')}
</p> </p>
</div> </div>
{myProfiles.length === 0 ? ( {myProfiles.length === 0 ? (
<p className="text-xs text-gray-500 italic"> <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> </p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <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" /> <GraduationCap className="h-3.5 w-3.5" />
<span className="truncate max-w-[10rem]">{p.title}</span> <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" />} {selected && <X className="h-3 w-3 opacity-80" />}
</button> </button>
); );

View file

@ -2,6 +2,7 @@ import { Sparkles, Check } from 'lucide-react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { PlatformFeePayment } from '@/components/PlatformFeePayment'; import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee'; import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface LabProUpgradeProps { interface LabProUpgradeProps {
@ -31,6 +32,7 @@ export function LabProUpgrade({
entityCoord, entityCoord,
className, className,
}: LabProUpgradeProps) { }: LabProUpgradeProps) {
const { t } = useTranslation();
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee('labProfile'); const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee('labProfile');
return ( return (
@ -48,17 +50,17 @@ export function LabProUpgrade({
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className={cn('font-black uppercase tracking-wide text-sm', accentText)}> <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>
<p className="text-xs text-gray-400 mt-0.5"> <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 {t('labUi.proUpgrade.freeTo')} <strong className="text-amber-400">{t('labUi.proUpgrade.proBadge')}</strong> and
{' '}<strong className="text-amber-400">featured placement</strong> at the top of The Lab. {' '}<strong className="text-amber-400">{t('labUi.proUpgrade.featuredPlacement')}</strong> {t('labUi.proUpgrade.atTop')}
</p> </p>
</div> </div>
</div> </div>
{isActive ? ( {isActive ? (
<span className="flex items-center gap-1 text-[11px] font-bold text-green-400 flex-shrink-0"> <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> </span>
) : ( ) : (
<Switch <Switch
@ -72,9 +74,9 @@ export function LabProUpgrade({
{/* Benefits list */} {/* Benefits list */}
{!isActive && ( {!isActive && (
<ul className="mt-3 grid gap-1.5 text-xs text-gray-400"> <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" /> {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" /> 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" /> {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" /> 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.benefit3')}</li>
</ul> </ul>
)} )}
@ -82,8 +84,8 @@ export function LabProUpgrade({
{isActive && ( {isActive && (
<p className="mt-2 text-xs text-gray-500"> <p className="mt-2 text-xs text-gray-500">
{isPlatformOwner {isPlatformOwner
? 'Platform owner — Pro is always on.' ? t('labUi.proUpgrade.platformOwner')
: `Pro active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining. Your profile will be featured.`} : t(daysRemaining === 1 ? 'labUi.proUpgrade.proActiveOne' : 'labUi.proUpgrade.proActiveMany', { count: daysRemaining })}
</p> </p>
)} )}
</div> </div>
@ -95,7 +97,7 @@ export function LabProUpgrade({
type="labProfile" type="labProfile"
entityCoord={entityCoord} entityCoord={entityCoord}
onSuccess={() => { /* hook auto-updates isActive */ }} 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> </div>
)} )}

View file

@ -20,6 +20,9 @@ import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { PayWithSats } from '@/components/PayWithSats'; import { PayWithSats } from '@/components/PayWithSats';
import { Handshake, CheckCircle, X, Loader2, ArrowLeftRight, Undo2, Zap } from 'lucide-react'; 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 }) { function PeerName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey); 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) { switch (status) {
case 'accepted': 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': 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': 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: 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[] = []; const parts: string[] = [];
if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`); if (m.amount) parts.push(`${m.amount.toLocaleString()} ${m.currency ?? 'SATS'}`);
if (m.offerItem) parts.push(m.offerItem); 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. * whose turn it is.
*/ */
export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) { export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; isOwner: boolean }) {
const { t } = useTranslation();
const { user, metadata } = useCurrentUser(); const { user, metadata } = useCurrentUser();
const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier }); const { data } = useListingOffers({ pubkey: item.pubkey, identifier: item.identifier });
const { respondToOffer, counterOffer } = useOfferActions(); const { respondToOffer, counterOffer } = useOfferActions();
@ -97,14 +101,14 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
await nudge( await nudge(
thread.latest.author, thread.latest.author,
status === 'accepted' status === 'accepted'
? '✅ Your offer was accepted! Message to arrange the handoff.' ? t('marketUi.offers.dmAccepted')
: '❌ Your offer was declined.', : 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) { } catch (e) {
toast({ toast({
title: 'Could not respond', title: t('marketUi.offers.respondFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@ -116,11 +120,11 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
setBusy(thread.latest.id); setBusy(thread.latest.id);
try { try {
await respondToOffer(thread.latest, 'withdrawn'); await respondToOffer(thread.latest, 'withdrawn');
toast({ title: 'Offer withdrawn' }); toast({ title: t('marketUi.offers.offerWithdrawn') });
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not withdraw', title: t('marketUi.offers.withdrawFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@ -132,7 +136,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
const amount = Number(counterAmount); const amount = Number(counterAmount);
const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0; const hasAmount = counterAmount.trim() !== '' && Number.isFinite(amount) && amount > 0;
if (!hasAmount && !counterItem.trim()) { 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; return;
} }
setBusy(thread.latest.id); 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] const what = [hasAmount ? `${amount.toLocaleString()} sats` : null, counterItem.trim() || null]
.filter(Boolean) .filter(Boolean)
.join(' + '); .join(' + ');
await nudge(thread.latest.author, `↩️ Counter-offer: ${what}`); await nudge(thread.latest.author, `↩️ ${t('marketUi.offers.counterDm')}: ${what}`);
toast({ title: 'Counter-offer sent', description: 'It’s their turn now.' }); toast({ title: t('marketUi.offers.counterSent'), description: t('marketUi.offers.counterSentBody') });
setCounterFor(null); setCounterFor(null);
setCounterAmount(''); setCounterAmount('');
setCounterItem(''); setCounterItem('');
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not send the counter', title: t('marketUi.offers.counterFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.offers.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@ -166,7 +170,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
<Card className="bg-black/90 border-2 border-orange-500/25"> <Card className="bg-black/90 border-2 border-orange-500/25">
<CardContent className="space-y-3 p-4"> <CardContent className="space-y-3 p-4">
<h3 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-orange-400"> <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> </h3>
{mine.map((thread) => { {mine.map((thread) => {
@ -188,8 +192,8 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
<span className="text-sm text-gray-200"> <span className="text-sm text-gray-200">
<PeerName pubkey={other} /> <PeerName pubkey={other} />
</span> </span>
{statusBadge(latest.status)} {statusBadge(latest.status, t)}
<span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest)}</span> <span className="ml-auto text-sm font-bold text-[#ccff00]">{amountLabel(latest, t)}</span>
</div> </div>
{/* Earlier rounds — the volley, so the haggling is visible. */} {/* 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"> <div className="space-y-0.5 border-l border-white/10 pl-2">
{history.map((m) => ( {history.map((m) => (
<p key={m.id} className="text-[11px] text-gray-500"> <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}` : ''} {m.status !== 'open' ? ` · ${m.status}` : ''}
</p> </p>
))} ))}
@ -210,7 +214,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
{latest.status === 'accepted' && user.pubkey === thread.buyer && ( {latest.status === 'accepted' && user.pubkey === thread.buyer && (
paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? ( paidThreads.has(`${thread.listingCoord}:${thread.buyer}`) ? (
<p className="flex items-center gap-1.5 text-xs font-bold text-green-400"> <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> </p>
) : ( ) : (
<Button <Button
@ -218,7 +222,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
onClick={() => setPayFor(thread)} onClick={() => setPayFor(thread)}
className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" 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> </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" 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" />} {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>
<Button <Button
size="sm" size="sm"
@ -243,7 +247,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
disabled={busy === latest.id} disabled={busy === latest.id}
className="flex-1 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10" 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>
<Button <Button
size="sm" 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" 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> </Button>
</div> </div>
{counterFor === latest.id && ( {counterFor === latest.id && (
@ -267,7 +271,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
inputMode="numeric" inputMode="numeric"
value={counterAmount} value={counterAmount}
onChange={(e) => setCounterAmount(e.target.value)} 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" className="h-9 border-white/15 bg-white/5 text-sm text-white"
/> />
<Button <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" 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} {busy === latest.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Send {t('marketUi.offers.send')}
</Button> </Button>
</div> </div>
<Input <Input
value={counterItem} value={counterItem}
onChange={(e) => setCounterItem(e.target.value)} 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" className="h-9 border-white/15 bg-white/5 text-sm text-white"
/> />
</div> </div>
@ -295,7 +299,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
{!myTurn && latest.status === 'open' && ( {!myTurn && latest.status === 'open' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="flex-1 text-xs text-gray-500"> <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> </p>
{latest.author === user.pubkey && ( {latest.author === user.pubkey && (
<Button <Button
@ -305,7 +309,7 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
disabled={busy === latest.id} disabled={busy === latest.id}
className="shrink-0 gap-1.5 border-white/15" 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> </Button>
)} )}
</div> </div>

View file

@ -24,6 +24,7 @@ import { useUsdToSats } from '@/hooks/useExchangeRate';
import { parseEscrowToken } from '@/lib/cashuEscrow'; import { parseEscrowToken } from '@/lib/cashuEscrow';
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react'; import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
/** /**
* Buyer/seller trade + escrow actions for a marketplace listing, shared by the * 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). * funds on native).
*/ */
export function ListingTradeActions({ item }: { item: MarketplaceItem }) { export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const author = useAuthor(item.pubkey); const author = useAuthor(item.pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -153,16 +155,16 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
clearReceipt(coord); clearReceipt(coord);
clearReceipt(coord, 'deposit'); clearReceipt(coord, 'deposit');
toast({ toast({
title: 'Trade confirmed', title: t('marketUi.tradeActions.tradeConfirmed'),
description: description:
paymentProof || depositProof paymentProof || depositProof
? 'A verifiable receipt was attached — this counts toward verified trades.' ? t('marketUi.tradeActions.tradeConfirmedWithProof')
: 'Reputation updated for both of you.', : t('marketUi.tradeActions.tradeConfirmedBody'),
}); });
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not confirm the trade', title: t('marketUi.tradeActions.tradeConfirmFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -174,16 +176,16 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
await publishRequest({ await publishRequest({
listing: listingRef, listing: listingRef,
attestor: item.pubkey, attestor: item.pubkey,
note: `Please confirm our trade for “${item.title}”.`, note: t('marketUi.tradeActions.requestNote', { title: item.title }),
}); });
toast({ toast({
title: 'Confirmation requested', title: t('marketUi.tradeActions.requestSent'),
description: 'The seller can now confirm the sale and build both reputations.', description: t('marketUi.tradeActions.requestSentBody'),
}); });
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not send the request', title: t('marketUi.tradeActions.requestFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -193,11 +195,11 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
if (!myEscrowToken) return; if (!myEscrowToken) return;
try { try {
await escrow.redeemEscrow(myEscrowToken); 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) { } catch (e) {
toast({ toast({
title: 'Could not refund escrow', title: t('marketUi.tradeActions.escrowRefundFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.tradeActions.somethingWrong'),
variant: 'destructive', 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" 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" />} {confirmingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle className="h-4 w-4" />}
Confirm this trade {t('marketUi.tradeActions.confirmThisTrade')}
</Button> </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" className="w-full gap-2 border-[#ff6600]/40 text-[#ff6600] hover:bg-[#ff6600]/10"
> >
<Lock className="h-4 w-4" /> <Lock className="h-4 w-4" />
Escrow &amp; request {t('marketUi.tradeActions.escrowAndRequest')}
</Button> </Button>
)} )}
{/* Shipped deals are the case escrow exists for — nudge it. */} {/* Shipped deals are the case escrow exists for — nudge it. */}
{canEscrow && item.fulfilment === 'ships' && ( {canEscrow && item.fulfilment === 'ships' && (
<p className="text-[11px] text-center text-amber-400/90"> <p className="text-[11px] text-center text-amber-400/90">
📦 Shipped deal — use escrow so neither side ships into the void. Share your 📦 {t('marketUi.tradeActions.shipsEscrowHint')}
address privately in the trade chat, never here.
</p> </p>
)} )}
{item.fulfilment === 'ships' && !canEscrow && ( {item.fulfilment === 'ships' && !canEscrow && (
<p className="text-[11px] text-center text-amber-400/90"> <p className="text-[11px] text-center text-amber-400/90">
📦 Shipped deal — prefer escrow if the seller offers it, and share your address 📦 {t('marketUi.tradeActions.shipsHint')}
only in the chat.
</p> </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" 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" />} {requestingTrade ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Ask seller to confirm {t('marketUi.tradeActions.askSeller')}
</Button> </Button>
)} )}
{iRequestedConfirmation && !iConfirmedTrade && ( {iRequestedConfirmation && !iConfirmedTrade && (
<p className="text-xs text-center text-gray-500"> <p className="text-xs text-center text-gray-500">
Confirmation requested — waiting on the seller. {t('marketUi.tradeActions.waitingSeller')}
</p> </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" className="w-full gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
> >
<RotateCcw className="h-4 w-4" /> <RotateCcw className="h-4 w-4" />
Refund escrow {t('marketUi.tradeActions.refundEscrow')}
</Button> </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" className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
> >
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
Buy with sats{buyAmountSats ? ` · ${buyAmountSats.toLocaleString()}` : ''} {t('marketUi.tradeActions.buyWithSats')}{buyAmountSats ? ` · ${buyAmountSats.toLocaleString()}` : ''}
</Button> </Button>
)} )}
@ -304,7 +304,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)} onClick={() => navigate(`/messages/${nip19.npubEncode(item.pubkey)}`)}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
{isWTB ? 'I Have This!' : 'Contact Seller'} {isWTB ? t('marketUi.card.iHaveThis') : t('marketplace.contact')}
</Button> </Button>
{/* Guided handoff — pre-filled DM templates, buyer only. */} {/* Guided handoff — pre-filled DM templates, buyer only. */}
@ -316,7 +316,7 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
{canDeposit && ( {canDeposit && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3"> <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"> <p className="text-xs font-bold uppercase tracking-wide text-gray-400">
Good-faith deposit (optional) {t('marketUi.tradeActions.depositTitle')}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@ -331,13 +331,12 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
onClick={() => setShowDeposit(true)} onClick={() => setShowDeposit(true)}
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
> >
Pay deposit {t('marketUi.tradeActions.payDeposit')}
</Button> </Button>
</div> </div>
<p className="text-[10px] leading-relaxed text-gray-600"> <p className="text-[10px] leading-relaxed text-gray-600">
A swap has no invoice, so this is how it gets a{' '} {t('marketUi.tradeActions.depositBefore')}{' '}
<strong className="text-gray-400">verifiable cost</strong>. Refund on delivery (or use <strong className="text-gray-400">{t('marketUi.tradeActions.verifiableCost')}</strong>{t('marketUi.tradeActions.depositAfter')}
Cashu escrow for an automatic refund). ⚠️ Proves commitment, not delivery.
</p> </p>
</div> </div>
)} )}
@ -351,8 +350,8 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
onPaid={(receipt) => { onPaid={(receipt) => {
if (receipt) saveReceipt(coord, receipt, 'deposit'); if (receipt) saveReceipt(coord, receipt, 'deposit');
toast({ toast({
title: 'Deposit paid', title: t('marketUi.tradeActions.depositPaid'),
description: 'Saved — it will be attached when the trade is confirmed.', description: t('marketUi.tradeActions.depositPaidBody'),
}); });
}} }}
/> />
@ -375,10 +374,10 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt); saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt);
} }
toast({ toast({
title: 'Paid ⚡', title: t('marketUi.tradeActions.paid'),
description: receipt?.preimage description: receipt?.preimage
? 'Saved a verifiable receipt — confirm the trade to make it count.' ? t('marketUi.tradeActions.paidWithProof')
: 'Ask the seller to confirm the trade so it counts for both of you.', : t('marketUi.tradeActions.paidBody'),
}); });
}} }}
/> />

View file

@ -11,6 +11,8 @@
* The instant real spots arrive the parent unmounts this — no layout shift. * The instant real spots arrive the parent unmounts this — no layout shift.
*/ */
import { useTranslation } from '@/contexts/language';
interface Ghost { interface Ghost {
/** left/top as viewport percentages so it scales on any screen. */ /** left/top as viewport percentages so it scales on any screen. */
left: number; left: number;
@ -31,6 +33,7 @@ const GHOSTS: Ghost[] = [
]; ];
export function MapSkeletonPins() { export function MapSkeletonPins() {
const { t } = useTranslation();
return ( return (
<div <div
className="pointer-events-none absolute inset-0 z-[900] overflow-hidden" 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 className="relative inline-flex h-2 w-2 rounded-full bg-[#ff0040]" />
</span> </span>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70"> <span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
Finding spots {t('mapUi.findingSpots')}
</span> </span>
</div> </div>
</div> </div>

View file

@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Check, Loader2 } from 'lucide-react'; import { Check, Loader2 } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { useTranslation } from '@/contexts/language';
function resolvePubkey(input: string): string | null { function resolvePubkey(input: string): string | null {
const t = input.trim(); const t = input.trim();
@ -48,6 +49,7 @@ export function MarkSoldDialog({
event: NostrEvent; event: NostrEvent;
listing: { pubkey: string; identifier: string }; listing: { pubkey: string; identifier: string };
}) { }) {
const { t } = useTranslation();
const { publishTrade, isPending: publishing } = useTradeAttestation(); const { publishTrade, isPending: publishing } = useTradeAttestation();
const { markSold, isPending: marking } = useMarkListingSold(); const { markSold, isPending: marking } = useMarkListingSold();
const { toast } = useToast(); const { toast } = useToast();
@ -61,7 +63,7 @@ export function MarkSoldDialog({
const submit = async () => { const submit = async () => {
if (!buyerPubkey) { if (!buyerPubkey) {
toast({ title: 'Enter the buyer’s npub', variant: 'destructive' }); toast({ title: t('marketUi.markSold.enterBuyer'), variant: 'destructive' });
return; return;
} }
try { try {
@ -74,16 +76,16 @@ export function MarkSoldDialog({
amountSats: amount.trim() ? Number(amount) : undefined, amountSats: amount.trim() ? Number(amount) : undefined,
}); });
toast({ toast({
title: 'Deal closed 🎉', title: t('marketUi.markSold.dealClosed'),
description: 'Listing marked sold and the trade was published for reputation.', description: t('marketUi.markSold.dealClosedBody'),
}); });
onOpenChange(false); onOpenChange(false);
setBuyer(''); setBuyer('');
setAmount(''); setAmount('');
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not close the deal', title: t('marketUi.markSold.closeFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.markSold.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -93,16 +95,16 @@ export function MarkSoldDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <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"> <DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-[#ccff00]/40 bg-black sm:max-w-md">
<DialogHeader> <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"> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="buyer" className="flex items-center gap-2 text-xs uppercase tracking-wide text-gray-400"> <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]" />} {buyerPubkey && <Check className="h-3.5 w-3.5 text-[#ccff00]" />}
</Label> </Label>
<Input <Input
@ -115,7 +117,7 @@ export function MarkSoldDialog({
</div> </div>
<div className="space-y-2"> <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"> <div className="flex flex-wrap gap-2">
{(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => ( {(Object.keys(TRADE_METHODS) as TradeMethod[]).map((m) => (
<button <button
@ -137,21 +139,21 @@ export function MarkSoldDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="amount" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="amount" className="text-xs uppercase tracking-wide text-gray-400">
Amount in sats (optional) {t('marketUi.markSold.amountLabel')}
</Label> </Label>
<Input <Input
id="amount" id="amount"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
inputMode="numeric" inputMode="numeric"
placeholder="e.g. 45000" placeholder={t('marketUi.offer.amountPlaceholder')}
className="h-11 border-gray-700 bg-gray-900 text-white" className="h-11 border-gray-700 bg-gray-900 text-white"
/> />
</div> </div>
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15"> <Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={submit} onClick={submit}
@ -159,7 +161,7 @@ export function MarkSoldDialog({
className="flex-1 gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" 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" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Mark sold {t('marketUi.markSold.submit')}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -30,6 +30,7 @@ import { Clock, Search, Tag, Flag, MoreVertical, ShieldCheck, Calendar, Package,
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types'; import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, LISTING_REPORT_REASONS, formatPrice } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
interface MarketplaceItemCardProps { interface MarketplaceItemCardProps {
item: MarketplaceItem; item: MarketplaceItem;
@ -37,6 +38,7 @@ interface MarketplaceItemCardProps {
} }
export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) { export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCardProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
// Distance from the viewer's last known location (no GPS call — cache only). // Distance from the viewer's last known location (no GPS call — cache only).
const viewerLoc = getLastLocation(); const viewerLoc = getLastLocation();
@ -119,7 +121,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
const handleReport = async () => { const handleReport = async () => {
if (!user || !reportReason) { if (!user || !reportReason) {
toast({ title: 'Please select a reason', variant: 'destructive' }); toast({ title: t('marketUi.card.selectReason'), variant: 'destructive' });
return; return;
} }
@ -138,8 +140,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
}); });
toast({ toast({
title: 'Report submitted', title: t('marketUi.card.reportSubmitted'),
description: 'Thank you for helping keep the marketplace safe.', description: t('marketUi.card.reportSubmittedBody'),
}); });
setShowReportDialog(false); setShowReportDialog(false);
setReportReason(''); setReportReason('');
@ -147,8 +149,8 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Failed to submit report', title: t('marketUi.card.reportFailed'),
description: error instanceof Error ? error.message : 'Please try again', description: error instanceof Error ? error.message : t('common.tryAgain'),
}); });
} finally { } finally {
setIsReporting(false); setIsReporting(false);
@ -166,11 +168,11 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
const handleRelist = async () => { const handleRelist = async () => {
try { try {
await relist(item.event); 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) { } catch (error) {
toast({ toast({
title: 'Could not update', title: t('marketUi.card.couldNotUpdate'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('marketUi.card.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -189,7 +191,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
<div className="absolute top-2 right-2 z-10"> <div className="absolute top-2 right-2 z-10">
<Badge variant="destructive" className="text-xs gap-1"> <Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" /> <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> </Badge>
</div> </div>
)} )}
@ -197,7 +199,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* Product Image */} {/* 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 ? ( {primaryImage ? (
<div <div
className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${ className={`w-20 h-20 rounded-lg overflow-hidden border-2 ${
@ -225,7 +227,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{isWTB && ( {isWTB && (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/50 mb-1 text-xs"> <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" /> <Search className="h-3 w-3 mr-1" />
WANT TO BUY {t('marketUi.listingDetail.wantToBuy')}
</Badge> </Badge>
)} )}
<Link to={detailUrl}> <Link to={detailUrl}>
@ -252,7 +254,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
)} )}
{priceInSats > 0 && ( {priceInSats > 0 && (
<div className="text-xs text-[#ccff00] whitespace-nowrap"> <div className="text-xs text-[#ccff00] whitespace-nowrap">
≈ {priceInSats.toLocaleString()} sats ≈ {priceInSats.toLocaleString()} {t('common.sats')}
</div> </div>
)} )}
</div> </div>
@ -271,7 +273,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
className="text-red-400 focus:text-red-400" className="text-red-400 focus:text-red-400"
> >
<Flag className="h-4 w-4 mr-2" /> <Flag className="h-4 w-4 mr-2" />
Report Listing {t('marketUi.card.reportListing')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -286,7 +288,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
size="icon" size="icon"
className="h-6 w-6 text-gray-400" className="h-6 w-6 text-gray-400"
disabled={isUpdatingStatus} disabled={isUpdatingStatus}
aria-label="Manage listing" aria-label={t('marketUi.card.manageListing')}
> >
{isUpdatingStatus ? ( {isUpdatingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -299,12 +301,12 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{isSold ? ( {isSold ? (
<DropdownMenuItem onClick={handleRelist} disabled={isUpdatingStatus}> <DropdownMenuItem onClick={handleRelist} disabled={isUpdatingStatus}>
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
Relist as active {t('marketUi.listingDetail.relistActive')}
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem onClick={() => setShowMarkSold(true)} disabled={isUpdatingStatus}> <DropdownMenuItem onClick={() => setShowMarkSold(true)} disabled={isUpdatingStatus}>
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
Mark as sold {t('marketUi.listingDetail.markSold')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@ -313,7 +315,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
className="text-red-400 focus:text-red-400" className="text-red-400 focus:text-red-400"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete listing {t('marketUi.listingDetail.deleteListing')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -342,10 +344,10 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{item.fulfilment && ( {item.fulfilment && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50"> <Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50">
{item.fulfilment === 'pickup' {item.fulfilment === 'pickup'
? '📍 Pickup' ? `📍 ${t('marketUi.create.pickup')}`
: item.fulfilment === 'ships' : item.fulfilment === 'ships'
? '📦 Ships' ? `📦 ${t('marketUi.create.ships')}`
: '📍📦 Pickup or ship'} : `📍📦 ${t('marketUi.gearSwap.pickupOrShip')}`}
</Badge> </Badge>
)} )}
{distanceKm != null && ( {distanceKm != null && (
@ -357,14 +359,14 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
)} )}
{item.shipping && ( {item.shipping && (
<Badge variant="outline" className="text-xs border-gray-600 bg-gray-800/50"> <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> </Badge>
)} )}
{typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && ( {typeof item.powDifficulty === 'number' && item.powDifficulty >= 16 && (
<Badge <Badge
variant="outline" variant="outline"
className="text-xs border-gray-600 bg-gray-800/50" 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} ⚡ PoW {item.powDifficulty}
</Badge> </Badge>
@ -372,7 +374,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{sellerRep && sellerRep.verified > 0 && ( {sellerRep && sellerRep.verified > 0 && (
<Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]"> <Badge variant="outline" className="text-xs border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]">
<CheckCircle className="h-3 w-3 mr-1" /> <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> </Badge>
)} )}
</div> </div>
@ -403,13 +405,13 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{isVerifiedSeller && ( {isVerifiedSeller && (
<Badge variant="outline" className="text-xs border-green-500/50 text-green-400 bg-green-500/10 gap-1"> <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" /> <CheckCircle className="h-3 w-3" />
Verified {t('common.verified')}
</Badge> </Badge>
)} )}
{isHighTrust && !isVerifiedSeller && ( {isHighTrust && !isVerifiedSeller && (
<Badge variant="outline" className="text-xs border-blue-500/50 text-blue-400 bg-blue-500/10 gap-1"> <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" /> <ShieldCheck className="h-3 w-3" />
Trusted {t('marketUi.card.trusted')}
</Badge> </Badge>
)} )}
<TrustBadge pubkey={item.pubkey} size="sm" showLabel={false} /> <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" /> <Calendar className="h-3 w-3" />
<span> <span>
{accountAgeDays < 30 {accountAgeDays < 30
? `${accountAgeDays}d old` ? t('marketUi.card.daysOld', { n: accountAgeDays })
: accountAgeDays < 365 : accountAgeDays < 365
? `${Math.floor(accountAgeDays / 30)}mo old` ? t('marketUi.card.monthsOld', { n: Math.floor(accountAgeDays / 30) })
: `${Math.floor(accountAgeDays / 365)}y old`} : t('marketUi.card.yearsOld', { n: Math.floor(accountAgeDays / 365) })}
</span> </span>
</div> </div>
)} )}
{previousListings > 1 && ( {previousListings > 1 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Package className="h-3 w-3" /> <Package className="h-3 w-3" />
<span>{previousListings} listings</span> <span>{t('marketUi.card.listingsCount', { count: previousListings })}</span>
</div> </div>
)} )}
</div> </div>
@ -444,7 +446,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
{item.availability && ( {item.availability && (
<div className="flex items-center gap-1 text-xs text-gray-400 mb-3"> <div className="flex items-center gap-1 text-xs text-gray-400 mb-3">
<Clock className="h-3 w-3 text-orange-500" /> <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> </div>
)} )}
@ -482,7 +484,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
event={item.event} event={item.event}
entityLabel="listing" entityLabel="listing"
entityName={item.title} entityName={item.title}
triggerText="Delete listing" triggerText={t('marketUi.listingDetail.deleteListing')}
open={showDeleteDialog} open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} onOpenChange={setShowDeleteDialog}
/> />
@ -493,16 +495,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-400" /> <Flag className="h-5 w-5 text-red-400" />
Report Listing {t('marketUi.card.reportListing')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Help keep the marketplace safe by reporting inappropriate or misleading listings. {t('marketUi.card.reportDialogBody')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Reason for report *</Label> <Label>{t('marketUi.card.reasonLabel')}</Label>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
{Object.entries(LISTING_REPORT_REASONS).map(([key, { label, description }]) => ( {Object.entries(LISTING_REPORT_REASONS).map(([key, { label, description }]) => (
<Button <Button
@ -526,10 +528,10 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="report-details">Additional details (optional)</Label> <Label htmlFor="report-details">{t('marketUi.card.detailsLabel')}</Label>
<Textarea <Textarea
id="report-details" id="report-details"
placeholder="Provide any additional context that might help..." placeholder={t('marketUi.card.detailsPlaceholder')}
value={reportDetails} value={reportDetails}
onChange={(e) => setReportDetails(e.target.value)} onChange={(e) => setReportDetails(e.target.value)}
rows={3} rows={3}
@ -540,8 +542,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
<Card className="bg-gray-800/50 border-gray-700"> <Card className="bg-gray-800/50 border-gray-700">
<CardContent className="p-3"> <CardContent className="p-3">
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Reports are published to Nostr and help the community identify problematic listings. {t('marketUi.card.reportNote')}
Listings with multiple reports may be automatically hidden.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -549,14 +550,14 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowReportDialog(false)}> <Button variant="outline" onClick={() => setShowReportDialog(false)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleReport} onClick={handleReport}
disabled={isReporting || !reportReason} disabled={isReporting || !reportReason}
className="bg-red-500 hover:bg-red-600" className="bg-red-500 hover:bg-red-600"
> >
{isReporting ? 'Submitting...' : 'Submit Report'} {isReporting ? t('marketUi.card.submitting') : t('marketUi.card.submitReport')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View file

@ -2,6 +2,7 @@ import { Zap, ShieldCheck, AlertCircle } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { isValidNoffer } from '@/lib/types'; import { isValidNoffer } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
interface NofferFieldProps { interface NofferFieldProps {
value: string; value: string;
@ -14,6 +15,7 @@ interface NofferFieldProps {
* Purely additive: leaving it blank changes nothing. * Purely additive: leaving it blank changes nothing.
*/ */
export function NofferField({ value, onChange }: NofferFieldProps) { export function NofferField({ value, onChange }: NofferFieldProps) {
const { t } = useTranslation();
const trimmed = value.trim(); const trimmed = value.trim();
const showError = trimmed.length > 0 && !isValidNoffer(trimmed); const showError = trimmed.length > 0 && !isValidNoffer(trimmed);
const showValid = 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"> <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"> <Label htmlFor="noffer" className="flex items-center gap-2 text-indigo-400">
<Zap className="h-4 w-4" /> <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> </Label>
<Input <Input
id="noffer" id="noffer"
@ -36,24 +38,23 @@ export function NofferField({ value, onChange }: NofferFieldProps) {
{showError ? ( {showError ? (
<p className="flex items-center gap-1.5 text-xs text-red-400"> <p className="flex items-center gap-1.5 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5 shrink-0" /> <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> </p>
) : showValid ? ( ) : showValid ? (
<p className="flex items-center gap-1.5 text-xs text-green-400"> <p className="flex items-center gap-1.5 text-xs text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" /> <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>
) : ( ) : (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Paste a self-custodial CLINK payment code (e.g. from ShockWallet / Lightning.Pub) to accept {t('marketUi.noffer.help')}{' '}
Nostr-native Lightning payments — no web server or custodian.{' '}
<a <a
href="https://clinkme.dev/specs.html" href="https://clinkme.dev/specs.html"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300" className="text-indigo-400 underline hover:text-indigo-300"
> >
Learn specs {t('marketUi.noffer.learnSpecs')}
</a> </a>
</p> </p>
)} )}

View file

@ -14,6 +14,7 @@ import { useOfferActions } from '@/hooks/useMarketplaceOffers';
import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { Handshake, Loader2 } from 'lucide-react'; import { Handshake, Loader2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
/** Buyer: make a structured offer (amount + note) on a listing. */ /** Buyer: make a structured offer (amount + note) on a listing. */
export function OfferDialog({ export function OfferDialog({
@ -29,6 +30,7 @@ export function OfferDialog({
title: string; title: string;
sellerName: string; sellerName: string;
}) { }) {
const { t } = useTranslation();
const { createOffer } = useOfferActions(); const { createOffer } = useOfferActions();
const { mutateAsync: sendDM } = useSendDirectMessage(); const { mutateAsync: sendDM } = useSendDirectMessage();
const { toast } = useToast(); const { toast } = useToast();
@ -40,11 +42,11 @@ export function OfferDialog({
const submit = async () => { const submit = async () => {
const parsed = amount.trim() ? Number(amount) : undefined; const parsed = amount.trim() ? Number(amount) : undefined;
if (parsed !== undefined && (!Number.isFinite(parsed) || parsed <= 0)) { 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; return;
} }
if (!parsed && !offerItem.trim() && !note.trim()) { 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; return;
} }
setBusy(true); setBusy(true);
@ -53,20 +55,20 @@ export function OfferDialog({
// Best-effort nudge to the seller via NIP-17 DM — never fail the offer. // Best-effort nudge to the seller via NIP-17 DM — never fail the offer.
try { try {
const amountLine = parsed ? ` for ${parsed.toLocaleString()} sats` : ''; const amountLine = parsed ? t('marketUi.offer.dmAmount', { amount: parsed.toLocaleString() }) : '';
const itemLine = offerItem.trim() ? ` (offering: ${offerItem.trim()})` : ''; const itemLine = offerItem.trim() ? t('marketUi.offer.dmItem', { item: offerItem.trim() }) : '';
const noteLine = note.trim() ? `\n\n“${note.trim()}”` : ''; const noteLine = note.trim() ? `\n\n“${note.trim()}”` : '';
await sendDM({ await sendDM({
recipients: listing.pubkey, 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 { } catch {
/* DM optional (no NIP-44 signer, etc.) */ /* DM optional (no NIP-44 signer, etc.) */
} }
toast({ toast({
title: 'Offer sent', title: t('marketUi.offer.sent'),
description: `${sellerName} can accept or decline it.`, description: t('marketUi.offer.sentBody', { sellerName }),
}); });
onOpenChange(false); onOpenChange(false);
setAmount(''); setAmount('');
@ -74,8 +76,8 @@ export function OfferDialog({
setNote(''); setNote('');
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not send the offer', title: t('marketUi.offer.sendFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.offer.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } 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"> <DialogContent className="mx-4 max-w-[calc(100vw-2rem)] border-2 border-orange-500/40 bg-black sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white"> <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> </DialogTitle>
<DialogDescription className="text-sm text-gray-400"> <DialogDescription className="text-sm text-gray-400">
Offer sats, or swap something — {sellerName} can accept, decline or counter. {t('marketUi.offer.description', { sellerName })}
You arrange the handover between yourselves.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="offer-amount" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="offer-amount" className="text-xs uppercase tracking-wide text-gray-400">
Amount (sats, optional) {t('marketUi.offer.amountLabel')}
</Label> </Label>
<Input <Input
id="offer-amount" id="offer-amount"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
inputMode="numeric" inputMode="numeric"
placeholder="e.g. 45000" placeholder={t('marketUi.offer.amountPlaceholder')}
className="h-11 border-gray-700 bg-gray-900 text-white" className="h-11 border-gray-700 bg-gray-900 text-white"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="offer-item" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="offer-item" className="text-xs uppercase tracking-wide text-gray-400">
Or trading something (optional) {t('marketUi.offer.itemLabel')}
</Label> </Label>
<Input <Input
id="offer-item" id="offer-item"
value={offerItem} value={offerItem}
onChange={(e) => setOfferItem(e.target.value)} 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" className="h-11 border-gray-700 bg-gray-900 text-white"
/> />
<p className="text-[11px] text-gray-500"> <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> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400"> <Label htmlFor="offer-note" className="text-xs uppercase tracking-wide text-gray-400">
Message (optional) {t('marketUi.offer.messageLabel')}
</Label> </Label>
<Textarea <Textarea
id="offer-note" id="offer-note"
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} rows={3}
placeholder="Anything the seller should know…" placeholder={t('marketUi.offer.messagePlaceholder')}
className="border-gray-700 bg-gray-900 text-white" className="border-gray-700 bg-gray-900 text-white"
/> />
</div> </div>
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15"> <Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 border-white/15">
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={submit} 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" 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" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
Send offer {t('marketUi.offer.send')}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -19,6 +19,7 @@ import { toast } from '@/hooks/useToast';
import type { PaymentKind } from '@/hooks/useMarketplaceOffers'; import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
import type { PaymentReceipt } from '@/lib/receipts'; import type { PaymentReceipt } from '@/lib/receipts';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
interface PayWithSatsProps { interface PayWithSatsProps {
open: boolean; open: boolean;
@ -43,6 +44,7 @@ export function PayWithSats({
pointerKind, pointerKind,
onPaid, onPaid,
}: PayWithSatsProps) { }: PayWithSatsProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const author = useAuthor(sellerPubkey); const author = useAuthor(sellerPubkey);
const { pay, isPaying } = usePayPaymentPointer(); const { pay, isPaying } = usePayPaymentPointer();
@ -75,16 +77,16 @@ export function PayWithSats({
const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats }); const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats });
setPaid(true); setPaid(true);
toast({ toast({
title: 'Payment sent ⚡', title: t('marketUi.pay.sent'),
description: receipt.preimage description: receipt.preimage
? 'Saved a verifiable payment receipt — it will count as payment-verified.' ? t('marketUi.pay.sentWithProof')
: 'Let the seller know — they can confirm the trade so it counts for both of you.', : t('marketUi.pay.sentBody'),
}); });
onPaid?.(receipt); onPaid?.(receipt);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Payment failed.'; const message = err instanceof Error ? err.message : t('marketUi.pay.failed');
setError(message); 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"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <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> </DialogTitle>
<DialogDescription> <DialogDescription>
{amountSats {amountSats
? <>Send <strong className="text-white">{amountSats.toLocaleString()} sats</strong> to {sellerName} to settle this deal.</> ? <>{t('marketUi.pay.descSendPrefix')} <strong className="text-white">{amountSats.toLocaleString()} {t('common.sats')}</strong> {t('marketUi.pay.descSendSuffix', { sellerName })}</>
: <>Settle this deal directly with {sellerName}.</>} : <>{t('marketUi.pay.descSettle', { sellerName })}</>}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{paid ? ( {paid ? (
<div className="space-y-2 py-4 text-center"> <div className="space-y-2 py-4 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-green-500" /> <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"> <p className="text-xs text-muted-foreground">
Ask the seller to confirm the trade — two confirmations build both reputations. {t('marketUi.pay.paidBody')}
</p> </p>
</div> </div>
) : !effectivePointer ? ( ) : !effectivePointer ? (
<div className="space-y-2 py-4 text-center"> <div className="space-y-2 py-4 text-center">
<AlertTriangle className="mx-auto h-8 w-8 text-amber-400" /> <AlertTriangle className="mx-auto h-8 w-8 text-amber-400" />
<p className="text-sm text-muted-foreground"> <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> </p>
</div> </div>
) : isSelf ? ( ) : 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"> <div className="space-y-3">
{error && ( {error && (
@ -132,10 +134,10 @@ export function PayWithSats({
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" 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 ? <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> </Button>
<p className="text-center text-[10px] leading-relaxed text-muted-foreground"> <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> </p>
</div> </div>
)} )}

View file

@ -3,6 +3,7 @@ import { Zap, CheckCircle2, AlertCircle } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { isValidLightningAddress, isValidSilentPayment } from '@/lib/types'; import { isValidLightningAddress, isValidSilentPayment } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
/** /**
* Payment-readiness note for provider / Lab / crew forms. * 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". * if it isn't, so they always have a clear path to "get paid".
*/ */
export function PaymentReadinessNote() { export function PaymentReadinessNote() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || ''); const author = useAuthor(user?.pubkey || '');
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -39,7 +41,7 @@ export function PaymentReadinessNote() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className={ready ? 'h-4 w-4 text-green-400' : 'h-4 w-4 text-yellow-400'} /> <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'}> <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> </span>
</div> </div>
@ -47,20 +49,19 @@ export function PaymentReadinessNote() {
<p className="flex items-start gap-1.5 text-xs text-gray-400"> <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" /> <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-green-400" />
<span> <span>
Your Lightning address{lud16 ? <> (<span className="font-mono">{lud16}</span>)</> : ''} is on your {t('marketUi.paymentReadiness.readyBefore')}{lud16 ? <> (<span className="font-mono">{lud16}</span>)</> : ''}{t('marketUi.paymentReadiness.readyAfter')}
profile — people can zap and pay you directly. {hasSilentPayment && t('marketUi.paymentReadiness.silentSet')}
{hasSilentPayment && ' A Bitcoin Silent Payment address is set too.'}
</span> </span>
</p> </p>
) : ( ) : (
<p className="flex items-start gap-1.5 text-xs text-gray-400"> <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" /> <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-400" />
<span> <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"> <Link to="/settings" className="text-yellow-400 underline hover:text-yellow-300">
Nostr profile {t('marketUi.paymentReadiness.nostrProfile')}
</Link>{' '} </Link>{' '}
so skaters can pay you. It's set once and works across every profile you have here. {t('marketUi.paymentReadiness.notReadyAfter')}
</span> </span>
</p> </p>
)} )}

View file

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loader2, Upload } from 'lucide-react'; import { Loader2, Upload } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface PhotoDropzoneProps { interface PhotoDropzoneProps {
/** Called with the image files the user picked or dropped. */ /** Called with the image files the user picked or dropped. */
@ -35,13 +36,16 @@ export function PhotoDropzone({
uploading = false, uploading = false,
disabled = false, disabled = false,
multiple = true, multiple = true,
label = 'Add Photos', label,
hint = 'Drag & drop or click', hint,
className, className,
inputLabel, inputLabel,
}: PhotoDropzoneProps) { }: PhotoDropzoneProps) {
const { t } = useTranslation();
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const busy = uploading || disabled; const busy = uploading || disabled;
const resolvedLabel = label ?? t('spotUi.addPhotos');
const resolvedHint = hint ?? t('spotUi.dragDropOrClick');
const emit = (fileList: FileList | null) => { const emit = (fileList: FileList | null) => {
if (!fileList) return; if (!fileList) return;
@ -78,7 +82,7 @@ export function PhotoDropzone({
multiple={multiple} multiple={multiple}
className="hidden" className="hidden"
disabled={busy} disabled={busy}
aria-label={inputLabel ?? label} aria-label={inputLabel ?? resolvedLabel}
onChange={(e) => { onChange={(e) => {
emit(e.target.files); emit(e.target.files);
e.target.value = ''; 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')} /> <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')}> <span className={cn('text-xs font-bold', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')}>
{dragging ? 'Drop photos' : label} {dragging ? t('spotUi.dropPhotos') : resolvedLabel}
</span> </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> </label>

View file

@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ProfileBadges } from '@/components/ProfileBadges'; import { ProfileBadges } from '@/components/ProfileBadges';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isSkateSpotsNip05, profileBadges } from '@/lib/badges'; import { isSkateSpotsNip05, profileBadges } from '@/lib/badges';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
@ -18,6 +19,7 @@ import { cn } from '@/lib/utils';
*/ */
export function ProfilePreviewCard({ className }: { className?: string }) { export function ProfilePreviewCard({ className }: { className?: string }) {
const { user, metadata, isLoadingProfile } = useCurrentUser(); const { user, metadata, isLoadingProfile } = useCurrentUser();
const { t } = useTranslation();
const [bannerOk, setBannerOk] = useState(true); const [bannerOk, setBannerOk] = useState(true);
if (!user) return null; if (!user) return null;
@ -45,7 +47,7 @@ export function ProfilePreviewCard({ className }: { className?: string }) {
to={`/${nip19.npubEncode(user.pubkey)}`} 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" 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" /> <ExternalLink className="h-3 w-3" />
</Link> </Link>
</div> </div>
@ -59,7 +61,7 @@ export function ProfilePreviewCard({ className }: { className?: string }) {
</Avatar> </Avatar>
<div className="min-w-0 flex-1 pb-1"> <div className="min-w-0 flex-1 pb-1">
<p className="truncate text-lg font-black leading-tight text-white"> <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> </p>
{nip05 ? ( {nip05 ? (
<span <span
@ -72,7 +74,7 @@ export function ProfilePreviewCard({ className }: { className?: string }) {
<span className="truncate">{nip05}</span> <span className="truncate">{nip05}</span>
</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>
</div> </div>

View file

@ -14,58 +14,60 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Loader2, Flag, AlertTriangle, CheckCircle2 } from 'lucide-react'; import { Loader2, Flag, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; 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 = [ const REPORT_REASONS = [
{ {
id: 'other', id: 'other',
label: 'Not a skate spot', labelKey: 'spotUi.report.notASkateSpot.label',
description: 'A test entry, or a place that is not for skating at all', descriptionKey: 'spotUi.report.notASkateSpot.description',
icon: '🛹', icon: '🛹',
subtype: 'not-a-skate-spot', subtype: 'not-a-skate-spot',
}, },
{ {
id: 'spam', id: 'spam',
label: 'Fake/Non-existent spot', labelKey: 'spotUi.report.fakeSpot.label',
description: 'This spot does not exist or location is wrong', descriptionKey: 'spotUi.report.fakeSpot.description',
icon: '📍', icon: '📍',
}, },
{ {
id: 'other', id: 'other',
label: 'Stolen/Fake photos', labelKey: 'spotUi.report.stolenPhotos.label',
description: 'Photos are stock images or stolen from elsewhere', descriptionKey: 'spotUi.report.stolenPhotos.description',
icon: '📸', icon: '📸',
}, },
{ {
id: 'spam', id: 'spam',
label: 'Spam or advertising', labelKey: 'spotUi.report.spamAd.label',
description: 'Promotional content or duplicate listings', descriptionKey: 'spotUi.report.spamAd.description',
icon: '🗑️', icon: '🗑️',
subtype: 'spam-ad', subtype: 'spam-ad',
}, },
{ {
id: 'illegal', id: 'illegal',
label: 'Dangerous location', labelKey: 'spotUi.report.dangerousLocation.label',
description: 'Private property, unsafe, or illegal to access', descriptionKey: 'spotUi.report.dangerousLocation.description',
icon: '⚠️', icon: '⚠️',
}, },
{ {
id: 'nudity', id: 'nudity',
label: 'Inappropriate content', labelKey: 'spotUi.report.inappropriateContent.label',
description: 'Contains offensive or adult content', descriptionKey: 'spotUi.report.inappropriateContent.description',
icon: '🔞', icon: '🔞',
}, },
{ {
id: 'impersonation', id: 'impersonation',
label: 'Impersonation', labelKey: 'spotUi.report.impersonation.label',
description: 'Someone pretending to be someone else', descriptionKey: 'spotUi.report.impersonation.description',
icon: '👤', icon: '👤',
}, },
{ {
id: 'other', id: 'other',
label: 'Other', labelKey: 'spotUi.report.other.label',
description: 'Something else not listed above', descriptionKey: 'spotUi.report.other.description',
icon: '📝', icon: '📝',
subtype: 'other-general', subtype: 'other-general',
}, },
@ -92,6 +94,7 @@ export function ReportDialog({
targetTitle, targetTitle,
onReported, onReported,
}: ReportDialogProps) { }: ReportDialogProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast(); const { toast } = useToast();
@ -143,13 +146,13 @@ export function ReportDialog({
onReported?.(); onReported?.();
toast({ toast({
title: 'Report submitted', title: t('spotUi.reportSubmitted'),
description: 'Thank you for helping keep SkateSpots safe.', description: t('spotUi.reportSubmittedDesc'),
}); });
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to submit report', title: t('spotUi.failedToSubmitReport'),
description: error instanceof Error ? error.message : 'Please try again', description: error instanceof Error ? error.message : t('spotUi.pleaseTryAgain'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -176,13 +179,13 @@ export function ReportDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-500" /> <Flag className="h-5 w-5 text-red-500" />
Report Content {t('spotUi.reportContent')}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="text-center py-6"> <div className="text-center py-6">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" /> <AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
You need to be logged in to report content. {t('spotUi.logInToReport')}
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
@ -196,11 +199,11 @@ export function ReportDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-red-500" /> <Flag className="h-5 w-5 text-red-500" />
Report Content {t('spotUi.reportContent')}
</DialogTitle> </DialogTitle>
{step === 'form' && ( {step === 'form' && (
<DialogDescription> <DialogDescription>
Help keep the community safe by reporting inappropriate content. {t('spotUi.reportContentDesc')}
</DialogDescription> </DialogDescription>
)} )}
</DialogHeader> </DialogHeader>
@ -211,7 +214,7 @@ export function ReportDialog({
{targetTitle && ( {targetTitle && (
<div className="p-3 rounded-lg bg-muted/50 border"> <div className="p-3 rounded-lg bg-muted/50 border">
<p className="text-xs text-muted-foreground uppercase mb-1"> <p className="text-xs text-muted-foreground uppercase mb-1">
Reporting {targetType} {t('spotUi.reportingType', { type: t(`spotUi.targetType.${targetType}`) })}
</p> </p>
<p className="font-medium text-sm truncate">{targetTitle}</p> <p className="font-medium text-sm truncate">{targetTitle}</p>
</div> </div>
@ -219,7 +222,7 @@ export function ReportDialog({
{/* Reason selection */} {/* Reason selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Why are you reporting this?</Label> <Label>{t('spotUi.whyReporting')}</Label>
<RadioGroup <RadioGroup
value={selectedReason} value={selectedReason}
onValueChange={setSelectedReason} onValueChange={setSelectedReason}
@ -245,10 +248,10 @@ export function ReportDialog({
className="flex items-center gap-2 cursor-pointer font-medium" className="flex items-center gap-2 cursor-pointer font-medium"
> >
<span>{reason.icon}</span> <span>{reason.icon}</span>
{reason.label} {t(reason.labelKey)}
</Label> </Label>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{reason.description} {t(reason.descriptionKey)}
</p> </p>
</div> </div>
</div> </div>
@ -259,10 +262,10 @@ export function ReportDialog({
{/* Additional details */} {/* Additional details */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="details">Additional details (optional)</Label> <Label htmlFor="details">{t('spotUi.additionalDetails')}</Label>
<Textarea <Textarea
id="details" id="details"
placeholder="Provide any additional context that might help..." placeholder={t('spotUi.additionalDetailsPlaceholder')}
value={additionalDetails} value={additionalDetails}
onChange={(e) => setAdditionalDetails(e.target.value)} onChange={(e) => setAdditionalDetails(e.target.value)}
rows={3} rows={3}
@ -278,7 +281,7 @@ export function ReportDialog({
className="flex-1" className="flex-1"
disabled={isPending} disabled={isPending}
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@ -288,12 +291,12 @@ export function ReportDialog({
{isPending ? ( {isPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting... {t('spotUi.submitting')}
</> </>
) : ( ) : (
<> <>
<Flag className="h-4 w-4 mr-2" /> <Flag className="h-4 w-4 mr-2" />
Submit Report {t('spotUi.submitReport')}
</> </>
)} )}
</Button> </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"> <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" /> <CheckCircle2 className="h-8 w-8 text-green-500" />
</div> </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"> <p className="text-muted-foreground text-sm mb-6">
Thanks for helping keep SkateSpots safe and accurate. Your report {t('spotUi.reportSubmittedLong')}
will be reviewed by trusted community members.
</p> </p>
<Button onClick={handleClose} className="w-full"> <Button onClick={handleClose} className="w-full">
Done {t('common.done')}
</Button> </Button>
</div> </div>
)} )}

View file

@ -18,6 +18,7 @@ import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus'; import { SPOT_STATUS_META, SPOT_STATUS_ORDER, statusTimeAgo } from '@/lib/spotStatus';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import type { SpotStatusValue, SpotStatusReport } from '@/lib/types'; import type { SpotStatusValue, SpotStatusReport } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -29,6 +30,7 @@ interface ReportSpotStatusDialogProps {
} }
function ReportRow({ report }: { report: SpotStatusReport }) { function ReportRow({ report }: { report: SpotStatusReport }) {
const { t } = useTranslation();
const author = useAuthor(report.pubkey); const author = useAuthor(report.pubkey);
const name = author.data?.metadata?.name ?? genUserName(report.pubkey); const name = author.data?.metadata?.name ?? genUserName(report.pubkey);
const meta = SPOT_STATUS_META[report.status]; 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="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="font-semibold">{meta.label}</span> <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"> <span className="text-muted-foreground ml-auto flex-shrink-0">
{statusTimeAgo(report.createdAt)} {statusTimeAgo(report.createdAt)}
</span> </span>
@ -61,6 +63,7 @@ export function ReportSpotStatusDialog({
spotTitle, spotTitle,
trigger, trigger,
}: ReportSpotStatusDialogProps) { }: ReportSpotStatusDialogProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -81,16 +84,16 @@ export function ReportSpotStatusDialog({
note: note.trim() || undefined, note: note.trim() || undefined,
}); });
toast({ toast({
title: 'Status reported', title: t('spotUi.statusReported'),
description: 'Thanks for keeping the map fresh. Your report is live.', description: t('spotUi.statusReportedDesc'),
}); });
setSelected(null); setSelected(null);
setNote(''); setNote('');
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Could not submit', title: t('spotUi.couldNotSubmit'),
description: error instanceof Error ? error.message : 'Something went wrong.', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -102,7 +105,7 @@ export function ReportSpotStatusDialog({
{trigger ?? ( {trigger ?? (
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Flag className="h-4 w-4" /> <Flag className="h-4 w-4" />
Report Status {t('spotUi.reportStatus')}
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
@ -110,20 +113,20 @@ export function ReportSpotStatusDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Flag className="h-5 w-5 text-[#ff0040]" /> <Flag className="h-5 w-5 text-[#ff0040]" />
Spot Status {t('spotUi.spotStatus')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Is "{spotTitle}" still skateable? Let the community know its current state. {t('spotUi.spotStatusDesc', { title: spotTitle })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Current consensus */} {/* Current consensus */}
<div className="flex items-center gap-2 text-sm"> <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 ? ( {aggregated.status ? (
<SpotStatusBadge aggregated={aggregated} /> <SpotStatusBadge aggregated={aggregated} />
) : ( ) : (
<span className="text-muted-foreground italic">No reports yet</span> <span className="text-muted-foreground italic">{t('spotUi.noReportsYet')}</span>
)} )}
</div> </div>
@ -162,7 +165,7 @@ export function ReportSpotStatusDialog({
{/* Optional note */} {/* Optional note */}
<Textarea <Textarea
placeholder="Optional: add details (e.g. 'new knobs on the ledge', 'guard kicks out after 6pm')" placeholder={t('spotUi.statusNotePlaceholder')}
value={note} value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))} onChange={(e) => setNote(e.target.value.slice(0, 500))}
rows={2} rows={2}
@ -177,17 +180,17 @@ export function ReportSpotStatusDialog({
{isPending ? ( {isPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Submitting... {t('spotUi.submitting')}
</> </>
) : ( ) : (
'Submit Report' t('spotUi.submitReport')
)} )}
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Log in to report this spot's status. {t('spotUi.logInToReportStatus')}
</p> </p>
<LoginArea className="w-full" /> <LoginArea className="w-full" />
</div> </div>
@ -197,7 +200,7 @@ export function ReportSpotStatusDialog({
{reports.length > 0 && ( {reports.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1"> <h4 className="text-xs font-semibold uppercase text-muted-foreground mb-1">
Recent reports {t('spotUi.recentReports')}
</h4> </h4>
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{reports.slice(0, 20).map((r) => ( {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 { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { SasquatchProDialog } from '@/components/SasquatchProDialog'; import { SasquatchProDialog } from '@/components/SasquatchProDialog';
import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions'; import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions';
import { useTranslation } from '@/contexts/language';
import { SasquatchActionCard } from '@/components/SasquatchActionCard'; import { SasquatchActionCard } from '@/components/SasquatchActionCard';
import { callSasquatchAI, streamSasquatchAI } from '@/lib/sasquatchBrain'; import { callSasquatchAI, streamSasquatchAI } from '@/lib/sasquatchBrain';
import { buildProAuthHeaders } from '@/lib/proAuth'; import { buildProAuthHeaders } from '@/lib/proAuth';
@ -644,6 +645,7 @@ function ChatText({ text }: { text: string }) {
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { language } = useTranslation();
const cvm = useCVMClient(); const cvm = useCVMClient();
const personal = usePersonalContext(); const personal = usePersonalContext();
const { isPro, expiresAt, proof } = useSasquatchPro(); 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 // smuggled into a user turn — the model then treats the relay/derived
// text below as lower-trust input, blunting prompt-injection via event // text below as lower-trust input, blunting prompt-injection via event
// content. Personal tools are only offered to logged-in Pro supporters. // 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}${PERSONAL_TOOLS_PROMPT}`
: SYSTEM_PROMPT; : SYSTEM_PROMPT) + languageDirective;
const historyWithContext: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [ const historyWithContext: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: question }, { role: 'user', content: question },
@ -1321,7 +1328,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
{ {
role: 'system', role: 'system',
content: 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', role: 'user',
@ -1481,7 +1488,7 @@ Instructions:
setIsThinking(false); setIsThinking(false);
setStreamingText(''); 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 // Listen for external events from FAQ/About pages
useEffect(() => { useEffect(() => {

View file

@ -8,16 +8,18 @@ import { MarkSoldDialog } from '@/components/MarkSoldDialog';
import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
function MyListingRow({ item }: { item: MarketplaceItem }) { function MyListingRow({ item }: { item: MarketplaceItem }) {
const { t } = useTranslation();
const [openSold, setOpenSold] = useState(false); const [openSold, setOpenSold] = useState(false);
const listing = { pubkey: item.pubkey, identifier: item.identifier }; const listing = { pubkey: item.pubkey, identifier: item.identifier };
const soldOut = item.status === 'sold'; const soldOut = item.status === 'sold';
const price = item.price const price = item.price
? `${Number(item.price.amount).toLocaleString()} ${item.price.currency}` ? `${Number(item.price.amount).toLocaleString()} ${item.price.currency}`
: item.priceType === 'trade' : item.priceType === 'trade'
? 'Trade' ? t('marketUi.seller.trade')
: 'Free'; : t('common.free');
return ( return (
<> <>
@ -41,9 +43,9 @@ function MyListingRow({ item }: { item: MarketplaceItem }) {
variant="outline" variant="outline"
className={soldOut ? 'border-red-500/40 text-red-400' : 'border-emerald-500/40 text-emerald-400'} 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> </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}`}> <Link to={`/listing/${item.pubkey}/${item.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</Link> </Link>
@ -54,7 +56,7 @@ function MyListingRow({ item }: { item: MarketplaceItem }) {
onClick={() => setOpenSold(true)} onClick={() => setOpenSold(true)}
className="shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" className="shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
> >
Mark sold {t('marketUi.seller.markSold')}
</Button> </Button>
)} )}
</CardContent> </CardContent>
@ -69,6 +71,7 @@ function MyListingRow({ item }: { item: MarketplaceItem }) {
* "mark sold" (which also records the trade for reputation). * "mark sold" (which also records the trade for reputation).
*/ */
export function SellerDashboard() { export function SellerDashboard() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: items, isLoading } = useMarketplaceItems(200); const { data: items, isLoading } = useMarketplaceItems(200);
@ -81,30 +84,30 @@ export function SellerDashboard() {
<div className="flex items-center justify-between gap-3"> <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"> <h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
<Package className="h-4 w-4 text-[#ccff00]" /> <Package className="h-4 w-4 text-[#ccff00]" />
Your listings {t('marketUi.seller.yourListings')}
{mine.length > 0 && ( {mine.length > 0 && (
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]"> <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> </span>
)} )}
</h3> </h3>
<Button asChild size="sm" className="gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600"> <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"> <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> </Link>
</Button> </Button>
</div> </div>
{isLoading ? ( {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 ? ( ) : mine.length === 0 ? (
<Card className="border-dashed border-white/10"> <Card className="border-dashed border-white/10">
<CardContent className="space-y-3 py-10 text-center"> <CardContent className="space-y-3 py-10 text-center">
<Package className="mx-auto h-8 w-8 text-gray-600" /> <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"> <Button asChild className="gap-1.5 bg-orange-500 font-bold text-white hover:bg-orange-600">
<Link to="/create-listing?returnTo=/marketplace"> <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> </Link>
</Button> </Button>
</CardContent> </CardContent>
@ -116,7 +119,7 @@ export function SellerDashboard() {
))} ))}
{sold.length > 0 && ( {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) => ( {sold.map((item) => (
<MyListingRow key={item.id} item={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 { useShipmentStatus, useSetShipmentStatus } from '@/hooks/useShipmentStatus';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import type { MarketplaceItem } from '@/lib/types'; import type { MarketplaceItem } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
export function ShippingStatusCard({ item }: { item: MarketplaceItem }) { export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const listingRef = { pubkey: item.pubkey, identifier: item.identifier }; 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') => { const mark = async (status: 'shipped' | 'received' | 'issue') => {
if (!counterparty) { if (!counterparty) {
toast({ toast({
title: 'No counterparty yet', title: t('marketUi.shipping.noCounterparty'),
description: 'The seller should record the sale (naming the buyer) first.', description: t('marketUi.shipping.noCounterpartyBody'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -54,16 +56,16 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
await setStatus({ listing: listingRef, counterparty, status, ref: tracking }); await setStatus({ listing: listingRef, counterparty, status, ref: tracking });
toast({ toast({
title: 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: description:
status === 'received' status === 'received'
? 'Thanks — this does not release escrow; settle directly with the seller.' ? t('marketUi.shipping.receivedBody')
: 'The other side has been notified.', : t('marketUi.shipping.notifiedBody'),
}); });
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not update', title: t('marketUi.shipping.updateFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.shipping.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -72,16 +74,16 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
return ( return (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3"> <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"> <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 <Package className="h-3.5 w-3.5 text-[#ff6600]" /> {t('marketUi.shipping.title')}
{shipped && <span className="text-[#ccff00]">· shipped</span>} {shipped && <span className="text-[#ccff00]">· {t('marketUi.shipping.shipped')}</span>}
{received && <span className="text-[#ccff00]">· received</span>} {received && <span className="text-[#ccff00]">· {t('marketUi.shipping.received')}</span>}
{issue && <span className="text-amber-400">· issue</span>} {issue && <span className="text-amber-400">· {t('marketUi.shipping.issue')}</span>}
</p> </p>
<Input <Input
value={tracking} value={tracking}
onChange={(e) => setTracking(e.target.value)} 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" 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" 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" />} {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> </Button>
)} )}
{!isSeller && ( {!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" 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" />} {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>
)} )}
<Button <Button
@ -115,14 +117,12 @@ export function ShippingStatusCard({ item }: { item: MarketplaceItem }) {
disabled={isPending} disabled={isPending}
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10" 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> </Button>
</div> </div>
<p className="text-[10px] leading-relaxed text-gray-600"> <p className="text-[10px] leading-relaxed text-gray-600">
Self-reported — a logistics note, <strong className="text-gray-400">not proof</strong>. Marking {t('marketUi.shipping.noteBefore')} <strong className="text-gray-400">{t('marketUi.shipping.notProof')}</strong>{t('marketUi.shipping.noteAfter')}
“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.
</p> </p>
</div> </div>
); );

View file

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import { PersonStanding, ChevronRight, Sparkles, Check } from 'lucide-react'; import { PersonStanding, ChevronRight, Sparkles, Check } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useTranslation } from '@/contexts/language';
import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types'; import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
/** /**
@ -12,6 +13,7 @@ import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
*/ */
export function SkaterDirectoryCard() { export function SkaterDirectoryCard() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { t } = useTranslation();
const { data: skater, isLoading } = useUserSkaterProfile(user?.pubkey); const { data: skater, isLoading } = useUserSkaterProfile(user?.pubkey);
if (!user || isLoading) return null; if (!user || isLoading) return null;
@ -27,9 +29,9 @@ export function SkaterDirectoryCard() {
<PersonStanding className="h-5 w-5 text-[#ff0040]" /> <PersonStanding className="h-5 w-5 text-[#ff0040]" />
</div> </div>
<div className="min-w-0 flex-1"> <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"> <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> </p>
</div> </div>
<Sparkles className="h-4 w-4 text-[#ff0040] flex-shrink-0" /> <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"> <div className="min-w-0 flex-1">
<p className="font-bold text-sm leading-tight flex items-center gap-1.5"> <p className="font-bold text-sm leading-tight flex items-center gap-1.5">
<Check className="h-3.5 w-3.5 text-[#ccff00]" /> <Check className="h-3.5 w-3.5 text-[#ccff00]" />
You're in the Skater Directory {t('labUi.skaterCard.inDirectory')}
</p> </p>
<div className="flex flex-wrap items-center gap-1.5 mt-1.5"> <div className="flex flex-wrap items-center gap-1.5 mt-1.5">
{level ? ( {level ? (
@ -80,7 +82,7 @@ export function SkaterDirectoryCard() {
</div> </div>
</div> </div>
<span className="flex items-center gap-0.5 text-xs font-bold uppercase tracking-wider text-[#ff0040] flex-shrink-0"> <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" /> <ChevronRight className="h-4 w-4" />
</span> </span>
</Link> </Link>

View file

@ -8,6 +8,7 @@ import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import { assertInvoiceAmount } from '@/lib/verifyInvoice';
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions'; import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react'; import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
// qrcode imported lazily in async function // qrcode imported lazily in async function
interface SpamFeePaymentProps { interface SpamFeePaymentProps {
@ -27,6 +28,7 @@ export function SpamFeePayment({
onPaymentSuccess, onPaymentSuccess,
onPowInstead, onPowInstead,
}: SpamFeePaymentProps) { }: SpamFeePaymentProps) {
const { t } = useTranslation();
const { toast } = useToast(); const { toast } = useToast();
const { webln, activeNWC } = useWallet(); const { webln, activeNWC } = useWallet();
const { sendPayment } = useNWC(); const { sendPayment } = useNWC();
@ -53,7 +55,7 @@ export function SpamFeePayment({
const bolt11 = await getInvoiceFromLightningAddress({ const bolt11 = await getInvoiceFromLightningAddress({
lightningAddress: feeRecipient, lightningAddress: feeRecipient,
amountSats: LISTING_FEE_SATS, amountSats: LISTING_FEE_SATS,
comment: 'SkateSpots listing fee', comment: t('marketUi.spamFee.invoiceComment'),
}); });
// SECURITY: verify the returned invoice really requests the listing fee // SECURITY: verify the returned invoice really requests the listing fee
@ -82,7 +84,7 @@ export function SpamFeePayment({
} }
} catch (err) { } catch (err) {
console.error('Failed to generate invoice:', 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'); setPaymentState('error');
} }
}; };
@ -98,8 +100,8 @@ export function SpamFeePayment({
await sendPayment(activeNWC, invoice); await sendPayment(activeNWC, invoice);
setPaymentState('success'); setPaymentState('success');
toast({ toast({
title: 'Payment successful!', title: t('marketUi.spamFee.paymentSuccess'),
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`, description: t('marketUi.spamFee.satsSent', { amount: LISTING_FEE_SATS.toLocaleString(), name: feeRecipientName }),
}); });
setTimeout(onPaymentSuccess, 1500); setTimeout(onPaymentSuccess, 1500);
return; return;
@ -113,8 +115,8 @@ export function SpamFeePayment({
await webln.sendPayment(invoice); await webln.sendPayment(invoice);
setPaymentState('success'); setPaymentState('success');
toast({ toast({
title: 'Payment successful!', title: t('marketUi.spamFee.paymentSuccess'),
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`, description: t('marketUi.spamFee.satsSent', { amount: LISTING_FEE_SATS.toLocaleString(), name: feeRecipientName }),
}); });
setTimeout(onPaymentSuccess, 1500); setTimeout(onPaymentSuccess, 1500);
return; return;
@ -127,8 +129,8 @@ export function SpamFeePayment({
// Fall back to manual payment // Fall back to manual payment
setPaymentState('waiting'); setPaymentState('waiting');
toast({ toast({
title: 'Auto-pay failed', title: t('marketUi.spamFee.autoPayFailed'),
description: 'Please pay manually using the QR code or invoice', description: t('marketUi.spamFee.autoPayFailedBody'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -138,9 +140,9 @@ export function SpamFeePayment({
if (!invoice) return; if (!invoice) return;
try { try {
await navigator.clipboard.writeText(invoice); await navigator.clipboard.writeText(invoice);
toast({ title: 'Invoice copied!' }); toast({ title: t('marketUi.spamFee.invoiceCopied') });
} catch { } 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"> <Card className="bg-green-950/30 border-2 border-green-500/50">
<CardContent className="py-8 text-center"> <CardContent className="py-8 text-center">
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" /> <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"> <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> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -170,36 +172,36 @@ export function SpamFeePayment({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-400"> <CardTitle className="flex items-center gap-2 text-yellow-400">
<ShieldAlert className="h-5 w-5" /> <ShieldAlert className="h-5 w-5" />
One-Time Listing Fee {t('marketUi.spamFee.title')}
</CardTitle> </CardTitle>
<CardDescription className="text-gray-300"> <CardDescription className="text-gray-300">
As a new member, a small fee is required for your first listing. {t('marketUi.spamFee.subtitle')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Why this fee exists */} {/* Why this fee exists */}
<div className="bg-black/30 rounded-lg p-4 space-y-3"> <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="space-y-2 text-sm">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<ShieldAlert className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" /> <ShieldAlert className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-gray-300"> <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> </p>
</div> </div>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<TrendingUp className="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" /> <TrendingUp className="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" />
<p className="text-gray-300"> <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> </p>
</div> </div>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-[#ff6600] mt-0.5 flex-shrink-0" /> <MapPin className="h-4 w-4 text-[#ff6600] mt-0.5 flex-shrink-0" />
<p className="text-gray-300"> <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> <span className="font-semibold text-yellow-400">{feeRecipientName}</span>
{feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && ' — the person who created this spot!'} {feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && t('marketUi.spamFee.supportsSpotOwner')}
{feeRecipient === PLATFORM_LIGHTNING_ADDRESS && ' — helping keep SkateSpots running.'} {feeRecipient === PLATFORM_LIGHTNING_ADDRESS && t('marketUi.spamFee.supportsPlatform')}
</p> </p>
</div> </div>
</div> </div>
@ -208,9 +210,9 @@ export function SpamFeePayment({
{/* Fee amount */} {/* Fee amount */}
<div className="text-center py-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30"> <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"> <div className="text-3xl font-black text-yellow-400">
⚡ {LISTING_FEE_SATS.toLocaleString()} sats ⚡ {LISTING_FEE_SATS.toLocaleString()} {t('common.sats')}
</div> </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> </div>
{/* Initial state — pay, or prove work instead (free) */} {/* 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" className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
> >
<Zap className="h-4 w-4 mr-2" /> <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> </Button>
{onPowInstead && ( {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 <Button
onClick={onPowInstead} onClick={onPowInstead}
variant="outline" variant="outline"
className="w-full font-bold border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10" className="w-full font-bold border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
> >
⚡ Prove work instead — free ⚡ {t('marketUi.spamFee.proveWork')}
</Button> </Button>
<p className="text-center text-xs text-gray-500"> <p className="text-center text-xs text-gray-500">
Spend a few seconds of CPU instead of {LISTING_FEE_SATS.toLocaleString()} sats. No {t('marketUi.spamFee.proveWorkBody', { amount: LISTING_FEE_SATS.toLocaleString() })}
money, no wallet — a spam tax, not a payment.
</p> </p>
</> </>
)} )}
@ -246,7 +247,7 @@ export function SpamFeePayment({
{paymentState === 'generating' && ( {paymentState === 'generating' && (
<div className="text-center py-4"> <div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" /> <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> </div>
)} )}
@ -254,7 +255,7 @@ export function SpamFeePayment({
{paymentState === 'paying' && ( {paymentState === 'paying' && (
<div className="text-center py-4"> <div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" /> <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> </div>
)} )}
@ -264,7 +265,7 @@ export function SpamFeePayment({
{/* QR Code */} {/* QR Code */}
<div className="flex justify-center"> <div className="flex justify-center">
<div className="bg-white p-3 rounded-lg"> <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>
</div> </div>
@ -276,7 +277,7 @@ export function SpamFeePayment({
className="border-yellow-500/50 hover:bg-yellow-500/10" className="border-yellow-500/50 hover:bg-yellow-500/10"
> >
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
Copy Invoice {t('marketUi.spamFee.copyInvoice')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -284,7 +285,7 @@ export function SpamFeePayment({
className="border-yellow-500/50 hover:bg-yellow-500/10" className="border-yellow-500/50 hover:bg-yellow-500/10"
> >
<ExternalLink className="h-4 w-4 mr-2" /> <ExternalLink className="h-4 w-4 mr-2" />
Open Wallet {t('marketUi.spamFee.openWallet')}
</Button> </Button>
</div> </div>
@ -295,12 +296,12 @@ export function SpamFeePayment({
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold" className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
> >
<Zap className="h-4 w-4 mr-2" /> <Zap className="h-4 w-4 mr-2" />
Pay with Connected Wallet {t('marketUi.spamFee.payWithWallet')}
</Button> </Button>
)} )}
<p className="text-xs text-gray-400 text-center"> <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> </p>
</div> </div>
)} )}
@ -316,7 +317,7 @@ export function SpamFeePayment({
variant="outline" variant="outline"
className="w-full border-yellow-500/50 hover:bg-yellow-500/10" className="w-full border-yellow-500/50 hover:bg-yellow-500/10"
> >
Try Again {t('common.tryAgain')}
</Button> </Button>
</div> </div>
)} )}
@ -324,24 +325,24 @@ export function SpamFeePayment({
{/* How to avoid future fees */} {/* How to avoid future fees */}
<div className="bg-green-500/10 rounded-lg p-3 border border-green-500/30"> <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"> <p className="text-green-400 font-semibold text-sm mb-2">
✨ How to skip this fee in the future: ✨ {t('marketUi.spamFee.skipTitle')}
</p> </p>
<ul className="text-gray-300 text-sm space-y-1"> <ul className="text-gray-300 text-sm space-y-1">
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<span className="text-green-400">→</span> <span className="text-green-400">→</span>
Add a skate spot to the map {t('marketUi.spamFee.skip1')}
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<span className="text-green-400">→</span> <span className="text-green-400">→</span>
Complete 2-3 successful listings {t('marketUi.spamFee.skip2')}
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<span className="text-green-400">→</span> <span className="text-green-400">→</span>
Build your community reputation {t('marketUi.spamFee.skip3')}
</li> </li>
</ul> </ul>
<p className="text-xs text-gray-400 mt-2"> <p className="text-xs text-gray-400 mt-2">
Trusted members get unlimited free listings! {t('marketUi.spamFee.skipNote')}
</p> </p>
</div> </div>
</CardContent> </CardContent>

View file

@ -24,6 +24,7 @@ import { SpotStatusBadge } from '@/components/SpotStatusBadge';
import type { SkateSpot } from '@/lib/types'; import type { SkateSpot } from '@/lib/types';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types'; import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus'; import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface SpotCardProps { interface SpotCardProps {
@ -33,6 +34,7 @@ interface SpotCardProps {
} }
export function SpotCard({ spot, status }: SpotCardProps) { export function SpotCard({ spot, status }: SpotCardProps) {
const { t } = useTranslation();
const author = useAuthor(spot.pubkey); const author = useAuthor(spot.pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const metadata = author.data?.metadata; 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"> <div className="absolute top-3 left-3 flex flex-col gap-1">
{isShop ? ( {isShop ? (
<Badge className="bg-[#ccff00]/90 text-black backdrop-blur-sm border-2 border-white font-black uppercase text-xs"> <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> </Badge>
) : ( ) : (
<> <>
@ -217,7 +219,7 @@ export function SpotCard({ spot, status }: SpotCardProps) {
className="text-red-500 focus:text-red-500" className="text-red-500 focus:text-red-500"
> >
<Flag className="h-4 w-4 mr-2" /> <Flag className="h-4 w-4 mr-2" />
Report {t('mapUi.report')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View file

@ -17,6 +17,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { NoteContent } from '@/components/NoteContent'; import { NoteContent } from '@/components/NoteContent';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
@ -125,6 +126,7 @@ export function SpotChat({
defaultPrecision = 'block', defaultPrecision = 'block',
className, className,
}: SpotChatProps) { }: SpotChatProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const [precision, setPrecision] = useState<ChatPrecision>(defaultPrecision); const [precision, setPrecision] = useState<ChatPrecision>(defaultPrecision);
@ -181,11 +183,14 @@ export function SpotChat({
if (text === '/who' || text.startsWith('/who ')) { if (text === '/who' || text.startsWith('/who ')) {
const recent = new Set((rawMessages ?? []).map((m) => m.pubkey)); const recent = new Set((rawMessages ?? []).map((m) => m.pubkey));
toast({ 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: description:
recent.size > 0 recent.size > 0
? 'People who posted in this channel in the last 24h.' ? t('spotUi.skatersActiveDesc')
: 'Be the first to say something.', : t('spotUi.beFirstToSay'),
}); });
setDraft(''); setDraft('');
return; return;
@ -196,8 +201,8 @@ export function SpotChat({
setDraft(''); setDraft('');
} catch (err) { } catch (err) {
toast({ toast({
title: 'Message failed', title: t('spotUi.messageFailed'),
description: err instanceof Error ? err.message : 'Could not send message.', description: err instanceof Error ? err.message : t('spotUi.couldNotSendMessage'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -209,14 +214,14 @@ export function SpotChat({
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"> <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"> <CardTitle className="text-lg flex flex-wrap items-center gap-2 min-w-0">
<MessageCircle className="h-5 w-5 text-orange-500" /> <MessageCircle className="h-5 w-5 text-orange-500" />
Spot Chat {t('spotUi.spotChat')}
<Badge <Badge
variant="outline" 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" 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" /> <Globe className="h-3 w-3" />
Public {t('spotUi.public')}
</Badge> </Badge>
</CardTitle> </CardTitle>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -253,7 +258,7 @@ export function SpotChat({
> >
{isLoading && ( {isLoading && (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading chat… {t('spotUi.loadingChat')}
</div> </div>
)} )}
{!isLoading && (!messages || messages.length === 0) && ( {!isLoading && (!messages || messages.length === 0) && (
@ -261,12 +266,13 @@ export function SpotChat({
<MessageCircle className="h-8 w-8 text-muted-foreground/40" /> <MessageCircle className="h-8 w-8 text-muted-foreground/40" />
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<p className="text-sm text-muted-foreground max-w-[16rem]"> <p className="text-sm text-muted-foreground max-w-[16rem]">
{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter. {hiddenCount === 1
Lower the filter to see more. ? t('spotUi.messagesHiddenOne', { count: hiddenCount })
: t('spotUi.messagesHiddenOther', { count: hiddenCount })}
</p> </p>
) : ( ) : (
<p className="text-sm text-muted-foreground max-w-[16rem]"> <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> </p>
)} )}
</div> </div>
@ -276,7 +282,9 @@ export function SpotChat({
))} ))}
{!isLoading && messages && messages.length > 0 && hiddenCount > 0 && ( {!isLoading && messages && messages.length > 0 && hiddenCount > 0 && (
<div className="pt-2 text-center text-[11px] text-muted-foreground"> <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>
)} )}
</div> </div>
@ -288,7 +296,7 @@ export function SpotChat({
<Input <Input
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
placeholder="Message this spot… (try /me or /who)" placeholder={t('spotUi.messagePlaceholder')}
maxLength={500} maxLength={500}
className="flex-1" className="flex-1"
/> />
@ -303,13 +311,12 @@ export function SpotChat({
</form> </form>
<p className="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground"> <p className="mt-1.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<Info className="h-3 w-3 shrink-0" /> <Info className="h-3 w-3 shrink-0" />
Anyone can read this — messages are public &amp; ephemeral. For private {t('spotUi.chatPublicNote')}
chats, use Direct Messages.
</p> </p>
</> </>
) : ( ) : (
<div className="flex flex-col items-center gap-2 py-1 text-center"> <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" /> <LoginArea className="max-w-60" />
</div> </div>
)} )}

View file

@ -10,6 +10,10 @@ import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces'; import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { createCachedTileLayer } from '@/lib/cachedTileLayer'; 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. * SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
@ -526,7 +530,8 @@ interface SpotMapProps {
function createPopupContent( function createPopupContent(
spot: SkateSpot, spot: SkateSpot,
onViewDetails: () => void, onViewDetails: () => void,
weatherData?: SpotWeatherData weatherData: SpotWeatherData | undefined,
t: Translate,
): HTMLElement { ): HTMLElement {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'p-2 min-w-[200px]'; container.className = 'p-2 min-w-[200px]';
@ -581,9 +586,9 @@ function createPopupContent(
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}"> <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>
<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> </div>
`; `;
weatherSection.appendChild(currentWeather); weatherSection.appendChild(currentWeather);
@ -592,9 +597,9 @@ function createPopupContent(
const details = document.createElement('div'); const details = document.createElement('div');
details.className = 'text-xs space-y-1 text-gray-600'; details.className = 'text-xs space-y-1 text-gray-600';
details.innerHTML = ` details.innerHTML = `
<div>💨 Wind: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div> <div>💨 ${escapeHtml(t('mapUi.wind'))}: ${escapeHtml(weatherData.weather.windSpeed)} km/h ${escapeHtml(weatherData.weather.windDirectionLabel)}</div>
<div>💧 Humidity: ${escapeHtml(weatherData.weather.humidity)}%</div> <div>💧 ${escapeHtml(t('mapUi.humidity'))}: ${escapeHtml(weatherData.weather.humidity)}%</div>
${weatherData.airQuality ? `<div>🌫️ Air Quality: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''} ${weatherData.airQuality ? `<div>🌫️ ${escapeHtml(t('mapUi.airQuality'))}: ${escapeHtml(weatherData.airQuality.aqiLabel)} (${escapeHtml(weatherData.airQuality.aqi)})</div>` : ''}
`; `;
weatherSection.appendChild(details); weatherSection.appendChild(details);
@ -605,7 +610,7 @@ function createPopupContent(
const forecastTitle = document.createElement('div'); const forecastTitle = document.createElement('div');
forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700'; forecastTitle.className = 'text-xs font-semibold mb-1 text-gray-700';
forecastTitle.textContent = '3-Day Forecast'; forecastTitle.textContent = t('mapUi.threeDayForecast');
forecastDiv.appendChild(forecastTitle); forecastDiv.appendChild(forecastTitle);
const forecastGrid = document.createElement('div'); const forecastGrid = document.createElement('div');
@ -648,7 +653,7 @@ function createPopupContent(
// Button // Button
const button = document.createElement('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.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) => { button.onclick = (e) => {
e.preventDefault(); e.preventDefault();
onViewDetails(); onViewDetails();
@ -662,7 +667,8 @@ function createPopupContent(
function createEnhancedPopupContent( function createEnhancedPopupContent(
spot: SkateSpot, spot: SkateSpot,
marketplaceItems: MarketplaceItem[], marketplaceItems: MarketplaceItem[],
onViewDetails: () => void onViewDetails: () => void,
t: Translate,
): HTMLElement { ): HTMLElement {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'p-2 min-w-[250px] max-w-[350px]'; container.className = 'p-2 min-w-[250px] max-w-[350px]';
@ -682,11 +688,11 @@ function createEnhancedPopupContent(
const isShop = spot.types.includes('shop'); const isShop = spot.types.includes('shop');
const spotIcon = isShop ? '🏪' : '🛹'; const spotIcon = isShop ? '🏪' : '🛹';
const spotLabel = isShop ? 'SKATE SHOP' : 'SKATE SPOT'; const spotLabel = isShop ? t('mapUi.skateShop') : t('mapUi.skateSpot');
const spotTitle = document.createElement('div'); const spotTitle = document.createElement('div');
spotTitle.className = 'font-bold text-sm text-gray-200 mb-2'; spotTitle.className = 'font-bold text-sm text-gray-200 mb-2';
spotTitle.textContent = `${spotIcon} ${spotLabel}`; spotTitle.textContent = `${spotIcon} ${spotLabel.toUpperCase()}`;
spotSection.appendChild(spotTitle); spotSection.appendChild(spotTitle);
// Spot types // Spot types
@ -705,7 +711,7 @@ function createEnhancedPopupContent(
// View spot button // View spot button
const spotButton = document.createElement('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.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) => { spotButton.onclick = (e) => {
e.preventDefault(); e.preventDefault();
onViewDetails(); onViewDetails();
@ -724,7 +730,7 @@ function createEnhancedPopupContent(
const gearTitle = document.createElement('div'); const gearTitle = document.createElement('div');
gearTitle.className = 'font-bold text-sm text-orange-400 mb-2'; 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); gearSection.appendChild(gearTitle);
// Show up to 3 items // Show up to 3 items
@ -746,7 +752,7 @@ function createEnhancedPopupContent(
// View all gear button // View all gear button
const gearButton = document.createElement('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.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) => { gearButton.onclick = (e) => {
e.preventDefault(); e.preventDefault();
onViewDetails(); // Goes to spot page which has gear section onViewDetails(); // Goes to spot page which has gear section
@ -786,6 +792,7 @@ export function SpotMap({
flyTo = null, flyTo = null,
userLocation = null, userLocation = null,
}: SpotMapProps) { }: SpotMapProps) {
const { t } = useTranslation();
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null); const mapInstanceRef = useRef<L.Map | null>(null);
const markersRef = useRef<L.Marker[]>([]); const markersRef = useRef<L.Marker[]>([]);
@ -1043,9 +1050,9 @@ export function SpotMap({
keyboard: false, keyboard: false,
}) })
.addTo(map) .addTo(map)
.bindPopup('<b>You are here</b>'); .bindPopup(`<b>${escapeHtml(t('mapUi.youAreHere'))}</b>`);
} }
}, [userLocation]); }, [userLocation, t]);
// Update markers // Update markers
useEffect(() => { useEffect(() => {
@ -1161,8 +1168,8 @@ export function SpotMap({
// Use enhanced popup if there are marketplace items, otherwise use standard // Use enhanced popup if there are marketplace items, otherwise use standard
const popupContent = hasGear const popupContent = hasGear
? createEnhancedPopupContent(primarySpot, group.marketplaceItems, () => handleViewDetails(primarySpot)) ? createEnhancedPopupContent(primarySpot, group.marketplaceItems, () => handleViewDetails(primarySpot), t)
: createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather); : createPopupContent(primarySpot, () => handleViewDetails(primarySpot), spotWeather, t);
marker.bindPopup(popupContent, { marker.bindPopup(popupContent, {
maxWidth: 350, maxWidth: 350,
@ -1180,7 +1187,7 @@ export function SpotMap({
const title = document.createElement('h3'); const title = document.createElement('h3');
title.className = 'font-bold text-sm text-orange-400 mb-2'; 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); container.appendChild(title);
const itemsList = document.createElement('div'); const itemsList = document.createElement('div');
@ -1198,7 +1205,7 @@ export function SpotMap({
const button = document.createElement('button'); 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.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) => { button.onclick = (e) => {
e.preventDefault(); e.preventDefault();
// Navigate to marketplace or first item's linked spot // Navigate to marketplace or first item's linked spot
@ -1261,7 +1268,7 @@ export function SpotMap({
tourTitle.textContent = tour.title; tourTitle.textContent = tour.title;
const tourKicker = document.createElement('p'); const tourKicker = document.createElement('p');
tourKicker.className = 'text-xs text-cyan-400 font-medium'; tourKicker.className = 'text-xs text-cyan-400 font-medium';
tourKicker.textContent = 'LOCAL TOUR'; tourKicker.textContent = t('mapUi.localTour').toUpperCase();
tourHeadText.appendChild(tourTitle); tourHeadText.appendChild(tourTitle);
tourHeadText.appendChild(tourKicker); tourHeadText.appendChild(tourKicker);
header.appendChild(tourIconBox); header.appendChild(tourIconBox);
@ -1276,17 +1283,17 @@ export function SpotMap({
details.appendChild(popupRow('📍', tour.location)); details.appendChild(popupRow('📍', tour.location));
} }
if (tour.meetingPoint) { if (tour.meetingPoint) {
details.appendChild(popupRow('🚩', `Meet at: ${tour.meetingPoint}`)); details.appendChild(popupRow('🚩', t('mapUi.meetAt', { place: tour.meetingPoint })));
} }
if (tour.duration) { if (tour.duration) {
details.appendChild(popupRow('⏱️', `${tour.duration} hours`)); details.appendChild(popupRow('⏱️', t('mapUi.hours', { count: tour.duration })));
} }
if (tour.skillLevel) { if (tour.skillLevel) {
const skillEmoji = tour.skillLevel === 'beginner' ? '🌱' : tour.skillLevel === 'intermediate' ? '🔥' : tour.skillLevel === 'advanced' ? '💀' : '🛹'; 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) { if (tour.price) {
details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} sats`)); details.appendChild(popupRow('⚡', `${tour.price.toLocaleString()} ${t('common.sats')}`));
} }
container.appendChild(details); container.appendChild(details);
@ -1306,7 +1313,7 @@ export function SpotMap({
// View button // View button
const button = document.createElement('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.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) => { button.onclick = (e) => {
e.preventDefault(); e.preventDefault();
navigate(`/tour/${tour.pubkey}/${tour.identifier}`); navigate(`/tour/${tour.pubkey}/${tour.identifier}`);
@ -1353,7 +1360,7 @@ export function SpotMap({
evtTitle.textContent = evt.title; evtTitle.textContent = evt.title;
const evtKicker = document.createElement('p'); const evtKicker = document.createElement('p');
evtKicker.className = 'text-xs text-[#a3cc00] font-medium'; evtKicker.className = 'text-xs text-[#a3cc00] font-medium';
evtKicker.textContent = 'UPCOMING EVENT'; evtKicker.textContent = t('mapUi.upcomingEvent').toUpperCase();
evtHeadText.appendChild(evtTitle); evtHeadText.appendChild(evtTitle);
evtHeadText.appendChild(evtKicker); evtHeadText.appendChild(evtKicker);
header.appendChild(evtIconBox); header.appendChild(evtIconBox);
@ -1375,7 +1382,7 @@ export function SpotMap({
// View button // View button
const button = document.createElement('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.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) => { button.onclick = (e) => {
e.preventDefault(); e.preventDefault();
navigate(`/event/${evt.pubkey}/${evt.identifier}`); navigate(`/event/${evt.pubkey}/${evt.identifier}`);
@ -1438,7 +1445,7 @@ export function SpotMap({
crewName.textContent = crew.name; crewName.textContent = crew.name;
const crewKicker = document.createElement('p'); const crewKicker = document.createElement('p');
crewKicker.className = 'text-xs text-teal-400 font-medium'; crewKicker.className = 'text-xs text-teal-400 font-medium';
crewKicker.textContent = 'SKATE CREW'; crewKicker.textContent = t('mapUi.skateCrew').toUpperCase();
crewHeadText.appendChild(crewName); crewHeadText.appendChild(crewName);
crewHeadText.appendChild(crewKicker); crewHeadText.appendChild(crewKicker);
header.appendChild(crewIconBox); header.appendChild(crewIconBox);
@ -1452,10 +1459,10 @@ export function SpotMap({
details.appendChild(popupRow('📍', crew.location)); details.appendChild(popupRow('📍', crew.location));
} }
if (crew.members.length > 0) { 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) { if (crew.founded) {
details.appendChild(popupRow('📅', `Since ${crew.founded}`)); details.appendChild(popupRow('📅', t('mapUi.since', { year: crew.founded })));
} }
container.appendChild(details); container.appendChild(details);
@ -1475,7 +1482,7 @@ export function SpotMap({
// View button // View button
const button = document.createElement('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.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) => { button.onclick = (e) => {
e.preventDefault(); e.preventDefault();
navigate(`/crew/${crew.pubkey}/${crew.identifier}`); navigate(`/crew/${crew.pubkey}/${crew.identifier}`);
@ -1522,7 +1529,7 @@ export function SpotMap({
if (raf) cancelAnimationFrame(raf); 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 // Update BTC Map markers
useEffect(() => { useEffect(() => {
@ -1561,7 +1568,7 @@ export function SpotMap({
btcName.textContent = place.name; btcName.textContent = place.name;
const btcKicker = document.createElement('p'); const btcKicker = document.createElement('p');
btcKicker.className = 'text-[10px] text-[#f7931a] font-medium uppercase'; btcKicker.className = 'text-[10px] text-[#f7931a] font-medium uppercase';
btcKicker.textContent = 'Accepts Bitcoin'; btcKicker.textContent = t('mapUi.acceptsBitcoin');
btcHeadText.appendChild(btcName); btcHeadText.appendChild(btcName);
btcHeadText.appendChild(btcKicker); btcHeadText.appendChild(btcKicker);
header.appendChild(btcIconBox); header.appendChild(btcIconBox);
@ -1572,10 +1579,10 @@ export function SpotMap({
const badges = document.createElement('div'); const badges = document.createElement('div');
badges.className = 'flex flex-wrap gap-1 mb-2'; badges.className = 'flex flex-wrap gap-1 mb-2';
if (boosted) { 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) { 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) { if (badges.innerHTML) {
container.appendChild(badges); container.appendChild(badges);
@ -1625,7 +1632,7 @@ export function SpotMap({
websiteBtn.target = '_blank'; websiteBtn.target = '_blank';
websiteBtn.rel = 'noopener noreferrer'; 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.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); actions.appendChild(websiteBtn);
} }
@ -1647,7 +1654,7 @@ export function SpotMap({
marker.addTo(map); marker.addTo(map);
btcMapMarkersRef.current.push(marker); btcMapMarkersRef.current.push(marker);
}); });
}, [btcMapPlaces, showBtcMap]); }, [btcMapPlaces, showBtcMap, t]);
return ( return (
<div className={className}> <div className={className}>

View file

@ -27,6 +27,7 @@ import { useToast } from '@/hooks/useToast';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { useTranslation } from '@/contexts/language';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
// 6 months in seconds // 6 months in seconds
@ -42,6 +43,7 @@ interface SpotOwnershipProps {
* 2. Claim abandoned spot (any user can claim if owner inactive 6+ months) * 2. Claim abandoned spot (any user can claim if owner inactive 6+ months)
*/ */
export function SpotOwnership({ spot }: SpotOwnershipProps) { export function SpotOwnership({ spot }: SpotOwnershipProps) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { toast } = useToast(); const { toast } = useToast();
@ -67,16 +69,16 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
const months = Math.floor(days / 30); const months = Math.floor(days / 30);
const years = Math.floor(months / 12); const years = Math.floor(months / 12);
if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`; if (years > 0) return years > 1 ? t('spotUi.yearsAgoPlural', { count: years }) : t('spotUi.yearsAgo', { count: years });
if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`; if (months > 0) return months > 1 ? t('spotUi.monthsAgoPlural', { count: months }) : t('spotUi.monthsAgo', { count: months });
return `${days} day${days > 1 ? 's' : ''} ago`; return days > 1 ? t('spotUi.daysAgoPlural', { count: days }) : t('spotUi.daysAgo', { count: days });
}, [spot.createdAt]); }, [spot.createdAt, t]);
const handleTransfer = async () => { const handleTransfer = async () => {
if (!recipientNpub.trim()) { if (!recipientNpub.trim()) {
toast({ toast({
title: 'Recipient required', title: t('spotUi.recipientRequired'),
description: 'Please enter the npub of the new owner.', description: t('spotUi.recipientRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -92,8 +94,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
recipientPubkey = decoded.data; recipientPubkey = decoded.data;
} catch { } catch {
toast({ toast({
title: 'Invalid npub', title: t('spotUi.invalidNpub'),
description: 'Please enter a valid npub address.', description: t('spotUi.invalidNpubDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -101,8 +103,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
if (recipientPubkey === spot.pubkey) { if (recipientPubkey === spot.pubkey) {
toast({ toast({
title: 'Same owner', title: t('spotUi.sameOwner'),
description: 'Cannot transfer to the current owner.', description: t('spotUi.sameOwnerDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -112,7 +114,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
// Publish a transfer event (kind 1111 comment with transfer marker) // Publish a transfer event (kind 1111 comment with transfer marker)
await publishEvent({ await publishEvent({
kind: 1111, kind: 1111,
content: `Ownership transfer request for spot "${spot.title}"`, content: t('spotUi.transferRequestContent', { title: spot.title }),
tags: [ tags: [
['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`], ['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`],
['K', String(SKATE_SPOT_KIND)], ['K', String(SKATE_SPOT_KIND)],
@ -122,8 +124,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
}); });
toast({ toast({
title: 'Transfer requested', title: t('spotUi.transferRequested'),
description: 'The new owner will need to claim the spot to complete the transfer.', description: t('spotUi.transferRequestedDesc'),
}); });
setTransferOpen(false); setTransferOpen(false);
@ -131,8 +133,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] }); queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] });
} catch (error) { } catch (error) {
toast({ toast({
title: 'Transfer failed', title: t('spotUi.transferFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -165,8 +167,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
}); });
toast({ toast({
title: 'Spot claimed!', title: t('spotUi.spotClaimed'),
description: 'You are now the owner of this spot. You can edit it from your profile.', description: t('spotUi.spotClaimedDesc'),
}); });
setClaimOpen(false); setClaimOpen(false);
@ -174,8 +176,8 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] }); queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] });
} catch (error) { } catch (error) {
toast({ toast({
title: 'Claim failed', title: t('spotUi.claimFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -194,30 +196,30 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<UserPlus className="h-4 w-4" /> <UserPlus className="h-4 w-4" />
Transfer Ownership {t('spotUi.transferOwnership')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-[#ff0040]" /> <UserPlus className="h-5 w-5 text-[#ff0040]" />
Transfer Spot Ownership {t('spotUi.transferSpotOwnership')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Transfer "{spot.title}" to another user. This action cannot be undone. {t('spotUi.transferDesc', { title: spot.title })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <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 <Input
placeholder="npub1..." placeholder="npub1..."
value={recipientNpub} value={recipientNpub}
onChange={(e) => setRecipientNpub(e.target.value)} onChange={(e) => setRecipientNpub(e.target.value)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enter the npub of the user you want to transfer this spot to. {t('spotUi.newOwnerNpubHint')}
</p> </p>
</div> </div>
@ -226,9 +228,9 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
<div className="flex gap-2 text-yellow-500"> <div className="flex gap-2 text-yellow-500">
<AlertTriangle className="h-5 w-5 flex-shrink-0" /> <AlertTriangle className="h-5 w-5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<p className="font-medium">Warning</p> <p className="font-medium">{t('spotUi.warning')}</p>
<p className="text-yellow-400/80"> <p className="text-yellow-400/80">
After transfer, you will no longer be able to edit or delete this spot. {t('spotUi.transferWarningDesc')}
</p> </p>
</div> </div>
</div> </div>
@ -237,7 +239,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setTransferOpen(false)}> <Button variant="outline" onClick={() => setTransferOpen(false)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleTransfer} onClick={handleTransfer}
@ -247,10 +249,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Transferring... {t('spotUi.transferring')}
</> </>
) : ( ) : (
'Transfer Ownership' t('spotUi.transferOwnership')
)} )}
</Button> </Button>
</div> </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" /> <Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1"> <h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
Spot May Be Abandoned {t('spotUi.spotMayBeAbandoned')}
</h3> </h3>
<p className="text-xs text-gray-400 mb-3"> <p className="text-xs text-gray-400 mb-3">
Last updated {lastUpdatedText}. If the original owner is inactive, {t('spotUi.abandonedDesc', { time: lastUpdatedText })}
you can claim this spot to maintain and improve it.
</p> </p>
<Button <Button
size="sm" size="sm"
@ -279,7 +280,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
onClick={() => setClaimOpen(true)} onClick={() => setClaimOpen(true)}
> >
<Crown className="h-4 w-4" /> <Crown className="h-4 w-4" />
Claim This Spot {t('spotUi.claimThisSpot')}
</Button> </Button>
</div> </div>
</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" /> <Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1"> <h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
Spot May Be Abandoned {t('spotUi.spotMayBeAbandoned')}
</h3> </h3>
<p className="text-xs text-gray-400 mb-3"> <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> </p>
<LoginArea className="justify-start" /> <LoginArea className="justify-start" />
</div> </div>
@ -313,27 +314,26 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <AlertDialogTitle className="flex items-center gap-2">
<Crown className="h-5 w-5 text-orange-500" /> <Crown className="h-5 w-5 text-orange-500" />
Claim "{spot.title}"? {t('spotUi.claimTitle', { title: spot.title })}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription asChild> <AlertDialogDescription asChild>
<div className="space-y-3"> <div className="space-y-3">
<p> <p>
You're about to claim ownership of this spot. This will create your own {t('spotUi.claimIntro')}
version of the spot that you can edit and maintain.
</p> </p>
<div className="bg-muted p-3 rounded-lg text-sm"> <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"> <ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>You become the owner and can edit the spot</li> <li>{t('spotUi.claimBullet1')}</li>
<li>The original spot remains (Nostr is decentralized)</li> <li>{t('spotUi.claimBullet2')}</li>
<li>Your version may appear alongside the original</li> <li>{t('spotUi.claimBullet3')}</li>
</ul> </ul>
</div> </div>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleClaim} onClick={handleClaim}
disabled={isPublishing} disabled={isPublishing}
@ -342,10 +342,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Claiming... {t('spotUi.claiming')}
</> </>
) : ( ) : (
'Claim Spot' t('spotUi.claimSpot')
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
import { MapPin, Search, X, Plus } from 'lucide-react'; import { MapPin, Search, X, Plus } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface SpotPickerProps { interface SpotPickerProps {
@ -23,6 +24,7 @@ export function SpotPicker({
label = 'Spots Where You\'re Available', label = 'Spots Where You\'re Available',
description = 'Select spots where you offer your services', description = 'Select spots where you offer your services',
}: SpotPickerProps) { }: SpotPickerProps) {
const { t } = useTranslation();
const { data: allSpots, isLoading: spotsLoading } = useSkateSpots(100); const { data: allSpots, isLoading: spotsLoading } = useSkateSpots(100);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
@ -112,7 +114,7 @@ export function SpotPicker({
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Spot {t('mapUi.addSpot')}
</Button> </Button>
) : ( ) : (
<Card className="bg-gray-900/50 border-gray-700"> <Card className="bg-gray-900/50 border-gray-700">
@ -121,7 +123,7 @@ export function SpotPicker({
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input <Input
placeholder="Search spots..." placeholder={t('mapUi.searchSpotsPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="bg-gray-800 border-gray-700 pl-9 h-9" className="bg-gray-800 border-gray-700 pl-9 h-9"
@ -170,7 +172,7 @@ export function SpotPicker({
</div> </div>
{isSelected && ( {isSelected && (
<Badge variant="outline" className="text-[10px] border-[#ff0040]/50 text-[#ff0040]"> <Badge variant="outline" className="text-[10px] border-[#ff0040]/50 text-[#ff0040]">
Selected {t('mapUi.selected')}
</Badge> </Badge>
)} )}
</button> </button>
@ -178,11 +180,11 @@ export function SpotPicker({
})} })}
{spotsLoading ? ( {spotsLoading ? (
<p className="text-sm text-gray-500 text-center py-4"> <p className="text-sm text-gray-500 text-center py-4">
Loading spots… {t('mapUi.loadingSpots')}
</p> </p>
) : filteredSpots.length === 0 ? ( ) : filteredSpots.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4"> <p className="text-sm text-gray-400 text-center py-4">
No spots found. Try a different search. {t('mapUi.noSpotsFound')}
</p> </p>
) : null} ) : null}
</div> </div>
@ -194,7 +196,7 @@ export function SpotPicker({
{selectedSpots.length === 0 && ( {selectedSpots.length === 0 && (
<p className="text-xs text-gray-400 italic"> <p className="text-xs text-gray-400 italic">
Adding spots helps users find you when browsing specific locations {t('mapUi.addingSpotsHint')}
</p> </p>
)} )}
</div> </div>

View file

@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import { SPOT_STATUS_META, statusTimeAgo } from '@/lib/spotStatus'; import { SPOT_STATUS_META, statusTimeAgo } from '@/lib/spotStatus';
import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus'; import type { AggregatedSpotStatus } from '@/hooks/useSpotStatus';
import { import {
@ -27,6 +28,7 @@ export function SpotStatusBadge({
className, className,
hideWhenEmpty = true, hideWhenEmpty = true,
}: SpotStatusBadgeProps) { }: SpotStatusBadgeProps) {
const { t } = useTranslation();
const status = aggregated?.status ?? null; const status = aggregated?.status ?? null;
if (!status) { if (!status) {
@ -39,7 +41,7 @@ export function SpotStatusBadge({
className, className,
)} )}
> >
Unverified {t('spotUi.unverified')}
</span> </span>
); );
} }
@ -76,8 +78,8 @@ export function SpotStatusBadge({
<p className="font-medium">{meta.label}</p> <p className="font-medium">{meta.label}</p>
<p className="text-xs text-muted-foreground">{meta.description}</p> <p className="text-xs text-muted-foreground">{meta.description}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{reporterCount} {reporterCount === 1 ? 'report' : 'reports'} {reporterCount} {reporterCount === 1 ? t('spotUi.reportOne') : t('spotUi.reports')}
{lastAt ? ` · last ${statusTimeAgo(lastAt)}` : ''} {lastAt ? ` · ${t('spotUi.lastReported', { time: statusTimeAgo(lastAt) })}` : ''}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View file

@ -5,6 +5,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DeckRating } from '@/components/DeckRating'; import { DeckRating } from '@/components/DeckRating';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface TradeConfirmRowProps { interface TradeConfirmRowProps {
tone: 'sale' | 'purchase'; tone: 'sale' | 'purchase';
@ -33,6 +34,7 @@ export function TradeConfirmRow({
actionLabel, actionLabel,
onConfirm, onConfirm,
}: TradeConfirmRowProps) { }: TradeConfirmRowProps) {
const { t } = useTranslation();
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const cardCls = tone === 'sale' ? 'border-[#ff6600]/30 bg-[#ff6600]/5' : 'border-[#ccff00]/30 bg-[#ccff00]/5'; 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"> <div className="min-w-0 flex-1">
<p className="text-sm text-white">{text}</p> <p className="text-sm text-white">{text}</p>
<Link to={listingUrl} className="text-xs text-gray-400 hover:text-white"> <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> </Link>
</div> </div>
<Button <Button
@ -61,7 +63,7 @@ export function TradeConfirmRow({
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2 pl-8"> <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" /> <DeckRating value={rating} onChange={setRating} size="sm" />
</div> </div>
</CardContent> </CardContent>

View file

@ -5,18 +5,19 @@ import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications'; import { tradeNotificationText, type TradeNotification } from '@/hooks/useTradeNotifications';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
const TYPE_META: Record< const TYPE_META: Record<
TradeNotification['type'], 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 }, 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', label: 'Counter-offer', 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]', label: 'Offer accepted', actionable: false }, 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', label: 'Offer declined', 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', label: 'Offer withdrawn', 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', label: 'Confirm a trade', actionable: true }, '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]', label: 'Trade confirmed', 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. */ /** A buying/selling step in the notification bell. */
@ -29,6 +30,7 @@ export function TradeNotificationCard({
unread: boolean; unread: boolean;
onNavigate?: () => void; onNavigate?: () => void;
}) { }) {
const { t } = useTranslation();
const author = useAuthor(notification.counterparty); const author = useAuthor(notification.counterparty);
const name = author.data?.metadata?.name ?? genUserName(notification.counterparty); const name = author.data?.metadata?.name ?? genUserName(notification.counterparty);
const pic = author.data?.metadata?.picture; const pic = author.data?.metadata?.picture;
@ -49,7 +51,7 @@ export function TradeNotificationCard({
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <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"> <p className="flex items-center gap-1.5 text-sm leading-snug text-gray-200">
<Avatar className="h-4 w-4"> <Avatar className="h-4 w-4">
<AvatarImage src={pic} alt={name} /> <AvatarImage src={pic} alt={name} />
@ -59,7 +61,7 @@ export function TradeNotificationCard({
</p> </p>
<p className="mt-0.5 text-xs text-gray-400">{tradeNotificationText(notification)}</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]"> <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> </p>
</div> </div>
</Link> </Link>

View file

@ -14,6 +14,7 @@ import {
} from '@/hooks/useTraderRecommendations'; } from '@/hooks/useTraderRecommendations';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { ShieldCheck, Loader2 } from 'lucide-react'; import { ShieldCheck, Loader2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function Recommender({ pubkey }: { pubkey: string }) { function Recommender({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey); 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. * for this trader, and lets a logged-in viewer add their own recommendation.
*/ */
export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string; className?: string }) { export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string; className?: string }) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: recs, isLoading } = useTraderRecommendations(pubkey); const { data: recs, isLoading } = useTraderRecommendations(pubkey);
const { data: mine } = useMyRecommendation(pubkey); const { data: mine } = useMyRecommendation(pubkey);
@ -53,11 +55,11 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
setBusy(true); setBusy(true);
try { try {
await recommend(pubkey); await recommend(pubkey);
toast({ title: 'Recommendation published', description: 'Thanks for vouching.' }); toast({ title: t('marketUi.recommendations.published'), description: t('marketUi.recommendations.publishedBody') });
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not recommend', title: t('marketUi.recommendations.failed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.recommendations.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@ -70,18 +72,17 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]"> <CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
<ShieldCheck className="h-5 w-5" /> <ShieldCheck className="h-5 w-5" />
Trusted trader {t('marketUi.recommendations.title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
{count > 0 ? ( {count > 0 ? (
<> <>
Recommended by <span className="font-bold text-white">{count}</span> skater {t('marketUi.recommendations.recommendedBy')} <span className="font-bold text-white">{count}</span> {count === 1 ? t('marketUi.recommendations.skaterOne') : t('marketUi.recommendations.skaterMany')}.
{count === 1 ? '' : 's'}.
</> </>
) : ( ) : (
'No recommendations yet.' t('marketUi.recommendations.none')
)} )}
</p> </p>
@ -95,7 +96,7 @@ export function TradeRecommendationsCard({ pubkey, className }: { pubkey: string
{user && !isSelf && ( {user && !isSelf && (
mine ? ( mine ? (
<p className="text-xs text-[#ccff00]">You recommend this trader ✓</p> <p className="text-xs text-[#ccff00]">{t('marketUi.recommendations.youRecommend')}</p>
) : ( ) : (
<Button <Button
size="sm" 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" 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" />} {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
Recommend as trusted trader {t('marketUi.recommendations.recommend')}
</Button> </Button>
) )
)} )}

View file

@ -4,6 +4,7 @@ import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchActivity, useTradeRings } from '@/hooks/useTradeIntegrity'; import { useBatchActivity, useTradeRings } from '@/hooks/useTradeIntegrity';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Handshake } from 'lucide-react'; import { Handshake } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) { function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return ( 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. * an attestation for the same listing. Renders nothing when there are no trades.
*/ */
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) { export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
const { t } = useTranslation();
const { data } = useTradeReputation(pubkey); const { data } = useTradeReputation(pubkey);
const verifiedWith = data?.verifiedWith ?? []; const verifiedWith = data?.verifiedWith ?? [];
const partnerPubkeys = verifiedWith.map((v) => v.pubkey); const partnerPubkeys = verifiedWith.map((v) => v.pubkey);
@ -48,7 +50,7 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
0, 0,
); );
const trustLabel = 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. // The classic fake-reputation pattern: several "trades", all with one account.
const singlePartnerWarning = data.verified >= 3 && people <= 1; const singlePartnerWarning = data.verified >= 3 && people <= 1;
// A bigger ring: lots of confirmations from only a couple of accounts. // 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"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]"> <CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
<Handshake className="h-5 w-5" /> <Handshake className="h-5 w-5" />
Trades {t('marketUi.reputation.title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-center"> <div className="grid grid-cols-3 gap-2 text-center">
<Stat label="Verified" value={data.verified} accent /> <Stat label={t('marketUi.reputation.verified')} value={data.verified} accent />
<Stat label="Sales" value={data.sales} /> <Stat label={t('marketUi.reputation.sales')} value={data.sales} />
<Stat label="Purchases" value={data.purchases} /> <Stat label={t('marketUi.reputation.purchases')} value={data.purchases} />
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{data.verified > 0 {data.verified > 0
? `${data.verified} mutually-confirmed ${data.verified === 1 ? 'trade' : 'trades'} with ${people} ${people === 1 ? 'person' : 'people'}` ? `${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')}`
: 'Awaiting mutual confirmation'} : t('marketUi.reputation.awaiting')}
</p> </p>
{data.withPaymentProof > 0 && ( {data.withPaymentProof > 0 && (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> with a payment <span className="font-bold text-[#ccff00]">{data.withPaymentProof}</span> {t('marketUi.reputation.withPaymentProof')} <span className="text-gray-600">{t('marketUi.reputation.selfReported')}</span>
proof attached <span className="text-gray-600">(self-reported — not verified)</span>
</p> </p>
)} )}
{data.paymentVerified > 0 && ( {data.paymentVerified > 0 && (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
<span className="font-bold text-[#ccff00]">{data.paymentVerified}</span> payment-verified <span className="font-bold text-[#ccff00]">{data.paymentVerified}</span> {t('marketUi.reputation.paymentVerified')}
<span className="text-gray-600"> (zap receipt)</span> <span className="text-gray-600"> {t('marketUi.reputation.zapReceipt')}</span>
</p> </p>
)} )}
{singlePartnerWarning && ( {singlePartnerWarning && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300"> <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 ⚠ {t('marketUi.reputation.singlePartnerWarning')}
self-reported, so treat this record with care.
</p> </p>
)} )}
{lowDiversityWarning && ( {lowDiversityWarning && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300"> <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 ⚠ {t('marketUi.reputation.lowDiversityWarning')}
for each other can look busy — treat this record with care.
</p> </p>
)} )}
{rings?.isRing && ( {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"> <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 🔁 {t('marketUi.reputation.ringWarning')}
confirmation inside a ring is weak evidence.
</p> </p>
)} )}
{data.verified > 0 && ( {data.verified > 0 && (
<p className="text-xs text-gray-500"> <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} {trustLabel}
</p> </p>
)} )}
<p className="text-[10px] leading-tight text-gray-600"> <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> </p>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -10,9 +10,11 @@ import { useTradeReputation, TRADE_METHODS, type TradeRole } from '@/hooks/useTr
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
/** One trade record / review row. */ /** One trade record / review row. */
function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) { 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 role = ev.tags.find(([t]) => t === 'd')?.[1]?.split(':')[2] as TradeRole | undefined;
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? ''; const counterparty = ev.tags.find(([t]) => t === 'p')?.[1] ?? '';
const method = ev.tags.find(([t]) => t === 'method')?.[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' ? '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> </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-emerald-400">★ {t('marketUi.reputationPanel.reviewOfYou')}</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-gray-500">{t('marketUi.reputationPanel.yourRecord')}</span>}
{rating > 0 && <DeckRating value={rating} compact className="ml-auto" />} {rating > 0 && <DeckRating value={rating} compact className="ml-auto" />}
</div> </div>
{ev.content && <p className="mt-1 line-clamp-2 text-xs text-gray-300">{ev.content}</p>} {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"> <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>} {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> <span>{date}</span>
{listingPubkey && listingId && ( {listingPubkey && listingId && (
<Link to={`/listing/${listingPubkey}/${listingId}`} className="inline-flex items-center gap-0.5 text-gray-400 hover:text-[#ccff00]"> <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> </Link>
)} )}
</div> </div>
@ -74,6 +76,7 @@ function ReviewRow({ ev, selfPubkey }: { ev: NostrEvent; selfPubkey: string }) {
* and their trade records / reviews (from kind-31871 attestations). * and their trade records / reviews (from kind-31871 attestations).
*/ */
export function TradeReputationPanel({ pubkey }: { pubkey: string }) { export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
const { t } = useTranslation();
const { data, isLoading } = useTradeReputation(pubkey); const { data, isLoading } = useTradeReputation(pubkey);
const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at); const attestations = [...(data?.attestations ?? [])].sort((a, b) => b.created_at - a.created_at);
const reviews = attestations.filter((a) => a.pubkey !== pubkey); 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> <span className="text-lg font-black tabular-nums text-[#ccff00]">{avg.toFixed(1)}/10</span>
<DeckRating value={Math.round(avg)} compact className="text-[#ccff00]" /> <DeckRating value={Math.round(avg)} compact className="text-[#ccff00]" />
<span className="text-xs text-gray-400"> <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> </span>
</div> </div>
)} )}
@ -99,23 +102,22 @@ export function TradeReputationPanel({ pubkey }: { pubkey: string }) {
<div> <div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white"> <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]" /> <Star className="h-4 w-4 text-[#ccff00]" />
Reviews & trade records {t('marketUi.reputationPanel.reviewsTitle')}
{reviews.length > 0 && ( {reviews.length > 0 && (
<span className="rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-bold text-[#ccff00]"> <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> </span>
)} )}
</h3> </h3>
{isLoading ? ( {isLoading ? (
<p className="text-sm text-gray-500">Loading…</p> <p className="text-sm text-gray-500">{t('common.loading')}</p>
) : attestations.length === 0 ? ( ) : attestations.length === 0 ? (
<Card className="border-dashed border-white/10"> <Card className="border-dashed border-white/10">
<CardContent className="py-10 text-center"> <CardContent className="py-10 text-center">
<ThumbsUp className="mx-auto mb-3 h-8 w-8 text-gray-600" /> <ThumbsUp className="mx-auto mb-3 h-8 w-8 text-gray-600" />
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
No trade records yet. Complete a deal and confirm it in <b>My Trades</b> — each {t('marketUi.reputationPanel.emptyBefore')} <b>{t('marketUi.trades.title')}</b> {t('marketUi.reputationPanel.emptyAfter')}
confirmation becomes a review on both profiles.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -12,12 +12,24 @@ interface LanguageProviderProps {
children: ReactNode; 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 * Provides language state + translation. The context, types and hooks live in
* `./language` so this file exports only the component (React Fast Refresh). * `./language` so this file exports only the component (React Fast Refresh).
*/ */
export function LanguageProvider({ children }: LanguageProviderProps) { 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); const [language, setLanguageState] = useState<Language>(storedLanguage);
// Sync with localStorage // 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, type EntityType,
} from '@/lib/types'; } from '@/lib/types';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react'; import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade'; import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -75,6 +76,7 @@ function ChipGrid({
} }
export default function BecomeBuilderPage() { export default function BecomeBuilderPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Become a Builder - SkateSpots', title: 'Become a Builder - SkateSpots',
description: 'List your ramp, park and DIY building services — what you build, materials and scale.', 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); const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`; 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) => { const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
@ -180,7 +182,7 @@ export default function BecomeBuilderPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
setIsGettingLocation(true); setIsGettingLocation(true);
@ -189,11 +191,11 @@ export default function BecomeBuilderPage() {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(error) => { (error) => {
setIsGettingLocation(false); 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]; const url = tags[0]?.[1];
if (url) { if (url) {
setImage(url); setImage(url);
toast({ title: 'Image uploaded' }); toast({ title: t('labUi.become.imageUploaded') });
} }
} catch (error) { } 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 = ''; 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 // Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden. // be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email) const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.' ? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website)) : website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.' ? t('labUi.become.invalidWebsite')
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.' ? t('labUi.become.invalidPortfolio')
: ''; : '';
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (contactError) { if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return; return;
} }
if (selectedServices.length === 0) { 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; return;
} }
@ -302,10 +304,10 @@ export default function BecomeBuilderPage() {
tags, 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}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) { } 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"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<HardHat className="h-12 w-12 mx-auto mb-4 text-gray-600" /> <HardHat className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">You need to log in to create a builder profile.</p> <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" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -334,19 +336,19 @@ export default function BecomeBuilderPage() {
<div className="mb-6"> <div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </Link>
<h1 className="text-3xl font-black uppercase text-amber-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}> <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> </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 && ( {!hasNip05 && (
<Nip05Required <Nip05Required
variant="banner" variant="banner"
color="#eab308" color="#eab308"
role="builders" 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" className="mt-4"
/> />
)} )}
@ -357,42 +359,42 @@ export default function BecomeBuilderPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-amber-400 flex-shrink-0 mt-0.5" /> <Wallet className="h-6 w-6 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <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"> <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> </p>
{hasWallet ? ( {hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50"> <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="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" /> <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> </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>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50"> <div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" /> <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> </div>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<WalletModal> <WalletModal>
<Button className="gap-2 bg-amber-500 hover:bg-amber-600 text-black flex-1"> <Button className="gap-2 bg-amber-500 hover:bg-amber-600 text-black flex-1">
<Wallet className="h-4 w-4" /> <Wallet className="h-4 w-4" />
Connect Wallet with NWC {t('labUi.become.connectWalletNwc')}
</Button> </Button>
</WalletModal> </WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1"> <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"> <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" /> <ExternalLink className="h-4 w-4" />
Learn How {t('labUi.become.learnHow')}
</Button> </Button>
</a> </a>
</div> </div>
<p className="text-xs text-gray-400 italic"> <p className="text-xs text-gray-400 italic">
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). {t('labUi.become.tip')}
</p> </p>
</div> </div>
)} )}
@ -406,21 +408,21 @@ export default function BecomeBuilderPage() {
<Card className="bg-gray-900 border-gray-700"> <Card className="bg-gray-900 border-gray-700">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <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> </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> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <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"> <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="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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Headline</Label> <Label htmlFor="title">{t('labUi.become.headline')}</Label>
<Input <Input
id="title" id="title"
value={titleInput} value={titleInput}
@ -430,13 +432,13 @@ export default function BecomeBuilderPage() {
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
/> />
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Builds</Label> <Label htmlFor="bio">{t('labUi.builderForm.bioLabel')}</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" /> <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> <p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div> </div>
@ -456,7 +458,7 @@ export default function BecomeBuilderPage() {
{/* Build types */} {/* Build types */}
<div className="space-y-3"> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => ( {SERVICE_ENTRIES.map(([service, label]) => (
<button <button
@ -481,82 +483,82 @@ export default function BecomeBuilderPage() {
{/* Materials / trade skills */} {/* Materials / trade skills */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={SKILL_ENTRIES} selected={selectedSkills} onToggle={toggle(setSelectedSkills)} />
</div> </div>
{/* Scale */} {/* Scale */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={SCALE_ENTRIES} selected={selectedScale} onToggle={toggle(setSelectedScale)} />
</div> </div>
{/* Freeform "how we work" */} {/* Freeform "how we work" */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="gear">How You Work / Equipment (optional)</Label> <Label htmlFor="gear">{t('labUi.builderForm.gearLabel')}</Label>
<Textarea <Textarea
id="gear" 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} value={gear}
onChange={(e) => setGear(e.target.value)} onChange={(e) => setGear(e.target.value)}
rows={3} rows={3}
className="bg-gray-800 border-gray-700" 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>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <Input id="experience" type="number" placeholder="10" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="rate">Rate / Quote Basis</Label> <Label htmlFor="rate">{t('labUi.builderForm.rateLabel')}</Label>
<Input id="rate" placeholder="per m² / by project" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" /> <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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serviceArea">Service Area</Label> <Label htmlFor="serviceArea">{t('labUi.builderForm.serviceArea')}</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" /> <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>
<div className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/60 p-3"> <div className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/60 p-3">
<div> <div>
<Label htmlFor="insured" className="font-bold">Licensed / Insured</Label> <Label htmlFor="insured" className="font-bold">{t('labUi.builderForm.insured')}</Label>
<p className="text-xs text-gray-500">Relevant for public park & council work</p> <p className="text-xs text-gray-500">{t('labUi.builderForm.insuredHelper')}</p>
</div> </div>
<Switch id="insured" checked={insured} onCheckedChange={setInsured} /> <Switch id="insured" checked={insured} onCheckedChange={setInsured} />
</div> </div>
<div className="space-y-2"> <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" /> <Input id="location" placeholder="Malmö, SE" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
{/* Contact */} {/* Contact */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-bold">Contact & Portfolio</Label> <Label className="text-base font-bold">{t('labUi.builderForm.contactPortfolio')}</Label>
<p className="text-sm text-gray-400">How the scene can see your builds and reach you (all optional, but recommended)</p> <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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2 sm:col-span-2"> <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" /> <Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</div> </div>
@ -564,7 +566,7 @@ export default function BecomeBuilderPage() {
<PaymentReadinessNote /> <PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5"> <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>
</div> </div>
@ -573,23 +575,23 @@ export default function BecomeBuilderPage() {
selectedSpots={selectedSpots} selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots} onSpotsChange={setSelectedSpots}
maxSpots={8} maxSpots={8}
label="Spots You've Built" label={t('labUi.builderForm.spotsLabel')}
description="Link spots and parks you built so people can see your work on the map" description={t('labUi.builderForm.spotsDesc')}
/> />
<div className="space-y-2"> <div className="space-y-2">
<Label>Coordinates (optional)</Label> <Label>{t('labUi.become.coordinatesOptional')}</Label>
<div className="grid grid-cols-2 gap-4"> <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={t('labUi.become.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.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700"> <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> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Logo / Photo</Label> <Label>{t('labUi.builderForm.logoPhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <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" /> <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"> <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} /> <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> </label>
)} )}
</div> </div>
@ -614,9 +616,9 @@ export default function BecomeBuilderPage() {
disabled={isPublishing || isUploading} disabled={isPublishing || isUploading}
> >
{isPublishing ? ( {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> </Button>
</CardContent> </CardContent>

View file

@ -24,6 +24,7 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; 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 { 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 { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react'; import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade'; import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -33,6 +34,7 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS); const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
export default function BecomeCoachPage() { export default function BecomeCoachPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Become a Coach - SkateSpots', title: 'Become a Coach - SkateSpots',
description: 'Create your skateboarding coach profile.', description: 'Create your skateboarding coach profile.',
@ -115,7 +117,7 @@ export default function BecomeCoachPage() {
const npub = nip19.npubEncode(user.pubkey); const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`; return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
} }
return 'Anonymous Coach'; return t('labUi.coachForm.anonymous');
}; };
const toggleSkill = (skill: string) => { const toggleSkill = (skill: string) => {
@ -124,7 +126,7 @@ export default function BecomeCoachPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
@ -134,11 +136,11 @@ export default function BecomeCoachPage() {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(error) => { (error) => {
setIsGettingLocation(false); 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]; const url = tags[0]?.[1];
if (url) { if (url) {
setImage(url); setImage(url);
toast({ title: 'Image uploaded' }); toast({ title: t('labUi.become.imageUploaded') });
} }
} catch (error) { } 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 = ''; 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 // Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden. // be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email) const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.' ? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website)) : website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.' ? t('labUi.become.invalidWebsite')
: ''; : '';
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (contactError) { if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return; return;
} }
if (selectedSkills.length === 0) { 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; return;
} }
@ -240,10 +242,10 @@ export default function BecomeCoachPage() {
tags, 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}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) { } 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"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<GraduationCap className="h-12 w-12 mx-auto mb-4 text-gray-600" /> <GraduationCap className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">You need to log in to create a coach profile.</p> <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" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -272,19 +274,19 @@ export default function BecomeCoachPage() {
<div className="mb-6"> <div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </Link>
<h1 className="text-3xl font-black uppercase neon-lime" style={{ <h1 className="text-3xl font-black uppercase neon-lime" style={{
fontFamily: 'Impact, "Arial Black", sans-serif' fontFamily: 'Impact, "Arial Black", sans-serif'
}}>Become a Coach</h1> }}>{t('labUi.coachForm.heading')}</h1>
<p className="text-gray-400 mt-1">Share your skateboarding knowledge</p> <p className="text-gray-400 mt-1">{t('labUi.coachForm.tagline')}</p>
{!hasNip05 && ( {!hasNip05 && (
<Nip05Required <Nip05Required
variant="banner" variant="banner"
color="#eab308" color="#eab308"
role="coaches" 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" className="mt-4"
/> />
)} )}
@ -295,19 +297,19 @@ export default function BecomeCoachPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" /> <Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <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"> <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> </p>
{hasWallet ? ( {hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50"> <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="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" /> <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> </div>
<p className="text-xs text-gray-400"> <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> </p>
</div> </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="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" /> <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> </div>
@ -323,7 +325,7 @@ export default function BecomeCoachPage() {
<WalletModal> <WalletModal>
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1"> <Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1">
<Wallet className="h-4 w-4" /> <Wallet className="h-4 w-4" />
Connect Wallet with NWC {t('labUi.become.connectWalletNwc')}
</Button> </Button>
</WalletModal> </WalletModal>
<a <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"> <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" /> <ExternalLink className="h-4 w-4" />
Learn How {t('labUi.become.learnHow')}
</Button> </Button>
</a> </a>
</div> </div>
<p className="text-xs text-gray-400 italic"> <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> </p>
</div> </div>
)} )}
@ -353,20 +355,20 @@ export default function BecomeCoachPage() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700"> <Card className="bg-gray-900 border-gray-700">
<CardHeader> <CardHeader>
<CardTitle>Coach Profile</CardTitle> <CardTitle>{t('labUi.coachForm.profileTitle')}</CardTitle>
<CardDescription className="text-gray-400">Tell students about your experience</CardDescription> <CardDescription className="text-gray-400">{t('labUi.coachForm.profileDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <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"> <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="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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Headline</Label> <Label htmlFor="title">{t('labUi.become.headline')}</Label>
<Input <Input
id="title" id="title"
value={titleInput} value={titleInput}
@ -376,13 +378,13 @@ export default function BecomeCoachPage() {
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
/> />
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bio">Bio / Coaching Philosophy</Label> <Label htmlFor="bio">{t('labUi.coachForm.bioLabel')}</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" /> <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> <p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div> </div>
@ -400,7 +402,7 @@ export default function BecomeCoachPage() {
)} )}
<div className="space-y-3"> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => ( {SKILL_ENTRIES.map(([skill, label]) => (
<button <button
@ -425,39 +427,39 @@ export default function BecomeCoachPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <Input id="experience" type="number" placeholder="15" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="rate">Hourly Rate</Label> <Label htmlFor="rate">{t('labUi.coachForm.hourlyRate')}</Label>
<Input id="rate" placeholder="5000 sats/hr" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" /> <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> </div>
<div className="space-y-2"> <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" /> <Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-bold">Contact Information</Label> <Label className="text-base font-bold">{t('labUi.become.contactInfo')}</Label>
<p className="text-sm text-gray-400">How students can reach you (all optional, but recommended)</p> <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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</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"> <div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
💬 Students can message you directly through SkateSpots' encrypted messaging {t('labUi.coachForm.dmNote')}
</p> </p>
</div> </div>
</div> </div>
@ -478,23 +480,23 @@ export default function BecomeCoachPage() {
selectedSpots={selectedSpots} selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots} onSpotsChange={setSelectedSpots}
maxSpots={5} maxSpots={5}
label="Spots Where You Coach" label={t('labUi.coachForm.spotsLabel')}
description="Select spots where you offer coaching sessions" description={t('labUi.coachForm.spotsDesc')}
/> />
<div className="space-y-2"> <div className="space-y-2">
<Label>Coordinates (optional)</Label> <Label>{t('labUi.become.coordinatesOptional')}</Label>
<div className="grid grid-cols-2 gap-4"> <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={t('labUi.become.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.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700"> <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> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Profile Photo</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <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" /> <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"> <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} /> <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> </label>
)} )}
</div> </div>
@ -521,12 +523,12 @@ export default function BecomeCoachPage() {
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Publishing... {t('labUi.become.publishing')}
</> </>
) : ( ) : (
<> <>
<GraduationCap className="h-4 w-4 mr-2" /> <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> </Button>

View file

@ -30,6 +30,7 @@ import {
import { EntityTypeField } from '@/components/EntityTypeField'; import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react'; import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade'; 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); const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
export default function BecomeEditorPage() { export default function BecomeEditorPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Become a Video Editor - SkateSpots', title: 'Become a Video Editor - SkateSpots',
description: 'Create your skateboarding video editor profile on SkateSpots.', description: 'Create your skateboarding video editor profile on SkateSpots.',
@ -123,7 +125,7 @@ export default function BecomeEditorPage() {
const npub = nip19.npubEncode(user.pubkey); const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`; return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
} }
return 'Anonymous Editor'; return t('labUi.editorForm.anonymous');
}; };
const toggleService = (s: string) => const toggleService = (s: string) =>
@ -134,7 +136,7 @@ export default function BecomeEditorPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
setIsGettingLocation(true); setIsGettingLocation(true);
@ -143,11 +145,11 @@ export default function BecomeEditorPage() {
setLatitude(pos.coords.latitude.toFixed(6)); setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6)); setLongitude(pos.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(err) => { (err) => {
setIsGettingLocation(false); 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 { try {
const tags = await uploadFile(files[0]); const tags = await uploadFile(files[0]);
const url = tags[0]?.[1]; 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) { } 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 = ''; 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 // Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden. // be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email) const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.' ? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website)) : website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.' ? t('labUi.become.invalidWebsite')
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.' ? t('labUi.become.invalidPortfolio')
: ''; : '';
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (contactError) { if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return; return;
} }
if (selectedServices.length === 0) { 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; return;
} }
@ -235,10 +237,10 @@ export default function BecomeEditorPage() {
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags }); 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}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (err) { } 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"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<Scissors className="h-12 w-12 mx-auto mb-4 text-gray-600" /> <Scissors className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">Log in to create your video editor profile.</p> <p className="text-gray-400 mb-6">{t('labUi.editorForm.loginDesc')}</p>
<LoginArea className="justify-center max-w-60 mx-auto" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -267,12 +269,12 @@ export default function BecomeEditorPage() {
<div className="mb-6"> <div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </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)' }}> <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> </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 && ( {!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50"> <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"> <div className="flex items-start gap-3">
<span className="text-yellow-500">⚠️</span> <span className="text-yellow-500">⚠️</span>
<div> <div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Recommended</h3> <h3 className="font-bold text-yellow-400 mb-1">{t('labUi.become.nip05Recommended')}</h3>
<p className="text-sm text-gray-300 mb-2">Verify your identity to build trust with clients.</p> <p className="text-sm text-gray-300 mb-2">{t('labUi.become.nip05RecommendedDesc')}</p>
<Link to="/settings"> <Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold"> <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> </Button>
</Link> </Link>
</div> </div>
@ -298,13 +300,13 @@ export default function BecomeEditorPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-purple-400 flex-shrink-0 mt-0.5" /> <Wallet className="h-6 w-6 text-purple-400 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h3 className="font-bold text-purple-400 mb-2">⚡ Lightning Wallet for Payments</h3> <h3 className="font-bold text-purple-400 mb-2">{t('labUi.editorForm.walletTitle')}</h3>
<p className="text-sm text-gray-300 mb-3">Receive Bitcoin ⚡ payments directly from clients for your editing work.</p> <p className="text-sm text-gray-300 mb-3">{t('labUi.editorForm.walletDesc')}</p>
{hasWallet ? ( {hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50"> <div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" /> <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>
</div> </div>
) : ( ) : (
@ -312,12 +314,12 @@ export default function BecomeEditorPage() {
<WalletModal> <WalletModal>
<Button className="gap-2 bg-purple-600 hover:bg-purple-700 flex-1"> <Button className="gap-2 bg-purple-600 hover:bg-purple-700 flex-1">
<Wallet className="h-4 w-4" /> <Wallet className="h-4 w-4" />
Connect Wallet {t('labUi.become.connectWallet')}
</Button> </Button>
</WalletModal> </WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1"> <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"> <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> </Button>
</a> </a>
</div> </div>
@ -333,22 +335,22 @@ export default function BecomeEditorPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Scissors className="h-5 w-5 text-purple-400" /> <Scissors className="h-5 w-5 text-purple-400" />
Editor Profile {t('labUi.editorForm.profileTitle')}
</CardTitle> </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> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Identity */} {/* Identity */}
<div className="space-y-2"> <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"> <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="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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Headline</Label> <Label htmlFor="title">{t('labUi.become.headline')}</Label>
<Input <Input
id="title" id="title"
value={titleInput} value={titleInput}
@ -358,13 +360,13 @@ export default function BecomeEditorPage() {
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
/> />
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
{/* Profile image */} {/* Profile image */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Profile / Showreel Thumbnail</Label> <Label>{t('labUi.editorForm.thumbnail')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32"> <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" /> <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"> <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" /> <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} /> <input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
</label> </label>
)} )}
@ -383,8 +385,8 @@ export default function BecomeEditorPage() {
{/* Bio */} {/* Bio */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label> <Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</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" /> <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> <p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div> </div>
@ -403,7 +405,7 @@ export default function BecomeEditorPage() {
{/* Edit Types */} {/* Edit Types */}
<div className="space-y-3"> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => ( {SERVICE_ENTRIES.map(([service, label]) => (
<button <button
@ -426,7 +428,7 @@ export default function BecomeEditorPage() {
{/* Software / Hardware Skills */} {/* Software / Hardware Skills */}
<div className="space-y-3"> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => ( {SKILL_ENTRIES.map(([skill, label]) => (
<button <button
@ -451,53 +453,53 @@ export default function BecomeEditorPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="hardware" className="flex items-center gap-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> </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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="software" className="flex items-center gap-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> </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>
</div> </div>
{/* Experience & Rate */} {/* Experience & Rate */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="experience">Years of Experience</Label> <Label htmlFor="experience">{t('labUi.editorForm.yearsExperience')}</Label>
<Input id="experience" placeholder="e.g. 5 years" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" /> <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>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="rate">Rate / Pricing</Label> <Label htmlFor="rate">{t('labUi.editorForm.rate')}</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" /> <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>
</div> </div>
{/* Location */} {/* Location */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Location (optional)</Label> <Label>{t('labUi.editorForm.locationOptional')}</Label>
<Input placeholder="City, Country" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" /> <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"> <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={t('labUi.become.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.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="gap-2 border-gray-700"> <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" />} {isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
Use My Location {t('labUi.become.useMyLocation')}
</Button> </Button>
</div> </div>
{/* Contact & Portfolio */} {/* Contact & Portfolio */}
<div className="space-y-4"> <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"> <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={t('labUi.become.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={t('labUi.editorForm.phoneTelegram')} 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={t('labUi.editorForm.portfolioPlaceholder')} 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={t('labUi.become.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.editorForm.socialPlaceholder')} value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
{contactError && <p className="text-sm text-red-400">{contactError}</p>} {contactError && <p className="text-sm text-red-400">{contactError}</p>}
<PaymentReadinessNote /> <PaymentReadinessNote />
@ -513,7 +515,7 @@ export default function BecomeEditorPage() {
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
<Link to="/lab" className="flex-1"> <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> </Link>
<Button <Button
type="submit" 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" className="flex-1 bg-purple-600 hover:bg-purple-700 font-black uppercase gap-2"
> >
{isPublishing ? ( {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> </Button>
</div> </div>

View file

@ -24,6 +24,7 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; 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 { 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 { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react'; import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade'; 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); const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
export default function BecomeFilmerPage() { export default function BecomeFilmerPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Become a Filmer - SkateSpots', title: 'Become a Filmer - SkateSpots',
description: 'Create your skateboarding filming profile.', description: 'Create your skateboarding filming profile.',
@ -125,7 +127,7 @@ export default function BecomeFilmerPage() {
const npub = nip19.npubEncode(user.pubkey); const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`; return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
} }
return 'Anonymous Filmer'; return t('labUi.filmerForm.anonymous');
}; };
const toggleService = (service: string) => { const toggleService = (service: string) => {
@ -138,7 +140,7 @@ export default function BecomeFilmerPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
@ -148,11 +150,11 @@ export default function BecomeFilmerPage() {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(error) => { (error) => {
setIsGettingLocation(false); 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]; const url = tags[0]?.[1];
if (url) { if (url) {
setImage(url); setImage(url);
toast({ title: 'Image uploaded' }); toast({ title: t('labUi.become.imageUploaded') });
} }
} catch (error) { } 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 = ''; 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 // Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden. // be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email) const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.' ? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website)) : website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.' ? t('labUi.become.invalidWebsite')
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.' ? t('labUi.become.invalidPortfolio')
: ''; : '';
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (contactError) { if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return; return;
} }
if (selectedServices.length === 0) { 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; return;
} }
if (selectedSkills.length === 0) { 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; return;
} }
@ -264,10 +266,10 @@ export default function BecomeFilmerPage() {
tags, 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}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) { } 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"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<Video className="h-12 w-12 mx-auto mb-4 text-gray-600" /> <Video className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">You need to log in to create a filming profile.</p> <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" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -296,19 +298,19 @@ export default function BecomeFilmerPage() {
<div className="mb-6"> <div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </Link>
<h1 className="text-3xl font-black uppercase neon-lime" style={{ <h1 className="text-3xl font-black uppercase neon-lime" style={{
fontFamily: 'Impact, "Arial Black", sans-serif' fontFamily: 'Impact, "Arial Black", sans-serif'
}}>Become a Filmer</h1> }}>{t('labUi.filmerForm.heading')}</h1>
<p className="text-gray-400 mt-1">Offer your skate video filming — clips, lines, full parts &amp; contest coverage</p> <p className="text-gray-400 mt-1">{t('labUi.filmerForm.tagline')}</p>
{!hasNip05 && ( {!hasNip05 && (
<Nip05Required <Nip05Required
variant="banner" variant="banner"
color="#eab308" color="#eab308"
role="filmers" 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" className="mt-4"
/> />
)} )}
@ -319,19 +321,19 @@ export default function BecomeFilmerPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" /> <Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <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"> <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> </p>
{hasWallet ? ( {hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50"> <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="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" /> <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> </div>
<p className="text-xs text-gray-400"> <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> </p>
</div> </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="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" /> <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> </div>
@ -347,7 +349,7 @@ export default function BecomeFilmerPage() {
<WalletModal> <WalletModal>
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1"> <Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1">
<Wallet className="h-4 w-4" /> <Wallet className="h-4 w-4" />
Connect Wallet with NWC {t('labUi.become.connectWalletNwc')}
</Button> </Button>
</WalletModal> </WalletModal>
<a <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"> <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" /> <ExternalLink className="h-4 w-4" />
Learn How {t('labUi.become.learnHow')}
</Button> </Button>
</a> </a>
</div> </div>
<p className="text-xs text-gray-400 italic"> <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> </p>
</div> </div>
)} )}
@ -377,20 +379,20 @@ export default function BecomeFilmerPage() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700"> <Card className="bg-gray-900 border-gray-700">
<CardHeader> <CardHeader>
<CardTitle>Filming Profile</CardTitle> <CardTitle>{t('labUi.filmerForm.profileTitle')}</CardTitle>
<CardDescription className="text-gray-400">Tell clients about your filming expertise</CardDescription> <CardDescription className="text-gray-400">{t('labUi.filmerForm.profileDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <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"> <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="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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Headline</Label> <Label htmlFor="title">{t('labUi.become.headline')}</Label>
<Input <Input
id="title" id="title"
value={titleInput} value={titleInput}
@ -400,13 +402,13 @@ export default function BecomeFilmerPage() {
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
/> />
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label> <Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</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" /> <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> <p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div> </div>
@ -424,7 +426,7 @@ export default function BecomeFilmerPage() {
)} )}
<div className="space-y-3"> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => ( {SERVICE_ENTRIES.map(([service, label]) => (
<button <button
@ -448,7 +450,7 @@ export default function BecomeFilmerPage() {
</div> </div>
<div className="space-y-3"> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => ( {SKILL_ENTRIES.map(([skill, label]) => (
<button <button
@ -473,57 +475,57 @@ export default function BecomeFilmerPage() {
{/* Freeform gear list */} {/* Freeform gear list */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="gear">What's in the Bag (optional)</Label> <Label htmlFor="gear">{t('labUi.filmerForm.gearLabel')}</Label>
<Textarea <Textarea
id="gear" 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} value={gear}
onChange={(e) => setGear(e.target.value)} onChange={(e) => setGear(e.target.value)}
rows={3} rows={3}
className="bg-gray-800 border-gray-700" 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>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <Input id="experience" type="number" placeholder="5" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="rate">Rate</Label> <Label htmlFor="rate">{t('labUi.filmerForm.rate')}</Label>
<Input id="rate" placeholder="10000 sats/video" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" /> <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> </div>
<div className="space-y-2"> <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" /> <Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-bold">Contact Information</Label> <Label className="text-base font-bold">{t('labUi.become.contactInfo')}</Label>
<p className="text-sm text-gray-400">How clients can reach you (all optional, but recommended)</p> <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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2 sm:col-span-2"> <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" /> <Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</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"> <div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
💬 Clients can message you directly through SkateSpots' encrypted messaging {t('labUi.filmerForm.dmNote')}
</p> </p>
</div> </div>
</div> </div>
@ -544,23 +546,23 @@ export default function BecomeFilmerPage() {
selectedSpots={selectedSpots} selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots} onSpotsChange={setSelectedSpots}
maxSpots={5} maxSpots={5}
label="Spots Where You Film" label={t('labUi.filmerForm.spotsLabel')}
description="Select spots where you offer filming services" description={t('labUi.filmerForm.spotsDesc')}
/> />
<div className="space-y-2"> <div className="space-y-2">
<Label>Coordinates (optional)</Label> <Label>{t('labUi.become.coordinatesOptional')}</Label>
<div className="grid grid-cols-2 gap-4"> <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={t('labUi.become.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.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700"> <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> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Profile Photo</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <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" /> <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"> <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} /> <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> </label>
)} )}
</div> </div>
@ -587,12 +589,12 @@ export default function BecomeFilmerPage() {
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Publishing... {t('labUi.become.publishing')}
</> </>
) : ( ) : (
<> <>
<Video className="h-4 w-4 mr-2" /> <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> </Button>

View file

@ -36,6 +36,7 @@ import {
isValidNoffer, isValidNoffer,
} from '@/lib/types'; } from '@/lib/types';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react'; import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade'; import { LabProUpgrade } from '@/components/LabProUpgrade';
@ -81,6 +82,7 @@ function ChipGrid({
} }
export default function BecomePhotographerPage() { export default function BecomePhotographerPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Become a Photographer - SkateSpots', title: 'Become a Photographer - SkateSpots',
description: 'Create your skate photography profile — cameras, lenses, lighting and services.', 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); const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`; 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) => { const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
@ -191,7 +193,7 @@ export default function BecomePhotographerPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
setIsGettingLocation(true); setIsGettingLocation(true);
@ -200,11 +202,11 @@ export default function BecomePhotographerPage() {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(error) => { (error) => {
setIsGettingLocation(false); 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]; const url = tags[0]?.[1];
if (url) { if (url) {
setImage(url); setImage(url);
toast({ title: 'Image uploaded' }); toast({ title: t('labUi.become.imageUploaded') });
} }
} catch (error) { } 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 = ''; 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 // Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden. // be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email) const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.' ? t('labUi.become.invalidEmail')
: website.trim() && !sanitizeUrl(normalizeUrlInput(website)) : website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.' ? t('labUi.become.invalidWebsite')
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio)) : portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.' ? t('labUi.become.invalidPortfolio')
: ''; : '';
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (contactError) { if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' }); toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
return; return;
} }
if (selectedServices.length === 0) { 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; return;
} }
if (selectedCameras.length === 0) { 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; return;
} }
@ -323,10 +325,10 @@ export default function BecomePhotographerPage() {
tags, 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}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) { } 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"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<Camera className="h-12 w-12 mx-auto mb-4 text-gray-600" /> <Camera className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
<p className="text-gray-400 mb-6">You need to log in to create a photography profile.</p> <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" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -355,19 +357,19 @@ export default function BecomePhotographerPage() {
<div className="mb-6"> <div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </Link>
<h1 className="text-3xl font-black uppercase text-indigo-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}> <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> </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 && ( {!hasNip05 && (
<Nip05Required <Nip05Required
variant="banner" variant="banner"
color="#eab308" color="#eab308"
role="photographers" 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" className="mt-4"
/> />
)} )}
@ -378,42 +380,42 @@ export default function BecomePhotographerPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-indigo-400 flex-shrink-0 mt-0.5" /> <Wallet className="h-6 w-6 text-indigo-400 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <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"> <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> </p>
{hasWallet ? ( {hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50"> <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="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" /> <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> </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>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50"> <div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" /> <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> </div>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<WalletModal> <WalletModal>
<Button className="gap-2 bg-indigo-600 hover:bg-indigo-700 flex-1"> <Button className="gap-2 bg-indigo-600 hover:bg-indigo-700 flex-1">
<Wallet className="h-4 w-4" /> <Wallet className="h-4 w-4" />
Connect Wallet with NWC {t('labUi.become.connectWalletNwc')}
</Button> </Button>
</WalletModal> </WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1"> <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"> <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" /> <ExternalLink className="h-4 w-4" />
Learn How {t('labUi.become.learnHow')}
</Button> </Button>
</a> </a>
</div> </div>
<p className="text-xs text-gray-400 italic"> <p className="text-xs text-gray-400 italic">
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). {t('labUi.become.tip')}
</p> </p>
</div> </div>
)} )}
@ -427,21 +429,21 @@ export default function BecomePhotographerPage() {
<Card className="bg-gray-900 border-gray-700"> <Card className="bg-gray-900 border-gray-700">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <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> </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> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <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"> <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="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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Headline</Label> <Label htmlFor="title">{t('labUi.become.headline')}</Label>
<Input <Input
id="title" id="title"
value={titleInput} value={titleInput}
@ -451,13 +453,13 @@ export default function BecomePhotographerPage() {
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
/> />
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label> <Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</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" /> <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> <p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
</div> </div>
@ -477,7 +479,7 @@ export default function BecomePhotographerPage() {
{/* Services */} {/* Services */}
<div className="space-y-3"> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => ( {SERVICE_ENTRIES.map(([service, label]) => (
<button <button
@ -502,87 +504,87 @@ export default function BecomePhotographerPage() {
{/* Camera */} {/* Camera */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={CAMERA_ENTRIES} selected={selectedCameras} onToggle={toggle(setSelectedCameras)} />
</div> </div>
{/* Lenses */} {/* Lenses */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={LENS_ENTRIES} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
</div> </div>
{/* Lighting */} {/* Lighting */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={LIGHTING_ENTRIES} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
</div> </div>
{/* Support */} {/* Support */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={SUPPORT_ENTRIES} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
</div> </div>
{/* Style */} {/* Style */}
<div className="space-y-3"> <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)} /> <ChipGrid entries={STYLE_ENTRIES} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
</div> </div>
{/* Freeform gear list */} {/* Freeform gear list */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="gear">What's in the Bag (optional)</Label> <Label htmlFor="gear">{t('labUi.filmerForm.gearLabel')}</Label>
<Textarea <Textarea
id="gear" 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} value={gear}
onChange={(e) => setGear(e.target.value)} onChange={(e) => setGear(e.target.value)}
rows={3} rows={3}
className="bg-gray-800 border-gray-700" 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>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <Input id="experience" type="number" placeholder="5" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="rate">Rate</Label> <Label htmlFor="rate">{t('labUi.filmerForm.rate')}</Label>
<Input id="rate" placeholder="20000 sats/shoot" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" /> <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> </div>
<div className="space-y-2"> <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" /> <Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
{/* Contact */} {/* Contact */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base font-bold">Contact & Portfolio</Label> <Label className="text-base font-bold">{t('labUi.builderForm.contactPortfolio')}</Label>
<p className="text-sm text-gray-400">How riders can see your work and reach you (all optional, but recommended)</p> <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="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2"> <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" /> <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>
<div className="space-y-2 sm:col-span-2"> <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" /> <Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</div> </div>
@ -590,7 +592,7 @@ export default function BecomePhotographerPage() {
<PaymentReadinessNote /> <PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5"> <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>
</div> </div>
@ -599,23 +601,23 @@ export default function BecomePhotographerPage() {
selectedSpots={selectedSpots} selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots} onSpotsChange={setSelectedSpots}
maxSpots={5} maxSpots={5}
label="Spots Where You Shoot" label={t('labUi.photoForm.spotsLabel')}
description="Select spots where you offer photography" description={t('labUi.photoForm.spotsDesc')}
/> />
<div className="space-y-2"> <div className="space-y-2">
<Label>Coordinates (optional)</Label> <Label>{t('labUi.become.coordinatesOptional')}</Label>
<div className="grid grid-cols-2 gap-4"> <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={t('labUi.become.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.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700"> <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> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Profile Photo</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800"> <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" /> <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"> <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} /> <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> </label>
)} )}
</div> </div>
@ -640,9 +642,9 @@ export default function BecomePhotographerPage() {
disabled={isPublishing || isUploading} disabled={isPublishing || isUploading}
> >
{isPublishing ? ( {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> </Button>
</CardContent> </CardContent>

View file

@ -30,10 +30,12 @@ import {
type SkateSpot, type SkateSpot,
} from '@/lib/types'; } from '@/lib/types';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; 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'; import { X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles, UserRound, ShieldCheck, Zap, FileText, Globe, Map as MapIcon, Search, Check } from 'lucide-react';
export default function BecomeSkaterPage() { export default function BecomeSkaterPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Join the Skater Directory - SkateSpots', title: 'Join the Skater Directory - SkateSpots',
description: 'Create your skater profile so the community can find you.', 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 { data: allSpots, isLoading: loadingSpots } = useSkateSpots(500);
const metadata = author.data?.metadata; 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; const avatar = metadata?.picture;
// kind-0 values we borrow from so we never re-ask for what the profile has. // kind-0 values we borrow from so we never re-ask for what the profile has.
const kind0About = metadata?.about ?? ''; const kind0About = metadata?.about ?? '';
@ -110,7 +112,7 @@ export default function BecomeSkaterPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' }); toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
return; return;
} }
setIsGettingLocation(true); setIsGettingLocation(true);
@ -119,11 +121,11 @@ export default function BecomeSkaterPage() {
setLatitude(pos.coords.latitude.toFixed(6)); setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6)); setLongitude(pos.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ title: 'Location found' }); toast({ title: t('labUi.become.locationFound') });
}, },
(err) => { (err) => {
setIsGettingLocation(false); 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 (!user) return;
if (styles.length === 0) { 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; return;
} }
@ -198,12 +200,12 @@ export default function BecomeSkaterPage() {
await createEvent({ kind: SKATE_SKATER_KIND, content, tags }); await createEvent({ kind: SKATE_SKATER_KIND, content, tags });
toast({ toast({
title: existing ? 'Profile updated!' : "You're on the map! 🛹", title: existing ? t('labUi.skaterForm.updated') : t('labUi.skaterForm.created'),
description: 'Other skaters can now discover you.', description: t('labUi.skaterForm.discoverable'),
}); });
navigate('/skaters'); navigate('/skaters');
} catch (error) { } 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"> <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" /> <PersonStanding className="h-8 w-8 text-white" />
</div> </div>
<h2 className="text-2xl font-black uppercase mb-2">Log in to join</h2> <h2 className="text-2xl font-black uppercase mb-2">{t('labUi.skaterForm.loginToJoin')}</h2>
<p className="text-gray-400 mb-6">Create your skater profile so the community can find you.</p> <p className="text-gray-400 mb-6">{t('labUi.skaterForm.loginToJoinDesc')}</p>
<LoginArea className="justify-center max-w-60 mx-auto" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </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"> <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"> <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" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Skaters {t('labUi.skaterForm.backToSkaters')}
</Link> </Link>
{/* Hero */} {/* Hero */}
@ -250,10 +252,10 @@ export default function BecomeSkaterPage() {
</Avatar> </Avatar>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-2xl font-black uppercase leading-none"> <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> </h1>
<p className="text-gray-400 text-sm mt-1.5 truncate"> <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> </p>
</div> </div>
</div> </div>
@ -267,8 +269,7 @@ export default function BecomeSkaterPage() {
> >
<UserRound className="h-5 w-5 text-gray-400 flex-shrink-0" /> <UserRound className="h-5 w-5 text-gray-400 flex-shrink-0" />
<p className="text-xs text-gray-400 flex-1 leading-relaxed"> <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. {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')}
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.
</p> </p>
</Link> </Link>
@ -276,7 +277,7 @@ export default function BecomeSkaterPage() {
<div className="flex flex-wrap items-center gap-1.5 pl-8"> <div className="flex flex-wrap items-center gap-1.5 pl-8">
{avatar && ( {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"> <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>
)} )}
<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"> <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> </span>
{kind0About.trim() && ( {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"> <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> </span>
)} )}
{kind0Website.trim() && ( {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"> <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> </span>
)} )}
{kind0Nip05 && ( {kind0Nip05 && (
@ -299,7 +300,7 @@ export default function BecomeSkaterPage() {
)} )}
{kind0Lud16 && ( {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"> <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> </span>
)} )}
</div> </div>
@ -307,7 +308,7 @@ export default function BecomeSkaterPage() {
{loadingExisting ? ( {loadingExisting ? (
<div className="flex items-center justify-center py-16 text-gray-500"> <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> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
@ -316,16 +317,16 @@ export default function BecomeSkaterPage() {
<CardContent className="p-5 space-y-5"> <CardContent className="p-5 space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-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() && ( {bio.trim() && bio.trim() === kind0About.trim() && (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500 flex items-center gap-1"> <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> </span>
)} )}
</div> </div>
<Textarea <Textarea
id="bio" 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} value={bio}
onChange={(e) => setBio(e.target.value)} onChange={(e) => setBio(e.target.value)}
rows={4} rows={4}
@ -333,14 +334,14 @@ export default function BecomeSkaterPage() {
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{kind0About.trim() {kind0About.trim()
? 'Pulled from your profile — tweak it for your skater card, or leave it as is.' ? t('labUi.skaterForm.bioHelperPulled')
: 'A short intro for your skater card.'} : t('labUi.skaterForm.bioHelperShort')}
</p> </p>
</div> </div>
{/* Profile Pic — always your Nostr profile photo, no separate override */} {/* Profile Pic — always your Nostr profile photo, no separate override */}
<div className="space-y-2"> <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="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"> <div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0 ring-2 ring-white/10">
{avatar ? ( {avatar ? (
@ -351,19 +352,19 @@ export default function BecomeSkaterPage() {
</div> </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"> <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> </span>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-muted-foreground mb-2"> <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> </p>
<Link <Link
to="/settings?section=profile" to="/settings?section=profile"
className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ff0040] hover:underline" 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" /> <UserRound className="h-3.5 w-3.5" />
Change profile photo {t('labUi.skaterForm.changePhoto')}
</Link> </Link>
</div> </div>
</div> </div>
@ -375,8 +376,8 @@ export default function BecomeSkaterPage() {
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-3"> <CardContent className="p-5 space-y-3">
<div> <div>
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">What you skate *</Label> <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">Pick everything that fits your style.</p> <p className="text-xs text-muted-foreground mt-0.5">{t('labUi.skaterForm.pickEverything')}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{styleEntries.map(([key, { label, emoji }]) => { {styleEntries.map(([key, { label, emoji }]) => {
@ -405,7 +406,7 @@ export default function BecomeSkaterPage() {
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5"> <CardContent className="p-5 space-y-5">
<div className="space-y-3"> <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"> <div className="flex flex-wrap gap-2">
{stanceEntries.map(([key, { label, emoji }]) => ( {stanceEntries.map(([key, { label, emoji }]) => (
<button <button
@ -424,7 +425,7 @@ export default function BecomeSkaterPage() {
</div> </div>
<div className="space-y-3"> <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"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{levelEntries.map(([key, { label, emoji, color }]) => { {levelEntries.map(([key, { label, emoji, color }]) => {
const active = levelMode === 'preset' && level === key; const active = levelMode === 'preset' && level === key;
@ -466,42 +467,42 @@ export default function BecomeSkaterPage() {
)} )}
> >
<div className="text-lg leading-none mb-0.5">✏️</div> <div className="text-lg leading-none mb-0.5">✏️</div>
Custom {t('labUi.skaterForm.custom')}
</button> </button>
</div> </div>
{levelMode === 'custom' && ( {levelMode === 'custom' && (
<div className="pt-1"> <div className="pt-1">
<Input <Input
id="custom-level" id="custom-level"
placeholder="e.g. OG, Weekend Warrior, Retired…" placeholder={t('labUi.skaterForm.customPlaceholder')}
value={customLevel} value={customLevel}
maxLength={40} maxLength={40}
onChange={(e) => setCustomLevel(e.target.value)} onChange={(e) => setCustomLevel(e.target.value)}
className="border-[#ccff00]/30 focus-visible:ring-[#ccff00]/40" className="border-[#ccff00]/30 focus-visible:ring-[#ccff00]/40"
/> />
<p className="text-xs text-muted-foreground mt-1.5"> <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> </p>
</div> </div>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">Years skating</Label> <Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">{t('labUi.skaterForm.yearsSkating')}</Label>
<Input id="years" placeholder="e.g. 8" value={years} onChange={(e) => setYears(e.target.value)} className="max-w-[160px]" /> <Input id="years" placeholder={t('labUi.skaterForm.yearsPlaceholder')} value={years} onChange={(e) => setYears(e.target.value)} className="max-w-[160px]" />
</div> </div>
{/* Home spot — free text OR link a real mapped spot */} {/* Home spot — free text OR link a real mapped spot */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-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 <button
type="button" type="button"
onClick={() => setSpotPickerOpen(true)} onClick={() => setSpotPickerOpen(true)}
className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ccff00] hover:underline" 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" /> <MapIcon className="h-3.5 w-3.5" />
Browse spots {t('labUi.skaterForm.browseSpots')}
</button> </button>
</div> </div>
@ -512,16 +513,16 @@ export default function BecomeSkaterPage() {
<MapPin className="h-4 w-4 text-[#ccff00]" /> <MapPin className="h-4 w-4 text-[#ccff00]" />
</div> </div>
<div className="min-w-0 flex-1"> <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"> <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> </p>
</div> </div>
<button <button
type="button" type="button"
onClick={clearSpotLink} onClick={clearSpotLink}
className="p-1.5 rounded-full hover:bg-white/10 transition-colors flex-shrink-0" 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" /> <X className="h-4 w-4 text-gray-400" />
</button> </button>
@ -529,13 +530,13 @@ export default function BecomeSkaterPage() {
) : ( ) : (
<Input <Input
id="home" id="home"
placeholder="Local park / plaza — or tap “Browse spots”" placeholder={t('labUi.skaterForm.homePlaceholder')}
value={homeSpot} value={homeSpot}
onChange={(e) => setHomeSpot(e.target.value)} onChange={(e) => setHomeSpot(e.target.value)}
/> />
)} )}
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
</CardContent> </CardContent>
@ -545,25 +546,25 @@ export default function BecomeSkaterPage() {
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5"> <CardContent className="p-5 space-y-5">
<div className="space-y-2"> <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)} /> <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"> <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> </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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <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)} /> <Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-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() && ( {website.trim() && website.trim() === kind0Website.trim() && (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500 flex items-center gap-1"> <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> </span>
)} )}
</div> </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}> <Button type="submit" className="w-full h-12 bg-[#ff0040] hover:bg-[#ff0040]/90 uppercase font-black text-base" disabled={isPublishing}>
{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> </Button>
</form> </form>
@ -590,7 +591,7 @@ export default function BecomeSkaterPage() {
<DialogHeader className="p-5 pb-3 border-b border-white/10"> <DialogHeader className="p-5 pb-3 border-b border-white/10">
<DialogTitle className="flex items-center gap-2 uppercase font-black"> <DialogTitle className="flex items-center gap-2 uppercase font-black">
<MapIcon className="h-5 w-5 text-[#ccff00]" /> <MapIcon className="h-5 w-5 text-[#ccff00]" />
Pick your home spot {t('labUi.skaterForm.pickHomeSpot')}
</DialogTitle> </DialogTitle>
</DialogHeader> </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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input <Input
autoFocus autoFocus
placeholder="Search spots by name or city…" placeholder={t('labUi.skaterForm.searchSpots')}
value={spotSearch} value={spotSearch}
onChange={(e) => setSpotSearch(e.target.value)} onChange={(e) => setSpotSearch(e.target.value)}
className="pl-9" className="pl-9"
@ -610,16 +611,16 @@ export default function BecomeSkaterPage() {
<ScrollArea className="max-h-[50vh] px-4 pb-4"> <ScrollArea className="max-h-[50vh] px-4 pb-4">
{loadingSpots ? ( {loadingSpots ? (
<div className="flex items-center justify-center py-12 text-gray-500"> <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> </div>
) : filteredSpots.length === 0 ? ( ) : filteredSpots.length === 0 ? (
<div className="text-center py-12 px-4"> <div className="text-center py-12 px-4">
<MapPin className="h-8 w-8 mx-auto text-gray-600 mb-3" /> <MapPin className="h-8 w-8 mx-auto text-gray-600 mb-3" />
<p className="text-sm text-gray-400"> <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> </p>
<Link to="/map" className="text-xs font-bold uppercase tracking-wide text-[#ccff00] hover:underline mt-2 inline-block"> <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> </Link>
</div> </div>
) : ( ) : (

View file

@ -22,6 +22,7 @@ import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { isAdminPubkey } from '@/lib/admins'; 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 { 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 { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language';
import { OfficialBadge } from '@/components/OfficialBadge'; import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge'; import { ProBadge } from '@/components/ProBadge';
import NotFound from './NotFound'; import NotFound from './NotFound';
@ -82,6 +83,7 @@ function CoachDetailSkeleton() {
} }
export default function CoachDetailPage() { export default function CoachDetailPage() {
const { t } = useTranslation();
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: profile, isLoading, error, refetch } = useLabProfile(identifier || '', pubkey); const { data: profile, isLoading, error, refetch } = useLabProfile(identifier || '', pubkey);
const author = useAuthor(profile?.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 getSkillDescription = (skill: string): string | undefined => DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill];
const TYPE_NOUNS: Record<string, string> = { const TYPE_NOUNS: Record<string, string> = {
coach: 'coach', filmer: 'filmer', editor: 'video editor', photographer: 'photographer', builder: 'builder', 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: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician', judge: 'event judge', 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: 'event organizer', organizer: t('labUi.detail.noun.organizer'),
barber: 'barber', 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> = { const SKILLS_TITLES: Record<string, string> = {
coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build', coach: t('labUi.detail.skillsTitle.skills'), photographer: t('labUi.detail.skillsTitle.servicesGear'), builder: t('labUi.detail.skillsTitle.whatWeBuild'),
artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles', artist: t('labUi.detail.skillsTitle.whatTheyMake'), graffiti: t('labUi.detail.skillsTitle.stylesWork'), tattoo: t('labUi.detail.skillsTitle.styles'),
trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties', trainer: t('labUi.detail.skillsTitle.focusSpecialties'), musician: t('labUi.detail.skillsTitle.genresInstruments'), judge: t('labUi.detail.skillsTitle.formatsSpecialties'),
barber: 'Services & Styles', organizer: 'Event Types & Services', 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 ( return (
<> <>
@ -236,7 +238,7 @@ export default function CoachDetailPage() {
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab {t('labUi.become.backToLab')}
</Link> </Link>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <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}`} /> <TypeIcon className={`h-7 w-7 ${accent.text}`} />
{profile.entityType === 'collective' && ( {profile.entityType === 'collective' && (
<Badge variant="outline" className={`gap-1 ${accent.text} ${accent.border}`}> <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> </Badge>
)} )}
<TrustBadge pubkey={profile.pubkey} size="md" /> <TrustBadge pubkey={profile.pubkey} size="md" />
@ -286,7 +288,7 @@ export default function CoachDetailPage() {
</div> </div>
<p className="text-gray-400 flex items-center gap-2"> <p className="text-gray-400 flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Joined {new Date(profile.createdAt * 1000).toLocaleDateString()} {t('labUi.detail.joined')} {new Date(profile.createdAt * 1000).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>
@ -294,7 +296,7 @@ export default function CoachDetailPage() {
{/* Bio */} {/* Bio */}
{profile.bio && ( {profile.bio && (
<div className="mb-6"> <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> <p className="text-gray-300 whitespace-pre-wrap">{profile.bio}</p>
</div> </div>
)} )}
@ -329,7 +331,7 @@ export default function CoachDetailPage() {
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2"> <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 ? <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> </h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}> <p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('gear')} {pget('gear')}
@ -342,13 +344,13 @@ export default function CoachDetailPage() {
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('hardware') && ( {pget('hardware') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}> <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> <p className="text-sm text-gray-300">{pget('hardware')}</p>
</div> </div>
)} )}
{pget('software') && ( {pget('software') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}> <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> <p className="text-sm text-gray-300">{pget('software')}</p>
</div> </div>
)} )}
@ -359,7 +361,7 @@ export default function CoachDetailPage() {
{isJudge && pget('events_judged') && ( {isJudge && pget('events_judged') && (
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2"> <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> </h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}> <p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('events_judged')} {pget('events_judged')}
@ -371,7 +373,7 @@ export default function CoachDetailPage() {
{isOrganizer && pget('events_hosted') && ( {isOrganizer && pget('events_hosted') && (
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2"> <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> </h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}> <p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{pget('events_hosted')} {pget('events_hosted')}
@ -383,7 +385,7 @@ export default function CoachDetailPage() {
{'spotRefs' in profile && profile.spotRefs && profile.spotRefs.length > 0 && ( {'spotRefs' in profile && profile.spotRefs && profile.spotRefs.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2"> <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> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{profile.spotRefs.map((ref) => { {profile.spotRefs.map((ref) => {
@ -395,7 +397,7 @@ export default function CoachDetailPage() {
to={`/spot/${pk}/${rest.join(':')}`} 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`} 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> </Link>
); );
})} })}
@ -409,7 +411,7 @@ export default function CoachDetailPage() {
{pget('serviceArea') && ( {pget('serviceArea') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}> <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"> <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> </h4>
<p className="text-sm text-gray-300">{pget('serviceArea')}</p> <p className="text-sm text-gray-300">{pget('serviceArea')}</p>
</div> </div>
@ -417,7 +419,7 @@ export default function CoachDetailPage() {
{pbool('insured') && ( {pbool('insured') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}> <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}`} /> <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>
)} )}
</div> </div>
@ -429,7 +431,7 @@ export default function CoachDetailPage() {
{pget('studio') && ( {pget('studio') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}> <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"> <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> </h4>
<p className="text-sm text-gray-300">{pget('studio')}</p> <p className="text-sm text-gray-300">{pget('studio')}</p>
</div> </div>
@ -437,7 +439,7 @@ export default function CoachDetailPage() {
{pbool('booksOpen') && ( {pbool('booksOpen') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}> <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}`} /> <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>
)} )}
</div> </div>
@ -449,7 +451,7 @@ export default function CoachDetailPage() {
{pget('studio') && ( {pget('studio') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}> <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"> <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> </h4>
<p className="text-sm text-gray-300">{pget('studio')}</p> <p className="text-sm text-gray-300">{pget('studio')}</p>
</div> </div>
@ -457,7 +459,7 @@ export default function CoachDetailPage() {
{pbool('booksOpen') && ( {pbool('booksOpen') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}> <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}`} /> <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>
)} )}
</div> </div>
@ -469,7 +471,7 @@ export default function CoachDetailPage() {
{pget('credentials') && ( {pget('credentials') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}> <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"> <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> </h4>
<p className="text-sm text-gray-300">{pget('credentials')}</p> <p className="text-sm text-gray-300">{pget('credentials')}</p>
</div> </div>
@ -477,7 +479,7 @@ export default function CoachDetailPage() {
{pbool('remote') && ( {pbool('remote') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}> <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}`} /> <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>
)} )}
</div> </div>
@ -487,7 +489,7 @@ export default function CoachDetailPage() {
{isGraffiti && pget('crew') && ( {isGraffiti && pget('crew') && (
<div className="mb-6"> <div className="mb-6">
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1"> <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> </h4>
<p className="text-sm text-gray-300">{pget('crew')}</p> <p className="text-sm text-gray-300">{pget('crew')}</p>
</div> </div>
@ -498,14 +500,14 @@ export default function CoachDetailPage() {
<div className="mb-6 space-y-3"> <div className="mb-6 space-y-3">
{'actType' in profile && profile.actType && ( {'actType' in profile && profile.actType && (
<div> <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> <p className="text-sm text-gray-300 capitalize">{profile.actType}</p>
</div> </div>
)} )}
{(('spotify' in profile && profile.spotify) || ('bandcamp' in profile && profile.bandcamp) || ('soundcloud' in profile && profile.soundcloud) || ('youtube' in profile && profile.youtube)) && ( {(('spotify' in profile && profile.spotify) || ('bandcamp' in profile && profile.bandcamp) || ('soundcloud' in profile && profile.soundcloud) || ('youtube' in profile && profile.youtube)) && (
<div> <div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-1"> <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> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{'spotify' in profile && sanitizeUrl(profile.spotify) && ( {'spotify' in profile && sanitizeUrl(profile.spotify) && (
@ -531,7 +533,7 @@ export default function CoachDetailPage() {
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2"> <h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
<Calendar className={`h-4 w-4 ${accent.text}`} /> <Calendar className={`h-4 w-4 ${accent.text}`} />
Upcoming Gigs {t('labUi.detail.upcomingGigs')}
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{gigs.slice(0, 6).map((gig) => { {gigs.slice(0, 6).map((gig) => {
@ -553,7 +555,7 @@ export default function CoachDetailPage() {
</p> </p>
</div> </div>
{!isUpcoming && ( {!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> </Link>
); );
@ -566,13 +568,13 @@ export default function CoachDetailPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{profile.experience && ( {profile.experience && (
<div> <div>
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">Experience</h4> <h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.experience')}</h4>
<p className="text-gray-300">{profile.experience} years</p> <p className="text-gray-300">{t('labUi.detail.yearsCount', { count: profile.experience })}</p>
</div> </div>
)} )}
{profile.rate && ( {profile.rate && (
<div> <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> <p className="text-gray-300">{profile.rate}</p>
</div> </div>
)} )}
@ -588,7 +590,7 @@ export default function CoachDetailPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-lg text-[#ccff00] uppercase font-black flex items-center gap-2"> <CardTitle className="text-lg text-[#ccff00] uppercase font-black flex items-center gap-2">
<MessageCircle className="h-5 w-5" /> <MessageCircle className="h-5 w-5" />
Contact {t('labUi.detail.contact')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@ -599,7 +601,7 @@ export default function CoachDetailPage() {
onClick={() => navigate(`/messages/${npub}`)} onClick={() => navigate(`/messages/${npub}`)}
> >
<MessageCircle className="h-4 w-4" /> <MessageCircle className="h-4 w-4" />
Send Message {t('labUi.detail.sendMessage')}
</Button> </Button>
{/* Book this act for an event — deep-links to event creation {/* 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" /> <Calendar className="h-4 w-4" />
Book for a Gig {t('labUi.detail.bookGig')}
</Button> </Button>
)} )}
@ -658,7 +660,7 @@ export default function CoachDetailPage() {
{!isCoach && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && ( {!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!)}`}> <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]" /> <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> </a>
)} )}
@ -704,21 +706,21 @@ export default function CoachDetailPage() {
<Card className="bg-black/90 border-2 border-red-500/30"> <Card className="bg-black/90 border-2 border-red-500/30">
<CardHeader> <CardHeader>
<CardTitle className="text-lg text-red-400 uppercase font-black"> <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{user.pubkey === profile.pubkey {user.pubkey === profile.pubkey
? `Remove your ${typeNoun} profile from The Lab.` ? t('labUi.detail.manageRemove', { noun: typeNoun })
: `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`} : t('labUi.detail.adminTakeDown', { noun: typeNoun })}
</p> </p>
<DeleteEntityDialog <DeleteEntityDialog
event={profile.event} event={profile.event}
entityLabel={`${typeNoun} profile`} entityLabel={t('labUi.detail.entityLabel', { noun: typeNoun })}
entityName={profile.title} entityName={profile.title}
redirectTo="/lab" redirectTo="/lab"
triggerText="Delete Profile" triggerText={t('labUi.detail.deleteProfile')}
className="w-full justify-center" className="w-full justify-center"
/> />
</CardContent> </CardContent>
@ -760,7 +762,7 @@ export default function CoachDetailPage() {
<CardHeader> <CardHeader>
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2"> <CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
<MapPin className={`h-5 w-5 ${accent.text}`} /> <MapPin className={`h-5 w-5 ${accent.text}`} />
Location {t('labUi.detail.location')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View file

@ -21,9 +21,11 @@ import { Nip05Required } from '@/components/Nip05Required';
import { SpamFeePayment } from '@/components/SpamFeePayment'; 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 { 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 { ShoppingBag, Upload, X, Loader2, ArrowLeft, MapPin, AlertTriangle, CheckCircle, Search, Clock, Shield, Zap } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
export default function CreateListingPage() { export default function CreateListingPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'List Gear - SkateSpots', title: 'List Gear - SkateSpots',
description: 'List skateboarding gear for sale or find gear you want to buy.', 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]; const url = tags[0]?.[1];
if (url) { if (url) {
setImages((prev) => [...prev, url]); setImages((prev) => [...prev, url]);
toast({ title: 'Image uploaded' }); toast({ title: t('marketUi.create.imageUploaded') });
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Upload failed', title: t('marketUi.create.uploadFailed'),
description: error instanceof Error ? error.message : 'Failed', description: error instanceof Error ? error.message : t('marketUi.create.failed'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -172,16 +174,16 @@ export default function CreateListingPage() {
if (!restrictions.canCreateListing && !feePaid) { if (!restrictions.canCreateListing && !feePaid) {
if (restrictions.isInCooldown) { if (restrictions.isInCooldown) {
toast({ toast({
title: 'Cooldown active', title: t('marketUi.create.cooldownActive'),
description: `Please wait ${formatCooldownTime(restrictions.timeUntilCooldownEnds)} before creating another listing`, description: t('marketUi.create.cooldownWait', { time: formatCooldownTime(restrictions.timeUntilCooldownEnds) }),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
} }
if (restrictions.remainingListings <= 0) { if (restrictions.remainingListings <= 0) {
toast({ toast({
title: 'Listing limit reached', title: t('marketUi.create.limitReached'),
description: `You've reached your monthly limit of ${restrictions.listingLimit} listings`, description: t('marketUi.create.limitReachedBody', { limit: restrictions.listingLimit }),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -190,8 +192,8 @@ export default function CreateListingPage() {
if (restrictions.requiresFee && !feePaid) { if (restrictions.requiresFee && !feePaid) {
toast({ toast({
title: 'Fee required', title: t('marketUi.create.feeRequired'),
description: 'Please pay the anti-spam fee to continue', description: t('marketUi.create.feeRequiredBody'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -199,19 +201,19 @@ export default function CreateListingPage() {
// Validation // Validation
if (!title.trim()) { if (!title.trim()) {
toast({ title: 'Title required', variant: 'destructive' }); toast({ title: t('marketUi.create.titleRequired'), variant: 'destructive' });
return; return;
} }
if (!category) { if (!category) {
toast({ title: 'Category required', variant: 'destructive' }); toast({ title: t('marketUi.create.categoryRequired'), variant: 'destructive' });
return; return;
} }
if (listingType === 'selling' && images.length === 0) { if (listingType === 'selling' && images.length === 0) {
toast({ toast({
title: 'Photo required', title: t('marketUi.create.photoRequired'),
description: 'At least one photo is required when selling gear', description: t('marketUi.create.photoRequiredBody'),
variant: 'destructive' variant: 'destructive'
}); });
return; return;
@ -219,23 +221,23 @@ export default function CreateListingPage() {
if (!hasNip05) { if (!hasNip05) {
toast({ toast({
title: 'NIP-05 verification required', title: t('marketUi.create.nip05Required'),
description: 'You need a verified Nostr address to list gear', description: t('marketUi.create.nip05RequiredBody'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
} }
if (priceType === 'fixed' && !price.trim()) { if (priceType === 'fixed' && !price.trim()) {
toast({ title: 'Price required for fixed price listings', variant: 'destructive' }); toast({ title: t('marketUi.create.priceRequired'), variant: 'destructive' });
return; return;
} }
// Category-specific validation // Category-specific validation
if (requiresSize && !size) { if (requiresSize && !size) {
toast({ toast({
title: 'Size required', title: t('marketUi.create.sizeRequired'),
description: `Please select a size for ${GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES]}`, description: t('marketUi.create.sizeRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
variant: 'destructive' variant: 'destructive'
}); });
return; return;
@ -243,8 +245,8 @@ export default function CreateListingPage() {
if (requiresCondition && !condition) { if (requiresCondition && !condition) {
toast({ toast({
title: 'Condition required', title: t('marketUi.create.conditionRequired'),
description: `Please select the condition of your ${GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES]}`, description: t('marketUi.create.conditionRequiredBody', { category: GEAR_CATEGORIES[category as keyof typeof GEAR_CATEGORIES] }),
variant: 'destructive' variant: 'destructive'
}); });
return; return;
@ -325,8 +327,8 @@ export default function CreateListingPage() {
} }
toast({ toast({
title: listingType === 'wtb' ? 'Want to Buy posted!' : 'Listing created!', title: listingType === 'wtb' ? t('marketUi.create.wtbPosted') : t('marketUi.create.listingCreated'),
description: listingType === 'wtb' ? 'Sellers will be able to find your request.' : 'Your gear is now listed.', description: listingType === 'wtb' ? t('marketUi.create.wtbPostedBody') : t('marketUi.create.listingCreatedBody'),
}); });
// Navigate back // Navigate back
@ -337,8 +339,8 @@ export default function CreateListingPage() {
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to publish', title: t('marketUi.create.publishFailed'),
description: error instanceof Error ? error.message : 'Error', description: error instanceof Error ? error.message : t('common.error'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -351,8 +353,8 @@ export default function CreateListingPage() {
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700"> <Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-gray-600" /> <ShoppingBag className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2> <h2 className="text-xl font-bold mb-2">{t('marketUi.create.loginRequired')}</h2>
<p className="text-gray-400 mb-6">You need to log in to list gear.</p> <p className="text-gray-400 mb-6">{t('marketUi.create.loginRequiredBody')}</p>
<LoginArea className="justify-center max-w-60 mx-auto" /> <LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent> </CardContent>
</Card> </Card>
@ -370,15 +372,15 @@ export default function CreateListingPage() {
className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <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> </Link>
<PageHeader <PageHeader
title={listingType === 'wtb' ? 'Want to Buy' : 'List Your Gear'} title={listingType === 'wtb' ? t('marketUi.create.wantToBuy') : t('marketUi.create.listYourGear')}
accent="#ff6600" accent="#ff6600"
subtitle={ subtitle={
listingType === 'wtb' listingType === 'wtb'
? "Tell the community what gear you're looking for" ? t('marketUi.create.wtbSubtitle')
: 'Sell skateboarding gear to the community' : t('marketUi.create.sellSubtitle')
} }
/> />
</div> </div>
@ -392,9 +394,9 @@ export default function CreateListingPage() {
<ShoppingBag className="h-5 w-5 text-purple-400" /> <ShoppingBag className="h-5 w-5 text-purple-400" />
</div> </div>
<div className="flex-1"> <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"> <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> </p>
</div> </div>
<Link to="/stash" className="text-purple-400 hover:text-purple-300"> <Link to="/stash" className="text-purple-400 hover:text-purple-300">
@ -410,7 +412,7 @@ export default function CreateListingPage() {
<Nip05Required <Nip05Required
variant="banner" variant="banner"
role="sellers" 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" className="mb-6"
/> />
)} )}
@ -421,7 +423,7 @@ export default function CreateListingPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-400 flex-shrink-0" /> <CheckCircle className="h-5 w-5 text-green-400 flex-shrink-0" />
<div> <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> <span className="text-gray-300">{metadata?.nip05}</span>
</div> </div>
</div> </div>
@ -436,7 +438,7 @@ export default function CreateListingPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" /> <Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
<div> <div>
<span className="text-gray-400">Checking your trust level...</span> <span className="text-gray-400">{t('marketUi.create.checkingTrust')}</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -452,7 +454,7 @@ export default function CreateListingPage() {
<Shield className="h-5 w-5 text-blue-400 flex-shrink-0" /> <Shield className="h-5 w-5 text-blue-400 flex-shrink-0" />
<div> <div>
<div className="flex items-center gap-2"> <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 <Badge
variant="outline" variant="outline"
className={ className={
@ -462,14 +464,14 @@ export default function CreateListingPage() {
'border-gray-500 text-gray-400' 'border-gray-500 text-gray-400'
} }
> >
{restrictions.trustLevel === 'trusted' ? '⭐ Trusted' : {restrictions.trustLevel === 'trusted' ? `⭐ ${t('marketUi.create.trusted')}` :
restrictions.trustLevel === 'active' ? '🔥 Active' : restrictions.trustLevel === 'active' ? `🔥 ${t('marketUi.create.active')}` :
restrictions.trustLevel === 'newcomer' ? '🌱 Newcomer' : restrictions.trustLevel === 'newcomer' ? `🌱 ${t('marketUi.create.newcomer')}` :
'👋 New User'} `👋 ${t('marketUi.create.newUser')}`}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-gray-400 mt-1"> <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> </p>
</div> </div>
</div> </div>
@ -485,12 +487,12 @@ export default function CreateListingPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Clock className="h-5 w-5 text-orange-400 flex-shrink-0 mt-0.5" /> <Clock className="h-5 w-5 text-orange-400 flex-shrink-0 mt-0.5" />
<div> <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"> <p className="text-sm text-gray-300">
New users have a 24-hour cooldown between listings. {t('marketUi.create.cooldownBody')}
<br /> <br />
<span className="font-semibold"> <span className="font-semibold">
Time remaining: {formatCooldownTime(restrictions.timeUntilCooldownEnds)} {t('marketUi.create.timeRemaining', { time: formatCooldownTime(restrictions.timeUntilCooldownEnds) })}
</span> </span>
</p> </p>
</div> </div>
@ -506,10 +508,9 @@ export default function CreateListingPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div> <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"> <p className="text-sm text-gray-300">
You've reached your limit of {restrictions.listingLimit} listings this month. {t('marketUi.create.monthlyLimitBody', { limit: restrictions.listingLimit })}
Build your reputation by creating spots or completing sales to increase your limit.
</p> </p>
</div> </div>
</div> </div>
@ -540,9 +541,9 @@ export default function CreateListingPage() {
<Zap className="h-5 w-5 text-green-400 flex-shrink-0" /> <Zap className="h-5 w-5 text-green-400 flex-shrink-0" />
<div> <div>
<span className="font-bold text-green-400"> <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>
<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>
</div> </div>
</CardContent> </CardContent>
@ -553,7 +554,7 @@ export default function CreateListingPage() {
{/* Listing Type Toggle */} {/* Listing Type Toggle */}
<Card className="bg-gray-900 border-gray-700 mb-6"> <Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader> <CardHeader>
<CardTitle>What do you want to do?</CardTitle> <CardTitle>{t('marketUi.create.whatDoTitle')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -568,7 +569,7 @@ export default function CreateListingPage() {
onClick={() => setListingType('selling')} onClick={() => setListingType('selling')}
> >
<span className="text-2xl">💰</span> <span className="text-2xl">💰</span>
<span className="font-bold">SELL GEAR</span> <span className="font-bold">{t('marketUi.create.sellGear')}</span>
</Button> </Button>
<Button <Button
type="button" type="button"
@ -581,7 +582,7 @@ export default function CreateListingPage() {
onClick={() => setListingType('wtb')} onClick={() => setListingType('wtb')}
> >
<span className="text-2xl">🔍</span> <span className="text-2xl">🔍</span>
<span className="font-bold">WANT TO BUY</span> <span className="font-bold">{t('marketUi.create.wantToBuy')}</span>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -590,8 +591,8 @@ export default function CreateListingPage() {
{/* Category Selection */} {/* Category Selection */}
<Card className="bg-gray-900 border-gray-700 mb-6"> <Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader> <CardHeader>
<CardTitle>Category *</CardTitle> <CardTitle>{t('marketUi.create.categoryTitle')}</CardTitle>
<CardDescription className="text-gray-400">What type of gear?</CardDescription> <CardDescription className="text-gray-400">{t('marketUi.create.categoryHint')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2"> <div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
@ -623,17 +624,17 @@ export default function CreateListingPage() {
{/* Gear Details */} {/* Gear Details */}
<Card className="bg-gray-900 border-gray-700 mb-6"> <Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader> <CardHeader>
<CardTitle>Gear Details</CardTitle> <CardTitle>{t('marketUi.create.detailsTitle')}</CardTitle>
<CardDescription className="text-gray-400"> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Title *</Label> <Label htmlFor="title">{t('marketUi.create.titleLabel')}</Label>
<Input <Input
id="title" 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} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
@ -642,10 +643,10 @@ export default function CreateListingPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="summary">Short Summary</Label> <Label htmlFor="summary">{t('marketUi.create.summaryLabel')}</Label>
<Input <Input
id="summary" 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} value={summary}
onChange={(e) => setSummary(e.target.value)} onChange={(e) => setSummary(e.target.value)}
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
@ -653,13 +654,13 @@ export default function CreateListingPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t('marketUi.create.descriptionLabel')}</Label>
<Textarea <Textarea
id="description" id="description"
placeholder={ placeholder={
listingType === 'wtb' listingType === 'wtb'
? 'Describe exactly what you\'re looking for - brand preferences, condition requirements, etc.' ? t('marketUi.create.descriptionPlaceholderWtb')
: 'Got this deck for Christmas but prefer a smaller size. Happy to let you try it before buying!' : t('marketUi.create.descriptionPlaceholderSell')
} }
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
@ -671,7 +672,7 @@ export default function CreateListingPage() {
{/* Condition (only for selling) */} {/* Condition (only for selling) */}
{listingType === 'selling' && ( {listingType === 'selling' && (
<div className="space-y-2"> <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"> <div className="grid grid-cols-2 gap-2">
{Object.entries(GEAR_CONDITIONS).map(([key, { label, emoji, description }]) => ( {Object.entries(GEAR_CONDITIONS).map(([key, { label, emoji, description }]) => (
<Button <Button
@ -697,12 +698,12 @@ export default function CreateListingPage() {
{listingType === 'selling' && ( {listingType === 'selling' && (
<div className="space-y-2"> <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"> <div className="grid grid-cols-3 gap-2">
{([ {([
['pickup', '📍 Pickup', 'Meet locally'], ['pickup', `📍 ${t('marketUi.create.pickup')}`, t('marketUi.create.pickupHint')],
['ships', '📦 Ships', 'Post it'], ['ships', `📦 ${t('marketUi.create.ships')}`, t('marketUi.create.shipsHint')],
['both', '📍📦 Both', 'Either'], ['both', `📍📦 ${t('marketUi.create.both')}`, t('marketUi.create.bothHint')],
] as const).map(([key, label, hint]) => ( ] as const).map(([key, label, hint]) => (
<Button <Button
key={key} key={key}
@ -721,19 +722,19 @@ export default function CreateListingPage() {
))} ))}
</div> </div>
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
)} )}
{listingType === 'selling' && fulfilment !== 'pickup' && ( {listingType === 'selling' && fulfilment !== 'pickup' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Who pays postage?</Label> <Label>{t('marketUi.create.postageLabel')}</Label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{([ {([
['buyer', 'Buyer pays'], ['buyer', t('marketUi.create.postageBuyer')],
['seller', 'I pay'], ['seller', t('marketUi.create.postageSeller')],
['split', 'Split'], ['split', t('marketUi.create.postageSplit')],
] as const).map(([key, label]) => ( ] as const).map(([key, label]) => (
<Button <Button
key={key} key={key}
@ -756,10 +757,10 @@ export default function CreateListingPage() {
{/* Size */} {/* Size */}
{currentSizeOptions.length > 0 && ( {currentSizeOptions.length > 0 && (
<div className="space-y-2"> <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}> <Select value={size} onValueChange={setSize}>
<SelectTrigger className="bg-gray-800 border-gray-700"> <SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue placeholder="Select size" /> <SelectValue placeholder={t('marketUi.create.selectSize')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{currentSizeOptions.map((s) => ( {currentSizeOptions.map((s) => (
@ -775,10 +776,10 @@ export default function CreateListingPage() {
{/* Truck Height */} {/* Truck Height */}
{showTruckHeight && ( {showTruckHeight && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Truck Height</Label> <Label>{t('marketUi.create.truckHeight')}</Label>
<Select value={truckHeight} onValueChange={setTruckHeight}> <Select value={truckHeight} onValueChange={setTruckHeight}>
<SelectTrigger className="bg-gray-800 border-gray-700"> <SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue placeholder="Select height" /> <SelectValue placeholder={t('marketUi.create.selectHeight')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TRUCK_HEIGHTS.map((h) => ( {TRUCK_HEIGHTS.map((h) => (
@ -794,10 +795,10 @@ export default function CreateListingPage() {
{/* Wheel Hardness */} {/* Wheel Hardness */}
{showWheelHardness && ( {showWheelHardness && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Wheel Hardness</Label> <Label>{t('marketUi.create.wheelHardness')}</Label>
<Select value={wheelHardness} onValueChange={setWheelHardness}> <Select value={wheelHardness} onValueChange={setWheelHardness}>
<SelectTrigger className="bg-gray-800 border-gray-700"> <SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue placeholder="Select hardness" /> <SelectValue placeholder={t('marketUi.create.selectHardness')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{WHEEL_HARDNESS.map((h) => ( {WHEEL_HARDNESS.map((h) => (
@ -815,9 +816,9 @@ export default function CreateListingPage() {
{/* Pricing */} {/* Pricing */}
<Card className="bg-gray-900 border-gray-700 mb-6"> <Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader> <CardHeader>
<CardTitle>Pricing</CardTitle> <CardTitle>{t('marketUi.create.pricingTitle')}</CardTitle>
<CardDescription className="text-gray-400"> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@ -843,7 +844,7 @@ export default function CreateListingPage() {
{priceType === 'fixed' && ( {priceType === 'fixed' && (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2"> <div className="col-span-2 space-y-2">
<Label htmlFor="price">Price *</Label> <Label htmlFor="price">{t('marketUi.create.priceLabel')}</Label>
<Input <Input
id="price" id="price"
type="number" type="number"
@ -857,7 +858,7 @@ export default function CreateListingPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="currency">Currency</Label> <Label htmlFor="currency">{t('marketUi.create.currencyLabel')}</Label>
<Select value={currency} onValueChange={setCurrency}> <Select value={currency} onValueChange={setCurrency}>
<SelectTrigger className="bg-gray-800 border-gray-700"> <SelectTrigger className="bg-gray-800 border-gray-700">
<SelectValue /> <SelectValue />
@ -880,12 +881,12 @@ export default function CreateListingPage() {
<Card className="bg-gray-900 border-gray-700 mb-6"> <Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
Photos {listingType === 'selling' && '*'} {t('marketUi.create.photosTitle')} {listingType === 'selling' && '*'}
</CardTitle> </CardTitle>
<CardDescription className="text-gray-400"> <CardDescription className="text-gray-400">
{listingType === 'selling' {listingType === 'selling'
? 'At least one photo is required' ? t('marketUi.create.photosSellHint')
: 'Optional - add reference images'} : t('marketUi.create.photosWtbHint')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -897,7 +898,7 @@ export default function CreateListingPage() {
> >
<img <img
src={imageUrl} src={imageUrl}
alt={`Product ${index + 1}`} alt={t('marketUi.create.productPhoto', { n: index + 1 })}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<button <button
@ -923,14 +924,14 @@ export default function CreateListingPage() {
) : ( ) : (
<> <>
<Upload className="h-6 w-6 mb-1 text-gray-500" /> <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> </label>
)} )}
</div> </div>
<p className="text-xs text-gray-400 mt-2"> <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> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -940,10 +941,10 @@ export default function CreateListingPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-orange-500" /> <MapPin className="h-5 w-5 text-orange-500" />
Pickup Location {t('marketUi.create.pickupLocationTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-gray-400"> <CardDescription className="text-gray-400">
Link to a skate spot for easy meetups {t('marketUi.create.pickupLocationHint')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -979,7 +980,7 @@ export default function CreateListingPage() {
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input <Input
placeholder="Search spots..." placeholder={t('marketUi.create.searchSpots')}
value={spotSearchQuery} value={spotSearchQuery}
onChange={(e) => setSpotSearchQuery(e.target.value)} onChange={(e) => setSpotSearchQuery(e.target.value)}
className="bg-gray-800 border-gray-700 pl-9" className="bg-gray-800 border-gray-700 pl-9"
@ -1006,7 +1007,7 @@ export default function CreateListingPage() {
))} ))}
{filteredSpots.length === 0 && ( {filteredSpots.length === 0 && (
<p className="text-sm text-gray-400 text-center py-4"> <p className="text-sm text-gray-400 text-center py-4">
No spots found. Try a different search. {t('marketUi.create.noSpotsFound')}
</p> </p>
)} )}
</div> </div>
@ -1014,16 +1015,16 @@ export default function CreateListingPage() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="availability">When are you usually available?</Label> <Label htmlFor="availability">{t('marketUi.create.availabilityLabel')}</Label>
<Input <Input
id="availability" id="availability"
placeholder="Weekends 10am-2pm, Tue/Thu evenings" placeholder={t('marketUi.create.availabilityPlaceholder')}
value={availability} value={availability}
onChange={(e) => setAvailability(e.target.value)} onChange={(e) => setAvailability(e.target.value)}
className="bg-gray-800 border-gray-700" className="bg-gray-800 border-gray-700"
/> />
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
💡 Helps buyers coordinate meetups with you 💡 {t('marketUi.create.availabilityHint')}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -1032,9 +1033,9 @@ export default function CreateListingPage() {
{/* Expiration */} {/* Expiration */}
<Card className="bg-gray-900 border-gray-700 mb-6"> <Card className="bg-gray-900 border-gray-700 mb-6">
<CardHeader> <CardHeader>
<CardTitle>Listing Duration</CardTitle> <CardTitle>{t('marketUi.create.durationTitle')}</CardTitle>
<CardDescription className="text-gray-400"> <CardDescription className="text-gray-400">
How long should this listing stay active? {t('marketUi.create.durationHint')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1075,32 +1076,32 @@ export default function CreateListingPage() {
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-5 w-5 mr-2 animate-spin" /> <Loader2 className="h-5 w-5 mr-2 animate-spin" />
Publishing... {t('marketUi.create.publishing')}
</> </>
) : restrictions.isLoading && !restrictions.isPlatformOwner ? ( ) : restrictions.isLoading && !restrictions.isPlatformOwner ? (
<> <>
<Loader2 className="h-5 w-5 mr-2 animate-spin" /> <Loader2 className="h-5 w-5 mr-2 animate-spin" />
Checking eligibility... {t('marketUi.create.checkingEligibility')}
</> </>
) : restrictions.isInCooldown && !restrictions.isPlatformOwner ? ( ) : restrictions.isInCooldown && !restrictions.isPlatformOwner ? (
<> <>
<Clock className="h-5 w-5 mr-2" /> <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 ? ( ) : restrictions.remainingListings <= 0 && !restrictions.isPlatformOwner ? (
<> <>
<AlertTriangle className="h-5 w-5 mr-2" /> <AlertTriangle className="h-5 w-5 mr-2" />
LIMIT REACHED {t('marketUi.create.limitReachedButton')}
</> </>
) : restrictions.requiresFee && !feePaid && !restrictions.isPlatformOwner ? ( ) : restrictions.requiresFee && !feePaid && !restrictions.isPlatformOwner ? (
<> <>
<Zap className="h-5 w-5 mr-2" /> <Zap className="h-5 w-5 mr-2" />
PAY FEE TO CONTINUE {t('marketUi.create.payFeeButton')}
</> </>
) : ( ) : (
<> <>
<ShoppingBag className="h-5 w-5 mr-2" /> <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> </Button>

View file

@ -40,8 +40,8 @@ export default function CreateSpotPage() {
const { t } = useTranslation(); const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Add a Skate Spot - SkateSpots', title: `${t('createSpot.title')} - SkateSpots`,
description: 'Share a new skateboarding spot with the community.', description: t('createSpot.subtitle'),
}); });
const navigate = useNavigate(); const navigate = useNavigate();
@ -113,9 +113,9 @@ export default function CreateSpotPage() {
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Photo upload failed', title: t('spotUi.photoUploadFailed'),
description: 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', variant: 'destructive',
}); });
} finally { } finally {
@ -134,8 +134,8 @@ export default function CreateSpotPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ toast({
title: 'Geolocation not supported', title: t('spotUi.geolocationNotSupported'),
description: 'Your browser does not support geolocation.', description: t('spotUi.geolocationNotSupportedDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -148,14 +148,14 @@ export default function CreateSpotPage() {
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ toast({
title: 'Location found', title: t('spotUi.locationFound'),
description: 'Your current location has been set.', description: t('spotUi.locationFoundDesc'),
}); });
}, },
(error) => { (error) => {
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ toast({
title: 'Location error', title: t('spotUi.locationError'),
description: error.message, description: error.message,
variant: 'destructive', variant: 'destructive',
}); });
@ -179,15 +179,15 @@ export default function CreateSpotPage() {
} }
if (added > 0) { if (added > 0) {
toast({ toast({
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded', title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
description: description:
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.', added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'),
}); });
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Upload failed', title: t('spotUi.uploadFailed'),
description: error instanceof Error ? error.message : 'Failed to upload image', description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -202,14 +202,14 @@ export default function CreateSpotPage() {
setImages((prev) => [...prev, url]); setImages((prev) => [...prev, url]);
setShowCamera(false); setShowCamera(false);
toast({ toast({
title: 'Photo captured!', title: t('spotUi.photoCaptured'),
description: 'Your photo has been uploaded successfully.', description: t('spotUi.photoCapturedDesc'),
}); });
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Upload failed', title: t('spotUi.uploadFailed'),
description: error instanceof Error ? error.message : 'Failed to upload photo', description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -224,8 +224,8 @@ export default function CreateSpotPage() {
if (!title.trim()) { if (!title.trim()) {
toast({ toast({
title: 'Title required', title: t('spotUi.titleRequired'),
description: 'Please enter a name for this spot.', description: t('spotUi.titleRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -233,8 +233,8 @@ export default function CreateSpotPage() {
if (!latitude || !longitude) { if (!latitude || !longitude) {
toast({ toast({
title: 'Location required', title: t('spotUi.locationRequired'),
description: 'Please enter coordinates or use your current location.', description: t('spotUi.locationRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -245,8 +245,8 @@ export default function CreateSpotPage() {
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
toast({ toast({
title: 'Invalid coordinates', title: t('spotUi.invalidCoordinates'),
description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).', description: t('spotUi.invalidCoordinatesDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -254,8 +254,8 @@ export default function CreateSpotPage() {
if (!selectedCategory) { if (!selectedCategory) {
toast({ toast({
title: 'Spot type required', title: t('spotUi.spotTypeRequired'),
description: 'Please pick a spot type so skaters know what they’re rolling up to.', description: t('spotUi.spotTypeRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -265,8 +265,8 @@ export default function CreateSpotPage() {
// spot would go out without the photo the user just took. // spot would go out without the photo the user just took.
if (isUploadingQuickPhoto) { if (isUploadingQuickPhoto) {
toast({ toast({
title: 'Hang tight', title: t('spotUi.hangTight'),
description: 'Your photo is still uploading. It’ll be attached in a second.', description: t('spotUi.hangTightDesc'),
}); });
return; return;
} }
@ -277,8 +277,8 @@ export default function CreateSpotPage() {
// mapper who has no picture on hand but can describe the place. // mapper who has no picture on hand but can describe the place.
if (images.length === 0 && !description.trim()) { if (images.length === 0 && !description.trim()) {
toast({ toast({
title: 'Add a photo or a description', title: t('spotUi.addPhotoOrDescription'),
description: 'Give skaters something to go on — a picture, or a line about the place.', description: t('spotUi.addPhotoOrDescriptionDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -300,8 +300,8 @@ export default function CreateSpotPage() {
if (nearby.length > 0) { if (nearby.length > 0) {
setDupWarning(nearby); setDupWarning(nearby);
toast({ toast({
title: 'That spot may already be on the map', title: t('spotUi.spotMayAlreadyBeOnMap'),
description: 'Check the pin below before adding a duplicate.', description: t('spotUi.checkPinBelow'),
}); });
return; return;
} }
@ -380,7 +380,7 @@ export default function CreateSpotPage() {
} }
// Add alt tag for NIP-31 (human-readable description) // 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({ await createEvent({
kind: SKATE_SPOT_KIND, kind: SKATE_SPOT_KIND,
@ -389,10 +389,10 @@ export default function CreateSpotPage() {
}); });
toast({ toast({
title: 'Spot added!', title: t('spotUi.spotAdded'),
description: images.length === 0 description: images.length === 0
? 'Published — and marked Unverified until someone adds a photo. A picture of the spot goes a long way.' ? t('spotUi.spotAddedNoPhoto')
: 'Your skate spot has been published to the network.', : t('spotUi.spotAddedDesc'),
}); });
// Navigate to spot detail using pubkey and identifier // Navigate to spot detail using pubkey and identifier
@ -400,8 +400,8 @@ export default function CreateSpotPage() {
} catch (error) { } catch (error) {
console.error('Failed to create spot:', error); console.error('Failed to create spot:', error);
toast({ toast({
title: 'Failed to publish', title: t('spotUi.failedToPublish'),
description: error instanceof Error ? error.message : 'Something went wrong. Check console for details.', description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -663,9 +663,9 @@ export default function CreateSpotPage() {
{/* Shelter / weather protection */} {/* Shelter / weather protection */}
<div className="space-y-3"> <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"> <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> </p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => ( {shelterOptions.map((option) => (
@ -700,14 +700,14 @@ export default function CreateSpotPage() {
{/* Freeform Tags */} {/* Freeform Tags */}
<div className="space-y-3"> <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"> <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> </p>
<TagInput <TagInput
tags={tags} tags={tags}
onChange={setTags} onChange={setTags}
placeholder="Type to search or add tags..." placeholder={t('spotUi.typeToSearchTags')}
maxTags={MAX_TAGS} maxTags={MAX_TAGS}
/> />
</div> </div>
@ -717,13 +717,13 @@ export default function CreateSpotPage() {
<Label> <Label>
{t('spots.photos')} {t('spots.photos')}
<span className="ml-2 text-[11px] font-normal text-muted-foreground"> <span className="ml-2 text-[11px] font-normal text-muted-foreground">
strongly recommended — spots without a photo stay marked Unverified {t('spotUi.photosRecommended')}
</span> </span>
</Label> </Label>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{images.map((url) => ( {images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted"> <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 <button
type="button" type="button"
onClick={() => removeImage(url)} onClick={() => removeImage(url)}
@ -737,7 +737,7 @@ export default function CreateSpotPage() {
{isUploadingQuickPhoto && ( {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"> <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" /> <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> </div>
)} )}
{/* Camera capture button */} {/* 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" 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" /> <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> </button>
{/* File upload / drop zone — accepts several photos at once */} {/* File upload / drop zone — accepts several photos at once */}
<PhotoDropzone <PhotoDropzone
@ -783,12 +783,12 @@ export default function CreateSpotPage() {
{dupWarning && ( {dupWarning && (
<div className="rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4"> <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"> <p className="text-sm font-black uppercase tracking-wide text-amber-300">
This spot may already exist {t('spotUi.spotMayAlreadyExist')}
</p> </p>
<p className="mt-1 text-xs text-gray-300"> <p className="mt-1 text-xs text-gray-300">
{dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of {dupWarning.length === 1
your pin. If it's the same place, add your photo or a status report there ? t('spotUi.duplicateOne')
instead of creating a duplicate. : t('spotUi.duplicateOther')}
</p> </p>
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{dupWarning.map(({ spot, km }) => ( {dupWarning.map(({ spot, km }) => (
@ -797,7 +797,7 @@ export default function CreateSpotPage() {
to={`/spot/${spot.pubkey}/${spot.identifier}`} to={`/spot/${spot.pubkey}/${spot.identifier}`}
className="text-[#ccff00] hover:opacity-80" 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> </Link>
</li> </li>
))} ))}
@ -813,7 +813,7 @@ export default function CreateSpotPage() {
void handleSubmit(); void handleSubmit();
}} }}
> >
It's a different spot — publish anyway {t('spotUi.differentSpotPublishAnyway')}
</Button> </Button>
<Button <Button
type="button" type="button"
@ -821,7 +821,7 @@ export default function CreateSpotPage() {
className="text-gray-400" className="text-gray-400"
onClick={() => setDupWarning(null)} onClick={() => setDupWarning(null)}
> >
Cancel {t('common.cancel')}
</Button> </Button>
</div> </div>
</div> </div>
@ -841,7 +841,7 @@ export default function CreateSpotPage() {
) : isUploadingQuickPhoto ? ( ) : isUploadingQuickPhoto ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <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 { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useSkateSpot } from '@/hooks/useSkateSpots'; import { useSkateSpot } from '@/hooks/useSkateSpots';
import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { PhotoDropzone } from '@/components/PhotoDropzone'; import { PhotoDropzone } from '@/components/PhotoDropzone';
import { TagInput } from '@/components/TagInput'; import { TagInput } from '@/components/TagInput';
@ -60,6 +61,7 @@ function EditSpotSkeleton() {
} }
export default function EditSpotPage() { export default function EditSpotPage() {
const { t } = useTranslation();
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
@ -84,8 +86,8 @@ export default function EditSpotPage() {
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
useSeoMeta({ useSeoMeta({
title: spot ? `Edit ${spot.title} - SkateSpots` : 'Edit Spot - SkateSpots', title: spot ? `${t('spots.editSpot')} ${spot.title} - SkateSpots` : `${t('spots.editSpot')} - SkateSpots`,
description: 'Edit your skateboarding spot.', description: t('spotUi.editSpotSeoDesc'),
}); });
// Initialize form with existing data // Initialize form with existing data
@ -123,8 +125,8 @@ export default function EditSpotPage() {
const getCurrentLocation = () => { const getCurrentLocation = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
toast({ toast({
title: 'Geolocation not supported', title: t('spotUi.geolocationNotSupported'),
description: 'Your browser does not support geolocation.', description: t('spotUi.geolocationNotSupportedDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -137,14 +139,14 @@ export default function EditSpotPage() {
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ toast({
title: 'Location found', title: t('spotUi.locationFound'),
description: 'Your current location has been set.', description: t('spotUi.locationFoundDesc'),
}); });
}, },
(error) => { (error) => {
setIsGettingLocation(false); setIsGettingLocation(false);
toast({ toast({
title: 'Location error', title: t('spotUi.locationError'),
description: error.message, description: error.message,
variant: 'destructive', variant: 'destructive',
}); });
@ -167,15 +169,15 @@ export default function EditSpotPage() {
} }
if (added > 0) { if (added > 0) {
toast({ toast({
title: added > 1 ? `${added} photos uploaded` : 'Image uploaded', title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
description: description:
added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.', added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'),
}); });
} }
} catch (error) { } catch (error) {
toast({ toast({
title: 'Upload failed', title: t('spotUi.uploadFailed'),
description: error instanceof Error ? error.message : 'Failed to upload image', description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -192,8 +194,8 @@ export default function EditSpotPage() {
if (!title.trim()) { if (!title.trim()) {
toast({ toast({
title: 'Title required', title: t('spotUi.titleRequired'),
description: 'Please enter a name for this spot.', description: t('spotUi.titleRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -201,8 +203,8 @@ export default function EditSpotPage() {
if (!latitude || !longitude) { if (!latitude || !longitude) {
toast({ toast({
title: 'Location required', title: t('spotUi.locationRequired'),
description: 'Please enter coordinates or use your current location.', description: t('spotUi.locationRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -213,8 +215,8 @@ export default function EditSpotPage() {
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
toast({ toast({
title: 'Invalid coordinates', title: t('spotUi.invalidCoordinates'),
description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).', description: t('spotUi.invalidCoordinatesDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -222,8 +224,8 @@ export default function EditSpotPage() {
if (!selectedCategory) { if (!selectedCategory) {
toast({ toast({
title: 'Spot type required', title: t('spotUi.spotTypeRequired'),
description: 'Please pick a spot type so skaters know what they’re rolling up to.', description: t('spotUi.spotTypeRequiredDesc'),
variant: 'destructive', variant: 'destructive',
}); });
return; return;
@ -248,7 +250,7 @@ export default function EditSpotPage() {
['access', accessType], ['access', accessType],
['lat', lat.toString()], ['lat', lat.toString()],
['lon', lng.toString()], ['lon', lng.toString()],
['alt', `Skate spot: ${title.trim()}`], ['alt', t('spotUi.altSkateSpot', { title: title.trim() })],
]; ];
pushRegionTags(eventTags, geo); pushRegionTags(eventTags, geo);
@ -290,15 +292,15 @@ export default function EditSpotPage() {
}); });
toast({ toast({
title: 'Spot updated!', title: t('spotUi.spotUpdated'),
description: 'Your changes have been published to the network.', description: t('spotUi.spotUpdatedDesc'),
}); });
navigate(`/spot/${user?.pubkey}/${spot.identifier}`); navigate(`/spot/${user?.pubkey}/${spot.identifier}`);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to update', title: t('spotUi.failedToUpdate'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -320,9 +322,9 @@ export default function EditSpotPage() {
<Card className="max-w-xl mx-auto border-dashed"> <Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" /> <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"> <p className="text-muted-foreground mb-6">
You need to log in to edit this spot. {t('spotUi.loginToEditSpot')}
</p> </p>
<LoginArea className="justify-center" /> <LoginArea className="justify-center" />
</CardContent> </CardContent>
@ -339,12 +341,12 @@ export default function EditSpotPage() {
<Card className="max-w-xl mx-auto border-destructive"> <Card className="max-w-xl mx-auto border-destructive">
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-destructive" /> <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"> <p className="text-muted-foreground mb-6">
You can only edit spots that you created. {t('spotUi.accessDeniedDesc')}
</p> </p>
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}> <Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
<Button variant="outline">Back to Spot</Button> <Button variant="outline">{t('spotUi.backToSpot')}</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@ -362,29 +364,29 @@ export default function EditSpotPage() {
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Spot {t('spotUi.backToSpot')}
</Link> </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"> <p className="text-muted-foreground mt-1">
Update the details for {spot.title} {t('spotUi.updateDetailsFor', { title: spot.title })}
</p> </p>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Spot Details</CardTitle> <CardTitle>{t('spots.spotDetails')}</CardTitle>
<CardDescription> <CardDescription>
Update the information about this spot. All fields marked with * are required. {t('spotUi.updateSpotDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Title */} {/* Title */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">Spot Name *</Label> <Label htmlFor="title">{t('spots.spotName')} *</Label>
<Input <Input
id="title" id="title"
placeholder="e.g., Hollywood High School" placeholder={t('createSpot.namePlaceholder')}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
required required
@ -393,10 +395,10 @@ export default function EditSpotPage() {
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t('spots.description')}</Label>
<Textarea <Textarea
id="description" id="description"
placeholder="Describe the spot, obstacles, best times to skate, security info, etc." placeholder={t('createSpot.descriptionPlaceholder')}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={4} rows={4}
@ -405,10 +407,10 @@ export default function EditSpotPage() {
{/* Location Address */} {/* Location Address */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="location">Address / Location</Label> <Label htmlFor="location">{t('spots.address')}</Label>
<Input <Input
id="location" id="location"
placeholder="e.g., 1521 N Highland Ave, Los Angeles, CA" placeholder={t('createSpot.addressPlaceholder')}
value={location} value={location}
onChange={(e) => setLocation(e.target.value)} onChange={(e) => setLocation(e.target.value)}
/> />
@ -416,11 +418,11 @@ export default function EditSpotPage() {
{/* Coordinates */} {/* Coordinates */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Coordinates *</Label> <Label>{t('createSpot.coordinates')} *</Label>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Input <Input
placeholder="Latitude (e.g., 34.1012)" placeholder={t('createSpot.latitudePlaceholder')}
value={latitude} value={latitude}
onChange={(e) => setLatitude(e.target.value)} onChange={(e) => setLatitude(e.target.value)}
required required
@ -428,7 +430,7 @@ export default function EditSpotPage() {
</div> </div>
<div> <div>
<Input <Input
placeholder="Longitude (e.g., -118.3389)" placeholder={t('createSpot.longitudePlaceholder')}
value={longitude} value={longitude}
onChange={(e) => setLongitude(e.target.value)} onChange={(e) => setLongitude(e.target.value)}
required required
@ -446,12 +448,12 @@ export default function EditSpotPage() {
{isGettingLocation ? ( {isGettingLocation ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Getting location... {t('createSpot.gettingLocation')}
</> </>
) : ( ) : (
<> <>
<MapPin className="h-4 w-4 mr-2" /> <MapPin className="h-4 w-4 mr-2" />
Use my current location {t('createSpot.useCurrentLocation')}
</> </>
)} )}
</Button> </Button>
@ -459,7 +461,7 @@ export default function EditSpotPage() {
{/* Access Type */} {/* Access Type */}
<div className="space-y-3"> <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"> <div className="space-y-2">
{/* Public */} {/* Public */}
<Card <Card
@ -475,8 +477,8 @@ export default function EditSpotPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="text-2xl">🌍</div> <div className="text-2xl">🌍</div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-bold text-white mb-1">Public - Open to Everyone</h4> <h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
<p className="text-sm text-gray-400">Skate parks, public plazas, street spots</p> <p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
</div> </div>
{accessType === 'public' && ( {accessType === 'public' && (
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center"> <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="flex items-start gap-3">
<div className="text-2xl">🔒</div> <div className="text-2xl">🔒</div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-bold text-white mb-1">Private Property - Permission Needed</h4> <h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
<p className="text-sm text-gray-400">Backyard ramps, private land, gated spots</p> <p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
</div> </div>
{accessType === 'private' && ( {accessType === 'private' && (
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center"> <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="flex items-start gap-3">
<div className="text-2xl">👥</div> <div className="text-2xl">👥</div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-bold text-white mb-1">Ask Locals First</h4> <h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
<p className="text-sm text-gray-400">Respect the spot, don't blow it out</p> <p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
</div> </div>
{accessType === 'locals' && ( {accessType === 'locals' && (
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center"> <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 */} {/* Contact Info for Private/Locals */}
{(accessType === 'private' || accessType === 'locals') && ( {(accessType === 'private' || accessType === 'locals') && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="contact">Contact Information</Label> <Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
<Input <Input
id="contact" id="contact"
placeholder="e.g., Phone number, Instagram, email..." placeholder={t('createSpot.contactPlaceholder')}
value={contactInfo} value={contactInfo}
onChange={(e) => setContactInfo(e.target.value)} onChange={(e) => setContactInfo(e.target.value)}
className="bg-black border-[#ff6600]/30" className="bg-black border-[#ff6600]/30"
/> />
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
{accessType === 'private' {accessType === 'private'
? 'How should people contact you for permission?' ? t('createSpot.contactHintPrivate')
: 'How should people reach out to ask about the spot?'} : t('createSpot.contactHintLocals')}
</p> </p>
</div> </div>
)} )}
{/* Spot Category (required, mutually exclusive) */} {/* Spot Category (required, mutually exclusive) */}
<div className="space-y-3"> <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"> <div className="grid grid-cols-2 gap-2">
{spotCategories.map((category) => ( {spotCategories.map((category) => (
<Card <Card
@ -591,9 +593,9 @@ export default function EditSpotPage() {
{/* Shelter / weather protection */} {/* Shelter / weather protection */}
<div className="space-y-3"> <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"> <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> </p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => ( {shelterOptions.map((option) => (
@ -628,14 +630,14 @@ export default function EditSpotPage() {
{/* Freeform Tags */} {/* Freeform Tags */}
<div className="space-y-3"> <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"> <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> </p>
<TagInput <TagInput
tags={tags} tags={tags}
onChange={setTags} onChange={setTags}
placeholder="Type to search or add tags..." placeholder={t('spotUi.typeToSearchTags')}
maxTags={MAX_TAGS} maxTags={MAX_TAGS}
/> />
</div> </div>
@ -644,11 +646,11 @@ export default function EditSpotPage() {
{/* Images */} {/* Images */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Photos</Label> <Label>{t('spots.photos')}</Label>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{images.map((url) => ( {images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted"> <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 <button
type="button" type="button"
onClick={() => removeImage(url)} onClick={() => removeImage(url)}
@ -661,8 +663,8 @@ export default function EditSpotPage() {
<PhotoDropzone <PhotoDropzone
onFiles={uploadImageFiles} onFiles={uploadImageFiles}
uploading={isUploading} uploading={isUploading}
label="Upload" label={t('createSpot.uploadPhotos')}
hint="Drag & drop or click" hint={t('spotUi.dragDropOrClick')}
className="aspect-video" className="aspect-video"
/> />
</div> </div>
@ -677,12 +679,12 @@ export default function EditSpotPage() {
{isPublishing ? ( {isPublishing ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving... {t('spotUi.saving')}
</> </>
) : ( ) : (
<> <>
<MapPin className="h-4 w-4 mr-2" /> <MapPin className="h-4 w-4 mr-2" />
Save Changes {t('spotUi.saveChanges')}
</> </>
)} )}
</Button> </Button>

View file

@ -20,11 +20,13 @@ import { GEAR_CATEGORIES } from '@/lib/types';
import { getLastLocation } from '@/lib/lastLocation'; import { getLastLocation } from '@/lib/lastLocation';
import { itemDistanceKm } from '@/lib/sortOptions'; import { itemDistanceKm } from '@/lib/sortOptions';
import { ShoppingCart, Plus, Search, Filter, ArrowUpDown, Users, ShieldCheck, UserCheck, AlertCircle, MapPin } from 'lucide-react'; 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 SortOption = 'newest' | 'price_low' | 'price_high' | 'trust' | 'distance';
type TrustFilter = 'all' | 'verified' | 'trusted' | 'following'; type TrustFilter = 'all' | 'verified' | 'trusted' | 'following';
export default function GearSwapPage() { export default function GearSwapPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Gear Swap - SkateSpots', title: 'Gear Swap - SkateSpots',
description: 'Buy & sell used skate gear with other skaters. Decks, trucks, wheels, shoes, and more.', 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]); }, [items, searchQuery, selectedCategory, sortBy, trustFilter, showOnlyWTB, showOnlySelling, fulfilmentFilter, trustScores, followingSet]);
const categoryOptions = [ const categoryOptions = [
{ value: 'all', label: 'All Categories' }, { value: 'all', label: t('marketUi.gearSwap.allCategories') },
...Object.entries(GEAR_CATEGORIES).map(([key, label]) => ({ ...Object.entries(GEAR_CATEGORIES).map(([key, label]) => ({
value: key, value: key,
label, label,
@ -191,10 +193,10 @@ export default function GearSwapPage() {
<> <>
<div className="container px-4 py-8 max-w-7xl"> <div className="container px-4 py-8 max-w-7xl">
<PageHeader <PageHeader
title="Gear Swap" title={t('marketUi.gearSwap.title')}
icon={ShoppingCart} icon={ShoppingCart}
accent="#ff6600" accent="#ff6600"
subtitle="Buy & sell used skate gear with other skaters" subtitle={t('marketUi.gearSwap.subtitle')}
className="mb-8" className="mb-8"
actions={ actions={
<Link to="/create-listing?returnTo=/marketplace"> <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)]" 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" /> <Plus className="h-5 w-5" />
List Gear {t('marketUi.gearSwap.listGear')}
</Button> </Button>
</Link> </Link>
} }
@ -216,7 +218,7 @@ export default function GearSwapPage() {
<div className="relative mb-4"> <div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" />
<Input <Input
placeholder="Search gear..." placeholder={t('marketUi.gearSwap.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-gray-900 border-gray-700 h-12 text-lg" 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' : 'border-gray-600 text-gray-400 hover:bg-gray-800'
} }
> >
For Sale {t('marketUi.gearSwap.forSale')}
</Button> </Button>
<Button <Button
variant={showOnlyWTB ? 'default' : 'outline'} variant={showOnlyWTB ? 'default' : 'outline'}
@ -275,7 +277,7 @@ export default function GearSwapPage() {
} }
> >
<Search className="h-3 w-3 mr-1" /> <Search className="h-3 w-3 mr-1" />
WTB {t('marketUi.gearSwap.wtb')}
</Button> </Button>
</div> </div>
@ -290,7 +292,7 @@ export default function GearSwapPage() {
className="gap-2 border-gray-600 text-gray-300 hover:bg-gray-800" className="gap-2 border-gray-600 text-gray-300 hover:bg-gray-800"
> >
<ShieldCheck className="h-4 w-4" /> <ShieldCheck className="h-4 w-4" />
Trust {t('marketUi.gearSwap.trust')}
{trustFilter !== 'all' && ( {trustFilter !== 'all' && (
<Badge className="ml-1 bg-orange-500/20 text-orange-400 text-xs"> <Badge className="ml-1 bg-orange-500/20 text-orange-400 text-xs">
{trustFilter} {trustFilter}
@ -299,28 +301,28 @@ export default function GearSwapPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-48"> <DropdownMenuContent className="w-48">
<DropdownMenuLabel>Filter by Trust</DropdownMenuLabel> <DropdownMenuLabel>{t('marketUi.gearSwap.filterByTrust')}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
checked={trustFilter === 'all'} checked={trustFilter === 'all'}
onCheckedChange={() => setTrustFilter('all')} onCheckedChange={() => setTrustFilter('all')}
> >
<Users className="h-4 w-4 mr-2" /> <Users className="h-4 w-4 mr-2" />
All Sellers {t('marketUi.gearSwap.allSellers')}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
checked={trustFilter === 'verified'} checked={trustFilter === 'verified'}
onCheckedChange={() => setTrustFilter('verified')} onCheckedChange={() => setTrustFilter('verified')}
> >
<UserCheck className="h-4 w-4 mr-2 text-green-400" /> <UserCheck className="h-4 w-4 mr-2 text-green-400" />
Verified Only {t('marketUi.gearSwap.verifiedOnly')}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
checked={trustFilter === 'trusted'} checked={trustFilter === 'trusted'}
onCheckedChange={() => setTrustFilter('trusted')} onCheckedChange={() => setTrustFilter('trusted')}
> >
<ShieldCheck className="h-4 w-4 mr-2 text-blue-400" /> <ShieldCheck className="h-4 w-4 mr-2 text-blue-400" />
Trusted+ {t('marketUi.gearSwap.trustedPlus')}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
checked={trustFilter === 'following'} checked={trustFilter === 'following'}
@ -328,7 +330,7 @@ export default function GearSwapPage() {
disabled={!user} disabled={!user}
> >
<Users className="h-4 w-4 mr-2 text-purple-400" /> <Users className="h-4 w-4 mr-2 text-purple-400" />
Following Only {t('marketUi.gearSwap.followingOnly')}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -342,12 +344,12 @@ export default function GearSwapPage() {
> >
<SelectTrigger className="w-40 bg-gray-900 border-gray-700"> <SelectTrigger className="w-40 bg-gray-900 border-gray-700">
<MapPin className="h-4 w-4 mr-2" /> <MapPin className="h-4 w-4 mr-2" />
<SelectValue placeholder="Pickup or ship" /> <SelectValue placeholder={t('marketUi.gearSwap.pickupOrShip')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Pickup or ship</SelectItem> <SelectItem value="all">{t('marketUi.gearSwap.pickupOrShip')}</SelectItem>
<SelectItem value="pickup">📍 Local pickup</SelectItem> <SelectItem value="pickup">📍 {t('marketUi.gearSwap.localPickup')}</SelectItem>
<SelectItem value="ships">📦 Ships</SelectItem> <SelectItem value="ships">📦 {t('marketUi.gearSwap.ships')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -358,14 +360,14 @@ export default function GearSwapPage() {
> >
<SelectTrigger className="w-40 bg-gray-900 border-gray-700"> <SelectTrigger className="w-40 bg-gray-900 border-gray-700">
<ArrowUpDown className="h-4 w-4 mr-2" /> <ArrowUpDown className="h-4 w-4 mr-2" />
<SelectValue placeholder="Sort by" /> <SelectValue placeholder={t('marketUi.gearSwap.sortBy')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="newest">Newest</SelectItem> <SelectItem value="newest">{t('marketUi.gearSwap.newest')}</SelectItem>
<SelectItem value="price_low">Price: Low → High</SelectItem> <SelectItem value="price_low">{t('marketUi.gearSwap.priceLowHigh')}</SelectItem>
<SelectItem value="price_high">Price: High → Low</SelectItem> <SelectItem value="price_high">{t('marketUi.gearSwap.priceHighLow')}</SelectItem>
<SelectItem value="trust">Trust Score</SelectItem> <SelectItem value="trust">{t('marketUi.gearSwap.trustScore')}</SelectItem>
<SelectItem value="distance">Nearest (local)</SelectItem> <SelectItem value="distance">{t('marketUi.gearSwap.nearest')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -376,7 +378,9 @@ export default function GearSwapPage() {
{!isLoading && ( {!isLoading && (
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-400"> <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> </p>
</div> </div>
)} )}
@ -405,8 +409,8 @@ export default function GearSwapPage() {
<Card className="bg-red-950/30 border-2 border-red-500/50"> <Card className="bg-red-950/30 border-2 border-red-500/50">
<CardContent className="py-8 text-center"> <CardContent className="py-8 text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-red-400" /> <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> <h3 className="text-lg font-bold text-red-400 mb-2">{t('marketUi.gearSwap.loadFailed')}</h3>
<p className="text-gray-400">Please try again later.</p> <p className="text-gray-400">{t('marketUi.gearSwap.tryLater')}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -416,11 +420,11 @@ export default function GearSwapPage() {
<Card className="bg-black/90 border-2 border-dashed border-orange-500/30"> <Card className="bg-black/90 border-2 border-dashed border-orange-500/30">
<CardContent className="py-16 text-center"> <CardContent className="py-16 text-center">
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-600" /> <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"> <p className="text-gray-400 mb-6 max-w-md mx-auto">
{searchQuery || selectedCategory !== 'all' || trustFilter !== 'all' {searchQuery || selectedCategory !== 'all' || trustFilter !== 'all'
? 'No gear matches your current filters. Try clearing them or searching something else.' ? t('marketUi.gearSwap.noneFiltered')
: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!'} : t('marketUi.gearSwap.noneFirst')}
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3"> <div className="flex flex-col sm:flex-row items-center justify-center gap-3">
{(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && ( {(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && (
@ -434,18 +438,18 @@ export default function GearSwapPage() {
}} }}
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
Clear Filters {t('common.clearFilters')}
</Button> </Button>
)} )}
<Link to="/create-listing"> <Link to="/create-listing">
<Button className="gap-2 bg-orange-500 hover:bg-orange-600"> <Button className="gap-2 bg-orange-500 hover:bg-orange-600">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
List Your Gear {t('marketUi.gearSwap.listYourGear')}
</Button> </Button>
</Link> </Link>
</div> </div>
<Link to="/faq" className="mt-5 inline-block text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors"> <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> </Link>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -344,6 +344,7 @@ const ACCENT = {
// ─── FAB — offer services ───────────────────────────────────────────────────── // ─── FAB — offer services ─────────────────────────────────────────────────────
function OfferFAB() { function OfferFAB() {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -358,20 +359,20 @@ function OfferFAB() {
}, [open]); }, [open]);
const items = [ const items = [
{ to: '/become/coach', label: 'Offer Coaching', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap }, { to: '/become/coach', label: t('labUi.offerCoaching'), 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/filmer', label: t('labUi.offerFilming'), 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/photographer', label: t('labUi.offerPhotography'), 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/editor', label: t('labUi.offerEditing'), 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/builder', label: t('labUi.offerBuilding'), 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/artist', label: t('labUi.offerDesignArt'), 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/graffiti', label: t('labUi.offerGraffiti'), 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/tattoo', label: t('labUi.offerTattoo'), 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/trainer', label: t('labUi.offerTraining'), 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/musician', label: t('labUi.addMusicProfile'), 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/judge', label: t('labUi.judgeEvents'), 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/organizer', label: t('labUi.organizeEvents'), 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: '/become/barber', label: t('labUi.offerCuts'), 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: '/card', label: t('labUi.getRiderCard'), color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
]; ];
return ( return (
@ -386,8 +387,8 @@ function OfferFAB() {
)} )}
> >
{open ? <X className="h-4 w-4" /> : <Plus className="h-4 w-4" />} {open ? <X className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
<span className="hidden sm:inline">Offer Services</span> <span className="hidden sm:inline">{t('labUi.offerServices')}</span>
<span className="sm:hidden">Offer</span> <span className="sm:hidden">{t('labUi.offer')}</span>
</button> </button>
{/* Dropdown */} {/* Dropdown */}
@ -471,20 +472,21 @@ function LabToolbar({
trustFilter, trustFilter,
onClearAll, onClearAll,
}: ToolbarProps) { }: ToolbarProps) {
const { t } = useTranslation();
const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [ const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'coaching', label: 'Coaches', icon: GraduationCap }, { key: 'coaching', label: t('labUi.tabCoaches'), icon: GraduationCap },
{ key: 'filming', label: 'Filmers', icon: Video }, { key: 'filming', label: t('labUi.tabFilmers'), icon: Video },
{ key: 'photography', label: 'Photo', icon: Camera }, { key: 'photography', label: t('labUi.tabPhoto'), icon: Camera },
{ key: 'editing', label: 'Editors', icon: Scissors }, { key: 'editing', label: t('labUi.tabEditors'), icon: Scissors },
{ key: 'building', label: 'Builders', icon: HardHat }, { key: 'building', label: t('labUi.tabBuilders'), icon: HardHat },
{ key: 'art', label: 'Artists', icon: Palette }, { key: 'art', label: t('labUi.tabArtists'), icon: Palette },
{ key: 'graffiti', label: 'Graffiti', icon: SprayCan }, { key: 'graffiti', label: t('labUi.tabGraffiti'), icon: SprayCan },
{ key: 'tattoo', label: 'Tattoo', icon: Brush }, { key: 'tattoo', label: t('labUi.tabTattoo'), icon: Brush },
{ key: 'training', label: 'Trainers', icon: Dumbbell }, { key: 'training', label: t('labUi.tabTrainers'), icon: Dumbbell },
{ key: 'music', label: 'Music', icon: Music }, { key: 'music', label: t('labUi.tabMusic'), icon: Music },
{ key: 'judging', label: 'Judges', icon: Gavel }, { key: 'judging', label: t('labUi.tabJudges'), icon: Gavel },
{ key: 'organizing', label: 'Organizers', icon: Tent }, { key: 'organizing', label: t('labUi.tabOrganizers'), icon: Tent },
{ key: 'barbering', label: 'Barbers', icon: Sparkles }, { key: 'barbering', label: t('labUi.tabBarbers'), icon: Sparkles },
]; ];
const accent = ACCENT[serviceType]; const accent = ACCENT[serviceType];
@ -543,7 +545,7 @@ function LabToolbar({
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)} 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" 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 && ( {searchQuery && (
@ -563,7 +565,7 @@ function LabToolbar({
type="text" type="text"
value={locationQuery} value={locationQuery}
onChange={(e) => onLocationChange(e.target.value)} 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" 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 && ( {locationQuery && (
@ -587,7 +589,7 @@ function LabToolbar({
)} )}
> >
<SlidersHorizontal className="h-3.5 w-3.5" /> <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 && ( {activeFilterCount > 0 && (
<span className="flex items-center justify-center w-4 h-4 text-[9px] font-black rounded-full bg-current/30 leading-none"> <span className="flex items-center justify-center w-4 h-4 text-[9px] font-black rounded-full bg-current/30 leading-none">
{activeFilterCount} {activeFilterCount}
@ -618,7 +620,7 @@ function LabToolbar({
{sortBy !== 'recent' && ( {sortBy !== 'recent' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}> <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" /> <Star className="h-2.5 w-2.5" />
Top Rated {t('labUi.topRated')}
</span> </span>
)} )}
@ -626,7 +628,7 @@ function LabToolbar({
{trustFilter !== 'all' && ( {trustFilter !== 'all' && (
<span className={cn('flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border', accent.badge)}> <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" /> <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> </span>
)} )}
@ -635,7 +637,7 @@ function LabToolbar({
onClick={onClearAll} 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" 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> </button>
</div> </div>
)} )}
@ -644,8 +646,8 @@ function LabToolbar({
{!isLoading && ( {!isLoading && (
<p className="text-[11px] text-gray-500 font-semibold leading-none"> <p className="text-[11px] text-gray-500 font-semibold leading-none">
{totalCount === 0 {totalCount === 0
? 'No results' ? t('labUi.noResults')
: `${totalCount} result${totalCount !== 1 ? 's' : ''}`} : t(totalCount === 1 ? 'labUi.resultsOne' : 'labUi.resultsMany', { count: totalCount })}
</p> </p>
)} )}
</div> </div>
@ -681,87 +683,88 @@ function FilterDrawer({
onClearAll, onClearAll,
activeCount, activeCount,
}: FilterDrawerProps) { }: FilterDrawerProps) {
const { t } = useTranslation();
const accent = ACCENT[serviceType]; const accent = ACCENT[serviceType];
const skillSections: { title: string; entries: [string, string][]; activeColor: string }[] = const skillSections: { title: string; entries: [string, string][]; activeColor: string }[] =
serviceType === 'coaching' 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' : serviceType === 'filming'
? [ ? [
{ title: 'Content Type', entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterContentType'), 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.filterEquipmentStyle'), entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' },
] ]
: serviceType === 'photography' : serviceType === 'photography'
? [ ? [
{ title: 'Services', entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterServices'), entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Camera', entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' }, { title: t('labUi.filterCamera'), 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: t('labUi.filterLenses'), 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: t('labUi.filterLighting'), 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: t('labUi.filterSupportRig'), 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.filterStyle'), entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
] ]
: serviceType === 'building' : serviceType === 'building'
? [ ? [
{ title: 'What They Build', entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterWhatTheyBuild'), entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Materials & Trade', entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' }, { title: t('labUi.filterMaterialsTrade'), 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.filterProjectScale'), entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
] ]
: serviceType === 'art' : serviceType === 'art'
? [ ? [
{ title: 'What They Make', entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterWhatTheyMake'), entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Medium', entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' }, { title: t('labUi.filterMedium'), 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.filterSoftwareTools'), entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
] ]
: serviceType === 'graffiti' : serviceType === 'graffiti'
? [ ? [
{ title: 'Style', entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterStyle'), entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip },
{ title: 'Medium', entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' }, { title: t('labUi.filterMedium'), 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.filterWorkContext'), entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
] ]
: serviceType === 'tattoo' : serviceType === 'tattoo'
? [ ? [
{ title: 'Style', entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterStyle'), 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.filterMethod'), entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' },
] ]
: serviceType === 'training' : serviceType === 'training'
? [ ? [
{ title: 'Focus', entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterFocus'), entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip },
{ title: 'Injuries Treated', entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' }, { title: t('labUi.filterInjuriesTreated'), 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.filterCredentialsSetting'), entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
] ]
: serviceType === 'music' : serviceType === 'music'
? [ ? [
{ title: 'Genre', entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterGenre'), 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.filterInstrumentsRole'), entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' },
] ]
: serviceType === 'judging' : serviceType === 'judging'
? [ ? [
{ title: 'Event Formats', entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterEventFormats'), entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip },
{ title: 'Specialty', entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' }, { title: t('labUi.filterSpecialty'), 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.filterLevelCircuit'), entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
] ]
: serviceType === 'organizing' : serviceType === 'organizing'
? [ ? [
{ title: 'Event Types', entries: ORGANIZER_EVENT_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterEventTypes'), 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: t('labUi.filterWhatYouHandle'), 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.filterScale'), entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
] ]
: serviceType === 'barbering' : serviceType === 'barbering'
? [ ? [
{ title: 'What They Offer', entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip }, { title: t('labUi.filterWhatTheyOffer'), entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Signature Styles', entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' }, { title: t('labUi.filterSignatureStyles'), 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.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: t('labUi.filterEditType'), 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.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 }> }[] = [ const trustOptions: { value: TrustFilterLevel; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ value: 'all', label: 'All', icon: Users }, { value: 'all', label: t('labUi.all'), icon: Users },
{ value: 'verified', label: 'Verified', icon: CheckCircle2 }, { value: 'verified', label: t('common.verified'), icon: CheckCircle2 },
{ value: 'trusted', label: 'Trusted', icon: Shield }, { value: 'trusted', label: t('labUi.trusted'), icon: Shield },
{ value: 'following', label: 'Following', icon: CheckCircle2 }, { value: 'following', label: t('labUi.following'), icon: CheckCircle2 },
]; ];
const drawerContent = ( 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 justify-between px-4 pt-4 pb-3 flex-shrink-0 border-b border-white/5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SlidersHorizontal className={cn('h-4 w-4', accent.icon)} /> <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 && ( {activeCount > 0 && (
<Badge className={cn('text-xs px-1.5 py-0', accent.badge)}> <Badge className={cn('text-xs px-1.5 py-0', accent.badge)}>
{activeCount} active {t('labUi.activeCount', { count: activeCount })}
</Badge> </Badge>
)} )}
</div> </div>
@ -783,7 +786,7 @@ function FilterDrawer({
onClick={onClearAll} onClick={onClearAll}
className="text-xs text-gray-400 hover:text-red-400 transition-colors font-semibold" className="text-xs text-gray-400 hover:text-red-400 transition-colors font-semibold"
> >
Clear all {t('labUi.clearAll')}
</button> </button>
)} )}
<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"> <div className="flex-1 min-h-0 overflow-y-auto px-4 space-y-5 py-4 scroll-container">
{/* Sort */} {/* Sort */}
<div> <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"> <div className="flex gap-2">
{[ {[
{ value: 'recent' as SortOption, label: 'Newest', icon: Clock }, { value: 'recent' as SortOption, label: t('labUi.newest'), icon: Clock },
{ value: 'trust' as SortOption, label: 'Top Rated', icon: Star }, { value: 'trust' as SortOption, label: t('labUi.topRated'), icon: Star },
].map(({ value, label, icon: Icon }) => ( ].map(({ value, label, icon: Icon }) => (
<button <button
key={value} key={value}
@ -824,7 +827,7 @@ function FilterDrawer({
{/* Trust — inline pill buttons, no dropdown */} {/* Trust — inline pill buttons, no dropdown */}
<div> <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"> <div className="flex flex-wrap gap-2">
{trustOptions.map(({ value, label, icon: Icon }) => ( {trustOptions.map(({ value, label, icon: Icon }) => (
<button <button
@ -880,7 +883,7 @@ function FilterDrawer({
accent.active accent.active
)} )}
> >
Show Results{activeCount > 0 ? ` · ${activeCount} active` : ''} {activeCount > 0 ? t('labUi.showResultsActive', { count: activeCount }) : t('labUi.showResults')}
</button> </button>
</div> </div>
</> </>
@ -893,7 +896,7 @@ function FilterDrawer({
<Sheet open={open} onOpenChange={(v) => !v && onClose()}> <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]"> <SheetContent side="bottom" className="h-[82vh] max-h-[700px] rounded-t-2xl p-0 flex flex-col bg-[#0a0a0a]">
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Filters</SheetTitle> <SheetTitle>{t('labUi.filters')}</SheetTitle>
</SheetHeader> </SheetHeader>
{drawerContent} {drawerContent}
</SheetContent> </SheetContent>
@ -905,7 +908,7 @@ function FilterDrawer({
<Sheet open={open} onOpenChange={(v) => !v && onClose()}> <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"> <SheetContent side="right" className="w-80 p-0 flex flex-col bg-[#0a0a0a] border-l border-white/8">
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Filters</SheetTitle> <SheetTitle>{t('labUi.filters')}</SheetTitle>
</SheetHeader> </SheetHeader>
{drawerContent} {drawerContent}
</SheetContent> </SheetContent>
@ -918,6 +921,7 @@ function FilterDrawer({
// ─── Profile Card ───────────────────────────────────────────────────────────── // ─── Profile Card ─────────────────────────────────────────────────────────────
function ProfileCard({ profile }: { profile: LabProfile }) { function ProfileCard({ profile }: { profile: LabProfile }) {
const { t } = useTranslation();
const author = useAuthor(profile.pubkey); const author = useAuthor(profile.pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(profile.pubkey); const displayName = metadata?.name ?? genUserName(profile.pubkey);
@ -984,7 +988,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
{profile.entityType === 'collective' && ( {profile.entityType === 'collective' && (
<span <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)} 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" /> <Users className="h-2.5 w-2.5" />
{profile.members.length > 0 ? profile.members.length : ''} {profile.members.length > 0 ? profile.members.length : ''}
@ -1014,7 +1018,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
</span> </span>
)} )}
{profile.experience && ( {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> </div>
@ -1072,108 +1076,109 @@ function SkeletonCard() {
// ─── Empty state per-category ───────────────────────────────────────────────── // ─── Empty state per-category ─────────────────────────────────────────────────
function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boolean }) { function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boolean }) {
const { t } = useTranslation();
const configs = { const configs = {
coaching: { coaching: {
icon: GraduationCap, icon: GraduationCap,
title: 'No coaches yet', title: t('labUi.empty.coachesTitle'),
body: 'Be the first to offer skateboarding coaching in your area.', body: t('labUi.empty.coachesBody'),
cta: 'Offer Coaching', cta: t('labUi.offerCoaching'),
to: '/become/coach', to: '/become/coach',
color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90',
}, },
filming: { filming: {
icon: Video, icon: Video,
title: 'No filmers yet', title: t('labUi.empty.filmersTitle'),
body: 'Be the first to offer skate filming — clips, full parts, contest coverage and more.', body: t('labUi.empty.filmersBody'),
cta: 'Offer Filming', cta: t('labUi.offerFilming'),
to: '/become/filmer', to: '/become/filmer',
color: 'bg-orange-500 hover:bg-orange-600', color: 'bg-orange-500 hover:bg-orange-600',
}, },
photography: { photography: {
icon: Camera, icon: Camera,
title: 'No photographers yet', title: t('labUi.empty.photographersTitle'),
body: 'Be the first to offer skate photography in your area — action shots, sequences, editorial and more.', body: t('labUi.empty.photographersBody'),
cta: 'Offer Photography', cta: t('labUi.offerPhotography'),
to: '/become/photographer', to: '/become/photographer',
color: 'bg-indigo-600 hover:bg-indigo-700', color: 'bg-indigo-600 hover:bg-indigo-700',
}, },
editing: { editing: {
icon: Scissors, icon: Scissors,
title: 'No video editors yet', title: t('labUi.empty.editorsTitle'),
body: 'Be the first to offer skate video editing services.', body: t('labUi.empty.editorsBody'),
cta: 'Offer Editing', cta: t('labUi.offerEditing'),
to: '/become/editor', to: '/become/editor',
color: 'bg-purple-600 hover:bg-purple-700', color: 'bg-purple-600 hover:bg-purple-700',
}, },
building: { building: {
icon: HardHat, icon: HardHat,
title: 'No builders yet', title: t('labUi.empty.buildersTitle'),
body: 'Be the first to list your ramp, park or DIY building — concrete, wood, rails and more.', body: t('labUi.empty.buildersBody'),
cta: 'Offer Building', cta: t('labUi.offerBuilding'),
to: '/become/builder', to: '/become/builder',
color: 'bg-amber-500 hover:bg-amber-600 text-black', color: 'bg-amber-500 hover:bg-amber-600 text-black',
}, },
art: { art: {
icon: Palette, icon: Palette,
title: 'No artists yet', title: t('labUi.empty.artistsTitle'),
body: 'Be the first to offer board graphics, logos, posters and merch for the scene.', body: t('labUi.empty.artistsBody'),
cta: 'Offer Design / Art', cta: t('labUi.offerDesignArt'),
to: '/become/artist', to: '/become/artist',
color: 'bg-fuchsia-600 hover:bg-fuchsia-700', color: 'bg-fuchsia-600 hover:bg-fuchsia-700',
}, },
graffiti: { graffiti: {
icon: SprayCan, icon: SprayCan,
title: 'No graffiti artists yet', title: t('labUi.empty.graffitiTitle'),
body: 'Be the first to offer pieces, murals and skatepark art in your area.', body: t('labUi.empty.graffitiBody'),
cta: 'Offer Graffiti', cta: t('labUi.offerGraffiti'),
to: '/become/graffiti', to: '/become/graffiti',
color: 'bg-lime-500 hover:bg-lime-600 text-black', color: 'bg-lime-500 hover:bg-lime-600 text-black',
}, },
tattoo: { tattoo: {
icon: Brush, icon: Brush,
title: 'No tattoo artists yet', title: t('labUi.empty.tattooTitle'),
body: 'Be the first skate-culture tattoo artist to list — flash, custom, fine line and more.', body: t('labUi.empty.tattooBody'),
cta: 'Offer Tattoo', cta: t('labUi.offerTattoo'),
to: '/become/tattoo', to: '/become/tattoo',
color: 'bg-rose-600 hover:bg-rose-700', color: 'bg-rose-600 hover:bg-rose-700',
}, },
training: { training: {
icon: Dumbbell, icon: Dumbbell,
title: 'No trainers yet', title: t('labUi.empty.trainersTitle'),
body: 'Be the first trainer or physio to help skaters get strong and stay healthy.', body: t('labUi.empty.trainersBody'),
cta: 'Offer Training / Physio', cta: t('labUi.offerTraining'),
to: '/become/trainer', to: '/become/trainer',
color: 'bg-emerald-600 hover:bg-emerald-700', color: 'bg-emerald-600 hover:bg-emerald-700',
}, },
music: { music: {
icon: Music, icon: Music,
title: 'No musicians yet', title: t('labUi.empty.musiciansTitle'),
body: 'Skate and make music? Add your band or solo project — genres, instruments and links.', body: t('labUi.empty.musiciansBody'),
cta: 'Add Music Profile', cta: t('labUi.addMusicProfile'),
to: '/become/musician', to: '/become/musician',
color: 'bg-sky-500 hover:bg-sky-600', color: 'bg-sky-500 hover:bg-sky-600',
}, },
judging: { judging: {
icon: Gavel, icon: Gavel,
title: 'No judges yet', title: t('labUi.empty.judgesTitle'),
body: 'Run a fair contest? List yourself to judge Game of S.K.A.T.E., best trick jams, contests and touring stops.', body: t('labUi.empty.judgesBody'),
cta: 'Judge Events', cta: t('labUi.judgeEvents'),
to: '/become/judge', to: '/become/judge',
color: 'bg-yellow-500 hover:bg-yellow-600 text-black', color: 'bg-yellow-500 hover:bg-yellow-600 text-black',
}, },
organizing: { organizing: {
icon: Tent, icon: Tent,
title: 'No organizers yet', title: t('labUi.empty.organizersTitle'),
body: 'Put on jams, comps, meetups or demos? List yourself so skaters can find and book you.', body: t('labUi.empty.organizersBody'),
cta: 'Organize Events', cta: t('labUi.organizeEvents'),
to: '/become/organizer', to: '/become/organizer',
color: 'bg-teal-600 hover:bg-teal-700', color: 'bg-teal-600 hover:bg-teal-700',
}, },
barbering: { barbering: {
icon: Sparkles, icon: Sparkles,
title: 'No barbers yet', title: t('labUi.empty.barbersTitle'),
body: 'Cut hair for skaters? List your chair or mobile service so the scene can get a fresh trim.', body: t('labUi.empty.barbersBody'),
cta: 'Offer Cuts', cta: t('labUi.offerCuts'),
to: '/become/barber', to: '/become/barber',
color: 'bg-cyan-600 hover:bg-cyan-700', color: 'bg-cyan-600 hover:bg-cyan-700',
}, },
@ -1193,9 +1198,9 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
<> <>
{c.body}{' '} {c.body}{' '}
<Link to="/settings" className="text-[#ccff00] underline underline-offset-2"> <Link to="/settings" className="text-[#ccff00] underline underline-offset-2">
Log in {t('auth.login')}
</Link>{' '} </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 }] ? [{ 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 /> <OfferFAB />
) : ( ) : (
<Badge variant="outline" className="border-yellow-500/40 text-yellow-500/80 text-xs px-2.5 py-1 flex-shrink-0"> <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> </Badge>
)} )}
</div> </div>
@ -1401,13 +1406,13 @@ export default function LabPage() {
<div className="min-w-0 flex-1"> <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"> <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" /> <PersonStanding className="h-3.5 w-3.5" />
The Crew {t('labUi.theCrew')}
</p> </p>
<p className="font-black uppercase text-sm leading-tight"> <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>
<p className="text-xs text-gray-400 mt-0.5 leading-snug"> <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> </p>
</div> </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"> <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, Handshake,
Share2, Share2,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
/** /**
* Full marketplace listing page (`/listing/:pubkey/:identifier`). * Full marketplace listing page (`/listing/:pubkey/:identifier`).
@ -45,6 +46,7 @@ import {
* actions (`ListingTradeActions`, also used by the grid card). * actions (`ListingTradeActions`, also used by the grid card).
*/ */
export default function ListingDetailPage() { export default function ListingDetailPage() {
const { t } = useTranslation();
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useCurrentUser(); 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"> <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" /> <Package className="mx-auto h-10 w-10 text-gray-600" />
<h1 className="text-xl font-black uppercase text-white">Listing not found</h1> <h1 className="text-xl font-black uppercase text-white">{t('marketUi.listingDetail.notFound')}</h1>
<p className="text-sm text-gray-400">It may have been deleted, or the link is wrong.</p> <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"> <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> </Button>
</div> </div>
</> </>
@ -102,7 +104,7 @@ export default function ListingDetailPage() {
: formatPrice(undefined, item.priceType); : formatPrice(undefined, item.priceType);
const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null; const conditionInfo = item.condition ? GEAR_CONDITIONS[item.condition] : null;
const categoryLabel = item.category.find((c) => GEAR_CATEGORIES[c as keyof typeof GEAR_CATEGORIES]); 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 npub = pubkey ? nip19.npubEncode(pubkey) : '';
const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing })); const lightboxImages: LightboxImage[] = item.images.map((url) => ({ url, isOwner: isOwnListing }));
@ -110,20 +112,20 @@ export default function ListingDetailPage() {
const handleShare = async () => { const handleShare = async () => {
try { try {
await navigator.clipboard.writeText(window.location.href); 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 { } catch {
toast({ title: 'Could not copy the link', variant: 'destructive' }); toast({ title: t('marketUi.listingDetail.copyFailed'), variant: 'destructive' });
} }
}; };
const handleRelist = async () => { const handleRelist = async () => {
try { try {
await relist(item.event); 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) { } catch (error) {
toast({ toast({
title: 'Could not update', title: t('marketUi.listingDetail.couldNotUpdate'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('marketUi.listingDetail.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -136,7 +138,7 @@ export default function ListingDetailPage() {
onClick={() => navigate('/marketplace')} onClick={() => navigate('/marketplace')}
className="mb-4 inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors" 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> </button>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_320px]"> <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', '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', 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 <ImageWithFallback
src={url} 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]" className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/> />
</button> </button>
@ -171,12 +173,12 @@ export default function ListingDetailPage() {
<div className="mb-2 flex flex-wrap items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2">
{isWTB && ( {isWTB && (
<Badge className="border-blue-500/50 bg-blue-500/20 text-blue-400"> <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> </Badge>
)} )}
{isSold && ( {isSold && (
<Badge variant="secondary" className="bg-gray-700 text-gray-300"> <Badge variant="secondary" className="bg-gray-700 text-gray-300">
SOLD {t('marketUi.listingDetail.sold')}
</Badge> </Badge>
)} )}
{item.status && item.status !== 'active' && !isSold && ( {item.status && item.status !== 'active' && !isSold && (
@ -215,7 +217,7 @@ export default function ListingDetailPage() {
<Card className="bg-black/90 border-white/10"> <Card className="bg-black/90 border-white/10">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm uppercase tracking-wide text-gray-400"> <CardTitle className="text-sm uppercase tracking-wide text-gray-400">
Description {t('marketUi.listingDetail.description')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -236,12 +238,12 @@ export default function ListingDetailPage() {
)} )}
{item.availability && ( {item.availability && (
<div className="flex items-center gap-2 text-gray-300"> <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>
)} )}
<div className="flex items-center gap-2 text-gray-500"> <div className="flex items-center gap-2 text-gray-500">
<Calendar className="h-4 w-4" /> Listed{' '} <Calendar className="h-4 w-4" />{' '}
{new Date(item.createdAt * 1000).toLocaleDateString()} {t('marketUi.listingDetail.listed', { date: new Date(item.createdAt * 1000).toLocaleDateString() })}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -272,8 +274,9 @@ export default function ListingDetailPage() {
</Link> </Link>
{sellerInfo && sellerInfo.listingCount > 0 && ( {sellerInfo && sellerInfo.listingCount > 0 && (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{sellerInfo.listingCount} listing{sellerInfo.listingCount === 1 ? '' : 's'} ·{' '} {sellerInfo.listingCount === 1
{sellerInfo.accountAge}d old ? t('marketUi.listingDetail.listingCountOne', { count: sellerInfo.listingCount, age: sellerInfo.accountAge })
: t('marketUi.listingDetail.listingCountMany', { count: sellerInfo.listingCount, age: sellerInfo.accountAge })}
</p> </p>
)} )}
</CardContent> </CardContent>
@ -291,7 +294,7 @@ export default function ListingDetailPage() {
disabled={isUpdatingStatus} disabled={isUpdatingStatus}
className="w-full gap-2 border-white/15" 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>
) : ( ) : (
<Button <Button
@ -299,7 +302,7 @@ export default function ListingDetailPage() {
onClick={() => setShowMarkSold(true)} onClick={() => setShowMarkSold(true)}
className="w-full gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" 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>
)} )}
<Button <Button
@ -308,7 +311,7 @@ export default function ListingDetailPage() {
onClick={() => setShowDelete(true)} onClick={() => setShowDelete(true)}
className="w-full gap-2 border-red-500/30 text-red-400 hover:bg-red-500/10" 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> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -322,7 +325,7 @@ export default function ListingDetailPage() {
onClick={() => setShowOffer(true)} onClick={() => setShowOffer(true)}
className="mt-2 w-full gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10" 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>
<Button <Button
variant="ghost" variant="ghost"
@ -330,7 +333,7 @@ export default function ListingDetailPage() {
onClick={handleShare} onClick={handleShare}
className="mt-1 w-full gap-2 text-gray-400 hover:text-white" 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> </Button>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -94,6 +94,7 @@ type ViewMode = 'map' | 'list';
// Compact stash item card for the drawer // Compact stash item card for the drawer
function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) { function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) {
const { t } = useTranslation();
const category = STASH_CATEGORIES[item.category]; const category = STASH_CATEGORIES[item.category];
const condition = CONDITION_LABELS[item.condition]; const condition = CONDITION_LABELS[item.condition];
const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_TIERS; const rarity = (item.rarity || category.rarity) as keyof typeof RARITY_TIERS;
@ -116,7 +117,7 @@ function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => vo
{isWishlist && ( {isWishlist && (
<Badge className="absolute -top-2 -left-1 bg-cyan-500 text-black text-[9px] px-1.5 py-0"> <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" /> <Target className="h-2.5 w-2.5 mr-0.5" />
Wishlist {t('mapUi.wishlist')}
</Badge> </Badge>
)} )}
@ -179,6 +180,7 @@ function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => vo
// Stash drawer component // Stash drawer component
function StashDrawer() { function StashDrawer() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { items, stats, isLoading, removeItem, isEncryptionAvailable } = useStash(); const { items, stats, isLoading, removeItem, isEncryptionAvailable } = useStash();
const author = useAuthor(user?.pubkey || ''); const author = useAuthor(user?.pubkey || '');
@ -195,9 +197,9 @@ function StashDrawer() {
return ( return (
<div className="p-6 text-center"> <div className="p-6 text-center">
<Backpack className="h-12 w-12 mx-auto mb-4 text-[#ff6600]/50" /> <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"> <p className="text-sm text-muted-foreground mb-4">
Track your gear, set prices, and find trades with other skaters. {t('mapUi.stashIntro')}
</p> </p>
<LoginArea className="justify-center" /> <LoginArea className="justify-center" />
</div> </div>
@ -209,12 +211,12 @@ function StashDrawer() {
return ( return (
<div className="p-6 text-center"> <div className="p-6 text-center">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-yellow-500" /> <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"> <p className="text-sm text-muted-foreground mb-4">
Use a signer with NIP-44 support (Alby, nos2x, Amber). {t('mapUi.encryptionRequiredDesc')}
</p> </p>
<Link to="/signers"> <Link to="/signers">
<Button variant="outline" size="sm">Learn More</Button> <Button variant="outline" size="sm">{t('common.learnMore')}</Button>
</Link> </Link>
</div> </div>
); );
@ -233,7 +235,7 @@ function StashDrawer() {
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate">{displayName}</h3> <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>
<div className="text-right"> <div className="text-right">
<div className="flex items-center gap-1 text-amber-400"> <div className="flex items-center gap-1 text-amber-400">
@ -243,7 +245,7 @@ function StashDrawer() {
</span> </span>
</div> </div>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{usdValue ? `~$${usdValue.toFixed(0)}` : 'sats'} {usdValue ? `~$${usdValue.toFixed(0)}` : t('common.sats')}
</p> </p>
</div> </div>
</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="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-center p-2 rounded bg-black/40">
<div className="text-lg font-black text-[#ff0040]">{stashItems.length}</div> <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>
<div className="text-center p-2 rounded bg-black/40"> <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-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>
<div className="text-center p-2 rounded bg-black/40"> <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-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>
</div> </div>
@ -274,11 +276,11 @@ function StashDrawer() {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Package className="h-10 w-10 mx-auto mb-3 text-muted-foreground/30" /> <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"> <Link to="/stash">
<Button size="sm" className="bg-[#ff6600] hover:bg-[#ff6600]/90"> <Button size="sm" className="bg-[#ff6600] hover:bg-[#ff6600]/90">
<Plus className="h-3.5 w-3.5 mr-1" /> <Plus className="h-3.5 w-3.5 mr-1" />
Add First Item {t('mapUi.addFirstItem')}
</Button> </Button>
</Link> </Link>
</div> </div>
@ -289,7 +291,7 @@ function StashDrawer() {
<div className="mb-3"> <div className="mb-3">
<h4 className="text-[10px] font-bold uppercase text-cyan-400 mb-2 flex items-center gap-1"> <h4 className="text-[10px] font-bold uppercase text-cyan-400 mb-2 flex items-center gap-1">
<Target className="h-3 w-3" /> <Target className="h-3 w-3" />
Wishlist ({wishlistItems.length}) {t('mapUi.wishlistCount', { count: wishlistItems.length })}
</h4> </h4>
{wishlistItems.slice(0, 3).map(item => ( {wishlistItems.slice(0, 3).map(item => (
<MiniStashCard <MiniStashCard
@ -306,7 +308,7 @@ function StashDrawer() {
<div> <div>
<h4 className="text-[10px] font-bold uppercase text-[#ff6600] mb-2 flex items-center gap-1"> <h4 className="text-[10px] font-bold uppercase text-[#ff6600] mb-2 flex items-center gap-1">
<Package className="h-3 w-3" /> <Package className="h-3 w-3" />
My Gear ({stashItems.length}) {t('mapUi.myGearCount', { count: stashItems.length })}
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2">
{stashItems.slice(0, 5).map(item => ( {stashItems.slice(0, 5).map(item => (
@ -328,20 +330,20 @@ function StashDrawer() {
<Link to="/stash" className="block"> <Link to="/stash" className="block">
<Button className="w-full bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 font-bold"> <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" /> <Backpack className="h-4 w-4 mr-2" />
Open Full Stash {t('mapUi.openFullStash')}
</Button> </Button>
</Link> </Link>
<div className="flex gap-2"> <div className="flex gap-2">
<Link to="/stash" className="flex-1"> <Link to="/stash" className="flex-1">
<Button variant="outline" size="sm" className="w-full border-[#ff6600]/50 text-[#ff6600] hover:bg-[#ff6600]/10"> <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" /> <Plus className="h-3.5 w-3.5 mr-1" />
Add Item {t('mapUi.addItem')}
</Button> </Button>
</Link> </Link>
<Link to="/stash" className="flex-1"> <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"> <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" /> <Target className="h-3.5 w-3.5 mr-1" />
Wishlist {t('mapUi.wishlist')}
</Button> </Button>
</Link> </Link>
</div> </div>
@ -369,6 +371,7 @@ function DirectionsButton({
*/ */
onDirections?: (target: { lat: number; lng: number; label: string }) => void; onDirections?: (target: { lat: number; lng: number; label: string }) => void;
}) { }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const destination = useMemo(() => { const destination = useMemo(() => {
const la = parseFloat(lat); const la = parseFloat(lat);
@ -389,7 +392,7 @@ function DirectionsButton({
disabled={!destination} disabled={!destination}
> >
<Navigation className="h-4 w-4 mr-2 text-[#ccff00]" /> <Navigation className="h-4 w-4 mr-2 text-[#ccff00]" />
Directions {t('mapUi.directions')}
</Button> </Button>
{!onDirections && ( {!onDirections && (
<RouteDialog <RouteDialog
@ -413,6 +416,7 @@ function SpotBottomSheet({
onClose: () => void; onClose: () => void;
onDirections: (target: { lat: number; lng: number; label: string }) => void; onDirections: (target: { lat: number; lng: number; label: string }) => void;
}) { }) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const author = useAuthor(spot.pubkey); const author = useAuthor(spot.pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -496,7 +500,7 @@ function SpotBottomSheet({
<AvatarImage src={metadata?.picture} /> <AvatarImage src={metadata?.picture} />
<AvatarFallback className="text-[8px] bg-white/10">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback> <AvatarFallback className="text-[8px] bg-white/10">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar> </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> </div>
{/* CTA buttons */} {/* 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" 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" /> <ExternalLink className="h-4 w-4 mr-2" />
View Details {t('mapUi.viewDetails')}
</Button> </Button>
{spot.geohash && (() => { {spot.geohash && (() => {
const { latitude, longitude } = ngeohash.decode(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 // 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. // fused provider) can take a while — let the user know it's working.
const locatingToast = toast({ const locatingToast = toast({
title: cached ? 'Refining your location…' : 'Locating you…', title: cached ? t('mapUi.refiningLocation') : t('mapUi.locatingYou'),
description: cached description: cached
? 'Centered on your last spot — dialing in a fresh GPS fix.' ? t('mapUi.refiningLocationDesc')
: 'Getting a GPS fix. This can take up to a minute the first time.', : t('mapUi.locatingDesc'),
duration: 60000, duration: 60000,
}); });
@ -715,8 +719,8 @@ export default function MapPage() {
setNearMeLoading(false); setNearMeLoading(false);
setNearMeError(null); setNearMeError(null);
toast({ toast({
title: 'Found you', title: t('mapUi.foundYou'),
description: 'Centered the map on your location.', description: t('mapUi.foundYouDesc'),
duration: 3000, duration: 3000,
}); });
} catch (err) { } catch (err) {
@ -735,9 +739,9 @@ export default function MapPage() {
if (isNativeGeolocation()) { if (isNativeGeolocation()) {
// Native app: it's an OS permission, not a browser setting. // Native app: it's an OS permission, not a browser setting.
toast({ toast({
title: 'Location permission needed', title: t('mapUi.locationPermissionNeeded'),
description: description:
'Allow location for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then tap Near Me again.' + t('mapUi.permissionNeededDesc') +
diag, diag,
variant: 'destructive', variant: 'destructive',
duration: 12000, duration: 12000,
@ -749,22 +753,22 @@ export default function MapPage() {
} else if (kind === 'timeout') { } else if (kind === 'timeout') {
setNearMeError('timeout'); setNearMeError('timeout');
toast({ toast({
title: 'Location timed out', title: t('mapUi.locationTimedOut'),
description: description:
(isNativeGeolocation() (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." ? t('mapUi.timeoutNativeDesc')
: 'Could not get your location in time. Check your signal and try again.') + diag, : t('mapUi.timeoutWebDesc')) + diag,
variant: 'destructive', variant: 'destructive',
duration: 12000, duration: 12000,
}); });
} else if (kind === 'no_provider') { } else if (kind === 'no_provider') {
setNearMeError('unavailable'); setNearMeError('unavailable');
toast({ toast({
title: 'No location provider', title: t('mapUi.noLocationProvider'),
description: description:
(isNativeGeolocation() (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." ? t('mapUi.noProviderNativeDesc')
: 'No location provider is available. Enable location services and try again.') + : t('mapUi.noProviderWebDesc')) +
diag, diag,
variant: 'destructive', variant: 'destructive',
duration: 14000, duration: 14000,
@ -772,11 +776,11 @@ export default function MapPage() {
} else { } else {
setNearMeError('unavailable'); setNearMeError('unavailable');
toast({ toast({
title: 'Location unavailable', title: t('mapUi.locationUnavailable'),
description: description:
(isNativeGeolocation() (isNativeGeolocation()
? 'Turn on Location/GPS in your phone settings, allow location for SkateSpots (Settings → Apps → SkateSpots → Permissions), then try again.' ? t('mapUi.unavailableNativeDesc')
: 'Your location could not be determined. Make sure location services are enabled.') + : t('mapUi.unavailableWebDesc')) +
diag, diag,
variant: 'destructive', variant: 'destructive',
duration: 12000, duration: 12000,
@ -1060,8 +1064,8 @@ export default function MapPage() {
<div className="container px-4 py-2.5 flex items-center gap-3"> <div className="container px-4 py-2.5 flex items-center gap-3">
<span className="text-xl flex-shrink-0">🛹</span> <span className="text-xl flex-shrink-0">🛹</span>
<div className="flex-1 min-w-0"> <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-sm font-black text-white uppercase tracking-wide leading-tight">{t('mapUi.newHere')}</p>
<p className="text-xs text-white/70 leading-tight">Discover skate spots, events &amp; the community.</p> <p className="text-xs text-white/70 leading-tight">{t('mapUi.welcomeDesc')}</p>
</div> </div>
{/* Desktop-only: grab the mobile app via Zapstore */} {/* Desktop-only: grab the mobile app via Zapstore */}
<a <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" 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" /> <Smartphone className="h-3.5 w-3.5" />
Get on Zapstore {t('mapUi.getOnZapstore')}
</a> </a>
<Link <Link
to="/onboarding" 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" 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> </Link>
<button <button
onClick={() => setWelcomeDismissed(true)} 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" 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" /> <X className="h-3.5 w-3.5" />
</button> </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', 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')} viewMode === 'map' ? 'bg-[#ff0040] shadow-lg shadow-[#ff0040]/30' : 'text-gray-500 hover:text-gray-300')}
onClick={() => setViewMode('map')} onClick={() => setViewMode('map')}
aria-label="Map view" aria-label={t('mapUi.mapViewAria')}
> >
<Map className="h-3.5 w-3.5" /> <Map className="h-3.5 w-3.5" />
</button> </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', 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')} viewMode === 'list' ? 'bg-[#ff0040] shadow-lg shadow-[#ff0040]/30' : 'text-gray-500 hover:text-gray-300')}
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
aria-label="List view" aria-label={t('mapUi.listViewAria')}
> >
<List className="h-3.5 w-3.5" /> <List className="h-3.5 w-3.5" />
</button> </button>
@ -1151,7 +1155,7 @@ export default function MapPage() {
? 'bg-[#ff0040]/25 border-[#ff0040]/60 text-[#ff0040]' ? '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' : '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" /> <SlidersHorizontal className="h-3.5 w-3.5" />
{totalActiveFilters > 0 && ( {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"> <SheetContent side="bottom" className="h-[82vh] max-h-[680px] rounded-t-2xl px-4 flex flex-col">
<SheetHeader className="pb-2 flex-shrink-0"> <SheetHeader className="pb-2 flex-shrink-0">
<SheetTitle className="flex items-center justify-between text-base"> <SheetTitle className="flex items-center justify-between text-base">
<span>Filters</span> <span>{t('mapPage.filters')}</span>
{hasActiveFilters && ( {hasActiveFilters && (
<Button <Button
variant="ghost" variant="ghost"
@ -1175,7 +1179,7 @@ export default function MapPage() {
}} }}
> >
<X className="h-3 w-3 mr-1" /> <X className="h-3 w-3 mr-1" />
Clear {t('common.clear')}
</Button> </Button>
)} )}
</SheetTitle> </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"> <div className="space-y-4 overflow-y-auto flex-1 min-h-0 -mx-4 px-4 pb-2 scroll-container">
{/* Layer toggles */} {/* Layer toggles */}
<div> <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"> <div className="grid grid-cols-3 gap-1.5">
<Button <Button
variant={showSpots ? 'default' : 'outline'} variant={showSpots ? 'default' : 'outline'}
@ -1195,7 +1199,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowSpots(!showSpots)} onClick={() => setShowSpots(!showSpots)}
> >
🛹 Spots 🛹 {t('mapUi.spots')}
</Button> </Button>
<Button <Button
variant={showShops ? 'default' : 'outline'} variant={showShops ? 'default' : 'outline'}
@ -1206,7 +1210,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowShops(!showShops)} onClick={() => setShowShops(!showShops)}
> >
🏪 Shops 🏪 {t('mapUi.shops')}
</Button> </Button>
<Button <Button
variant={showGear ? 'default' : 'outline'} variant={showGear ? 'default' : 'outline'}
@ -1217,7 +1221,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowGear(!showGear)} onClick={() => setShowGear(!showGear)}
> >
🛒 Gear 🛒 {t('mapUi.gear')}
</Button> </Button>
<Button <Button
variant={showTours ? 'default' : 'outline'} variant={showTours ? 'default' : 'outline'}
@ -1228,7 +1232,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowTours(!showTours)} onClick={() => setShowTours(!showTours)}
> >
🗺️ Tours 🗺️ {t('mapUi.tours')}
</Button> </Button>
<Button <Button
variant={showEvents ? 'default' : 'outline'} variant={showEvents ? 'default' : 'outline'}
@ -1239,7 +1243,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowEvents(!showEvents)} onClick={() => setShowEvents(!showEvents)}
> >
📅 Events 📅 {t('mapUi.events')}
</Button> </Button>
<Button <Button
variant={showCrews ? 'default' : 'outline'} variant={showCrews ? 'default' : 'outline'}
@ -1250,7 +1254,7 @@ export default function MapPage() {
)} )}
onClick={() => setShowCrews(!showCrews)} onClick={() => setShowCrews(!showCrews)}
> >
🤘 Crews 🤘 {t('mapUi.crews')}
</Button> </Button>
<Button <Button
variant={showBtcMap ? 'default' : 'outline'} variant={showBtcMap ? 'default' : 'outline'}
@ -1268,7 +1272,7 @@ export default function MapPage() {
{/* Spot categories */} {/* Spot categories */}
<div> <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"> <div className="flex flex-wrap gap-1.5">
{Object.entries(SPOT_CATEGORIES).map(([key, cat]) => ( {Object.entries(SPOT_CATEGORIES).map(([key, cat]) => (
<Button <Button
@ -1292,7 +1296,7 @@ export default function MapPage() {
{/* Sort & Trust */} {/* Sort & Trust */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <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 <SortDropdown
value={sortBy} value={sortBy}
onChange={setSortBy} onChange={setSortBy}
@ -1301,7 +1305,7 @@ export default function MapPage() {
/> />
</div> </div>
<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 <TrustFilter
value={trustFilter} value={trustFilter}
onChange={setTrustFilter} onChange={setTrustFilter}
@ -1312,7 +1316,7 @@ export default function MapPage() {
{/* Weather toggle */} {/* Weather toggle */}
<div> <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 <WeatherToggle
enabled={weatherEnabled} enabled={weatherEnabled}
onToggle={() => { onToggle={() => {
@ -1332,18 +1336,18 @@ export default function MapPage() {
Stash) to avoid duplication. */} Stash) to avoid duplication. */}
{user && ( {user && (
<div> <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"> <div className="grid grid-cols-2 gap-1.5">
<Link to="/marketplace" onClick={() => setMobileFiltersOpen(false)}> <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"> <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" /> <ArrowRightLeft className="h-3 w-3" />
Buy/Sell Gear {t('mapUi.buySellGear')}
</Button> </Button>
</Link> </Link>
<Link to="/stash" onClick={() => setMobileFiltersOpen(false)}> <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"> <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" /> <Backpack className="h-3 w-3" />
Stash {t('mapUi.stash')}
</Button> </Button>
</Link> </Link>
</div> </div>
@ -1357,7 +1361,7 @@ export default function MapPage() {
className="w-full h-12 font-bold text-base" className="w-full h-12 font-bold text-base"
onClick={() => setMobileFiltersOpen(false)} onClick={() => setMobileFiltersOpen(false)}
> >
Show Results {t('mapUi.showResults')}
</Button> </Button>
</div> </div>
</SheetContent> </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" 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" /> <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"> <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} {mobileLayerCount}
</span> </span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl"> <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"> <div className="space-y-0.5">
{[ {[
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) }, { key: 'spots', label: t('mapUi.spots'), dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
{ key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) }, { key: 'shops', label: t('mapUi.shops'), dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) }, { key: 'gear', label: t('mapUi.gear'), dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) }, { key: 'tours', label: t('mapUi.tours'), dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) }, { key: 'events', label: t('mapUi.events'), dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) }, { key: 'crews', label: t('mapUi.crews'), dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
].map(({ key, label, dot, active, toggle }) => ( ].map(({ key, label, dot, active, toggle }) => (
<button <button
key={key} key={key}
@ -1457,8 +1461,8 @@ export default function MapPage() {
<TooltipContent side="bottom" className="max-w-[200px]"> <TooltipContent side="bottom" className="max-w-[200px]">
<p className="text-xs"> <p className="text-xs">
{showBtcMap {showBtcMap
? `Showing ${btcMapPlaces?.length || 0} places where you can Spedn your sats nearby` ? t('mapUi.btcMapShowing', { count: btcMapPlaces?.length || 0 })
: 'Show places where you can Spedn your sats (via BTC Map)' : t('mapUi.btcMapShow')
} }
</p> </p>
</TooltipContent> </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]" 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" /> <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> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" className="w-[340px] sm:w-[400px] p-0 bg-black/95 border-l-2 border-[#ff6600]/30"> <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"> <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" /> <Backpack className="h-4 w-4 text-white" />
</div> </div>
My Stash {t('mapUi.myStash')}
<Badge variant="outline" className="ml-auto border-green-500/50 text-green-400 text-[10px]"> <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" /> <Shield className="h-2.5 w-2.5 mr-1" />
Encrypted {t('mapUi.encrypted')}
</Badge> </Badge>
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
@ -1544,10 +1548,10 @@ export default function MapPage() {
<SheetHeader className="p-4 pb-2"> <SheetHeader className="p-4 pb-2">
<SheetTitle className="flex items-center gap-2"> <SheetTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-purple-400" /> <MessageCircle className="h-5 w-5 text-purple-400" />
Local Chat {t('mapUi.localChat')}
</SheetTitle> </SheetTitle>
<p className="text-xs text-muted-foreground text-left"> <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> </p>
</SheetHeader> </SheetHeader>
<div className="flex-1 min-h-0 p-4 pt-0"> <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" 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" /> <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" /> <ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -1590,10 +1594,10 @@ export default function MapPage() {
<Store className="h-4 w-4" /> {t('shops.addShop')} <Store className="h-4 w-4" /> {t('shops.addShop')}
</Link> </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"> <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>
<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"> <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> </Link>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -1639,7 +1643,7 @@ export default function MapPage() {
<div className="h-full bg-black/20 flex items-center justify-center"> <div className="h-full bg-black/20 flex items-center justify-center">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin" /> <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>
</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"> <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="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"> <span className="font-mono text-[10px] uppercase tracking-[0.22em] text-white/70">
Loading spots {t('mapUi.loadingSpots')}
</span> </span>
</div> </div>
</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"> <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" /> <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"> <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-amber-200/90">
Offline · showing saved spots {t('mapUi.offlineSavedSpots')}
</span> </span>
<button <button
type="button" type="button"
@ -1715,7 +1719,7 @@ export default function MapPage() {
relay.recheck(); relay.recheck();
}} }}
className="ml-0.5 rounded-full p-1 text-amber-200/80 transition-colors hover:bg-white/10 hover:text-white" 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" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
@ -1723,7 +1727,7 @@ export default function MapPage() {
type="button" type="button"
onClick={() => setOfflineBannerDismissed(true)} onClick={() => setOfflineBannerDismissed(true)}
className="rounded-full p-1 text-amber-200/60 transition-colors hover:bg-white/10 hover:text-white" 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" /> <X className="h-3.5 w-3.5" />
</button> </button>
@ -1737,10 +1741,10 @@ export default function MapPage() {
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && allLocations.length > 0 && ( {!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-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"> <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="font-black uppercase tracking-wide text-white">{t('mapUi.noSpotsMatchTitle')}</p>
<p className="mt-1 text-xs text-gray-400">Try a different search or clear your filters.</p> <p className="mt-1 text-xs text-gray-400">{t('mapUi.noSpotsMatchDesc')}</p>
<Button size="sm" className="mt-3" onClick={clearFilters}> <Button size="sm" className="mt-3" onClick={clearFilters}>
Clear filters {t('common.clearFilters')}
</Button> </Button>
</div> </div>
</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"> <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" /> <WifiOff className="mx-auto h-7 w-7 text-amber-400" />
<p className="mt-2 font-black uppercase tracking-wide text-white"> <p className="mt-2 font-black uppercase tracking-wide text-white">
Can't reach the relays {t('mapUi.cantReachRelays')}
</p> </p>
<p className="mt-1 text-xs leading-relaxed text-gray-400"> <p className="mt-1 text-xs leading-relaxed text-gray-400">
{online {online
? "You're online, but the community relay isn't answering right now." ? t('mapUi.relayOnlineNoAnswer')
: "You're offline."}{' '} : t('mapUi.youreOffline')}{' '}
Spots you've already seen are saved on this device and will show here. {t('mapUi.savedOnDevice')}
</p> </p>
<Button <Button
size="sm" size="sm"
@ -1772,7 +1776,7 @@ export default function MapPage() {
}} }}
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
Try again {t('common.tryAgain')}
</Button> </Button>
</div> </div>
</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"> <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="text-2xl">🛹</p>
<p className="mt-2 font-black uppercase tracking-wide text-white"> <p className="mt-2 font-black uppercase tracking-wide text-white">
No spots mapped yet {t('mapUi.noSpotsMappedYet')}
</p> </p>
<p className="mt-1 text-xs leading-relaxed text-gray-400"> <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 {t('mapUi.noSpotsMappedDesc')}
skater who rolls through will thank you.
</p> </p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center"> <div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
<Link to="/create-spot"> <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" className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add the first spot {t('mapUi.addFirstSpot')}
</Button> </Button>
</Link> </Link>
<Button size="sm" variant="outline" className="gap-2 border-white/20" onClick={handleNearMe} disabled={nearMeLoading}> <Button size="sm" variant="outline" className="gap-2 border-white/20" onClick={handleNearMe} disabled={nearMeLoading}>
<LocateFixed className="h-4 w-4" /> <LocateFixed className="h-4 w-4" />
{nearMeLoading ? 'Locating…' : 'Find spots near me'} {nearMeLoading ? t('mapUi.locating') : t('mapUi.findSpotsNearMe')}
</Button> </Button>
</div> </div>
</div> </div>
@ -1837,18 +1840,18 @@ export default function MapPage() {
<LocateOff className="h-5 w-5 text-red-400" /> <LocateOff className="h-5 w-5 text-red-400" />
</div> </div>
<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"> <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> </p>
</div> </div>
</div> </div>
<div className="rounded-xl border border-white/8 bg-white/[0.03] divide-y divide-white/5 overflow-hidden"> <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: `📱 ${t('mapUi.locationPlatformAndroid')}`, steps: t('mapUi.locationStepsAndroid') },
{ platform: '🍎 iOS Safari', steps: 'Settings app → Safari → Location → Allow (or tap the "AA" button in Safari\'s address bar)' }, { platform: `🍎 ${t('mapUi.locationPlatformIos')}`, steps: t('mapUi.locationStepsIos') },
{ platform: '💻 Desktop Chrome', steps: 'Click the lock icon → Site settings → Location → Allow' }, { platform: `💻 ${t('mapUi.locationPlatformDesktop')}`, steps: t('mapUi.locationStepsDesktop') },
{ platform: '🦊 Firefox', steps: 'Click the lock icon → Connection secure → More info → Permissions → Access your location → Allow' }, { platform: `🦊 ${t('mapUi.locationPlatformFirefox')}`, steps: t('mapUi.locationStepsFirefox') },
].map(({ platform, steps }) => ( ].map(({ platform, steps }) => (
<div key={platform} className="px-3 py-2.5"> <div key={platform} className="px-3 py-2.5">
<p className="text-xs font-bold text-white mb-0.5">{platform}</p> <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]" 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> </button>
</div> </div>
</div> </div>
@ -1898,7 +1901,7 @@ export default function MapPage() {
? 'bg-red-900/80 border-red-500/30' ? 'bg-red-900/80 border-red-500/30'
: 'bg-black/70 border-white/15 hover:border-white/30' : 'bg-black/70 border-white/15 hover:border-white/30'
)} )}
aria-label="Find spots near me" aria-label={t('mapUi.findSpotsNearMe')}
> >
{nearMeLoading ? ( {nearMeLoading ? (
<span className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white animate-spin" /> <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-[#ff0040]/20 border-[#ff0040]/60'
: 'bg-black/70 border-white/15 hover:border-white/30' : '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')} /> <Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} />
{mobileLayerCount < totalPrimaryLayers && ( {mobileLayerCount < totalPrimaryLayers && (
@ -1934,15 +1937,15 @@ export default function MapPage() {
align="end" align="end"
className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl" 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"> <div className="space-y-0.5">
{[ {[
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) }, { key: 'spots', label: t('mapUi.spots'), dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
{ key: 'shops', label: 'Shops', dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) }, { key: 'shops', label: t('mapUi.shops'), dot: '#9333ea', active: showShops, toggle: () => setShowShops(v => !v) },
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) }, { key: 'gear', label: t('mapUi.gear'), dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) }, { key: 'tours', label: t('mapUi.tours'), dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) }, { key: 'events', label: t('mapUi.events'), dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) }, { key: 'crews', label: t('mapUi.crews'), dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
].map(({ key, label, dot, active, toggle }) => ( ].map(({ key, label, dot, active, toggle }) => (
<button <button
key={key} key={key}
@ -1985,11 +1988,11 @@ export default function MapPage() {
{addDialOpen && ( {addDialOpen && (
<> <>
{[ {[
{ label: 'Add Spot', to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin 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: 'Add Shop', to: '/create-shop', bg: 'bg-[#9333ea]', icon: <Store 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: 'Add Event', to: '/create-event', bg: 'bg-[#ccff00]', icon: <Calendar className="h-4 w-4 text-black" /> }, { label: t('mapUi.addEvent'), 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: t('mapUi.sellGear'), 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.offerTour'), to: '/create-tour', bg: 'bg-[#06b6d4]', icon: <Route className="h-4 w-4 text-white" /> },
].map((a, i) => ( ].map((a, i) => (
<Link <Link
key={a.to} key={a.to}
@ -2018,7 +2021,7 @@ export default function MapPage() {
? 'bg-white rotate-45' ? 'bg-white rotate-45'
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90' : '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} /> <Plus className={cn('h-7 w-7 transition-colors', addDialOpen ? 'text-black' : 'text-white')} strokeWidth={2.5} />
</button> </button>
@ -2097,7 +2100,7 @@ export default function MapPage() {
hideClose hideClose
className="h-auto max-h-[55vh] rounded-t-2xl px-0 pb-0 bg-black/95 border-t border-white/10" 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 && ( {selectedSpot && (
<SpotBottomSheet <SpotBottomSheet
spot={selectedSpot} spot={selectedSpot}

View file

@ -7,12 +7,13 @@ import { SellerDashboard } from '@/components/SellerDashboard';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
const TABS = [ const TABS = [
{ id: 'browse', label: 'Browse', icon: Store }, { id: 'browse', labelKey: 'marketUi.hub.browse', icon: Store },
{ id: 'selling', label: 'Selling', icon: Package }, { id: 'selling', labelKey: 'marketUi.hub.selling', icon: Package },
{ id: 'trades', label: 'My Trades', icon: Handshake }, { id: 'trades', labelKey: 'marketUi.hub.myTrades', icon: Handshake },
{ id: 'reputation', label: 'Reputation', icon: Star }, { id: 'reputation', labelKey: 'marketUi.hub.reputation', icon: Star },
] as const; ] as const;
type TabId = (typeof TABS)[number]['id']; type TabId = (typeof TABS)[number]['id'];
@ -23,6 +24,7 @@ type TabId = (typeof TABS)[number]['id'];
* working; this is just the front door. * working; this is just the front door.
*/ */
export default function MarketplaceHubPage() { export default function MarketplaceHubPage() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [tab, setTab] = useState<TabId>('browse'); 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="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="container max-w-7xl px-4">
<div className="scroll-container flex gap-1 overflow-x-auto py-2"> <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; const active = tab === id;
return ( return (
<button <button
@ -46,7 +48,7 @@ export default function MarketplaceHubPage() {
)} )}
> >
<Icon className="h-3.5 w-3.5" /> <Icon className="h-3.5 w-3.5" />
{label} {t(labelKey)}
</button> </button>
); );
})} })}
@ -61,7 +63,7 @@ export default function MarketplaceHubPage() {
<SellerDashboard /> <SellerDashboard />
) : ( ) : (
<div className="space-y-4 py-16 text-center"> <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" /> <LoginArea className="justify-center" />
</div> </div>
)} )}
@ -74,7 +76,7 @@ export default function MarketplaceHubPage() {
<TradeReputationPanel pubkey={user.pubkey} /> <TradeReputationPanel pubkey={user.pubkey} />
) : ( ) : (
<div className="space-y-4 py-16 text-center"> <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" /> <LoginArea className="justify-center" />
</div> </div>
)} )}

View file

@ -3,8 +3,10 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ShoppingBag, ExternalLink, Package, DollarSign, Shield, Zap, Store } from 'lucide-react'; import { ShoppingBag, ExternalLink, Package, DollarSign, Shield, Zap, Store } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
export default function MarketplacePage() { export default function MarketplacePage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Marketplace - SkateSpots', title: 'Marketplace - SkateSpots',
description: 'Buy and sell skateboarding gear on the decentralized Plebeian Market.', 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', fontFamily: 'Impact, "Arial Black", sans-serif',
letterSpacing: '0.05em' letterSpacing: '0.05em'
}}> }}>
Shops & Marketplace {t('marketUi.marketplace.heroTitle')}
</h1> </h1>
<p className="text-xl text-gray-300 max-w-2xl mx-auto"> <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. {t('marketUi.marketplace.heroBefore')} <strong className="text-[#ff6600]">Plebeian Market</strong> {t('marketUi.marketplace.heroAfter')}
Skate shops can list their products, and you can buy/sell used gear directly with Bitcoin.
</p> </p>
</div> </div>
@ -40,10 +41,9 @@ export default function MarketplacePage() {
<Store className="h-8 w-8 text-white" /> <Store className="h-8 w-8 text-white" />
</div> </div>
<div className="flex-1"> <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"> <p className="text-gray-300 mb-4">
Plebeian Market is a decentralized marketplace built on Nostr. Buy and sell skateboarding gear {t('marketUi.marketplace.whatIsBody')}
directly with other skaters using Bitcoin. No middlemen, no fees, total freedom.
</p> </p>
</div> </div>
</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="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"> <div className="p-4 rounded-lg bg-black/50 border border-[#ff0040]/20">
<Shield className="h-6 w-6 text-[#ff0040] mb-2" /> <Shield className="h-6 w-6 text-[#ff0040] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-1">No Fees</h3> <h3 className="font-bold text-white uppercase text-sm mb-1">{t('marketUi.marketplace.noFeesTitle')}</h3>
<p className="text-xs text-gray-400">No platform taking 30%. Direct peer-to-peer sales.</p> <p className="text-xs text-gray-400">{t('marketUi.marketplace.noFeesBody')}</p>
</div> </div>
<div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20"> <div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
<DollarSign className="h-6 w-6 text-[#ccff00] mb-2" /> <DollarSign className="h-6 w-6 text-[#ccff00] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-1">Bitcoin Payments</h3> <h3 className="font-bold text-white uppercase text-sm mb-1">{t('marketUi.marketplace.bitcoinTitle')}</h3>
<p className="text-xs text-gray-400">Pay with Bitcoin. Fast, secure, global.</p> <p className="text-xs text-gray-400">{t('marketUi.marketplace.bitcoinBody')}</p>
</div> </div>
<div className="p-4 rounded-lg bg-black/50 border border-[#ff6600]/20"> <div className="p-4 rounded-lg bg-black/50 border border-[#ff6600]/20">
<Package className="h-6 w-6 text-[#ff6600] mb-2" /> <Package className="h-6 w-6 text-[#ff6600] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-1">Decentralized</h3> <h3 className="font-bold text-white uppercase text-sm mb-1">{t('marketUi.marketplace.decentralizedTitle')}</h3>
<p className="text-xs text-gray-400">Built on Nostr. No company controls it.</p> <p className="text-xs text-gray-400">{t('marketUi.marketplace.decentralizedBody')}</p>
</div> </div>
</div> </div>
@ -70,7 +70,7 @@ export default function MarketplacePage() {
<a href="https://plebeian.market" target="_blank" rel="noopener noreferrer"> <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"> <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" /> <ExternalLink className="h-6 w-6" />
Visit Plebeian Market {t('marketUi.marketplace.visit')}
</Button> </Button>
</a> </a>
</div> </div>
@ -79,27 +79,51 @@ export default function MarketplacePage() {
{/* What You Can Buy/Sell */} {/* What You Can Buy/Sell */}
<div className="mb-12"> <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"> <div className="grid md:grid-cols-2 gap-6">
{[ {[
{ {
title: 'Complete Decks', title: t('marketUi.marketplace.catCompletes'),
items: ['Pre-built completes', 'Custom setups', 'Beginner boards'], items: [
t('marketUi.marketplace.preBuilt'),
t('marketUi.marketplace.customSetups'),
t('marketUi.marketplace.beginnerBoards'),
],
color: '#ff0040' color: '#ff0040'
}, },
{ {
title: 'Deck Parts', title: t('marketUi.marketplace.catParts'),
items: ['Decks', 'Trucks', 'Wheels', 'Bearings', 'Grip tape', 'Hardware'], 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' color: '#ff6600'
}, },
{ {
title: 'Apparel & Protection', title: t('marketUi.marketplace.catApparel'),
items: ['Skate shoes', 'Clothing', 'Helmets', 'Pads', 'Backpacks'], items: [
t('marketUi.marketplace.skateShoes'),
t('marketUi.marketplace.clothing'),
t('marketUi.marketplace.helmets'),
t('marketUi.marketplace.pads'),
t('marketUi.marketplace.backpacks'),
],
color: '#ccff00' color: '#ccff00'
}, },
{ {
title: 'Accessories', title: t('marketUi.marketplace.catAccessories'),
items: ['Tools', 'Wax', 'Rails', 'Stickers', 'Videos', 'Magazines'], 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' color: '#ff0040'
}, },
].map((category, i) => ( ].map((category, i) => (
@ -125,29 +149,29 @@ export default function MarketplacePage() {
{/* How It Works */} {/* How It Works */}
<Card className="bg-black/90 border-2 border-[#ccff00]/50"> <Card className="bg-black/90 border-2 border-[#ccff00]/50">
<CardContent className="p-8"> <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"> <div className="space-y-4">
{[ {[
{ {
num: 1, num: 1,
title: 'Browse Products', title: t('marketUi.marketplace.step1Title'),
desc: 'Visit Plebeian Market and browse skateboarding gear from shops and individuals worldwide.', desc: t('marketUi.marketplace.step1Body'),
}, },
{ {
num: 2, num: 2,
title: 'Contact Sellers', title: t('marketUi.marketplace.step2Title'),
desc: 'Message sellers directly via Nostr. Negotiate prices, ask questions, arrange shipping.', desc: t('marketUi.marketplace.step2Body'),
}, },
{ {
num: 3, num: 3,
title: 'Pay with Bitcoin', title: t('marketUi.marketplace.step3Title'),
desc: 'Complete your purchase using Bitcoin Lightning for instant, low-fee payments.', desc: t('marketUi.marketplace.step3Body'),
}, },
{ {
num: 4, num: 4,
title: 'Leave Feedback', title: t('marketUi.marketplace.step4Title'),
desc: 'Rate your experience and build seller reputation on Nostr. Your reviews help the community.', desc: t('marketUi.marketplace.step4Body'),
}, },
].map((step) => ( ].map((step) => (
<div key={step.num} className="flex gap-4 p-4 rounded-lg bg-black/50 border border-[#ccff00]/20"> <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"> <div className="flex items-start gap-3">
<Zap className="h-5 w-5 text-[#ff0040] flex-shrink-0 mt-0.5" /> <Zap className="h-5 w-5 text-[#ff0040] flex-shrink-0 mt-0.5" />
<div> <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"> <p className="text-sm text-gray-300">
You'll need a Lightning wallet with some sats to make purchases. {t('marketUi.marketplace.needBitcoinBefore')} <a href="/onboarding" className="text-[#ccff00] underline font-bold">{t('marketUi.marketplace.onboardingGuide')}</a> {t('marketUi.marketplace.needBitcoinAfter')}
Check out the <a href="/onboarding" className="text-[#ccff00] underline font-bold">onboarding guide</a> for wallet recommendations.
</p> </p>
</div> </div>
</div> </div>
@ -181,22 +204,21 @@ export default function MarketplacePage() {
<Card className="bg-black/90 border-2 border-[#ff0040]/50 mt-8"> <Card className="bg-black/90 border-2 border-[#ff0040]/50 mt-8">
<CardContent className="p-8 text-center"> <CardContent className="p-8 text-center">
<Store className="h-12 w-12 mx-auto mb-4 text-[#ff0040]" /> <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"> <p className="text-gray-300 mb-6 max-w-xl mx-auto">
List your shop on SkateSpots and sell your products on Plebeian Market. {t('marketUi.marketplace.ownShopBody')}
Reach customers worldwide with zero platform fees.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/create-shop"> <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"> <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" /> <Store className="h-5 w-5" />
Add Your Shop {t('marketUi.marketplace.addShop')}
</Button> </Button>
</a> </a>
<a href="https://plebeian.market/sell" target="_blank" rel="noopener noreferrer"> <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"> <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" /> <Package className="h-5 w-5" />
Start Selling {t('marketUi.marketplace.startSelling')}
</Button> </Button>
</a> </a>
</div> </div>

View file

@ -9,10 +9,12 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTrustScore } from '@/hooks/useTrustScore'; import { useTrustScore } from '@/hooks/useTrustScore';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { Trophy, Medal } from 'lucide-react'; import { Trophy, Medal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots: number; events: number; rank: number }) { function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots: number; events: number; rank: number }) {
const { t } = useTranslation();
const author = useAuthor(pubkey); const author = useAuthor(pubkey);
const { data: trustScore } = useTrustScore(pubkey); const { data: trustScore } = useTrustScore(pubkey);
const metadata = author.data?.metadata; 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="flex gap-4 text-sm">
<div className="text-center"> <div className="text-center">
<div className="text-[#ff0040] font-black">{spots}</div> <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>
<div className="text-center"> <div className="text-center">
<div className="text-[#ccff00] font-black">{events}</div> <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>
<div className="text-center"> <div className="text-center">
<div className="text-[#ff6600] font-black">{total}</div> <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>
<div className="text-center min-w-[48px]"> <div className="text-center min-w-[48px]">
<div className={cn("font-black", trustScoreColor)}> <div className={cn("font-black", trustScoreColor)}>
{trustScore ?? '—'} {trustScore ?? '—'}
</div> </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> </div>
</div> </div>
@ -73,6 +75,7 @@ function ContributorRow({ pubkey, spots, events, rank }: { pubkey: string; spots
} }
export default function PyramidPage() { export default function PyramidPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Pyramid Rankings - SkateSpots', title: 'Pyramid Rankings - SkateSpots',
description: 'Top contributors and community leaders.', description: 'Top contributors and community leaders.',
@ -121,9 +124,9 @@ export default function PyramidPage() {
<div className="text-center mb-12"> <div className="text-center mb-12">
<Trophy className="h-16 w-16 mx-auto mb-4 text-orange-500" /> <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"> <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> </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> </div>
{/* Your Stats */} {/* Your Stats */}
@ -134,22 +137,22 @@ export default function PyramidPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-4xl font-black text-[#ff0040]">#{userRank}</div> <div className="text-4xl font-black text-[#ff0040]">#{userRank}</div>
<div> <div>
<div className="font-black text-xl text-white uppercase">Your Rank</div> <div className="font-black text-xl text-white uppercase">{t('labUi.pyramid.yourRank')}</div>
<div className="text-sm text-gray-400">Keep contributing to climb higher!</div> <div className="text-sm text-gray-400">{t('labUi.pyramid.keepContributing')}</div>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-black text-[#ff0040]">{userStats.spots}</div> <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>
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-black text-[#ccff00]">{userStats.events}</div> <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>
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-black text-[#ff6600]">{userStats.total}</div> <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>
<div className="text-center"> <div className="text-center">
<div className={cn( <div className={cn(
@ -161,7 +164,7 @@ export default function PyramidPage() {
)}> )}>
{userTrustScore ?? '—'} {userTrustScore ?? '—'}
</div> </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> </div>
</div> </div>
@ -174,7 +177,7 @@ export default function PyramidPage() {
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<Medal className="h-8 w-8 text-[#ff0040]" /> <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> </div>
{rankings.length > 0 ? ( {rankings.length > 0 ? (
@ -192,7 +195,7 @@ export default function PyramidPage() {
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<Trophy className="h-16 w-16 mx-auto mb-4 text-gray-600" /> <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> </div>
)} )}
</CardContent> </CardContent>

View file

@ -36,6 +36,7 @@ import { useToast } from '@/hooks/useToast';
import { useNostr } from '@/hooks/useNostr'; import { useNostr } from '@/hooks/useNostr';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify'; 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'; 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. // Layout is STRICTLY pixel-fixed to match the 340×214px card container.
// No flex-1 / flex-grow — every row has a fixed pixel height. // No flex-1 / flex-grow — every row has a fixed pixel height.
function RiderFront({ d }: { d: CardData }) { function RiderFront({ d }: { d: CardData }) {
const { t } = useTranslation();
const witnessedBadges = d.receivedBadges.slice(0, 2); const witnessedBadges = d.receivedBadges.slice(0, 2);
return ( return (
@ -639,14 +641,14 @@ function RiderFront({ d }: { d: CardData }) {
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none"> <div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
🛹 SkateSpots 🛹 SkateSpots
</div> </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>
<div className="text-right"> <div className="text-right">
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div> <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]"> <div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
{shortNpub(d.pubkey)} {shortNpub(d.pubkey)}
</div> </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>
</div> </div>
@ -666,7 +668,7 @@ function RiderFront({ d }: { d: CardData }) {
<div className="space-y-[2px]"> <div className="space-y-[2px]">
{d.showSpots && ( {d.showSpots && (
<div className="flex items-center justify-between gap-1"> <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> <span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
</div> </div>
)} )}
@ -678,7 +680,7 @@ function RiderFront({ d }: { d: CardData }) {
)} )}
{d.showEvents && ( {d.showEvents && (
<div className="flex items-center justify-between gap-1"> <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> <span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
</div> </div>
)} )}
@ -736,7 +738,7 @@ function RiderFront({ d }: { d: CardData }) {
))} ))}
</div> </div>
</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>
</div> </div>
@ -758,6 +760,7 @@ function RiderFront({ d }: { d: CardData }) {
// ─── Card visual: Park Access front ────────────────────────────────────────── // ─── Card visual: Park Access front ──────────────────────────────────────────
function ParkFront({ d }: { d: CardData }) { function ParkFront({ d }: { d: CardData }) {
const { t } = useTranslation();
return ( return (
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}> <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 className="flex items-center justify-between" style={{ height: '26px' }}>
<div> <div>
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none"> <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>
<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>
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]"> <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> <span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
@ -791,12 +794,12 @@ function ParkFront({ d }: { d: CardData }) {
{/* Identity */} {/* Identity */}
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]"> <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 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 && ( {d.validThru && (
<div className="flex items-center gap-1 mt-[3px]"> <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> <span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
</div> </div>
)} )}
@ -811,7 +814,7 @@ function ParkFront({ d }: { d: CardData }) {
))} ))}
</div> </div>
</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>
</div> </div>
@ -825,7 +828,7 @@ function ParkFront({ d }: { d: CardData }) {
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Shield className="card-muted" style={{ width: '8px', height: '8px', opacity: 0.5 }} /> <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> </div>
</div> </div>
@ -854,6 +857,7 @@ function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObje
// ─── Received badges panel ──────────────────────────────────────────────────── // ─── Received badges panel ────────────────────────────────────────────────────
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) { function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
const { t } = useTranslation();
const { data: badgeEvents = [] } = useReceivedBadges(pubkey); const { data: badgeEvents = [] } = useReceivedBadges(pubkey);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -861,7 +865,7 @@ function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
return ( return (
<div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center"> <div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center">
<div className="text-2xl mb-1">🏅</div> <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> </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" 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 ? <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> </button>
)} )}
</div> </div>
@ -887,6 +891,7 @@ function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
} }
function BadgeEventRow({ event }: { event: NostrEvent }) { function BadgeEventRow({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v); const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
const spot = event.tags.find(([k]) => k === 'spot')?.[1]; const spot = event.tags.find(([k]) => k === 'spot')?.[1];
const witnessName = useWitnessName(event.pubkey); const witnessName = useWitnessName(event.pubkey);
@ -913,8 +918,8 @@ function BadgeEventRow({ event }: { event: NostrEvent }) {
})} })}
</div> </div>
<p className="text-[10px] text-gray-400"> <p className="text-[10px] text-gray-400">
Witnessed by <span className="text-yellow-400 font-medium">{witnessName}</span> {t('labUi.rider.witnessedBy')} <span className="text-yellow-400 font-medium">{witnessName}</span>
{spot && <> at <span className="text-gray-300">{spot}</span></>} {spot && <> {t('labUi.rider.at')} <span className="text-gray-300">{spot}</span></>}
</p> </p>
</div> </div>
<div className="text-[9px] text-gray-600 flex-shrink-0"> <div className="text-[9px] text-gray-600 flex-shrink-0">
@ -926,6 +931,7 @@ function BadgeEventRow({ event }: { event: NostrEvent }) {
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function RiderCardPage() { export default function RiderCardPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Skater Card — SkateSpots', title: 'Skater Card — SkateSpots',
description: 'Create your printable SkateSpots Skater License or Park Access Card.', description: 'Create your printable SkateSpots Skater License or Park Access Card.',
@ -1038,23 +1044,23 @@ export default function RiderCardPage() {
const handlePrint = useCallback(async () => { const handlePrint = useCallback(async () => {
const dataUrl = await renderCardToImage(); 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 w = Math.round(85.6 * 2.835);
const h = Math.round(54 * 2.835); const h = Math.round(54 * 2.835);
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`); 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.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(); win.document.close();
}, [renderCardToImage, toast]); }, [renderCardToImage, toast, t]);
const handleDownload = useCallback(async () => { const handleDownload = useCallback(async () => {
const dataUrl = await renderCardToImage(); 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'); const a = document.createElement('a');
a.href = dataUrl; a.href = dataUrl;
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`; a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
a.click(); a.click();
}, [renderCardToImage, cardData.npub, toast]); }, [renderCardToImage, cardData.npub, toast, t]);
const handleCopyNpub = useCallback(() => { const handleCopyNpub = useCallback(() => {
navigator.clipboard.writeText(npub); navigator.clipboard.writeText(npub);
@ -1064,7 +1070,7 @@ export default function RiderCardPage() {
const handleShare = useCallback(async () => { const handleShare = useCallback(async () => {
const dataUrl = await renderCardToImage(); 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 res = await fetch(dataUrl);
const blob = await res.blob(); const blob = await res.blob();
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' }); 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 */ } } catch { /* fall through */ }
} }
await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`); await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
toast({ title: '🔗 Link copied!' }); toast({ title: t('labUi.rider.linkCopied') });
}, [renderCardToImage, cardData.name, cardData.npub, toast]); }, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
const handlePublishParkCard = useCallback(() => { 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[][] = [ const tags: string[][] = [
['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`], ['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`],
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'], ['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
@ -1087,10 +1093,10 @@ export default function RiderCardPage() {
]; ];
if (validThru) tags.push(['valid-thru', validThru]); if (validThru) tags.push(['valid-thru', validThru]);
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, { publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
onSuccess: () => toast({ title: '✅ Park Access Card published!' }), onSuccess: () => toast({ title: t('labUi.rider.parkPublished') }),
onError: (e) => toast({ title: 'Publish failed', description: String(e), variant: 'destructive' }), 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) => const toggleSkill = (id: string) =>
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]); 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> <span className="text-white"> CARD</span>
</h1> </h1>
<p className="text-gray-400 max-w-sm mb-2"> <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>
<p className="text-gray-600 text-sm mb-8 max-w-xs"> <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> </p>
<LoginArea className="justify-center" /> <LoginArea className="justify-center" />
</div> </div>
@ -1131,15 +1137,15 @@ export default function RiderCardPage() {
<span className="neon-red">RIDER</span> <span className="neon-red">RIDER</span>
<span className="text-white"> CARD</span> <span className="text-white"> CARD</span>
</h1> </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> </div>
{/* ── Top tabs: My Card / My Badges / Award Badge ── */} {/* ── Top tabs: My Card / My Badges / Award Badge ── */}
<div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40"> <div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40">
{([ {([
{ id: 'card', label: '🪪 My Card' }, { id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` },
{ id: 'badges', label: `🏅 Badges${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` }, { id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
{ id: 'award', label: '⭐ Award' }, { id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` },
] as const).map(tab => ( ] as const).map(tab => (
<button <button
key={tab.id} 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" 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 ? <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> </Button>
{/* Secondary actions row */} {/* 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" 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" /> <Download className="h-3.5 w-3.5" />
Save PNG {t('labUi.rider.savePng')}
</Button> </Button>
<Button <Button
variant="outline" 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" 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" /> <Printer className="h-3.5 w-3.5" />
Print {t('labUi.rider.print')}
</Button> </Button>
<Button <Button
variant="outline" 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" 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 ? <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> </Button>
</div> </div>
</div> </div>
{/* ── Card type toggle ── */} {/* ── Card type toggle ── */}
<div> <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"> <div className="grid grid-cols-2 gap-2">
{([ {([
{ id: 'rider', label: 'Skater License', sub: 'Your personal rider identity', icon: User }, { id: 'rider', label: t('labUi.rider.licenseLabel'), sub: t('labUi.rider.licenseSub'), icon: User },
{ id: 'park', label: 'Park Access', sub: 'Issue passes as a park owner', icon: Building2 }, { id: 'park', label: t('labUi.rider.parkLabel'), sub: t('labUi.rider.parkSub'), icon: Building2 },
] as const).map(({ id, label, sub, icon: Icon }) => ( ] as const).map(({ id, label, sub, icon: Icon }) => (
<button <button
key={id} key={id}
@ -1247,23 +1253,23 @@ export default function RiderCardPage() {
{/* ── Theme picker ── */} {/* ── Theme picker ── */}
<div> <div>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2 flex items-center gap-1.5"> <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> </p>
<div className="grid grid-cols-6 gap-2"> <div className="grid grid-cols-6 gap-2">
{THEMES.map(t => ( {THEMES.map((th) => (
<button <button
key={t.id} key={th.id}
onClick={() => setTheme(t.id)} onClick={() => setTheme(th.id)}
className={cn( className={cn(
'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5', 'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
`card-theme-${t.id}`, `card-theme-${th.id}`,
theme === t.id theme === th.id
? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black scale-105 border-white/30' ? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black scale-105 border-white/30'
: 'border-white/10 opacity-60 hover:opacity-90' : 'border-white/10 opacity-60 hover:opacity-90'
)} )}
> >
<span className="text-xl">{t.emoji}</span> <span className="text-xl">{th.emoji}</span>
<span className="text-[8px] font-black uppercase opacity-80">{t.label}</span> <span className="text-[8px] font-black uppercase opacity-80">{th.label}</span>
</button> </button>
))} ))}
</div> </div>
@ -1272,8 +1278,8 @@ export default function RiderCardPage() {
{/* ── Holographic toggle ── */} {/* ── Holographic toggle ── */}
<div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8"> <div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8">
<div> <div>
<div className="text-sm font-bold">✨ Holographic Shimmer</div> <div className="text-sm font-bold">✨ {t('labUi.rider.holoTitle')}</div>
<div className="text-xs text-gray-500">Animated foil effect</div> <div className="text-xs text-gray-500">{t('labUi.rider.holoDesc')}</div>
</div> </div>
<Switch checked={holographic} onCheckedChange={setHolographic} /> <Switch checked={holographic} onCheckedChange={setHolographic} />
</div> </div>
@ -1281,11 +1287,11 @@ export default function RiderCardPage() {
{/* ── RIDER fields ── */} {/* ── RIDER fields ── */}
{cardType === 'rider' && ( {cardType === 'rider' && (
<div className="space-y-5"> <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 */} {/* Style */}
<div> <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}> <Select value={style} onValueChange={setStyle}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"> <SelectTrigger className="bg-black/50 border-white/10 h-11">
<SelectValue /> <SelectValue />
@ -1299,18 +1305,18 @@ export default function RiderCardPage() {
{/* Rides For + Sponsors — side by side */} {/* Rides For + Sponsors — side by side */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <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 <Input
placeholder="Local shop…" placeholder={t('labUi.rider.ridesForPlaceholder')}
value={ridesFor} value={ridesFor}
onChange={e => setRidesFor(e.target.value)} onChange={e => setRidesFor(e.target.value)}
className="bg-black/50 border-white/10 text-sm h-11" className="bg-black/50 border-white/10 text-sm h-11"
/> />
</div> </div>
<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 <Input
placeholder="Brands…" placeholder={t('labUi.rider.sponsorsPlaceholder')}
value={sponsors} value={sponsors}
onChange={e => setSponsors(e.target.value)} onChange={e => setSponsors(e.target.value)}
className="bg-black/50 border-white/10 text-sm h-11" className="bg-black/50 border-white/10 text-sm h-11"
@ -1320,12 +1326,12 @@ export default function RiderCardPage() {
{/* Stats toggles */} {/* Stats toggles */}
<div> <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"> <div className="grid grid-cols-3 gap-2">
{[ {[
{ label: 'Spots', value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots }, { label: t('labUi.pyramid.spots'), value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
{ label: 'Zaps ⚡', value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps }, { label: t('labUi.rider.zaps'), value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
{ label: 'Events', value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents }, { label: t('labUi.pyramid.events'), value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
].map(({ label, value, state, set }) => ( ].map(({ label, value, state, set }) => (
<button <button
key={label} key={label}
@ -1347,8 +1353,8 @@ export default function RiderCardPage() {
{/* Skills */} {/* Skills */}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<Label className="text-[10px] uppercase tracking-widest text-gray-500">Skills</Label> <Label className="text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.skills')}</Label>
<span className="text-[10px] text-gray-600">{skills.length}/6 selected</span> <span className="text-[10px] text-gray-600">{t('labUi.rider.selected', { count: skills.length })}</span>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => ( {SKILL_OPTIONS.map(s => (
@ -1368,7 +1374,7 @@ export default function RiderCardPage() {
))} ))}
</div> </div>
<p className="text-[10px] text-gray-600 mt-2"> <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> </p>
</div> </div>
</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"> <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" /> <Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div> <div>
<span className="font-bold text-blue-300">Park owners only.</span>{' '} <span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</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. {t('labUi.rider.parkOwnersDesc')}
</div> </div>
</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 className="grid grid-cols-2 gap-3">
<div> <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)} <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>
<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)} <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> </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 <Input
value={recipientNpub} value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)} onChange={e => setRecipientNpub(e.target.value)}
@ -1412,7 +1418,7 @@ export default function RiderCardPage() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <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}> <Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger> <SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@ -1422,7 +1428,7 @@ export default function RiderCardPage() {
</Select> </Select>
</div> </div>
<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)} <Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" /> placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
</div> </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" className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
> >
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
Publish to Nostr {t('labUi.rider.publishToNostr')}
</Button> </Button>
</div> </div>
)} )}
@ -1447,19 +1453,19 @@ export default function RiderCardPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center py-2"> <div className="text-center py-2">
<p className="text-gray-400 text-sm max-w-xs mx-auto"> <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> </p>
</div> </div>
<ReceivedBadgesPanel pubkey={user.pubkey} /> <ReceivedBadgesPanel pubkey={user.pubkey} />
<div className="pt-2 border-t border-white/8"> <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 <Button
variant="outline" variant="outline"
onClick={() => setPageTab('award')} onClick={() => setPageTab('award')}
className="w-full border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 gap-2" className="w-full border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 gap-2"
> >
<Award className="h-4 w-4" /> <Award className="h-4 w-4" />
Award a Badge to Someone Else {t('labUi.rider.awardSomeone')}
</Button> </Button>
</div> </div>
</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"> <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" /> <Award className="h-5 w-5 text-yellow-400 flex-shrink-0" />
<div> <div>
<div className="text-sm font-bold text-yellow-300">You saw it happen</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">Sign the proof on Nostr. It becomes part of their permanent record — cryptographically tied to your key.</div> <div className="text-xs text-gray-400 mt-0.5">{t('labUi.rider.youSawItDesc')}</div>
</div> </div>
</div> </div>
@ -1490,6 +1496,7 @@ export default function RiderCardPage() {
// ─── Inline award form (used on the Award tab) ──────────────────────────────── // ─── Inline award form (used on the Award tab) ────────────────────────────────
function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) { function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) {
const { t } = useTranslation();
const { mutate: publishEvent, isPending } = useNostrPublish(); const { mutate: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast(); const { toast } = useToast();
const [recipientNpub, setRecipientNpub] = useState(''); const [recipientNpub, setRecipientNpub] = useState('');
@ -1501,7 +1508,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
const handleAward = () => { const handleAward = () => {
if (!recipientNpub.trim() || selectedSkills.length === 0) { 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; return;
} }
let recipientHex = ''; let recipientHex = '';
@ -1510,7 +1517,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
if (decoded.type !== 'npub') throw new Error(); if (decoded.type !== 'npub') throw new Error();
recipientHex = decoded.data; recipientHex = decoded.data;
} catch { } catch {
toast({ title: 'Invalid npub', variant: 'destructive' }); toast({ title: t('labUi.rider.invalidNpub'), variant: 'destructive' });
return; return;
} }
const tags: string[][] = [ const tags: string[][] = [
@ -1521,18 +1528,18 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
if (spotName.trim()) tags.push(['spot', spotName.trim()]); if (spotName.trim()) tags.push(['spot', spotName.trim()]);
publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, { publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, {
onSuccess: () => { 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(''); setRecipientNpub(''); setSelectedSkills([]); setSpotName('');
onSuccess(); 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 ( return (
<div className="space-y-5"> <div className="space-y-5">
<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 <Input
value={recipientNpub} value={recipientNpub}
onChange={e => setRecipientNpub(e.target.value)} onChange={e => setRecipientNpub(e.target.value)}
@ -1542,7 +1549,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
</div> </div>
<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"> <div className="flex flex-wrap gap-1.5">
{SKILL_OPTIONS.map(s => ( {SKILL_OPTIONS.map(s => (
<button <button
@ -1562,7 +1569,7 @@ function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPu
</div> </div>
<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 <Input
value={spotName} value={spotName}
onChange={e => setSpotName(e.target.value)} 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" 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" /> <Award className="h-4 w-4" />
{isPending ? 'Signing on Nostr…' : 'Award Badge'} {isPending ? t('labUi.rider.signingOnNostr') : t('labUi.rider.awardBadge')}
</Button> </Button>
</div> </div>
); );

View file

@ -13,6 +13,7 @@ import { useSkaterVerifications } from '@/hooks/useSkaterVerifications';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { import {
SKATER_STYLES, SKATER_STYLES,
SKATER_STANCES, SKATER_STANCES,
@ -25,6 +26,7 @@ import { PersonStanding, Search, MapPin, Check, Sparkles, Users, ShieldCheck, X
// ─── Skater card ──────────────────────────────────────────────────────────── // ─── Skater card ────────────────────────────────────────────────────────────
function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }) { function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }) {
const { t } = useTranslation();
const author = useAuthor(skater.pubkey); const author = useAuthor(skater.pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const name = metadata?.name ?? skater.title ?? genUserName(skater.pubkey); 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"> <div className="flex items-center gap-1.5">
<h3 className="font-black text-white truncate group-hover:text-[#ff0040] transition-colors">{name}</h3> <h3 className="font-black text-white truncate group-hover:text-[#ff0040] transition-colors">{name}</h3>
{verified && ( {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]" /> <ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" />
</span> </span>
)} )}
@ -131,6 +133,7 @@ function SkaterCardSkeleton() {
// ─── Page ───────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────
export default function SkatersPage() { export default function SkatersPage() {
const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: 'Skaters - SkateSpots', title: 'Skaters - SkateSpots',
description: 'Discover skaters in the SkateSpots community. Browse by style, stance, level and location.', 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="container relative px-4 py-10 sm:py-14 max-w-6xl">
<div className="flex items-center gap-2 text-[#ff0040] mb-3"> <div className="flex items-center gap-2 text-[#ff0040] mb-3">
<PersonStanding className="h-5 w-5" /> <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> </div>
<h1 <h1
className="text-4xl sm:text-6xl font-black uppercase tracking-tight leading-none" className="text-4xl sm:text-6xl font-black uppercase tracking-tight leading-none"
@ -195,7 +198,7 @@ export default function SkatersPage() {
Skaters Skaters
</h1> </h1>
<p className="text-gray-300 mt-3 max-w-xl text-base sm:text-lg"> <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> </p>
<div className="flex flex-wrap items-center gap-3 mt-6"> <div className="flex flex-wrap items-center gap-3 mt-6">
@ -203,21 +206,21 @@ export default function SkatersPage() {
<Link to="/become/skater"> <Link to="/become/skater">
<Button className="h-11 bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2"> <Button className="h-11 bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Put me on the map {t('labUi.skaterForm.putOnMap')}
</Button> </Button>
</Link> </Link>
) : ( ) : (
<Link to="/become/skater"> <Link to="/become/skater">
<Button variant="outline" className="h-11 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10 font-bold gap-2"> <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" /> <Check className="h-4 w-4" />
Edit my skater profile {t('labUi.skaters.editProfile')}
</Button> </Button>
</Link> </Link>
)} )}
{total > 0 && ( {total > 0 && (
<span className="flex items-center gap-1.5 text-sm text-gray-400"> <span className="flex items-center gap-1.5 text-sm text-gray-400">
<Users className="h-4 w-4" /> <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> </span>
)} )}
</div> </div>
@ -230,7 +233,7 @@ export default function SkatersPage() {
<div className="relative"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<Input <Input
placeholder="Search by name, city or npub…" placeholder={t('labUi.skaters.searchPlaceholder')}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-10 bg-white/5 border-white/10" className="pl-9 h-10 bg-white/5 border-white/10"
@ -239,7 +242,7 @@ export default function SkatersPage() {
<button <button
onClick={() => setQuery('')} onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white" 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" /> <X className="h-4 w-4" />
</button> </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', 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> </button>
{/* Verified only — soft trust filter, not a gate */} {/* Verified only — soft trust filter, not a gate */}
@ -269,10 +272,10 @@ export default function SkatersPage() {
? 'bg-[#ccff00] border-[#ccff00] text-black' ? 'bg-[#ccff00] border-[#ccff00] text-black'
: 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ccff00]/40', : '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" /> <ShieldCheck className="h-3.5 w-3.5" />
Verified {t('common.verified')}
{verifying ? ( {verifying ? (
<span className="opacity-60">…</span> <span className="opacity-60">…</span>
) : ( ) : (
@ -313,32 +316,32 @@ export default function SkatersPage() {
</div> </div>
{total === 0 ? ( {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"> <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> </p>
<Link to="/become/skater"> <Link to="/become/skater">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2"> <Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Put me on the map {t('labUi.skaterForm.putOnMap')}
</Button> </Button>
</Link> </Link>
</> </>
) : ( ) : (
<> <>
<p className="text-lg font-bold text-white mb-1"> <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>
<p className="text-gray-400 text-sm mb-4"> <p className="text-gray-400 text-sm mb-4">
{verifiedOnly {verifiedOnly
? 'Try turning off the “Verified” filter to see everyone.' ? t('labUi.skaters.tryTurnOffVerified')
: 'Try a different search or clear your filters.'} : t('labUi.skaters.tryDifferentSearch')}
</p> </p>
<button <button
onClick={() => { setQuery(''); setStyleFilter('all'); setVerifiedOnly(false); }} onClick={() => { setQuery(''); setStyleFilter('all'); setVerifiedOnly(false); }}
className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline" className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline"
> >
Clear filters {t('common.clearFilters')}
</button> </button>
</> </>
)} )}

View file

@ -15,6 +15,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMySpotCollections, useCreateSpotCollection, useDeleteSpotCollection, useUpdateSpotCollection } from '@/hooks/useSpotCollections'; import { useMySpotCollections, useCreateSpotCollection, useDeleteSpotCollection, useUpdateSpotCollection } from '@/hooks/useSpotCollections';
import type { SpotCollection } from '@/lib/types'; import type { SpotCollection } from '@/lib/types';
import { FolderPlus, FolderOpen, MapPin, Plus, Loader2, Pencil, Trash2, Calendar, ArrowRight, Bookmark } from 'lucide-react'; import { FolderPlus, FolderOpen, MapPin, Plus, Loader2, Pencil, Trash2, Calendar, ArrowRight, Bookmark } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function CollectionCard({ function CollectionCard({
@ -26,6 +27,7 @@ function CollectionCard({
onEdit: (collection: SpotCollection) => void; onEdit: (collection: SpotCollection) => void;
onDelete: (collection: SpotCollection) => void; onDelete: (collection: SpotCollection) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Link to={`/collections/${collection.pubkey}/${collection.identifier}`}> <Link to={`/collections/${collection.pubkey}/${collection.identifier}`}>
<Card className={cn( <Card className={cn(
@ -49,7 +51,9 @@ function CollectionCard({
<div className="absolute top-2 left-2 sm:top-3 sm:left-3"> <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"> <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" /> <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> </Badge>
</div> </div>
{/* Action buttons - always visible on mobile, hover on desktop */} {/* Action buttons - always visible on mobile, hover on desktop */}
@ -99,7 +103,7 @@ function CollectionCard({
{new Date(collection.createdAt * 1000).toLocaleDateString()} {new Date(collection.createdAt * 1000).toLocaleDateString()}
</span> </span>
<span className="flex items-center gap-1 text-[#ccff00] sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"> <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" /> <ArrowRight className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</span> </span>
</div> </div>
@ -125,6 +129,7 @@ function CollectionCardSkeleton() {
} }
export default function SpotCollectionsPage() { export default function SpotCollectionsPage() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: collections, isLoading } = useMySpotCollections(); const { data: collections, isLoading } = useMySpotCollections();
const { createCollection, isPending: isCreating } = useCreateSpotCollection(); const { createCollection, isPending: isCreating } = useCreateSpotCollection();
@ -157,16 +162,16 @@ export default function SpotCollectionsPage() {
description: newDescription.trim() || undefined, description: newDescription.trim() || undefined,
}); });
toast({ toast({
title: 'Collection created!', title: t('mapUi.collectionCreated'),
description: `"${newTitle}" is ready for spots`, description: t('mapUi.collectionCreatedDesc', { title: newTitle }),
}); });
setNewTitle(''); setNewTitle('');
setNewDescription(''); setNewDescription('');
setShowCreateDialog(false); setShowCreateDialog(false);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to create collection', title: t('mapUi.collectionCreateFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('mapUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -190,15 +195,15 @@ export default function SpotCollectionsPage() {
description: editDescription.trim() || undefined, description: editDescription.trim() || undefined,
}); });
toast({ toast({
title: 'Collection updated!', title: t('mapUi.collectionUpdated'),
description: 'Your changes have been saved', description: t('mapUi.changesSaved'),
}); });
setShowEditDialog(false); setShowEditDialog(false);
setSelectedCollection(null); setSelectedCollection(null);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to update collection', title: t('mapUi.collectionUpdateFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('mapUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -215,15 +220,15 @@ export default function SpotCollectionsPage() {
try { try {
await deleteCollection(selectedCollection); await deleteCollection(selectedCollection);
toast({ toast({
title: 'Collection deleted', title: t('mapUi.collectionDeleted'),
description: `"${selectedCollection.title}" has been removed`, description: t('mapUi.collectionDeletedDesc', { title: selectedCollection.title }),
}); });
setShowDeleteDialog(false); setShowDeleteDialog(false);
setSelectedCollection(null); setSelectedCollection(null);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to delete collection', title: t('mapUi.collectionDeleteFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('mapUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -237,10 +242,10 @@ export default function SpotCollectionsPage() {
<div className="min-w-0"> <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"> <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" /> <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> </h1>
<p className="text-gray-400 mt-1 sm:mt-2 text-sm sm:text-base"> <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> </p>
</div> </div>
{user && ( {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" 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" /> <Plus className="h-4 w-4" />
New Collection {t('mapUi.newCollection')}
</Button> </Button>
)} )}
</div> </div>
@ -259,9 +264,9 @@ export default function SpotCollectionsPage() {
<Card className="border-dashed border-2 border-gray-700"> <Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center"> <CardContent className="py-16 text-center">
<FolderPlus className="h-16 w-16 mx-auto mb-4 text-gray-600" /> <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"> <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> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -281,16 +286,16 @@ export default function SpotCollectionsPage() {
<Card className="border-dashed border-2 border-gray-700"> <Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center"> <CardContent className="py-16 text-center">
<FolderOpen className="h-16 w-16 mx-auto mb-4 text-[#ccff00]/40" /> <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"> <p className="text-gray-400 mb-6">
Create your first collection to start organizing spots {t('mapUi.noCollectionsDesc')}
</p> </p>
<Button <Button
onClick={() => setShowCreateDialog(true)} onClick={() => setShowCreateDialog(true)}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase gap-2" className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase gap-2"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Your First Collection {t('mapUi.createFirstCollection')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -317,31 +322,31 @@ export default function SpotCollectionsPage() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white"> <DialogTitle className="flex items-center gap-2 text-white">
<FolderPlus className="h-5 w-5 text-[#ccff00]" /> <FolderPlus className="h-5 w-5 text-[#ccff00]" />
Create New Collection {t('mapUi.createNewCollection')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-gray-400 text-sm"> <DialogDescription className="text-gray-400 text-sm">
Give your collection a name and optional description {t('mapUi.createCollectionDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleCreate} className="space-y-4 mt-4"> <form onSubmit={handleCreate} className="space-y-4 mt-4">
<div className="space-y-2"> <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 <Input
id="create-title" id="create-title"
value={newTitle} value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
placeholder="Barcelona Trip 2024" placeholder={t('mapUi.collectionNamePlaceholder')}
className="bg-black border-gray-700 text-white h-11" className="bg-black border-gray-700 text-white h-11"
autoFocus autoFocus
/> />
</div> </div>
<div className="space-y-2"> <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 <Textarea
id="create-description" id="create-description"
value={newDescription} value={newDescription}
onChange={(e) => setNewDescription(e.target.value)} 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" className="bg-black border-gray-700 text-white resize-none"
rows={3} rows={3}
/> />
@ -353,7 +358,7 @@ export default function SpotCollectionsPage() {
onClick={() => setShowCreateDialog(false)} onClick={() => setShowCreateDialog(false)}
className="flex-1 border-gray-700 h-11" className="flex-1 border-gray-700 h-11"
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@ -363,13 +368,13 @@ export default function SpotCollectionsPage() {
{isCreating ? ( {isCreating ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating... {t('mapUi.creating')}
</> </>
) : ( ) : (
<> <>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Create Collection</span> <span className="hidden sm:inline">{t('mapUi.createCollection')}</span>
<span className="sm:hidden">Create</span> <span className="sm:hidden">{t('common.create')}</span>
</> </>
)} )}
</Button> </Button>
@ -384,12 +389,12 @@ export default function SpotCollectionsPage() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white"> <DialogTitle className="flex items-center gap-2 text-white">
<Pencil className="h-5 w-5 text-[#ccff00]" /> <Pencil className="h-5 w-5 text-[#ccff00]" />
Edit Collection {t('mapUi.editCollection')}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4"> <form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
<div className="space-y-2"> <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 <Input
id="edit-title" id="edit-title"
value={editTitle} value={editTitle}
@ -398,7 +403,7 @@ export default function SpotCollectionsPage() {
/> />
</div> </div>
<div className="space-y-2"> <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 <Textarea
id="edit-description" id="edit-description"
value={editDescription} value={editDescription}
@ -414,7 +419,7 @@ export default function SpotCollectionsPage() {
onClick={() => setShowEditDialog(false)} onClick={() => setShowEditDialog(false)}
className="flex-1 border-gray-700 h-11" className="flex-1 border-gray-700 h-11"
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@ -424,10 +429,10 @@ export default function SpotCollectionsPage() {
{isUpdating ? ( {isUpdating ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving... {t('mapUi.saving')}
</> </>
) : ( ) : (
'Save Changes' t('mapUi.saveChanges')
)} )}
</Button> </Button>
</div> </div>
@ -439,13 +444,13 @@ export default function SpotCollectionsPage() {
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <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"> <AlertDialogContent className="bg-black border-2 border-red-500/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-white">Delete Collection?</AlertDialogTitle> <AlertDialogTitle className="text-white">{t('mapUi.deleteCollectionTitle')}</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400 text-sm"> <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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0"> <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 <AlertDialogAction
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
disabled={isDeleting} disabled={isDeleting}
@ -454,10 +459,10 @@ export default function SpotCollectionsPage() {
{isDeleting ? ( {isDeleting ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting... {t('mapUi.deleting')}
</> </>
) : ( ) : (
'Delete' t('common.delete')
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -25,6 +25,7 @@ import { useSpotReports } from '@/hooks/useSpotReports';
import { useAdminModeration } from '@/hooks/useAdminModeration'; import { useAdminModeration } from '@/hooks/useAdminModeration';
import { isAdminPubkey } from '@/lib/admins'; import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useSpotWeather } from '@/hooks/useSpotWeather'; import { useSpotWeather } from '@/hooks/useSpotWeather';
import { useMarketplaceAtSpot } from '@/hooks/useMarketplace'; import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
@ -124,6 +125,7 @@ function SpotDetailSkeleton() {
} }
export default function SpotDetailPage() { export default function SpotDetailPage() {
const { t } = useTranslation();
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: spot, isLoading, error, refetch } = useSkateSpot(identifier || '', pubkey); const { data: spot, isLoading, error, refetch } = useSkateSpot(identifier || '', pubkey);
const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || ''); const { data: marketplaceItems, isLoading: isLoadingMarketplace } = useMarketplaceAtSpot(pubkey || '', identifier || '');
@ -195,8 +197,8 @@ export default function SpotDetailPage() {
}); });
useSeoMeta({ useSeoMeta({
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots', title: spot ? `${spot.title} - SkateSpots` : `${t('spotUi.skateSpot')} - SkateSpots`,
description: spot?.description || 'View this skateboarding spot on SkateSpots.', description: spot?.description || t('spotUi.spotSeoDesc'),
}); });
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -224,7 +226,7 @@ export default function SpotDetailPage() {
try { try {
await navigator.share({ await navigator.share({
title: spot?.title, 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, url: window.location.href,
}); });
} catch { } catch {
@ -249,15 +251,17 @@ export default function SpotDetailPage() {
// delete + a moderation label so the spot is hidden across the app. // delete + a moderation label so the spot is hidden across the app.
await deleteEntity({ await deleteEntity({
event: spot.event, 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); setShowDeleteDialog(false);
toast({ toast({
title: adminTakedown ? 'Spot removed' : 'Spot deleted', title: adminTakedown ? t('spotUi.spotRemoved') : t('spotUi.spotDeleted'),
description: adminTakedown description: adminTakedown
? 'This spot has been taken down and will be hidden across SkateSpots.' ? t('spotUi.spotRemovedDesc')
: 'Deletion request sent to relays. Most relays will remove this content.', : t('spotUi.spotDeletedDesc'),
}); });
queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
@ -267,8 +271,8 @@ export default function SpotDetailPage() {
setTimeout(() => navigate('/map'), 1000); setTimeout(() => navigate('/map'), 1000);
} catch (error) { } catch (error) {
toast({ toast({
title: 'Delete failed', title: t('spotUi.deleteFailed'),
description: error instanceof Error ? error.message : 'Something went wrong', description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
variant: 'destructive', variant: 'destructive',
}); });
setShowDeleteDialog(false); 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" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
> >
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
Back to Map {t('createSpot.backToMap')}
</Link> </Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
@ -309,7 +313,7 @@ export default function SpotDetailPage() {
type="button" type="button"
onClick={() => setLightboxIndex(0)} onClick={() => setLightboxIndex(0)}
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted" 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 <ImageWithFallback
src={galleryImages[0].url} src={galleryImages[0].url}
@ -319,7 +323,7 @@ export default function SpotDetailPage() {
{galleryImages[0].isOwner === false && ( {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]"> <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" /> <Users className="h-3 w-3" />
Community {t('spotUi.community')}
</div> </div>
)} )}
</button> </button>
@ -336,7 +340,7 @@ export default function SpotDetailPage() {
type="button" type="button"
onClick={() => setLightboxIndex(realIndex)} onClick={() => setLightboxIndex(realIndex)}
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted" 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 <ImageWithFallback
src={img.url} src={img.url}
@ -395,7 +399,7 @@ export default function SpotDetailPage() {
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setShowDeleteDialog(true)} onClick={() => setShowDeleteDialog(true)}
title={adminTakedown ? 'Remove (admin)' : 'Delete'} title={adminTakedown ? t('spotUi.removeAdmin') : t('common.delete')}
className={cn( className={cn(
'hover:bg-destructive hover:text-destructive-foreground', 'hover:bg-destructive hover:text-destructive-foreground',
adminTakedown && 'border-red-500/50 text-red-400', 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="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 className="relative inline-flex rounded-full h-2 w-2 bg-white" />
</span> </span>
I'm Here {t('spotUi.imHere')}
</Button> </Button>
</> </>
)} )}
@ -436,7 +440,7 @@ export default function SpotDetailPage() {
<Button variant="outline" size="icon" onClick={handleShare}> <Button variant="outline" size="icon" onClick={handleShare}>
<Share2 className="h-4 w-4" /> <Share2 className="h-4 w-4" />
</Button> </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" /> <Navigation className="h-4 w-4" />
</Button> </Button>
{/* More options menu */} {/* More options menu */}
@ -453,7 +457,7 @@ export default function SpotDetailPage() {
className="text-red-500 focus:text-red-500" className="text-red-500 focus:text-red-500"
> >
<Flag className="h-4 w-4 mr-2" /> <Flag className="h-4 w-4 mr-2" />
Report {isShop ? 'Shop' : 'Spot'} {isShop ? t('spotUi.reportShop') : t('spotUi.reportSpot')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{/* One tap: this pin is off-topic (test entry, airport, {/* 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" className="text-amber-500 focus:text-amber-500"
> >
<Flag className="h-4 w-4 mr-2" /> <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> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@ -504,10 +508,10 @@ export default function SpotDetailPage() {
{staffVerified && ( {staffVerified && (
<Badge <Badge
className="border border-[#ccff00]/60 bg-[#ccff00]/20 font-bold uppercase text-[#ccff00]" 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" /> <BadgeCheck className="mr-1 h-3 w-3" />
Verified by SkateSpots {t('spotUi.verifiedBySkateSpots')}
</Badge> </Badge>
)} )}
{/* Community confidence — NOT a quality/weather score. */} {/* Community confidence — NOT a quality/weather score. */}
@ -521,20 +525,20 @@ export default function SpotDetailPage() {
)} )}
title={ title={
communityVerified communityVerified
? 'Another skater has contributed here (photo or status report).' ? t('spotUi.communityVerifiedTitle')
: 'No community photo or status report yet — nobody has confirmed this place.' : t('spotUi.noCommunitySignalTitle')
} }
> >
{communityVerified ? ( {communityVerified ? (
<> <>
<ShieldCheck className="mr-1 h-3 w-3" /> <ShieldCheck className="mr-1 h-3 w-3" />
Community verified {t('spotUi.communityVerified')}
{communitySignalCount > 1 && ` · ${communitySignalCount}`} {communitySignalCount > 1 && ` · ${communitySignalCount}`}
</> </>
) : ( ) : (
<> <>
<ShieldQuestion className="mr-1 h-3 w-3" /> <ShieldQuestion className="mr-1 h-3 w-3" />
Unverified {t('spotUi.unverified')}
</> </>
)} )}
</Badge> </Badge>
@ -549,7 +553,7 @@ export default function SpotDetailPage() {
<Badge <Badge
key={tag} key={tag}
className="bg-blue-500/10 text-blue-400 border border-blue-500/30 font-medium" 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" /> <Users className="h-3 w-3 mr-1" />
{SPOT_TYPE_LABELS[tag] || tag} {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"> <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" /> <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-red-400" />
<p className="text-xs leading-relaxed text-red-200"> <p className="text-xs leading-relaxed text-red-200">
<strong className="text-red-300">Reported as not a skate spot</strong> <strong className="text-red-300">{t('spotUi.reportedNotASpot')}</strong>
{notASpotReporters > 1 ? ` by ${notASpotReporters} skaters` : ''} — under review. If {notASpotReporters > 1
this is a real spot, add a photo or a status report to confirm it. ? t('spotUi.reportedBySkaters', { count: notASpotReporters })
: t('spotUi.underReview')}
</p> </p>
</div> </div>
)} )}
@ -578,12 +583,10 @@ export default function SpotDetailPage() {
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" /> <ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-black uppercase tracking-wide text-amber-300"> <p className="text-sm font-black uppercase tracking-wide text-amber-300">
Hidden by a moderator {t('spotUi.hiddenByModerator')}
</p> </p>
<p className="mt-1 text-xs leading-relaxed text-gray-300"> <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 {t('spotUi.hiddenByModeratorDesc')}
it's yours. Editing it will not un-hide it — it stays hidden until a moderator
retracts the verdict.
</p> </p>
</div> </div>
</div> </div>
@ -593,10 +596,10 @@ export default function SpotDetailPage() {
<BadgeCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#ccff00]" /> <BadgeCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#ccff00]" />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-black uppercase tracking-wide text-[#ccff00]"> <p className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
Verified by SkateSpots {t('spotUi.verifiedBySkateSpots')}
</p> </p>
<p className="mt-1 text-xs leading-relaxed text-gray-300"> <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> </p>
</div> </div>
</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 flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<Users className="h-5 w-5 text-blue-400 flex-shrink-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 && ( {((communityPhotos?.length || 0) + aggregatedTags.length) > 0 && (
<Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-blue-500/50"> <Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-blue-500/50">
{(communityPhotos?.length || 0) + aggregatedTags.length} {(communityPhotos?.length || 0) + aggregatedTags.length}
@ -631,7 +634,7 @@ export default function SpotDetailPage() {
</div> </div>
</div> </div>
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
Help improve this spot by adding photos or suggesting tags {t('spotUi.helpImproveSpot')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -647,28 +650,28 @@ export default function SpotDetailPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="text-2xl">🏪</div> <div className="text-2xl">🏪</div>
<div className="flex-1"> <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"> <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> </p>
{hasNip05 ? ( {hasNip05 ? (
<div> <div>
<p className="text-xs text-gray-400 mb-3"> <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> </p>
<Link to="/settings"> <Link to="/settings">
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full"> <Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
Manage NIP-05 to Claim {t('spotUi.manageNip05ToClaim')}
</Button> </Button>
</Link> </Link>
</div> </div>
) : ( ) : (
<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"> <Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold"> <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> </Button>
</Link> </Link>
</div> </div>
@ -686,10 +689,10 @@ export default function SpotDetailPage() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="text-xl">{spot.accessType === 'private' ? '🔒' : '👥'}</div> <div className="text-xl">{spot.accessType === 'private' ? '🔒' : '👥'}</div>
<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-sm text-gray-300">{spot.contactInfo}</p>
<p className="text-xs text-gray-500 mt-1"> <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> </p>
</div> </div>
</div> </div>
@ -717,7 +720,7 @@ export default function SpotDetailPage() {
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="text-lg sm:text-2xl font-black uppercase flex items-center gap-2 flex-wrap"> <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" /> <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 && ( {marketplaceItems && marketplaceItems.length > 0 && (
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 border-orange-500/50"> <Badge variant="secondary" className="bg-orange-500/20 text-orange-400 border-orange-500/50">
{marketplaceItems.length} {marketplaceItems.length}
@ -725,7 +728,7 @@ export default function SpotDetailPage() {
)} )}
</CardTitle> </CardTitle>
<p className="text-sm text-gray-400 mt-2"> <p className="text-sm text-gray-400 mt-2">
Skaters at this spot are selling gear {t('spotUi.skatersSellingGear')}
</p> </p>
</div> </div>
{user && ( {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" className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase w-full sm:w-auto"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
List Gear {t('spotUi.listGear')}
</Button> </Button>
</Link> </Link>
)} )}
@ -755,22 +758,22 @@ export default function SpotDetailPage() {
))} ))}
<div className="pt-4 border-t border-orange-500/20 text-center"> <div className="pt-4 border-t border-orange-500/20 text-center">
<p className="text-sm text-gray-400 mb-2"> <p className="text-sm text-gray-400 mb-2">
💡 Meet at this spot for safe pickup {t('spotUi.meetAtSpotForPickup')}
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-orange-500/50" /> <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"> <p className="text-gray-400 mb-6">
Be the first to list gear at this spot! {t('spotUi.beFirstToListGear')}
</p> </p>
{user && ( {user && (
<Link to={`/create-listing?spot=${pubkey}:${identifier}`}> <Link to={`/create-listing?spot=${pubkey}:${identifier}`}>
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase"> <Button className="gap-2 bg-orange-500 hover:bg-orange-600 font-bold uppercase">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
List Your Gear Here {t('spotUi.listYourGearHere')}
</Button> </Button>
</Link> </Link>
)} )}
@ -786,7 +789,7 @@ export default function SpotDetailPage() {
<CardHeader className="border-b border-lime-500/20 px-4 sm:px-6"> <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"> <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" /> <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 && ( {((spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)) > 0 && (
<Badge variant="secondary" className="bg-lime-500/20 text-lime-400 border-lime-500/50"> <Badge variant="secondary" className="bg-lime-500/20 text-lime-400 border-lime-500/50">
{(spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)} {(spotServices?.coaches.length || 0) + (spotServices?.filmers.length || 0)}
@ -794,7 +797,7 @@ export default function SpotDetailPage() {
)} )}
</CardTitle> </CardTitle>
<p className="text-sm text-gray-400 mt-2"> <p className="text-sm text-gray-400 mt-2">
Coaches and filmers available at this spot {t('spotUi.coachesAndFilmersAvailable')}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="pt-6"> <CardContent className="pt-6">
@ -811,7 +814,7 @@ export default function SpotDetailPage() {
<div> <div>
<h3 className="text-sm font-bold text-lime-400 uppercase tracking-wide mb-3 flex items-center gap-2"> <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" /> <GraduationCap className="h-4 w-4" />
Coaches ({spotServices.coaches.length}) {t('spotUi.coachesCount', { count: spotServices.coaches.length })}
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{spotServices.coaches.map((coach) => ( {spotServices.coaches.map((coach) => (
@ -854,7 +857,7 @@ export default function SpotDetailPage() {
<div> <div>
<h3 className="text-sm font-bold text-purple-400 uppercase tracking-wide mb-3 flex items-center gap-2"> <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" /> <Video className="h-4 w-4" />
Filmers ({spotServices.filmers.length}) {t('spotUi.filmersCount', { count: spotServices.filmers.length })}
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{spotServices.filmers.map((filmer) => ( {spotServices.filmers.map((filmer) => (
@ -894,19 +897,19 @@ export default function SpotDetailPage() {
<div className="pt-4 border-t border-lime-500/20"> <div className="pt-4 border-t border-lime-500/20">
<p className="text-sm text-gray-400 text-center mb-3"> <p className="text-sm text-gray-400 text-center mb-3">
💡 Offer your services at this spot {t('spotUi.offerServicesHere')}
</p> </p>
<div className="flex gap-2 justify-center"> <div className="flex gap-2 justify-center">
<Link to="/become/coach"> <Link to="/become/coach">
<Button size="sm" variant="outline" className="gap-2 border-lime-500/50 text-lime-400 hover:bg-lime-500/10"> <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" /> <GraduationCap className="h-4 w-4" />
Coach Here {t('spotUi.coachHere')}
</Button> </Button>
</Link> </Link>
<Link to="/become/filmer"> <Link to="/become/filmer">
<Button size="sm" variant="outline" className="gap-2 border-purple-500/50 text-purple-400 hover:bg-purple-500/10"> <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" /> <Video className="h-4 w-4" />
Film Here {t('spotUi.filmHere')}
</Button> </Button>
</Link> </Link>
</div> </div>
@ -946,7 +949,7 @@ export default function SpotDetailPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-400" /> <Cloud className="h-5 w-5 text-blue-400" />
Weather Conditions {t('spotUi.weatherConditions')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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="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"> <div className="flex items-center justify-between mb-2">
<span className="font-semibold" style={{ color: weatherData.skateability.color }}> <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"> <span className="ml-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
weather only {t('spotUi.weatherOnly')}
</span> </span>
</span> </span>
<span className="text-2xl font-bold" style={{ color: weatherData.skateability.color }}> <span className="text-2xl font-bold" style={{ color: weatherData.skateability.color }}>
@ -987,8 +990,7 @@ export default function SpotDetailPage() {
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-3 text-xs text-muted-foreground"> <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 {t('spotUi.weatherOnlyUnconfirmed')}
conditions score — add a photo or a status report to confirm it.
</div> </div>
)} )}
@ -997,14 +999,14 @@ export default function SpotDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wind className="h-4 w-4 text-blue-400" /> <Wind className="h-4 w-4 text-blue-400" />
<div> <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 className="font-medium">{weatherData.weather.windSpeed} km/h {weatherData.weather.windDirectionLabel}</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Droplets className="h-4 w-4 text-blue-400" /> <Droplets className="h-4 w-4 text-blue-400" />
<div> <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 className="font-medium">{weatherData.weather.humidity}%</div>
</div> </div>
</div> </div>
@ -1012,7 +1014,7 @@ export default function SpotDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-yellow-400" /> <Sun className="h-4 w-4 text-yellow-400" />
<div> <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 className="font-medium">{weatherData.weather.uvIndex}</div>
</div> </div>
</div> </div>
@ -1021,7 +1023,7 @@ export default function SpotDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Cloud className="h-4 w-4" style={{ color: weatherData.airQuality.aqiColor }} /> <Cloud className="h-4 w-4" style={{ color: weatherData.airQuality.aqiColor }} />
<div> <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 className="font-medium">{weatherData.airQuality.aqiLabel}</div>
</div> </div>
</div> </div>
@ -1031,11 +1033,11 @@ export default function SpotDetailPage() {
{/* 3-Day Forecast */} {/* 3-Day Forecast */}
{weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && ( {weatherData.forecast?.daily && weatherData.forecast.daily.length > 0 && (
<div className="pt-3 border-t border-border"> <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"> <div className="grid grid-cols-3 gap-2">
{weatherData.forecast.daily.map((day, idx) => { {weatherData.forecast.daily.map((day, idx) => {
const date = new Date(day.date); 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 ( return (
<div key={day.date} className="text-center p-2 rounded-lg bg-background/50"> <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> <div className="text-xs font-semibold text-muted-foreground mb-1">{dayName}</div>
@ -1058,7 +1060,7 @@ export default function SpotDetailPage() {
{/* Feels Like */} {/* Feels Like */}
{weatherData.weather.feelsLike !== weatherData.weather.temperature && ( {weatherData.weather.feelsLike !== weatherData.weather.temperature && (
<div className="text-xs text-muted-foreground text-center pt-2 border-t border-border"> <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> </div>
)} )}
</CardContent> </CardContent>
@ -1071,7 +1073,7 @@ export default function SpotDetailPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-400" /> <Cloud className="h-5 w-5 text-blue-400" />
Weather Conditions {t('spotUi.weatherConditions')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -1087,7 +1089,7 @@ export default function SpotDetailPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<MapPin className="h-5 w-5 text-primary" /> <MapPin className="h-5 w-5 text-primary" />
Location {t('spots.location')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1106,7 +1108,7 @@ export default function SpotDetailPage() {
onClick={handleDirections} onClick={handleDirections}
> >
<Navigation className="h-4 w-4" /> <Navigation className="h-4 w-4" />
Get Directions {t('spotUi.getDirections')}
</Button> </Button>
)} )}
</CardContent> </CardContent>
@ -1117,7 +1119,7 @@ export default function SpotDetailPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<span className="text-[#ccff00]">⚡</span> <span className="text-[#ccff00]">⚡</span>
Tip the Mapper {t('spotUi.tipTheMapper')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1143,7 +1145,7 @@ export default function SpotDetailPage() {
</Link> </Link>
<p className="text-xs text-gray-400 mb-3"> <p className="text-xs text-gray-400 mb-3">
Found this spot useful? Send a zap to thank the mapper! {t('spotUi.tipTheMapperDesc')}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
@ -1161,7 +1163,7 @@ export default function SpotDetailPage() {
> >
<Link to={`/messages/${mapperNpub}`}> <Link to={`/messages/${mapperNpub}`}>
<MessageCircle className="h-4 w-4 mr-2" /> <MessageCircle className="h-4 w-4 mr-2" />
Message Mapper {t('spotUi.messageMapper')}
</Link> </Link>
</Button> </Button>
)} )}
@ -1178,21 +1180,21 @@ export default function SpotDetailPage() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2"> <AlertDialogTitle className="text-white flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" /> <AlertTriangle className="h-5 w-5 text-red-500" />
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'} {adminTakedown ? t('spotUi.removeSpotAdminTitle') : t('spotUi.deleteSpotTitle')}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-gray-300"> <AlertDialogDescription className="text-gray-300">
{adminTakedown {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.` ? t('spotUi.adminDeleteDialogDesc', { title: spot.title })
: `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`} : t('spotUi.deleteDialogDesc', { title: spot.title })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel className="border-gray-600">Cancel</AlertDialogCancel> <AlertDialogCancel className="border-gray-600">{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
className="bg-red-500 hover:bg-red-600 text-white" className="bg-red-500 hover:bg-red-600 text-white"
> >
{adminTakedown ? 'Remove Spot' : 'Delete Spot'} {adminTakedown ? t('spotUi.removeSpot') : t('spotUi.deleteSpot')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -1229,7 +1231,7 @@ export default function SpotDetailPage() {
open={showRouteDialog} open={showRouteDialog}
onOpenChange={setShowRouteDialog} onOpenChange={setShowRouteDialog}
destination={coordinates} destination={coordinates}
destinationLabel={spot?.title ?? 'Spot'} destinationLabel={spot?.title ?? t('spotUi.spot')}
/> />
{/* Full-screen photo viewer */} {/* Full-screen photo viewer */}

View file

@ -28,6 +28,7 @@ import {
} from '@/hooks/useTradeAttestations'; } from '@/hooks/useTradeAttestations';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { Handshake, CheckCircle, Loader2, Package, ArrowRight } from 'lucide-react'; import { Handshake, CheckCircle, Loader2, Package, ArrowRight } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function PeerName({ pubkey }: { pubkey: string }) { function PeerName({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey); 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. * attestation records. Mirrors the DM banners, in a dedicated surface.
*/ */
export default function TradesPage() { export default function TradesPage() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const { publishTrade, isPending: publishing } = useTradeAttestation(); const { publishTrade, isPending: publishing } = useTradeAttestation();
@ -85,11 +87,11 @@ export default function TradesPage() {
requestCoord, requestCoord,
rating, 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) { } catch (e) {
toast({ toast({
title: 'Could not confirm the sale', title: t('marketUi.trades.saleConfirmFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@ -106,11 +108,11 @@ export default function TradesPage() {
setBusy(coord); setBusy(coord);
try { try {
await publishTrade({ listing, role: 'bought', counterparty: seller, rating }); 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) { } catch (e) {
toast({ toast({
title: 'Could not confirm the trade', title: t('marketUi.trades.tradeConfirmFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } finally {
@ -127,17 +129,17 @@ export default function TradesPage() {
role: revokeTarget.role, role: revokeTarget.role,
counterparty: revokeTarget.counterparty, counterparty: revokeTarget.counterparty,
state: 'revoked', state: 'revoked',
note: 'Retracted by the author.', note: t('marketUi.trades.revokeNote'),
}); });
toast({ toast({
title: 'Trade record revoked', title: t('marketUi.trades.revoked'),
description: 'It no longer counts toward reputation.', description: t('marketUi.trades.revokedBody'),
}); });
setRevokeTarget(null); setRevokeTarget(null);
} catch (e) { } catch (e) {
toast({ toast({
title: 'Could not revoke the trade', title: t('marketUi.trades.revokeFailed'),
description: e instanceof Error ? e.message : 'Something went wrong.', description: e instanceof Error ? e.message : t('marketUi.trades.somethingWrong'),
variant: 'destructive', variant: 'destructive',
}); });
} finally { } 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"> <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" /> <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"> <p className="text-sm text-gray-400">
Log in to manage your marketplace trades and reputation. {t('marketUi.trades.loginBody')}
</p> </p>
<LoginArea className="justify-center" /> <LoginArea className="justify-center" />
</div> </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 className="container mx-auto max-w-3xl px-4 py-8 space-y-6">
<div> <div>
<h1 className="flex items-center gap-3 text-3xl font-black uppercase text-white"> <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> </h1>
<p className="mt-1 text-sm text-gray-400"> <p className="mt-1 text-sm text-gray-400">
Confirmations, requests, and the reputation they build. Payment is arranged {t('marketUi.trades.subtitle')}
with the other skater — the app only records the deal.
</p> </p>
</div> </div>
@ -184,7 +185,7 @@ export default function TradesPage() {
{/* Action needed */} {/* Action needed */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500"> <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> </h2>
{(loadingIncoming || loadingPending) && ( {(loadingIncoming || loadingPending) && (
@ -200,39 +201,39 @@ export default function TradesPage() {
key={r.coord} key={r.coord}
tone="sale" tone="sale"
icon={Handshake} 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)} listingUrl={listingUrl(r.listing.pubkey, r.listing.identifier)}
busy={busy === r.coord} busy={busy === r.coord}
disabled={busy === r.coord || publishing} disabled={busy === r.coord || publishing}
actionLabel="Confirm sale" actionLabel={t('marketUi.trades.confirmSale')}
onConfirm={(rating) => handleConfirmSale(r.listing, r.requestor, r.coord, rating)} onConfirm={(rating) => handleConfirmSale(r.listing, r.requestor, r.coord, rating)}
/> />
))} ))}
{/* Pending: a seller recorded a sale you haven't confirmed */} {/* Pending: a seller recorded a sale you haven't confirmed */}
{pending?.map((t) => ( {pending?.map((p) => (
<TradeConfirmRow <TradeConfirmRow
key={t.coord} key={p.coord}
tone="purchase" tone="purchase"
icon={CheckCircle} icon={CheckCircle}
text={<><PeerName pubkey={t.seller} /> recorded a sale — confirm your side</>} text={<><PeerName pubkey={p.seller} /> {t('marketUi.trades.recordedSale')}</>}
listingUrl={listingUrl(t.listing.pubkey, t.listing.identifier)} listingUrl={listingUrl(p.listing.pubkey, p.listing.identifier)}
busy={busy === t.coord} busy={busy === p.coord}
disabled={busy === t.coord || publishing} disabled={busy === p.coord || publishing}
actionLabel="Confirm" actionLabel={t('marketUi.trades.confirm')}
onConfirm={(rating) => handleConfirmPurchase(t.listing, t.seller, t.coord, rating)} onConfirm={(rating) => handleConfirmPurchase(p.listing, p.seller, p.coord, rating)}
/> />
))} ))}
{!loadingIncoming && !loadingPending && actionNeeded === 0 && ( {!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> </section>
{/* Your trade records */} {/* Your trade records */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500"> <h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
Your trade records {t('marketUi.trades.yourRecords')}
</h2> </h2>
{loadingRep && <Skeleton className="h-16 w-full rounded-2xl" />} {loadingRep && <Skeleton className="h-16 w-full rounded-2xl" />}
@ -242,10 +243,10 @@ export default function TradesPage() {
<CardContent className="py-10 text-center"> <CardContent className="py-10 text-center">
<Package className="mx-auto mb-3 h-8 w-8 text-gray-600" /> <Package className="mx-auto mb-3 h-8 w-8 text-gray-600" />
<p className="text-sm text-gray-400"> <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> </p>
<Button asChild variant="outline" className="mt-4 border-white/15"> <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> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -260,7 +261,7 @@ export default function TradesPage() {
: d.includes(':trade:bought:') : d.includes(':trade:bought:')
? 'bought' ? 'bought'
: null; : 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 state = ev.tags.find(([t]) => t === 's')?.[1] ?? 'valid';
const counterparty = ev.tags.find(([t]) => t === 'p')?.[1]; const counterparty = ev.tags.find(([t]) => t === 'p')?.[1];
return ( return (
@ -268,7 +269,7 @@ export default function TradesPage() {
<CardContent className="flex items-center gap-3 p-3"> <CardContent className="flex items-center gap-3 p-3">
<Badge <Badge
className={ className={
role === 'Sold' roleTyped === 'sold'
? 'bg-orange-500/15 text-orange-400 border border-orange-500/30' ? 'bg-orange-500/15 text-orange-400 border border-orange-500/30'
: 'bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30' : 'bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30'
} }
@ -277,7 +278,7 @@ export default function TradesPage() {
</Badge> </Badge>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-200"> <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>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
{new Date(ev.created_at * 1000).toLocaleDateString()} {new Date(ev.created_at * 1000).toLocaleDateString()}
@ -289,7 +290,7 @@ export default function TradesPage() {
to={listingUrl(parsed.pubkey, parsed.identifier)} to={listingUrl(parsed.pubkey, parsed.identifier)}
className="shrink-0 text-xs text-gray-400 hover:text-white" 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> </Link>
)} )}
{roleTyped && counterparty && parsed && ( {roleTyped && counterparty && parsed && (
@ -304,7 +305,7 @@ export default function TradesPage() {
} }
className="shrink-0 text-xs text-red-400/70 hover:text-red-400" className="shrink-0 text-xs text-red-400/70 hover:text-red-400"
> >
Revoke {t('marketUi.trades.revoke')}
</button> </button>
)} )}
</CardContent> </CardContent>
@ -317,10 +318,9 @@ export default function TradesPage() {
<Dialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}> <Dialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<DialogContent className="border-2 border-red-500/30 bg-black sm:max-w-md"> <DialogContent className="border-2 border-red-500/30 bg-black sm:max-w-md">
<DialogHeader> <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"> <DialogDescription className="text-sm text-gray-400">
It will stop counting toward your reputation. The other party keeps their own {t('marketUi.trades.revokeBody')}
attestation. Use this for a disputed or mistaken deal.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
@ -329,7 +329,7 @@ export default function TradesPage() {
onClick={() => setRevokeTarget(null)} onClick={() => setRevokeTarget(null)}
className="flex-1 border-white/15" className="flex-1 border-white/15"
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={handleRevoke} 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" 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} {revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Revoke {t('marketUi.trades.revoke')}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>