feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats

~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
This commit is contained in:
SkateSpots 2026-10-02 10:11:06 +07:00
parent c205ad34f0
commit 0b796440f1
136 changed files with 11615 additions and 3814 deletions

View file

@ -1642,14 +1642,23 @@ similar are internal endpoints/hook names, not credits — leave them.)
chain) fails the build if any literal `t('…')` key is missing from `en` OR `es`,
or if `en`/`es` drift. Dynamic template keys (`t(\`ns.${x}\`)`) are NOT checked —
keep those few in sync by hand.
- **Coverage (2026-10-01, batch 1 done):** ~1,680 new keys (2,297 total) and ~65
files wired — **Map (`mapUi`)**, **spots (`spotUi`)** incl. detail/create/edit/
chat, **Marketplace/trades/offers/escrow/shipping (`marketUi`)**, and **Lab/
`/become/*`/coaches/skaters (`labUi`)**. **Still to do (batch 2+):** Settings,
auth (LoginArea/dialogs), Onboarding, Events, Shops, Tours, Crews, DMs/group,
Stash, Newsletter, Community Stats, legal (About/FAQ/Privacy/License/Glossary),
Wallet/Mints, App Shop, Food, Bounties/leaderboards, Admin review. Untranslated
text falls back to English, so partial coverage degrades gracefully.
- **Coverage (2026-10-02, batches 1–2 done):** **~5,770 keys** (EN=ES) and ~180
files wired. Namespaces: batch 1 — `mapUi`, `spotUi`, `marketUi`, `labUi`;
batch 2 — `nav` (header/More/burger labels) + `chromeUi` (footer, toolbar
widgets, spot-type labels), `placesUi` (events/shops/tours/food), `socialUi`
(crews/DMs/notifications/collectives), `settingsUi` (settings/auth/signup/
onboarding/profile/legal), `walletUi` (wallet/Cashu, stash, mints, app shop,
newsletter, community stats, leaderboards, profile page).
- **Still to translate (batch 3):** `RouteDialog`/`StopDepartures`/`TripItineraryCard`
(routing UI), the Sasquatch panel/dialogs (`SasquatchGuide`, `SasquatchProDialog`,
`SasquatchActionCard`, `SasquatchAiStatusCard`), `AddToCollectionDialog` edge
copy, `SplashScreen`/`LazyMount`, and **shared data-module labels** that live
outside components (`SPOT_TYPE_LABELS`/`SPOT_CATEGORIES` in `src/lib/types.ts`,
`src/lib/skateTags.ts`, `src/lib/spotStatus.ts`, `src/lib/sortOptions.ts`).
**Dates & relative times** are still English (no localized date layer yet).
- ⚠️ A batch-2 agent dropped the `socialUi.` namespace prefix in ~36 files — the
`check:i18n` guard caught all 738 usages. **Always run `node scripts/check-i18n.mjs`
(it's in `npm test`) after wiring a file.**
- ⚠️ **When wiring a file, keep `en.ts`/`es.ts` key-for-key in sync** and run
`node scripts/check-i18n.mjs` before committing.

View file

@ -12,6 +12,7 @@ import {
type CalendarEventInput,
} from '@/lib/calendar';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface AddToCalendarProps {
event: CalendarEventInput;
@ -25,6 +26,7 @@ interface AddToCalendarProps {
* Offers a native .ics download (Apple/Outlook) and a Google Calendar link.
*/
export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCalendarProps) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -35,7 +37,7 @@ export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCale
style={{ borderColor: `${color}55`, color }}
>
<CalendarPlus className="h-4 w-4" />
Add to Calendar
{t('placesUi.addToCalendar.add')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
@ -44,7 +46,7 @@ export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCale
className="cursor-pointer"
>
<Download className="h-4 w-4 mr-2" />
Apple / Outlook (.ics)
{t('placesUi.addToCalendar.appleOutlook')}
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<a
@ -53,7 +55,7 @@ export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCale
rel="noopener noreferrer"
>
<CalendarClock className="h-4 w-4 mr-2" />
Google Calendar
{t('placesUi.addToCalendar.google')}
</a>
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
useMySpotCollections,
@ -29,6 +30,7 @@ interface AddToCollectionDialogProps {
}
export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollectionDialogProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: collections, isLoading } = useMySpotCollections();
const { createCollection, isPending: isCreating } = useCreateSpotCollection();
@ -45,8 +47,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
const handleAddToCollection = async (collection: SpotCollection) => {
if (collection.spotRefs.includes(spotRef)) {
toast({
title: 'Already saved',
description: `"${spot.title}" is already in "${collection.title}"`,
title: t('walletUi.addToCollection.alreadySaved'),
description: t('walletUi.addToCollection.alreadySavedDesc', { spot: spot.title, collection: collection.title }),
});
return;
}
@ -59,14 +61,14 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
spotIdentifier: spot.identifier,
});
toast({
title: 'Spot saved!',
description: `Added "${spot.title}" to "${collection.title}"`,
title: t('walletUi.addToCollection.saved'),
description: t('walletUi.addToCollection.savedDesc', { spot: spot.title, collection: collection.title }),
});
onOpenChange(false);
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Something went wrong',
title: t('walletUi.addToCollection.saveFailed'),
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
} finally {
@ -85,8 +87,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
spotRefs: [spotRef], // Add the current spot to the new collection
});
toast({
title: 'Collection created!',
description: `"${spot.title}" added to your new collection "${newTitle}"`,
title: t('walletUi.addToCollection.created'),
description: t('walletUi.addToCollection.createdDesc', { spot: spot.title, collection: newTitle }),
});
setNewTitle('');
setNewDescription('');
@ -94,8 +96,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
onOpenChange(false);
} catch (error) {
toast({
title: 'Failed to create collection',
description: error instanceof Error ? error.message : 'Something went wrong',
title: t('walletUi.addToCollection.createFailed'),
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
}
@ -108,11 +110,11 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
Save to Collection
{t('walletUi.addToCollection.title')}
</DialogTitle>
</DialogHeader>
<div className="py-8 text-center">
<p className="text-gray-400">Please log in to save spots to collections</p>
<p className="text-gray-400">{t('walletUi.addToCollection.loginRequired')}</p>
</div>
</DialogContent>
</Dialog>
@ -125,10 +127,10 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
Save to Collection
{t('walletUi.addToCollection.title')}
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
Save "{spot.title}" to one of your collections
{t('walletUi.addToCollection.desc', { spot: spot.title })}
</DialogDescription>
</DialogHeader>
@ -179,8 +181,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
</p>
<p className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
{isInCollection && ' · Already saved'}
{t('walletUi.addToCollection.spotCount', { count: collection.spotRefs.length })}
{isInCollection && ` · ${t('walletUi.addToCollection.alreadySavedTag')}`}
</p>
</div>
</button>
@ -190,8 +192,8 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
) : !showNewForm ? (
<div className="text-center py-4 sm:py-6 text-gray-400">
<FolderOpen className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-2 sm:mb-3 opacity-50" />
<p className="text-sm sm:text-base">You don't have any collections yet</p>
<p className="text-xs sm:text-sm">Create your first one below!</p>
<p className="text-sm sm:text-base">{t('walletUi.addToCollection.noCollections')}</p>
<p className="text-xs sm:text-sm">{t('walletUi.addToCollection.createFirst')}</p>
</div>
) : null}
@ -199,7 +201,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
{showNewForm ? (
<form onSubmit={handleCreateCollection} className="space-y-3 sm:space-y-4 pt-3 sm:pt-4 border-t border-gray-800">
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="title" className="text-white text-sm">Collection Name</Label>
<Label htmlFor="title" className="text-white text-sm">{t('walletUi.addToCollection.name')}</Label>
<Input
id="title"
value={newTitle}
@ -210,7 +212,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
/>
</div>
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="description" className="text-white text-sm">Description (optional)</Label>
<Label htmlFor="description" className="text-white text-sm">{t('walletUi.addToCollection.descriptionOptional')}</Label>
<Textarea
id="description"
value={newDescription}
@ -227,7 +229,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
onClick={() => setShowNewForm(false)}
className="flex-1 border-gray-700 h-11"
>
Cancel
{t('common.cancel')}
</Button>
<Button
type="submit"
@ -237,14 +239,14 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<span className="hidden sm:inline">Creating...</span>
<span className="hidden sm:inline">{t('walletUi.addToCollection.creating')}</span>
<span className="sm:hidden">...</span>
</>
) : (
<>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Create & Save</span>
<span className="sm:hidden">Create</span>
<span className="hidden sm:inline">{t('walletUi.addToCollection.createAndSave')}</span>
<span className="sm:hidden">{t('common.create')}</span>
</>
)}
</Button>
@ -257,7 +259,7 @@ export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollect
className="w-full border-dashed border-gray-600 hover:border-[#ccff00]/50 hover:bg-[#ccff00]/5 h-11"
>
<Plus className="h-4 w-4 mr-2" />
Create New Collection
{t('walletUi.addToCollection.createNew')}
</Button>
)}
</div>

View file

@ -5,6 +5,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { isAdminPubkey } from '@/lib/admins';
import { isFlagged } from '@/lib/spotQuality';
import { useTranslation } from '@/contexts/language';
/**
* Admin-only entry point for the review queue. Renders nothing for everyone
@ -14,6 +15,7 @@ export function AdminReviewCard() {
const { user } = useCurrentUser();
const isAdmin = isAdminPubkey(user?.pubkey);
const { data: spots } = useSkateSpots(300);
const { t } = useTranslation();
// Cheap signals only here (no per-spot trust/status queries just to draw a
// badge) — the review page does the deeper pass.
@ -29,11 +31,11 @@ export function AdminReviewCard() {
<div className="flex items-center gap-3 px-4 py-3.5">
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-amber-400">Review queue</p>
<p className="text-sm font-bold text-amber-400">{t('settingsUi.adminReview.title')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{flaggedCount > 0
? `${flaggedCount} spot${flaggedCount === 1 ? '' : 's'} trip the junk signals — hide or dismiss.`
: 'Spots that look like test entries or off-topic pins.'}
? t('settingsUi.adminReview.flagged', { count: flaggedCount, s: flaggedCount === 1 ? '' : 's' })
: t('settingsUi.adminReview.clean')}
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />

View file

@ -4,6 +4,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login';
import { isInteractiveSigner } from '@/lib/signerCapabilities';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface AmberApprovalHintProps {
/**
@ -32,13 +33,15 @@ interface AmberApprovalHintProps {
* We DON'T try to reduce the count (that would weaken the encryption/authorship
* model) — we just tell the truth up front. Renders nothing for silent signers.
*/
export function AmberApprovalHint({ approvals, action = 'send', className }: AmberApprovalHintProps) {
export function AmberApprovalHint({ approvals, action, className }: AmberApprovalHintProps) {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { t } = useTranslation();
if (!isInteractiveSigner(user, logins)) return null;
const multi = typeof approvals === 'number' && approvals > 1;
const actionText = action ?? t('settingsUi.amberHint.defaultAction');
return (
<p
@ -51,12 +54,14 @@ export function AmberApprovalHint({ approvals, action = 'send', className }: Amb
<span>
{multi ? (
<>
Your signer will ask you to approve{' '}
<strong className="font-semibold text-amber-300">{approvals} times</strong> to {action} —
that's normal for encrypted messages (one approval per recipient). Approve each.
{t('settingsUi.amberHint.multiBefore')}{' '}
<strong className="font-semibold text-amber-300">
{t('settingsUi.amberHint.multiTimes', { approvals })}
</strong>{' '}
{t('settingsUi.amberHint.multiAfter', { action: actionText })}
</>
) : (
<>Your signer will ask you to approve to {action}.</>
<>{t('settingsUi.amberHint.single', { action: actionText })}</>
)}
</span>
</p>

View file

@ -7,10 +7,12 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
const TRUSTED_THRESHOLD = 60;
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
const pic = author.data?.metadata?.picture;
@ -35,10 +37,10 @@ function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean })
{trusted && (
<span
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
title="Someone you follow or a trusted mapper"
title={t('placesUi.attendees.trustedTitle')}
>
<ShieldCheck className="h-3 w-3" />
Trusted
{t('placesUi.attendees.trusted')}
</span>
)}
</div>
@ -63,6 +65,7 @@ export function AttendeeList({
tentative: string[];
className?: string;
}) {
const { t } = useTranslation();
const [tab, setTab] = useState<'going' | 'tentative'>('going');
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
@ -76,7 +79,7 @@ export function AttendeeList({
if (going.length === 0 && tentative.length === 0) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
No RSVPs yet — be the first to say you're going.
{t('placesUi.attendees.noRsvps')}
</p>
);
}
@ -90,11 +93,11 @@ export function AttendeeList({
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<p className="text-xs text-gray-300">
<span className="font-black text-white">{going.length}</span> going
<span className="font-black text-white">{going.length}</span> {t('placesUi.attendees.goingCount')}
{trustedGoing > 0 && (
<>
{' · '}
<span className="font-black text-[#ccff00]">{trustedGoing}</span> you trust
<span className="font-black text-[#ccff00]">{trustedGoing}</span> {t('placesUi.attendees.youTrust')}
</>
)}
</p>
@ -113,7 +116,7 @@ export function AttendeeList({
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
Going ({going.length})
{t('placesUi.attendees.goingTab', { count: going.length })}
</button>
<button
type="button"
@ -125,7 +128,7 @@ export function AttendeeList({
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
Interested ({tentative.length})
{t('placesUi.attendees.interestedTab', { count: tentative.length })}
</button>
</div>
@ -142,7 +145,7 @@ export function AttendeeList({
</div>
) : (
<p className="py-4 text-center text-sm text-muted-foreground">
{tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'}
{tab === 'going' ? t('placesUi.attendees.nobodyGoing') : t('placesUi.attendees.nobodyInterested')}
</p>
)}
</div>

View file

@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { AlertTriangle, Code2, FlaskConical, ShieldOff, Wallet, ArrowRight } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
/**
* Bump this version to re-show the disclaimer to everyone
@ -24,6 +25,7 @@ const REPO_URL =
export function BetaDisclaimerDialog() {
const [accepted, setAccepted] = useLocalStorage<boolean>(STORAGE_KEY, false);
const [open, setOpen] = useState(false);
const { t } = useTranslation();
// Open on first launch (after mount so SSR/hydration is clean)
useEffect(() => {
@ -69,11 +71,11 @@ export function BetaDisclaimerDialog() {
</div>
<DialogTitle className="text-center text-xl font-black uppercase tracking-wide text-white">
Heads up — this is beta
{t('settingsUi.betaDisclaimer.title')}
</DialogTitle>
<DialogDescription className="text-center text-sm text-gray-400">
A quick word before you drop in.
{t('settingsUi.betaDisclaimer.subtitle')}
</DialogDescription>
</DialogHeader>
@ -81,18 +83,18 @@ export function BetaDisclaimerDialog() {
<div className="mt-5 space-y-3">
<DisclaimerRow
icon={<Code2 className="h-4 w-4 text-[#ccff00]" />}
title="Open-source & experimental"
body="Built in the open by the community. Poke around, fork it, break it."
title={t('settingsUi.betaDisclaimer.ossTitle')}
body={t('settingsUi.betaDisclaimer.ossBody')}
/>
<DisclaimerRow
icon={<FlaskConical className="h-4 w-4 text-[#ff6600]" />}
title="In beta — not audited"
body="The code has not undergone a formal security audit yet. Bugs happen."
title={t('settingsUi.betaDisclaimer.betaTitle')}
body={t('settingsUi.betaDisclaimer.betaBody')}
/>
<DisclaimerRow
icon={<ShieldOff className="h-4 w-4 text-[#ff0040]" />}
title="Handles real Bitcoin & Lightning"
body="Use money on it at your own risk. Only load what you can afford to lose."
title={t('settingsUi.betaDisclaimer.moneyTitle')}
body={t('settingsUi.betaDisclaimer.moneyBody')}
/>
</div>
@ -100,15 +102,14 @@ export function BetaDisclaimerDialog() {
<p className="mt-4 flex items-start gap-2 rounded-lg border border-gray-800 bg-gray-900/50 px-3 py-2.5 text-[11px] leading-relaxed text-gray-500">
<Wallet className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-gray-600" />
<span>
No warranty of any kind. You are solely responsible for your own
funds and keys.{' '}
{t('settingsUi.betaDisclaimer.finePrint')}{' '}
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-[#ccff00] underline decoration-dotted underline-offset-2 hover:text-[#ccff00]/80"
>
View the code
{t('settingsUi.betaDisclaimer.viewCode')}
</a>
.
</span>
@ -120,7 +121,7 @@ export function BetaDisclaimerDialog() {
onClick={handleAccept}
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-black uppercase tracking-wide h-12 shadow-[0_0_20px_rgba(255,0,64,0.3)] transition-all hover:shadow-[0_0_28px_rgba(255,0,64,0.45)]"
>
Got it, let me in
{t('settingsUi.betaDisclaimer.accept')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</DialogFooter>

View file

@ -8,6 +8,7 @@ import { useLabProfile } from '@/hooks/useLabProfiles';
import { useApproveCollectiveJoinRequest } from '@/hooks/useCollectiveJoinRequests';
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types';
import { UserPlus, X, Loader2, Hand, Check } from 'lucide-react';
@ -39,6 +40,7 @@ export function CollectiveJoinRequestCard({
onResolved?: () => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const requester = useAuthor(request.requester);
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
const requesterPic = requester.data?.metadata?.picture;
@ -66,14 +68,14 @@ export function CollectiveJoinRequestCard({
await approve({ request, profile });
setDone('added');
toast({
title: `Added ${requesterName} to ${request.collectiveName} 🛹`,
description: "They'll confirm from their side to appear as a verified member.",
title: t('socialUi.collectiveJoinRequest.addedTitle', { name: requesterName, collective: request.collectiveName }),
description: t('socialUi.collectiveJoinRequest.addedDesc'),
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not add member',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.collectiveJoinRequest.couldNotAdd'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -85,7 +87,7 @@ export function CollectiveJoinRequestCard({
setDone('dismissed');
onResolved?.();
} catch {
toast({ title: 'Could not dismiss', variant: 'destructive' });
toast({ title: t('socialUi.collectiveJoinRequest.couldNotDismiss'), variant: 'destructive' });
}
};
@ -95,13 +97,13 @@ export function CollectiveJoinRequestCard({
{done === 'added' ? (
<>
<Check className="h-4 w-4 text-sky-400" />
Added <span className="font-bold text-white">{requesterName}</span> to{' '}
{t('socialUi.collectiveJoinRequest.addedDone')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.collectiveJoinRequest.addedTo')}{' '}
{request.collectiveName}
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Dismissed request from {requesterName}
{t('socialUi.collectiveJoinRequest.dismissedDone', { name: requesterName })}
</>
)}
</div>
@ -133,7 +135,7 @@ export function CollectiveJoinRequestCard({
</Avatar>
{requesterName}
</Link>{' '}
wants to join{' '}
{t('socialUi.collectiveJoinRequest.wantsToJoin')}{' '}
<Link to={collectiveHref} className="font-black text-sky-300 hover:underline">
{request.collectiveName}
</Link>
@ -146,14 +148,14 @@ export function CollectiveJoinRequestCard({
{alreadyMember ? (
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-sky-400/90">
<Check className="h-3.5 w-3.5" /> Already on the roster
<Check className="h-3.5 w-3.5" /> {t('socialUi.collectiveJoinRequest.alreadyOnRoster')}
<button
type="button"
onClick={handleDismiss}
disabled={busy}
className="ml-auto text-gray-500 underline-offset-2 hover:text-white hover:underline"
>
Clear
{t('common.clear')}
</button>
</p>
) : !profileLoading && !profile ? (
@ -167,7 +169,7 @@ export function CollectiveJoinRequestCard({
>
<Link to={collectiveHref}>
<UserPlus className="h-3.5 w-3.5" />
Open collective
{t('socialUi.collectiveJoinRequest.openCollective')}
</Link>
</Button>
<Button
@ -178,7 +180,7 @@ export function CollectiveJoinRequestCard({
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
{t('socialUi.collectiveJoinRequest.dismiss')}
</Button>
</div>
) : (
@ -194,7 +196,7 @@ export function CollectiveJoinRequestCard({
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
{profileLoading ? 'Loading…' : 'Add to collective'}
{profileLoading ? t('common.loading') : t('socialUi.collectiveJoinRequest.addToCollective')}
</Button>
<Button
size="sm"
@ -204,7 +206,7 @@ export function CollectiveJoinRequestCard({
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
{t('socialUi.collectiveJoinRequest.dismiss')}
</Button>
</div>
)}

View file

@ -11,6 +11,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
@ -41,10 +42,11 @@ function RoleChip({ role }: { role: CrewRole }) {
}
function StatusChip({ status }: { status: CollectiveMember['status'] }) {
const { t } = useTranslation();
if (status === 'confirmed') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-500">
<Check className="h-3 w-3" /> Confirmed
<Check className="h-3 w-3" /> {t('socialUi.collectiveMembers.confirmed')}
</span>
);
}
@ -53,11 +55,11 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
<Clock className="h-3 w-3" /> Pending
<Clock className="h-3 w-3" /> {t('socialUi.collectiveMembers.pending')}
</span>
</TooltipTrigger>
<TooltipContent>
Listed by the collective — awaiting this member’s confirmation.
{t('socialUi.collectiveMembers.pendingTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -65,25 +67,26 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
}
function AdminChip() {
const { t } = useTranslation();
return (
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
<ShieldCheck className="h-3 w-3" /> Admin
<ShieldCheck className="h-3 w-3" /> {t('socialUi.inviteCard.admin')}
</span>
);
}
function CrewOnlyChip() {
const { t } = useTranslation();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-300">
<EyeOff className="h-3 w-3" /> Crew only
<EyeOff className="h-3 w-3" /> {t('socialUi.collectiveMembers.crewOnly')}
</span>
</TooltipTrigger>
<TooltipContent>
This member chose to be visible only to crew-mates. You can see them
because you're in the crew.
{t('socialUi.collectiveMembers.crewOnlyTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -124,6 +127,7 @@ function MemberRow({
/** Anonymous placeholder shown to NON-members for a `crew`-only member. */
function HiddenMemberRow() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Avatar className="h-9 w-9">
@ -132,10 +136,10 @@ function HiddenMemberRow() {
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-500 italic">
Hidden member
{t('socialUi.collectiveMembers.hiddenMember')}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
<EyeOff className="h-3 w-3" /> Crew only
<EyeOff className="h-3 w-3" /> {t('socialUi.collectiveMembers.crewOnly')}
</span>
</div>
);
@ -155,6 +159,7 @@ export function CollectiveMembers({
accentText = 'text-indigo-400',
accentBorder = 'border-gray-700',
}: CollectiveMembersProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: members, isLoading } = useCollectiveMembers({
kind,
@ -196,7 +201,7 @@ export function CollectiveMembers({
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Users className={cn('h-5 w-5', accentText)} />
Members · {claimedMembers.length}
{t('socialUi.collectiveMembers.title')} · {claimedMembers.length}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
@ -204,9 +209,12 @@ export function CollectiveMembers({
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
<span>
{confirmedCount > 0
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
{admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
? t('socialUi.collectiveMembers.verifiedOf', { confirmed: confirmedCount, total: claimedMembers.length })
: t('socialUi.collectiveMembers.verifiedPerMember')}
{admins.length > 0 &&
(admins.length === 1
? ` ${t('socialUi.collectiveMembers.adminsOne', { count: admins.length })}`
: ` ${t('socialUi.collectiveMembers.adminsMany', { count: admins.length })}`)}
</span>
</p>

View file

@ -6,6 +6,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import {
MEMBER_VISIBILITY_DESCRIPTIONS,
@ -31,9 +32,10 @@ export function VisibilityPicker({
onChange: (v: MemberVisibility) => void;
accentBg?: string;
}) {
const { t } = useTranslation();
const options: { key: MemberVisibility; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'public', label: 'Public', icon: Globe },
{ key: 'crew', label: 'Crew only', icon: EyeOff },
{ key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe },
{ key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff },
];
return (
<div className="space-y-2">
@ -79,6 +81,7 @@ export function ConfirmMembershipCard({
claimedMembers,
accentBg = 'bg-indigo-600 hover:bg-indigo-700',
}: ConfirmMembershipCardProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const { mutateAsync: confirm, isPending } = useConfirmMembership();
@ -105,16 +108,16 @@ export function ConfirmMembershipCard({
try {
await confirm({ kind, founderPubkey, identifier, collectiveName, visibility });
toast({
title: 'Membership confirmed!',
title: t('socialUi.confirmMembership.confirmedTitle'),
description:
visibility === 'crew'
? `You're verified in ${collectiveName} — visible to crew-mates only.`
: `You now show as a verified member of ${collectiveName}.`,
? t('socialUi.confirmMembership.confirmedCrew', { name: collectiveName })
: t('socialUi.confirmMembership.confirmedPublic', { name: collectiveName }),
});
} catch (error) {
toast({
title: 'Failed to confirm',
description: error instanceof Error ? error.message : 'Please try again.',
title: t('socialUi.confirmMembership.failed'),
description: error instanceof Error ? error.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -126,16 +129,16 @@ export function ConfirmMembershipCard({
<div className="flex items-start gap-3">
<Users className="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
<div>
<h3 className="font-bold text-amber-300">You've been added to {collectiveName}</h3>
<h3 className="font-bold text-amber-300">{t('socialUi.confirmMembership.addedTo', { name: collectiveName })}</h3>
<p className="text-sm text-gray-300">
Confirm your membership so it shows as verified on your profile and theirs.
{t('socialUi.confirmMembership.addedDesc')}
</p>
</div>
</div>
<div>
<p className="mb-2 text-xs font-black uppercase tracking-widest text-gray-400">
Your visibility
{t('socialUi.confirmMembership.yourVisibility')}
</p>
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
</div>
@ -148,11 +151,11 @@ export function ConfirmMembershipCard({
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Confirming…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.confirmMembership.confirming')}
</>
) : (
<>
<Check className="h-4 w-4" /> Confirm Membership
<Check className="h-4 w-4" /> {t('socialUi.confirmMembership.confirmButton')}
</>
)}
</Button>

View file

@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { useCoordinator, useCoordinatorHealth, type CoordinatorHealthStatus } from '@/hooks/useCoordinator';
import { useTranslation } from '@/contexts/language';
import { toast } from '@/hooks/useToast';
import {
CORDN_APP_URL,
@ -26,34 +27,34 @@ import {
} from '@/lib/coordinator';
import { cn } from '@/lib/utils';
function timeAgo(unixSeconds: number): string {
function timeAgo(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
const s = Math.floor(Date.now() / 1000 - unixSeconds);
if (s < 60) return `${s}s ago`;
if (s < 60) return t('socialUi.coordinator.secondsAgo', { count: s });
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
if (m < 60) return t('socialUi.coordinator.minutesAgo', { count: m });
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
if (h < 24) return t('socialUi.coordinator.hoursAgo', { count: h });
return t('socialUi.coordinator.daysAgo', { count: Math.floor(h / 24) });
}
const STATUS_META: Record<
CoordinatorHealthStatus,
{ label: string; color: string; ring: string; dot: string }
> = {
idle: { label: 'Not checked', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' },
checking: { label: 'Checking…', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' },
online: { label: 'Connected', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' },
stale: { label: 'Quiet', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' },
offline: { label: 'Not seen', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
error: { label: 'Unreachable', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
idle: { label: 'coordinator.statusIdle', color: 'text-gray-400', ring: 'border-white/10', dot: 'bg-gray-500' },
checking: { label: 'coordinator.statusChecking', color: 'text-blue-300', ring: 'border-blue-500/30', dot: 'bg-blue-400' },
online: { label: 'coordinator.statusOnline', color: 'text-green-400', ring: 'border-green-500/30', dot: 'bg-green-400' },
stale: { label: 'coordinator.statusStale', color: 'text-amber-400', ring: 'border-amber-500/30', dot: 'bg-amber-400' },
offline: { label: 'coordinator.statusOffline', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
error: { label: 'coordinator.statusError', color: 'text-red-400', ring: 'border-red-500/30', dot: 'bg-red-500' },
};
async function copyText(text: string, label: string) {
async function copyText(text: string, copiedText: string, failedTitle: string, failedDesc: string) {
try {
await navigator.clipboard.writeText(text);
toast({ title: `${label} copied` });
toast({ title: copiedText });
} catch {
toast({ title: 'Copy failed', description: 'Copy it manually.', variant: 'destructive' });
toast({ title: failedTitle, description: failedDesc, variant: 'destructive' });
}
}
@ -66,6 +67,7 @@ async function copyText(text: string, label: string) {
* probe: a ContextVM server is visible only if it announces.
*/
export function CoordinatorSettingsCard() {
const { t } = useTranslation();
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator);
@ -84,8 +86,8 @@ export function CoordinatorSettingsCard() {
if (!parsed) {
toast({
variant: 'destructive',
title: "That's not a coordinator",
description: 'Paste an nprofile, an npub, or a cordn.net link.',
title: t('socialUi.coordinator.notACoordinator'),
description: t('socialUi.coordinator.notACoordinatorDesc'),
});
return;
}
@ -94,7 +96,7 @@ export function CoordinatorSettingsCard() {
relays: parsed.relays.length ? parsed.relays : [CORDN_RELAY_URL],
});
setInput('');
toast({ title: 'Coordinator added', description: shortNpub(parsed.pubkey) });
toast({ title: t('socialUi.coordinator.added'), description: shortNpub(parsed.pubkey) });
};
const meta = STATUS_META[status];
@ -111,8 +113,10 @@ export function CoordinatorSettingsCard() {
<span className="truncate">{name ?? shortNpub(coordinator.pubkey)}</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{coordinator.relays.length} relay{coordinator.relays.length === 1 ? '' : 's'}
{lastSeen ? ` · last seen ${timeAgo(lastSeen)}` : ''}
{coordinator.relays.length === 1
? t('socialUi.coordinator.relayOne', { count: coordinator.relays.length })
: t('socialUi.coordinator.relayMany', { count: coordinator.relays.length })}
{lastSeen ? ` · ${t('socialUi.coordinator.lastSeen', { time: timeAgo(lastSeen, t) })}` : ''}
</p>
</div>
<span
@ -123,30 +127,32 @@ export function CoordinatorSettingsCard() {
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}
{t(meta.label)}
</span>
</div>
<div className="px-4 py-3.5 space-y-3">
<p className="text-xs leading-relaxed text-muted-foreground">
{status === 'online' && 'Announcing. Groups that use it can deliver messages now.'}
{status === 'stale' && 'Found, but it has not announced recently — it may be asleep.'}
{status === 'offline' &&
'No announcement seen. It may be private (that is fine), or simply offline.'}
{status === 'error' && (error ?? 'Could not reach the relays.')}
{status === 'idle' && 'Checking…'}
{status === 'checking' && 'Looking for its announcement…'}
{eventCount > 0 && ` · ${eventCount} event${eventCount === 1 ? '' : 's'} found.`}
{status === 'online' && t('socialUi.coordinator.onlineDesc')}
{status === 'stale' && t('socialUi.coordinator.staleDesc')}
{status === 'offline' && t('socialUi.coordinator.offlineDesc')}
{status === 'error' && (error ?? t('socialUi.coordinator.errorDesc'))}
{status === 'idle' && t('socialUi.coordinator.idleDesc')}
{status === 'checking' && t('socialUi.coordinator.checkingDesc')}
{eventCount > 0 &&
(eventCount === 1
? ` · ${t('socialUi.coordinator.eventFoundOne', { count: eventCount })}`
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />
Check
{t('socialUi.coordinator.check')}
</Button>
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), 'Add link')}>
<Button size="sm" variant="outline" onClick={() => void copyText(coordinatorAddUrl(coordinator), t('socialUi.coordinator.addLinkCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}>
<Copy className="mr-1.5 h-3.5 w-3.5" />
Add link
{t('socialUi.coordinator.addLink')}
</Button>
<Button size="sm" variant="outline" onClick={() => setShowQr((v) => !v)}>
<QrCode className="mr-1.5 h-3.5 w-3.5" />
@ -157,7 +163,7 @@ export function CoordinatorSettingsCard() {
variant="ghost"
className="ml-auto text-muted-foreground hover:text-red-400"
onClick={() => {
if (window.confirm('Remove this coordinator from this device?')) removeCoordinator();
if (window.confirm(t('socialUi.coordinator.removeConfirm'))) removeCoordinator();
}}
>
<Trash2 className="h-3.5 w-3.5" />
@ -168,7 +174,7 @@ export function CoordinatorSettingsCard() {
<div className="flex flex-col items-center gap-2 rounded-xl bg-black/30 border border-white/5 p-4">
<QRCodeCanvas value={coordinatorAddUrl(coordinator)} size={180} level="M" />
<p className="text-[11px] text-muted-foreground text-center">
Scan in Cordn to join this coordinator.
{t('socialUi.coordinator.scanToJoin')}
</p>
</div>
)}
@ -182,67 +188,66 @@ export function CoordinatorSettingsCard() {
<div className="rounded-2xl border border-cyan-500/25 bg-cyan-500/[0.06] p-4 space-y-3">
<p className="text-sm font-bold text-white flex items-center gap-2">
<Server className="h-4 w-4 text-cyan-400" />
No coordinator yet
{t('socialUi.coordinator.noCoordinator')}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
A coordinator delivers your crew&apos;s <strong className="text-white">encrypted</strong> group
messages in order. It sees <em>who, when and which group</em> — never the contents. Pick one,
or run your own.
{t('socialUi.coordinator.noCoordinatorDescA')}{' '}
<strong className="text-white">{t('socialUi.coordinator.encryptedWord')}</strong>{' '}
{t('socialUi.coordinator.noCoordinatorDescB')}
</p>
</div>
)}
{/* ── Add / change ────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
<p className="text-sm font-bold text-white">{coordinator ? 'Change coordinator' : 'Add a coordinator'}</p>
<p className="text-sm font-bold text-white">{coordinator ? t('socialUi.coordinator.change') : t('socialUi.coordinator.addTitle')}</p>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="nprofile… or a cordn.net link"
placeholder={t('socialUi.coordinator.inputPlaceholder')}
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Enter' && input.trim()) handleAdd(input);
}}
/>
<Button size="sm" onClick={() => handleAdd(input)} disabled={!input.trim()} className="shrink-0">
Add
{t('socialUi.coordinator.add')}
</Button>
</div>
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
Scan a coordinator QR
{t('socialUi.coordinator.scanQR')}
</Button>
</div>
{/* ── Run your own (the hand-off) ─────────────────────────────────── */}
<div className="rounded-2xl border border-white/8 bg-white/2 overflow-hidden">
<div className="px-4 py-3.5 border-b border-white/5">
<p className="text-sm font-bold text-white">Run your own</p>
<p className="text-sm font-bold text-white">{t('socialUi.coordinator.runOwn')}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
It can live on an old phone, a Raspberry Pi, or any small box you leave on. Your group keeps
working while it&apos;s up — and you can move the group whenever you like.
{t('socialUi.coordinator.runOwnDesc')}
</p>
</div>
<div className="px-4 py-3.5 space-y-3 border-b border-white/5">
<p className="flex items-center gap-2 text-xs font-bold text-white">
<Smartphone className="h-3.5 w-3.5 text-cyan-400" />
On a phone
{t('socialUi.coordinator.onPhone')}
</p>
<p className="text-xs text-muted-foreground">
Use the Cordn Coordinator app, then scan its QR here to add it.
{t('socialUi.coordinator.onPhoneDesc')}
</p>
<div className="flex flex-wrap gap-2">
<a href={CORDN_APP_URL} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
Open Cordn
{t('socialUi.coordinator.openCordn')}
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Button>
</a>
<Button size="sm" variant="outline" onClick={() => setScanOpen(true)}>
<ScanLine className="mr-1.5 h-3.5 w-3.5" />
Scan its QR
{t('socialUi.coordinator.scanItsQR')}
</Button>
</div>
</div>
@ -250,7 +255,7 @@ export function CoordinatorSettingsCard() {
<div className="px-4 py-3.5 space-y-3">
<p className="flex items-center gap-2 text-xs font-bold text-white">
<Server className="h-3.5 w-3.5 text-cyan-400" />
On a server
{t('socialUi.coordinator.onServer')}
</p>
<div className="flex items-center gap-2 rounded-xl bg-black/40 border border-white/5 px-3 py-2">
<code className="flex-1 min-w-0 truncate text-[11px] font-mono text-gray-300">
@ -258,23 +263,22 @@ export function CoordinatorSettingsCard() {
</code>
<button
type="button"
onClick={() => void copyText(CORDN_DOCKER_COMMAND, 'Command')}
onClick={() => void copyText(CORDN_DOCKER_COMMAND, t('socialUi.coordinator.commandCopied'), t('socialUi.coordinator.copyFailed'), t('socialUi.coordinator.copyManually'))}
className="shrink-0 text-muted-foreground hover:text-white transition-colors"
aria-label="Copy command"
aria-label={t('socialUi.coordinator.copyCommand')}
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
Android may sleep a phone coordinator. Expect to move a group now and then.
{t('socialUi.coordinator.androidSleep')}
</p>
</div>
</div>
<p className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
🔒 A coordinator sees ciphertext and metadata only — never message contents. Run your own to keep
even the metadata yours.
{t('socialUi.coordinator.privacyNote')}
</p>
<QRScannerModal

View file

@ -13,6 +13,7 @@ import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Send, Loader2, MapPin, ChevronRight, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
import { useCrewAgentChat, type CrewContext, type CrewAgentMessage } from '@/hooks/useCrewAgentChat';
@ -40,14 +41,14 @@ interface ReceiptItem {
}
/** Normalise a CVM tool result into flat receipt cards. */
function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
function toReceiptItems(t: (key: string) => string, tool: string, data: unknown): ReceiptItem[] {
const d = data as Record<string, unknown>;
const take = <T,>(arr: unknown): T[] => (Array.isArray(arr) ? (arr as T[]).slice(0, 6) : []);
if (tool === 'find_spots') {
return take<Record<string, string>>(d.spots).map((s, i) => ({
key: `${s.url}-${i}`,
title: s.name || 'Spot',
title: s.name || t('socialUi.agentPanel.spot'),
subtitle: [s.difficulty, s.location].filter(Boolean).join(' · ') || undefined,
image: s.image,
to: toAppPath(s.url),
@ -56,7 +57,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
if (tool === 'find_events') {
return take<Record<string, string>>(d.events).map((e, i) => ({
key: `${e.url}-${i}`,
title: e.title || 'Event',
title: e.title || t('socialUi.agentPanel.event'),
subtitle: e.location,
image: e.image,
to: toAppPath(e.url),
@ -65,7 +66,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
if (tool === 'find_coaches') {
return take<Record<string, string>>(d.profiles).map((p, i) => ({
key: `${p.url}-${i}`,
title: p.title || p.role || 'Lab pro',
title: p.title || p.role || t('socialUi.agentPanel.labPro'),
subtitle: [p.role, p.location].filter(Boolean).join(' · ') || undefined,
image: p.image,
to: toAppPath(p.url),
@ -74,7 +75,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
if (tool === 'find_gear') {
return take<Record<string, string>>(d.listings).map((g, i) => ({
key: `${g.url}-${i}`,
title: g.title || 'Gear',
title: g.title || t('socialUi.agentPanel.gear'),
subtitle: g.condition,
image: g.image,
to: toAppPath(g.url),
@ -83,7 +84,7 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
if (tool === 'find_crews') {
return take<Record<string, string>>(d.crews).map((c, i) => ({
key: `${c.url}-${i}`,
title: c.name || 'Crew',
title: c.name || t('socialUi.agentPanel.crew'),
subtitle: c.location,
image: c.image,
to: toAppPath(c.url),
@ -93,7 +94,8 @@ function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
}
function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown; onNavigate: () => void }) {
const items = toReceiptItems(tool, data);
const { t } = useTranslation();
const items = toReceiptItems(t, tool, data);
if (items.length === 0) return null;
return (
<div className="mt-2 space-y-1.5">
@ -150,18 +152,18 @@ function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () =>
);
}
const SUGGESTIONS = [
"What's our next jam?",
'Who runs this crew?',
'What are we looking for?',
'Best spots near us?',
"What's the weather for skating?",
];
export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps) {
const { t } = useTranslation();
const { messages, isThinking, send } = useCrewAgentChat(ctx);
const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const suggestions = [
t('socialUi.agentPanel.suggestion1'),
t('socialUi.agentPanel.suggestion2'),
t('socialUi.agentPanel.suggestion3'),
t('socialUi.agentPanel.suggestion4'),
t('socialUi.agentPanel.suggestion5'),
];
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
@ -183,13 +185,13 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[9px] font-black">SQ</AvatarFallback>
</Avatar>
Ask Sasquatch
{t('socialUi.crew.askSasquatch')}
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
Crew Agent
{t('socialUi.crew.agent')}
</span>
</DialogTitle>
<DialogDescription className="text-xs">
Knows this crew — its jams, roster & open call — plus the wider scene with live data.
{t('socialUi.agentPanel.description')}
</DialogDescription>
</DialogHeader>
@ -201,11 +203,10 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
<AvatarFallback className="bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
</Avatar>
<p className="text-sm text-muted-foreground max-w-[16rem]">
Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me about the crew — our next jam,
who runs it, what we're after — or the wider scene.
{t('socialUi.agentPanel.greeting', { crew: ctx.crewName })}
</p>
<div className="flex flex-wrap justify-center gap-1.5">
{SUGGESTIONS.map((s) => (
{suggestions.map((s) => (
<button
key={s}
onClick={() => submit(s)}
@ -225,7 +226,7 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
{isThinking && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-400" />
Sasquatch is thinking…
{t('socialUi.agentPanel.thinking')}
</div>
)}
</div>
@ -241,7 +242,7 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Ask about your local scene…"
placeholder={t('socialUi.agentPanel.placeholder')}
maxLength={300}
className="flex-1"
/>
@ -255,7 +256,7 @@ export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps)
</Button>
</form>
<p className="mt-1.5 flex items-center justify-center gap-1 text-[10px] text-muted-foreground">
<Sparkles className="h-2.5 w-2.5" /> AI can be wrong — links go to real listings.
<Sparkles className="h-2.5 w-2.5" /> {t('socialUi.agentPanel.disclaimer')}
</p>
</div>
</DialogContent>

View file

@ -1,4 +1,5 @@
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import { crewBadge, ACTIVITY_META, type CrewActivity } from '@/lib/crewHelpers';
import type { SkateCrew } from '@/lib/types';
@ -52,19 +53,28 @@ export function CrewActivityPulse({
showDormant?: boolean;
className?: string;
}) {
const { t } = useTranslation();
if (!activity) return null;
// In dense card grids we only surface positive signals — hide dormant crews.
if (activity.level === 'dormant' && !showDormant) return null;
const meta = ACTIVITY_META[activity.level];
const isHot = activity.level === 'hot';
const upcoming =
activity.upcomingEvents === 1
? t('socialUi.badge.upcomingOne', { count: activity.upcomingEvents })
: t('socialUi.badge.upcomingMany', { count: activity.upcomingEvents });
const coming =
activity.upcomingEvents === 1
? t('socialUi.badge.comingOne', { count: activity.upcomingEvents })
: t('socialUi.badge.comingMany', { count: activity.upcomingEvents });
return (
<span
className={cn('inline-flex items-center gap-1 text-[10px] font-bold', meta.text, className)}
title={
activity.upcomingEvents > 0
? `${activity.upcomingEvents} upcoming event${activity.upcomingEvents === 1 ? '' : 's'}`
? upcoming
: meta.label
}
>
@ -75,7 +85,7 @@ export function CrewActivityPulse({
<span className={cn('relative inline-flex h-1.5 w-1.5 rounded-full', meta.dot)} />
</span>
{activity.upcomingEvents > 0
? `${activity.upcomingEvents} event${activity.upcomingEvents === 1 ? '' : 's'} coming`
? coming
: meta.label}
</span>
);

View file

@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
@ -77,6 +78,7 @@ function CrewMessageBubble({
canModerate?: boolean;
onHide?: (message: DirectMessage) => void;
}) {
const { t } = useTranslation();
const author = useAuthor(message.pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(message.pubkey);
@ -101,7 +103,7 @@ function CrewMessageBubble({
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo}</span>
{isHidden && (
<span className="flex items-center gap-0.5 text-[10px] font-bold uppercase text-red-400">
<EyeOff className="h-2.5 w-2.5" /> Hidden
<EyeOff className="h-2.5 w-2.5" /> {t('socialUi.chat.hidden')}
</span>
)}
{canModerate && !isHidden && onHide && (
@ -109,8 +111,8 @@ function CrewMessageBubble({
type="button"
onClick={() => onHide(message)}
className="text-muted-foreground opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
aria-label="Hide this message"
title="Hide message (admin)"
aria-label={t('socialUi.chat.hideMessageAria')}
title={t('socialUi.chat.hideMessageTitle')}
>
<EyeOff className="h-3 w-3" />
</button>
@ -138,6 +140,7 @@ export function CrewChat({
canModerate = false,
crew,
}: CrewChatProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { toast } = useToast();
@ -167,11 +170,11 @@ export function CrewChat({
const handleHideMessage = async (m: DirectMessage) => {
try {
await hideMessage({ postId: m.id, postAuthor: m.pubkey });
toast({ title: 'Message hidden', description: 'Hidden from the crew chat for everyone.' });
toast({ title: t('socialUi.chat.messageHidden'), description: t('socialUi.chat.messageHiddenDesc') });
} catch (err) {
toast({
title: 'Failed to hide message',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.chat.messageHideFailed'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -270,8 +273,8 @@ export function CrewChat({
if (!canSendNip44) {
toast({
title: 'Encryption unavailable',
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
title: t('socialUi.chat.encryptionUnavailable'),
description: t('socialUi.chat.encryptionUnavailableDesc'),
variant: 'destructive',
});
return;
@ -284,8 +287,8 @@ export function CrewChat({
setDraft('');
} catch (err) {
toast({
title: 'Message failed',
description: err instanceof Error ? err.message : 'Could not send message.',
title: t('socialUi.chat.messageFailed'),
description: err instanceof Error ? err.message : t('socialUi.chat.couldNotSend'),
variant: 'destructive',
});
}
@ -296,8 +299,8 @@ export function CrewChat({
const recipients = participants.filter((p) => p !== user.pubkey);
if (recipients.length === 0) {
toast({
title: 'No one to message yet',
description: 'This crew has no other members to chat with.',
title: t('socialUi.chat.noOneToMessage'),
description: t('socialUi.chat.noOneToMessageDesc'),
});
return;
}
@ -306,13 +309,13 @@ export function CrewChat({
await sendNip17({
recipients,
content: text,
subject: `${crewName} crew chat`,
subject: t('socialUi.chat.subject', { crew: crewName }),
});
setDraft('');
} catch (err) {
toast({
title: 'Message failed',
description: err instanceof Error ? err.message : 'Could not send message.',
title: t('socialUi.chat.messageFailed'),
description: err instanceof Error ? err.message : t('socialUi.chat.couldNotSend'),
variant: 'destructive',
});
}
@ -323,13 +326,13 @@ export function CrewChat({
<CardHeader className="pb-3 border-b border-white/5">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Chat
<MessageSquare className="h-5 w-5 text-teal-400" /> {t('socialUi.chat.title')}
</CardTitle>
{usingCordn ? (
<Badge
variant="outline"
className="gap-1 border-emerald-500/40 bg-emerald-500/10 text-emerald-300 text-[10px] font-bold uppercase tracking-wide"
title="Routed through this crew's Cordn coordinator over ContextVM. End-to-end encrypted."
title={t('socialUi.chat.cordnBadgeTitle')}
>
<Server className="h-3 w-3" />
Cordn
@ -338,19 +341,19 @@ export function CrewChat({
<Badge
variant="outline"
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
title="End-to-end encrypted — only crew members can read these messages."
title={t('socialUi.chat.privateBadgeTitle')}
>
<Lock className="h-3 w-3" />
Private
{t('socialUi.chat.private')}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
{usingCordn
? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.'
: 'Encrypted group chat for the founder & all listed crew members.'}
{canModerate && ' You can hide messages from the crew as a moderator.'}
? t('socialUi.chat.encryptedCordn')
: t('socialUi.chat.encryptedNip17')}
{canModerate && t('socialUi.chat.moderatorNote')}
</p>
</CardHeader>
@ -358,14 +361,13 @@ export function CrewChat({
{!user ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
<Lock className="h-8 w-8 text-teal-400/40" />
<p className="text-sm text-muted-foreground">Log in to see if you can join the crew chat.</p>
<p className="text-sm text-muted-foreground">{t('socialUi.chat.loginToSee')}</p>
</div>
) : !isMember ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-6 text-center">
<Lock className="h-8 w-8 text-teal-400/40" />
<p className="text-sm text-muted-foreground max-w-xs">
This chat is private to crew members. Ask the founder to add you to the crew to join
the conversation.
{t('socialUi.chat.membersOnly')}
</p>
</div>
) : (
@ -388,7 +390,7 @@ export function CrewChat({
<div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8">
<MessageSquare className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground max-w-[16rem]">
No messages yet. Kick off the crew chat — plan a session, share a spot, hype a clip.
{t('socialUi.chat.noMessages')}
</p>
</div>
)}
@ -397,10 +399,10 @@ export function CrewChat({
.map((m) => (
<div key={m.id}>
{m.id === firstUnreadId && (
<div className="flex items-center gap-2 py-1.5" aria-label="New messages">
<div className="flex items-center gap-2 py-1.5" aria-label={t('socialUi.chat.newMessages')}>
<span className="h-px flex-1 bg-teal-500/30" />
<span className="text-[10px] font-bold uppercase tracking-wide text-teal-400">
New
{t('socialUi.chat.new')}
</span>
<span className="h-px flex-1 bg-teal-500/30" />
</div>
@ -427,7 +429,7 @@ export function CrewChat({
handleSend(e);
}
}}
placeholder="Message your crew…"
placeholder={t('socialUi.chat.placeholder')}
maxLength={2000}
rows={1}
className="flex-1 resize-none min-h-[40px] max-h-32"
@ -436,7 +438,7 @@ export function CrewChat({
type="submit"
size="icon"
disabled={isPending || !draft.trim()}
aria-label="Send message"
aria-label={t('socialUi.chat.sendAria')}
className="bg-teal-500 hover:bg-teal-600 text-black shrink-0"
>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}

View file

@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
@ -59,6 +60,7 @@ function MemberRow({
onToggleRole: (role: CrewRole) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
@ -75,7 +77,7 @@ function MemberRow({
{name}
{isAdmin && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
<ShieldCheck className="h-2.5 w-2.5" /> {t('socialUi.inviteCard.admin')}
</span>
)}
</p>
@ -90,19 +92,19 @@ function MemberRow({
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
)}
onClick={onToggleAdmin}
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
aria-label={isAdmin ? t('socialUi.form.removeAdminRole') : t('socialUi.form.makeAdmin')}
>
<ShieldCheck className="h-3.5 w-3.5" />
{isAdmin ? 'Admin' : 'Make admin'}
{isAdmin ? t('socialUi.inviteCard.admin') : t('socialUi.form.makeAdmin')}
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label={t('socialUi.form.removeMember')}>
<X className="h-4 w-4" />
</Button>
</div>
{/* Skate roles — the *functions* on the team. Additive, no powers. */}
<div className="flex flex-wrap items-center gap-1 pl-11">
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">Roles:</span>
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">{t('socialUi.form.roles')}</span>
{CREW_ROLE_ORDER.map((role) => {
const active = roles.includes(role);
const meta = CREW_ROLE_META[role];
@ -154,6 +156,7 @@ function decodePubkeyInput(raw: string): string | null {
* *before* adding. Shows a neutral "resolving…" state while metadata loads.
*/
function MemberPreview({ pubkey, alreadyAdded }: { pubkey: string; alreadyAdded: boolean }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
@ -170,11 +173,11 @@ function MemberPreview({ pubkey, alreadyAdded }: { pubkey: string; alreadyAdded:
</div>
{alreadyAdded ? (
<span className="shrink-0 rounded-full bg-gray-700 px-2 py-0.5 text-[10px] font-bold uppercase text-gray-300">
Added
{t('socialUi.form.added')}
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-bold text-teal-400">
<Check className="h-3 w-3" /> Ready
<Check className="h-3 w-3" /> {t('socialUi.form.ready')}
</span>
)}
</div>
@ -187,6 +190,7 @@ interface CrewFormProps {
}
export function CrewForm({ existing }: CrewFormProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
@ -350,11 +354,11 @@ export function CrewForm({ existing }: CrewFormProps) {
if (!raw) return;
const hex = decodePubkeyInput(raw);
if (!hex) {
setMemberError('Enter a valid npub, nprofile, or hex pubkey.');
setMemberError(t('socialUi.form.invalidMember'));
return;
}
if (members.includes(hex)) {
setMemberError('That member is already added.');
setMemberError(t('socialUi.form.memberAlreadyAdded'));
return;
}
setMembers([...members, hex]);
@ -364,7 +368,7 @@ export function CrewForm({ existing }: CrewFormProps) {
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
toast({ title: t('socialUi.form.geolocationUnsupported'), variant: 'destructive' });
return;
}
setIsGettingLocation(true);
@ -373,11 +377,11 @@ export function CrewForm({ existing }: CrewFormProps) {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
toast({ title: t('socialUi.form.locationFound') });
},
(error) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
toast({ title: t('socialUi.form.locationError'), description: error.message, variant: 'destructive' });
}
);
};
@ -390,10 +394,10 @@ export function CrewForm({ existing }: CrewFormProps) {
const url = tags[0]?.[1];
if (url) {
setImage(url);
toast({ title: 'Logo uploaded' });
toast({ title: t('socialUi.form.logoUploaded') });
}
} catch (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
toast({ title: t('socialUi.form.uploadFailed'), description: error instanceof Error ? error.message : t('socialUi.form.failed'), variant: 'destructive' });
}
e.target.value = '';
};
@ -405,7 +409,7 @@ export function CrewForm({ existing }: CrewFormProps) {
// advance rather than publish a half-filled crew. (Edit mode has no steps.)
if (!existing && step < WIZARD_LAST_STEP) {
if (step === 0 && !name.trim()) {
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' });
return;
}
setNavDir('forward');
@ -415,7 +419,7 @@ export function CrewForm({ existing }: CrewFormProps) {
}
if (!name.trim()) {
toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' });
toast({ title: t('socialUi.form.nameRequired'), description: t('socialUi.form.nameRequiredDesc'), variant: 'destructive' });
return;
}
@ -549,10 +553,10 @@ export function CrewForm({ existing }: CrewFormProps) {
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
toast({ title: existing ? t('socialUi.form.crewUpdated') : t('socialUi.form.crewCreated'), description: t('socialUi.form.publishedToScene') });
navigate(`/crew/${user?.pubkey}/${identifier}`);
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
toast({ title: t('socialUi.form.publishFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
}
};
@ -563,10 +567,10 @@ export function CrewForm({ existing }: CrewFormProps) {
const logoBlock = (
<div className="space-y-2">
<Label>Crew Logo / Photo</Label>
<Label>{t('socialUi.form.logoLabel')}</Label>
{image ? (
<div className="relative w-32 h-32 rounded-2xl overflow-hidden bg-gray-800">
<img src={image} alt="Crew" className="w-full h-full object-cover" />
<img src={image} alt={t('socialUi.form.logoAlt')} className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" />
</button>
@ -574,7 +578,7 @@ export function CrewForm({ existing }: CrewFormProps) {
) : (
<label className="w-32 h-32 rounded-2xl border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50 hover:border-teal-500/50 transition-colors">
<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('socialUi.form.upload')}</span></>}
</label>
)}
</div>
@ -584,17 +588,17 @@ export function CrewForm({ existing }: CrewFormProps) {
const nameBlock = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="crew-name">Crew Name <span className="text-teal-400">*</span></Label>
<Label htmlFor="crew-name">{t('socialUi.form.crewName')} <span className="text-teal-400">*</span></Label>
{nameValid && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-teal-400">
<Check className="h-3 w-3" /> Looks good
<Check className="h-3 w-3" /> {t('socialUi.form.looksGood')}
</span>
)}
</div>
<div className="relative">
<Input
id="crew-name"
placeholder="e.g. Backyard Bandits"
placeholder={t('socialUi.form.namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
@ -608,21 +612,21 @@ export function CrewForm({ existing }: CrewFormProps) {
<Check className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-teal-400" />
)}
</div>
<p className="text-[11px] text-gray-500">The only thing you truly need. You can change it later.</p>
<p className="text-[11px] text-gray-500">{t('socialUi.form.nameHint')}</p>
</div>
);
const bioBlock = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="crew-bio">About / Manifesto <span className="font-normal text-gray-500">· optional</span></Label>
<Label htmlFor="crew-bio">{t('socialUi.form.bioLabel')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span></Label>
{bio.length > 0 && (
<span className={cn('text-[11px] tabular-nums', bio.length > 2800 ? 'text-amber-400' : 'text-gray-500')}>
{bio.length}/3000
</span>
)}
</div>
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
<Textarea id="crew-bio" placeholder={t('socialUi.form.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
</div>
);
@ -630,27 +634,27 @@ export function CrewForm({ existing }: CrewFormProps) {
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>
<Input id="crew-location" placeholder="Bristol, UK" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
<Label htmlFor="crew-location">{t('socialUi.form.homeTurf')}</Label>
<Input id="crew-location" placeholder={t('socialUi.form.homeTurfPlaceholder')} value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-founded">Founded (Year)</Label>
<Label htmlFor="crew-founded">{t('socialUi.form.founded')}</Label>
<Input id="crew-founded" type="number" placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="space-y-2">
<Label>Map Pin <span className="font-normal text-gray-500">· optional</span></Label>
<p className="text-xs text-gray-500">Drop your crew on the map so locals can find you. Tap the button — no typing needed.</p>
<Label>{t('socialUi.form.mapPin')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span></Label>
<p className="text-xs text-gray-500">{t('socialUi.form.mapPinHint')}</p>
<div className="grid grid-cols-2 gap-4">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder={t('socialUi.form.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder={t('socialUi.form.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.gettingLocation')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('socialUi.form.useCurrentLocation')}</>}
</Button>
{existing?.geohash && !latitude && (
<p className="text-xs text-gray-500">Leave blank to keep the current home turf location.</p>
<p className="text-xs text-gray-500">{t('socialUi.form.leaveBlankLocation')}</p>
)}
</div>
</>
@ -660,7 +664,7 @@ export function CrewForm({ existing }: CrewFormProps) {
<>
{/* Vibe tags */}
<div className="space-y-3">
<Label>Vibe / Focus</Label>
<Label>{t('socialUi.form.vibeLabel')}</Label>
<div className="flex flex-wrap gap-2">
{VIBE_ENTRIES.map(([key, label]) => (
<button
@ -681,12 +685,12 @@ export function CrewForm({ existing }: CrewFormProps) {
{/* Custom vibe / focus (user-defined hashtags) */}
<div className="space-y-2">
<p className="text-xs text-gray-500">Add your own vibe or focus with a #</p>
<p className="text-xs text-gray-500">{t('socialUi.form.customVibeHint')}</p>
<div className="flex gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">#</span>
<Input
placeholder="e.g. downhill, night-sessions, no-comply"
placeholder={t('socialUi.form.customVibePlaceholder')}
value={customVibeInput}
onChange={(e) => setCustomVibeInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomVibe(); } }}
@ -696,7 +700,7 @@ export function CrewForm({ existing }: CrewFormProps) {
/>
</div>
<Button type="button" onClick={addCustomVibe} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
</Button>
</div>
{customVibes.length > 0 && (
@ -711,7 +715,7 @@ export function CrewForm({ existing }: CrewFormProps) {
type="button"
onClick={() => setCustomVibes(customVibes.filter((x) => x !== v))}
className="text-teal-400/70 hover:text-red-400"
aria-label={`Remove ${v}`}
aria-label={t('socialUi.form.removeVibe', { vibe: v })}
>
<X className="h-3 w-3" />
</button>
@ -728,12 +732,12 @@ export function CrewForm({ existing }: CrewFormProps) {
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400">
<Sparkles className="h-4 w-4" /> Crew Type
<Sparkles className="h-4 w-4" /> {t('socialUi.form.crewType')}
</Label>
<p className="mt-1 text-xs text-gray-500">
Is your crew focused on a craft? Pick one to also show up in that scene in{' '}
<strong className="text-gray-300">The Lab</strong>. Regular skate crews stay{' '}
<strong className="text-gray-300">Mixed</strong>.
{t('socialUi.form.crewTypeDescA')}{' '}
<strong className="text-gray-300">{t('socialUi.form.theLab')}</strong>{t('socialUi.form.crewTypeDescB')}{' '}
<strong className="text-gray-300">{t('socialUi.form.mixedWord')}</strong>{t('socialUi.form.crewTypeDescC')}
</p>
</div>
<div className="grid grid-cols-2 gap-2">
@ -780,21 +784,19 @@ export function CrewForm({ existing }: CrewFormProps) {
className="h-5 w-5 rounded-md object-cover"
loading="lazy"
/>
Crew Agent
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">Beta</span>
{t('socialUi.crew.agent')}
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">{t('socialUi.form.beta')}</span>
</Label>
<p className="mt-1 text-xs text-gray-500">
Add <strong className="text-gray-300">Sasquatch</strong>, an AI guide who actually knows your crew.
Members can ask about <strong className="text-gray-300">your next jam, who runs the crew, what you're
looking for and your vibe</strong> — plus nearby spots, events, coaches and gear from live SkateSpots
data. Runs right in the browser; no keys or setup. It's early (free-tier AI, so occasionally slow).
{t('socialUi.form.agentDescA')} <strong className="text-gray-300">{t('socialUi.form.sasquatch')}</strong>{t('socialUi.form.agentDescB')}{' '}
<strong className="text-gray-300">{t('socialUi.form.agentDescStrong')}</strong>{t('socialUi.form.agentDescC')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={agentEnabled}
aria-label="Enable Crew Agent"
aria-label={t('socialUi.form.enableAgent')}
onClick={() => setAgentEnabled((v) => !v)}
className={cn(
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
@ -811,7 +813,7 @@ export function CrewForm({ existing }: CrewFormProps) {
</div>
{agentEnabled && (
<p className="flex items-center gap-1.5 text-[11px] font-bold text-teal-400 animate-in fade-in duration-200">
<Check className="h-3.5 w-3.5" /> Sasquatch will appear on your crew page once you save.
<Check className="h-3.5 w-3.5" /> {t('socialUi.form.agentEnabledNote')}
</p>
)}
</div>
@ -821,11 +823,11 @@ export function CrewForm({ existing }: CrewFormProps) {
<div className="space-y-3 rounded-lg border border-fuchsia-500/30 bg-fuchsia-500/[0.04] p-4">
<div>
<Label className="flex items-center gap-2 text-fuchsia-400">
<Megaphone className="h-4 w-4" /> Open Call <span className="font-normal text-gray-500">· optional</span>
<Megaphone className="h-4 w-4" /> {t('socialUi.form.openCall')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
</Label>
<p className="mt-1 text-xs text-gray-500">
Looking for a filmer, editor or new blood? Put out a call — it shows on your crew page so
matching folks from <strong className="text-gray-300">The Lab</strong> can reach out.
{t('socialUi.form.openCallDescA')}{' '}
<strong className="text-gray-300">{t('socialUi.form.theLab')}</strong>{t('socialUi.form.openCallDescB')}
</p>
</div>
<div className="flex flex-wrap gap-2">
@ -857,19 +859,19 @@ export function CrewForm({ existing }: CrewFormProps) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Label className="flex items-center gap-2 text-orange-400">
<Target className="h-4 w-4" /> Crew Treasury <span className="font-normal text-gray-500">· optional</span>
<Target className="h-4 w-4" /> {t('socialUi.treasury.title')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
</Label>
<p className="mt-1 text-xs text-gray-500">
Raise sats for a shared goal — fixing the local DIY spot, a crew trip, a comp prize pot.
It's a transparent Lightning goal; zaps go <strong className="text-gray-300">straight to your wallet</strong>{' '}
(via your profile's Lightning address). SkateSpots never holds the funds.
{t('socialUi.form.treasuryDescA')}{' '}
<strong className="text-gray-300">{t('socialUi.form.treasuryDescStrong')}</strong>{' '}
{t('socialUi.form.treasuryDescB')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={goalEnabled}
aria-label="Enable Crew Treasury"
aria-label={t('socialUi.form.enableTreasury')}
onClick={() => setGoalEnabled((v) => !v)}
className={cn(
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
@ -887,17 +889,17 @@ export function CrewForm({ existing }: CrewFormProps) {
{goalEnabled && (
<div className="space-y-3 pt-1">
<div className="space-y-2">
<Label htmlFor="goal-title" className="text-xs text-gray-400">What's the goal?</Label>
<Label htmlFor="goal-title" className="text-xs text-gray-400">{t('socialUi.form.goalWhat')}</Label>
<Input
id="goal-title"
placeholder="e.g. Concrete the DIY spot"
placeholder={t('socialUi.form.goalPlaceholder')}
value={goalTitle}
onChange={(e) => setGoalTitle(e.target.value)}
className="bg-gray-800 border-gray-700"
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-target" className="text-xs text-gray-400">Target (sats)</Label>
<Label htmlFor="goal-target" className="text-xs text-gray-400">{t('socialUi.form.goalTarget')}</Label>
<Input
id="goal-target"
type="number"
@ -910,7 +912,7 @@ export function CrewForm({ existing }: CrewFormProps) {
</div>
<p className="flex items-start gap-1.5 text-[11px] text-gray-500">
<Target className="mt-0.5 h-3 w-3 shrink-0 text-orange-400" />
Make sure your Nostr profile has a Lightning address (lud16) so contributions can land.
{t('socialUi.form.goalHint')}
</p>
</div>
)}
@ -920,24 +922,21 @@ export function CrewForm({ existing }: CrewFormProps) {
const membersBlock = (
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> Members</Label>
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> {t('socialUi.form.members')}</Label>
<p className="mt-1 text-xs text-gray-500">
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
{t('socialUi.form.membersDesc')}
</p>
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
<span>
Make trusted members <strong className="text-teal-300">admins</strong> — they can invite members
and moderate the crew feed &amp; chat with their own keys. You (the founder) always stay the
owner of the crew's identity.
{t('socialUi.form.adminsDescA')} <strong className="text-teal-300">{t('socialUi.form.adminsWord')}</strong> {t('socialUi.form.adminsDescB')}
</span>
</p>
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
<Eye className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
<span>
Each member controls their <strong className="text-teal-300">own visibility</strong> — when they
confirm from their key they can choose <em>Public</em> or <em>Crew-only</em> (hidden from
outsiders). You can't set this for them; it's their call.
{t('socialUi.form.visibilityDescA')} <strong className="text-teal-300">{t('socialUi.form.ownVisibility')}</strong> {t('socialUi.form.visibilityDescB')}{' '}
<em>{t('socialUi.form.publicWord')}</em> {t('common.or')} <em>{t('socialUi.form.crewOnlyWord')}</em> {t('socialUi.form.visibilityDescC')}
</span>
</p>
</div>
@ -946,7 +945,7 @@ export function CrewForm({ existing }: CrewFormProps) {
{user && (
<div className="rounded-lg border border-teal-500/20 bg-teal-500/[0.04] p-2.5">
<p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider text-teal-400">
<ShieldCheck className="h-3 w-3" /> Your roles (Team Leader)
<ShieldCheck className="h-3 w-3" /> {t('socialUi.form.yourRoles')}
</p>
<div className="flex flex-wrap items-center gap-1">
{CREW_ROLE_ORDER.map((role) => {
@ -993,7 +992,7 @@ export function CrewForm({ existing }: CrewFormProps) {
disabled={!memberPreviewPk || members.includes(memberPreviewPk)}
className={cn('shrink-0 gap-1', TEAL)}
>
<Plus className="h-4 w-4" /> Add
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
</Button>
</div>
@ -1002,7 +1001,7 @@ export function CrewForm({ existing }: CrewFormProps) {
<MemberPreview pubkey={memberPreviewPk} alreadyAdded={members.includes(memberPreviewPk)} />
) : memberInputDirty ? (
<p className="text-xs text-amber-400/90">
That doesn't look like a Nostr key yet — paste an <span className="font-mono">npub1…</span>, <span className="font-mono">nprofile1…</span>, or hex pubkey.
{t('socialUi.form.notAKeyA')} <span className="font-mono">npub1…</span>{t('socialUi.form.notAKeyB')} <span className="font-mono">nprofile1…</span>{t('socialUi.form.notAKeyC')}
</p>
) : null}
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
@ -1029,8 +1028,8 @@ export function CrewForm({ existing }: CrewFormProps) {
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={10}
label="Home Spots"
description="Link the spots your crew haunts — they'll connect to your crew on the map"
label={t('socialUi.form.homeSpots')}
description={t('socialUi.form.homeSpotsDesc')}
/>
);
@ -1039,13 +1038,12 @@ export function CrewForm({ existing }: CrewFormProps) {
const videoBlock = (
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
<Label htmlFor="crew-video" className="flex items-center gap-2 text-teal-400">
<Film className="h-4 w-4" /> The Part <span className="font-normal text-gray-500">· optional</span>
<Film className="h-4 w-4" /> {t('socialUi.video.title')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
</Label>
<p className="text-xs text-gray-500">
Your crew's full part, montage or latest clip. Paste a{' '}
<span className="font-semibold text-teal-300">YouTube</span> or{' '}
<span className="font-semibold text-teal-300">Instagram</span> link — it'll play right on
your crew page.
{t('socialUi.form.videoDescA')}{' '}
<span className="font-semibold text-teal-300">YouTube</span> {t('common.or')}{' '}
<span className="font-semibold text-teal-300">Instagram</span>{t('socialUi.form.videoDescB')}
</p>
<Input
id="crew-video"
@ -1061,10 +1059,10 @@ export function CrewForm({ existing }: CrewFormProps) {
isCrewVideoUrl(videoUrl) ? (
<p className="flex items-center gap-1.5 text-xs font-bold text-teal-400">
<Film className="h-3.5 w-3.5" />
{parseCrewVideo(videoUrl)?.source === 'youtube' ? 'YouTube video detected' : 'Instagram clip detected'} ✓
{parseCrewVideo(videoUrl)?.source === 'youtube' ? t('socialUi.form.youtubeDetected') : t('socialUi.form.instagramDetected')} ✓
</p>
) : (
<p className="text-xs text-red-400">Only YouTube and Instagram links are supported.</p>
<p className="text-xs text-red-400">{t('socialUi.form.onlyYouTubeInstagram')}</p>
)
)}
</div>
@ -1073,11 +1071,10 @@ export function CrewForm({ existing }: CrewFormProps) {
const sponsorBlock = (
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
<Label className="flex items-center gap-2 text-amber-400">
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
<Store className="h-4 w-4" /> {t('socialUi.sponsorBanner.sponsoredBy')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
</Label>
<p className="text-xs text-gray-500">
If a local shop backs your crew, pick them here to show a
“Sponsored by” banner linking to their shop.
{t('socialUi.form.sponsorDesc')}
</p>
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
</div>
@ -1086,25 +1083,20 @@ export function CrewForm({ existing }: CrewFormProps) {
const coordinatorBlock = (
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-gray-300">
<Server className="h-4 w-4" /> Custom Chat Coordinator <span className="font-normal text-gray-500">· experimental · your own relay</span>
<Server className="h-4 w-4" /> {t('socialUi.coordinator.customTitle')} <span className="font-normal text-gray-500">{t('socialUi.coordinator.customBadge')}</span>
</Label>
<p className="text-xs text-gray-500">
<span className="font-semibold text-gray-300">You don't need this.</span> Your crew already has private,
end-to-end encrypted group chat built in — no setup. This field is only for crews who{' '}
<span className="font-semibold text-gray-300">run their own</span>{' '}
<span className="font-semibold text-gray-300">{t('socialUi.coordinator.customDescA')}</span> {t('socialUi.coordinator.customDescB')}{' '}
<span className="font-semibold text-gray-300">{t('socialUi.coordinator.customRunOwn')}</span>{' '}
<a
href="https://github.com/Cordn-msg/cordn"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
>
Cordn coordinator
{t('socialUi.coordinator.cordnCoordinator')}
</a>{' '}
— even on an old phone. Paste its <span className="font-mono">nprofile</span>, a cordn.net link, or an
endpoint URL. A <span className="font-mono">wss://</span> endpoint is used as the crew's own chat relay
(point it at a private one to keep even the metadata off public relays); an{' '}
<span className="font-mono">https://</span> coordinator is addressed by its pubkey over the shared
ContextVM relay. Leave blank to use the built-in chat.
{t('socialUi.coordinator.customDescC')}
</p>
<Input
id="crew-coordinator"
@ -1122,7 +1114,7 @@ export function CrewForm({ existing }: CrewFormProps) {
</p>
) : (
<p className="text-[11px] text-amber-400">
That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.
{t('socialUi.coordinator.invalid')}
</p>
))}
{myCoordinator && (
@ -1134,7 +1126,7 @@ export function CrewForm({ existing }: CrewFormProps) {
onClick={() => setCoordinatorInput(coordinatorAddUrl(myCoordinator))}
>
<Server className="mr-1.5 h-3.5 w-3.5" />
Use my coordinator
{t('socialUi.coordinator.useMine')}
</Button>
)}
</div>
@ -1143,11 +1135,10 @@ export function CrewForm({ existing }: CrewFormProps) {
const nip05Block = (
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-nip05" className="flex items-center gap-2 text-teal-400">
<AtSign className="h-4 w-4" /> Crew NIP-05 <span className="font-normal text-gray-500">· optional</span>
<AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
</Label>
<p className="text-xs text-gray-500">
A verifiable handle you control on any domain, e.g. <code className="text-teal-300">crew@yourdomain.com</code>.
It's verified against this crew's founder key — a green check shows when the domain confirms it.
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
</p>
<Input
id="crew-nip05"
@ -1163,30 +1154,30 @@ export function CrewForm({ existing }: CrewFormProps) {
const linksBlock = (
<div className="space-y-3">
<Label className="text-base font-bold">Links</Label>
<Label className="text-base font-bold">{t('socialUi.form.links')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-instagram">Instagram</Label>
<Input id="crew-instagram" placeholder="@crew" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="crew-website">Website</Label>
<Label htmlFor="crew-website">{t('socialUi.form.website')}</Label>
<Input id="crew-website" type="url" placeholder="https://crew.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="crew-social">Other Socials</Label>
<p className="text-xs text-gray-500">Add as many as you like — YouTube, TikTok, Discord, Telegram, etc.</p>
<Label htmlFor="crew-social">{t('socialUi.form.otherSocials')}</Label>
<p className="text-xs text-gray-500">{t('socialUi.form.otherSocialsDesc')}</p>
<div className="flex gap-2">
<Input
id="crew-social"
placeholder="@username or link"
placeholder={t('socialUi.form.socialPlaceholder')}
value={socialInput}
onChange={(e) => setSocialInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSocial(); } }}
className="bg-gray-800 border-gray-700"
/>
<Button type="button" onClick={addSocial} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
</Button>
</div>
{socials.length > 0 && (
@ -1200,7 +1191,7 @@ export function CrewForm({ existing }: CrewFormProps) {
size="icon"
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
onClick={() => setSocials(socials.filter((_, idx) => idx !== i))}
aria-label="Remove social link"
aria-label={t('socialUi.form.removeSocial')}
>
<X className="h-4 w-4" />
</Button>
@ -1218,17 +1209,17 @@ export function CrewForm({ existing }: CrewFormProps) {
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
<div className="flex items-center gap-2">
<Music className="h-4 w-4 text-purple-400" />
<Label className="text-base font-bold">Crew Band <span className="text-xs font-normal text-gray-500">(optional)</span></Label>
<Label className="text-base font-bold">{t('socialUi.crew.bandTitle')} <span className="text-xs font-normal text-gray-500">{t('socialUi.form.optionalParen')}</span></Label>
</div>
<p className="text-xs text-gray-500">
Got a band? Skate scenes and local music go hand in hand — drop your band name and a link to the tunes.
{t('socialUi.form.bandDesc')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-band-name">Band name</Label>
<Label htmlFor="crew-band-name">{t('socialUi.form.bandName')}</Label>
<Input
id="crew-band-name"
placeholder="e.g. The Bail Outs"
placeholder={t('socialUi.form.bandNamePlaceholder')}
value={bandName}
onChange={(e) => setBandName(e.target.value)}
maxLength={100}
@ -1236,7 +1227,7 @@ export function CrewForm({ existing }: CrewFormProps) {
/>
</div>
<div className="space-y-2">
<Label htmlFor="crew-band-url">Music link</Label>
<Label htmlFor="crew-band-url">{t('socialUi.form.musicLink')}</Label>
<Input
id="crew-band-url"
type="url"
@ -1259,17 +1250,17 @@ export function CrewForm({ existing }: CrewFormProps) {
>
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-purple-300" />
<span className="text-xs text-purple-200">
You've got a band — tap to set your <strong>Crew Type</strong> to{' '}
<strong>Band &amp; Music</strong> so you also show up in The Lab's Music scene.
{t('socialUi.form.bandNudgeA')} <strong>{t('socialUi.form.crewTypeWord')}</strong> {t('socialUi.form.bandNudgeB')}{' '}
<strong>{t('socialUi.form.bandMusicWord')}</strong> {t('socialUi.form.bandNudgeC')}
</span>
</button>
)}
<p className="flex items-start gap-1.5 text-[11px] leading-snug text-gray-500">
<Music className="mt-0.5 h-3 w-3 shrink-0 text-purple-400" />
For a full band profile (genres, instruments, gigs), create a{' '}
<strong className="text-gray-300">Musician</strong> in The Lab, then link it in the{' '}
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
{t('socialUi.form.bandFooterA')}{' '}
<strong className="text-gray-300">{t('socialUi.form.musicianWord')}</strong> {t('socialUi.form.bandFooterB')}{' '}
<strong className="text-gray-300">{t('socialUi.form.linkedLabWord')}</strong> {t('socialUi.form.bandFooterC')}
</p>
</div>
);
@ -1283,9 +1274,9 @@ export function CrewForm({ existing }: CrewFormProps) {
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-teal-400" /> Edit Crew
<Users className="h-5 w-5 text-teal-400" /> {t('socialUi.editCrew.title')}
</CardTitle>
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
<CardDescription className="text-gray-400">{t('socialUi.form.editDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{logoBlock}
@ -1308,9 +1299,9 @@ export function CrewForm({ existing }: CrewFormProps) {
{bandBlock}
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={busy}>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.publishing')}</>
) : (
<><Users className="h-4 w-4 mr-2" />Save Crew</>
<><Users className="h-4 w-4 mr-2" />{t('socialUi.form.saveCrew')}</>
)}
</Button>
</CardContent>
@ -1328,17 +1319,17 @@ export function CrewForm({ existing }: CrewFormProps) {
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
{
title: 'The Basics',
short: 'Basics',
title: t('socialUi.form.stepBasics'),
short: t('socialUi.form.stepBasicsShort'),
icon: Sparkles,
hint: 'Just the essentials — you can spin up a crew with only this step.',
hint: t('socialUi.form.stepBasicsHint'),
filled: nameValid,
content: (
<div className="space-y-6">
<div className="flex flex-col items-center gap-3 text-center sm:flex-row sm:items-start sm:text-left">
{logoBlock}
<p className="max-w-xs text-xs text-gray-500 sm:pt-8">
A logo makes your crew instantly recognisable on the map and in feeds. Square works best.
{t('socialUi.form.logoHint')}
</p>
</div>
{nameBlock}
@ -1348,10 +1339,10 @@ export function CrewForm({ existing }: CrewFormProps) {
),
},
{
title: 'Identity & Vibe',
short: 'Vibe',
title: t('socialUi.form.stepVibe'),
short: t('socialUi.form.stepVibeShort'),
icon: Palette,
hint: 'How your crew shows up around the app. All optional.',
hint: t('socialUi.form.stepVibeHint'),
filled: vibes.length > 0 || customVibes.length > 0 || focus !== 'mixed',
content: (
<div className="space-y-6">
@ -1361,10 +1352,10 @@ export function CrewForm({ existing }: CrewFormProps) {
),
},
{
title: 'Squad & Spots',
short: 'Squad',
title: t('socialUi.form.stepSquad'),
short: t('socialUi.form.stepSquadShort'),
icon: Users,
hint: 'Add your people and your home turf. You can always do this later.',
hint: t('socialUi.form.stepSquadHint'),
filled: members.length > 0 || selectedSpots.length > 0 || lookingFor.length > 0,
content: (
<div className="space-y-6">
@ -1375,10 +1366,10 @@ export function CrewForm({ existing }: CrewFormProps) {
),
},
{
title: 'Extras',
short: 'Extras',
title: t('socialUi.form.stepExtras'),
short: t('socialUi.form.stepExtrasShort'),
icon: Plus,
hint: 'Power-ups for later — skip anything you’re unsure about.',
hint: t('socialUi.form.stepExtrasHint'),
filled: hasExtras,
content: (
<div className="space-y-6">
@ -1402,7 +1393,7 @@ export function CrewForm({ existing }: CrewFormProps) {
const goNext = () => {
// Only gate on the one truly-required field, at the step it lives on.
if (step === 0 && !name.trim()) {
toast({ title: 'Name your crew', description: 'A crew needs a name before you continue.', variant: 'destructive' });
toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' });
return;
}
goToStep(Math.min(step + 1, lastStep));
@ -1413,7 +1404,7 @@ export function CrewForm({ existing }: CrewFormProps) {
return (
<form onSubmit={handleSubmit}>
{/* Progress stepper */}
<nav className="mb-6" aria-label="Progress">
<nav className="mb-6" aria-label={t('socialUi.form.progress')}>
<ol className="flex items-center">
{steps.map((s, i) => {
const isCurrent = i === step;
@ -1421,7 +1412,7 @@ export function CrewForm({ existing }: CrewFormProps) {
const StepIcon = s.icon;
const jumpTo = () => {
if (i > step && !name.trim()) {
toast({ title: 'Name your crew first', variant: 'destructive' });
toast({ title: t('socialUi.form.nameCrewFirst'), variant: 'destructive' });
return;
}
goToStep(i);
@ -1433,7 +1424,7 @@ export function CrewForm({ existing }: CrewFormProps) {
onClick={jumpTo}
className="group flex flex-col items-center gap-1.5"
aria-current={isCurrent ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${s.title}`}
aria-label={t('socialUi.form.stepAria', { step: i + 1, title: s.title })}
>
<span
className={cn(
@ -1479,7 +1470,7 @@ export function CrewForm({ existing }: CrewFormProps) {
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-widest text-teal-400/70">
Step {step + 1} of {steps.length}
{t('socialUi.form.stepOf', { step: step + 1, total: steps.length })}
</div>
<CardTitle className="flex items-center gap-2">
<current.icon className="h-5 w-5 text-teal-400" /> {current.title}
@ -1505,17 +1496,17 @@ export function CrewForm({ existing }: CrewFormProps) {
{nameValid ? (
<>
<Check className="h-3.5 w-3.5 text-teal-400" />
Ready to roll — hit <strong className="text-teal-300">Create Crew</strong> whenever you like.
{t('socialUi.form.readyToRollA')} <strong className="text-teal-300">{t('socialUi.crews.createCrew')}</strong> {t('socialUi.form.readyToRollB')}
</>
) : (
<>Add a crew name back in <strong className="text-teal-300">Basics</strong> to finish.</>
<>{t('socialUi.form.addNameInBasicsA')} <strong className="text-teal-300">{t('socialUi.form.stepBasicsShort')}</strong> {t('socialUi.form.addNameInBasicsB')}</>
)}
</p>
)}
<div className="flex items-center gap-3">
{step > 0 ? (
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="gap-1.5 border-gray-700">
<ArrowLeftIcon className="h-4 w-4" /> Back
<ArrowLeftIcon className="h-4 w-4" /> {t('common.back')}
</Button>
) : (
<span className="flex-1" />
@ -1529,7 +1520,7 @@ export function CrewForm({ existing }: CrewFormProps) {
variant="ghost"
onClick={() => {
if (!name.trim()) {
toast({ title: 'Name your crew first', variant: 'destructive' });
toast({ title: t('socialUi.form.nameCrewFirst'), variant: 'destructive' });
return;
}
goToStep(lastStep);
@ -1537,19 +1528,19 @@ export function CrewForm({ existing }: CrewFormProps) {
disabled={busy}
className="hidden text-gray-400 hover:text-white sm:inline-flex"
>
Skip to end
{t('socialUi.form.skipToEnd')}
</Button>
)}
<Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 gap-1.5 font-black uppercase', TEAL)}>
Continue <ArrowRight className="h-4 w-4" />
{t('socialUi.form.continue')} <ArrowRight className="h-4 w-4" />
</Button>
</>
) : (
<Button type="submit" disabled={busy || !nameValid} className={cn('flex-1 font-black uppercase', TEAL)}>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.publishing')}</>
) : (
<><Users className="h-4 w-4 mr-2" />Create Crew</>
<><Users className="h-4 w-4 mr-2" />{t('socialUi.crews.createCrew')}</>
)}
</Button>
)}

View file

@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useAcceptCrewInvite, useDeclineCrewInvite } from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CrewInvite } from '@/lib/types';
import { Users, Check, X, Loader2, ShieldCheck } from 'lucide-react';
@ -24,6 +25,7 @@ export function CrewInviteCard({
onResolved?: () => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const inviter = useAuthor(invite.inviter);
const inviterName = inviter.data?.metadata?.name ?? genUserName(invite.inviter);
const inviterPic = inviter.data?.metadata?.picture;
@ -40,14 +42,14 @@ export function CrewInviteCard({
await accept({ invite });
setDone('accepted');
toast({
title: 'Invite accepted 🛹',
description: `The crew founder confirms members on ${invite.crewName}. You'll appear on the roster once they approve.`,
title: t('socialUi.inviteCard.acceptedTitle'),
description: t('socialUi.inviteCard.acceptedDesc', { crew: invite.crewName }),
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not accept',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.inviteCard.couldNotAccept'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -59,7 +61,7 @@ export function CrewInviteCard({
setDone('declined');
onResolved?.();
} catch {
toast({ title: 'Could not decline', variant: 'destructive' });
toast({ title: t('socialUi.inviteCard.couldNotDecline'), variant: 'destructive' });
}
};
@ -69,12 +71,12 @@ export function CrewInviteCard({
{done === 'accepted' ? (
<>
<Check className="h-4 w-4 text-teal-400" />
Accepted — <span className="font-bold text-white">{invite.crewName}</span> confirms members
{t('socialUi.inviteCard.acceptedDone')} <span className="font-bold text-white">{invite.crewName}</span> {t('socialUi.inviteCard.confirmsMembers')}
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Declined invite to {invite.crewName}
{t('socialUi.inviteCard.declinedDone', { crew: invite.crewName })}
</>
)}
</div>
@ -98,13 +100,13 @@ export function CrewInviteCard({
</Avatar>
{inviterName}
</Link>{' '}
invited you to join{' '}
{t('socialUi.inviteCard.invitedYou')}{' '}
<Link to={crewHref} className="font-black text-teal-300 hover:underline">
{invite.crewName}
</Link>
{invite.role === 'admin' && (
<span className="ml-1 inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[9px] font-black uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
<ShieldCheck className="h-2.5 w-2.5" /> {t('socialUi.inviteCard.admin')}
</span>
)}
</p>
@ -121,7 +123,7 @@ export function CrewInviteCard({
className="min-h-11 flex-1 gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{accepting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Accept
{t('socialUi.inviteCard.accept')}
</Button>
<Button
size="sm"
@ -131,7 +133,7 @@ export function CrewInviteCard({
className="min-h-11 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{declining ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
{t('socialUi.inviteCard.decline')}
</Button>
</div>
</div>

View file

@ -18,6 +18,7 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { useSendCrewInvite } from '@/hooks/useCrewInvites';
import { genUserName } from '@/lib/genUserName';
@ -77,6 +78,7 @@ function InviteePreview({ pubkey }: { pubkey: string }) {
* 2. **Share link** — copy/share a link for people not yet on Nostr.
*/
export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
@ -94,8 +96,8 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
const crewUrl = `${window.location.origin}/crew/${crew.pubkey}/${crew.identifier}`;
const inviteMessage =
`Yo — join the ${crew.name} crew on SkateSpots 🛹\n${crewUrl}\n\n` +
`Log in with your Nostr key, then request to join from the crew page.`;
`${t('socialUi.inviteDialog.messageIntro', { crew: crew.name })}\n${crewUrl}\n\n` +
`${t('socialUi.inviteDialog.messageBody')}`;
const copy = async (text: string, which: 'link' | 'msg') => {
try {
@ -107,16 +109,16 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
setCopiedMsg(true);
setTimeout(() => setCopiedMsg(false), 2000);
}
toast({ title: 'Copied!' });
toast({ title: t('common.copied') });
} catch {
toast({ title: 'Copy failed', variant: 'destructive' });
toast({ title: t('socialUi.inviteDialog.copyFailed'), variant: 'destructive' });
}
};
const nativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({ title: `Join ${crew.name}`, text: inviteMessage, url: crewUrl });
await navigator.share({ title: t('socialUi.inviteDialog.shareTitle', { crew: crew.name }), text: inviteMessage, url: crewUrl });
} catch {
/* cancelled */
}
@ -136,8 +138,8 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
isFounder,
});
toast({
title: 'Invite sent! 🛹',
description: "They'll get a notification to accept or decline.",
title: t('socialUi.inviteDialog.sentTitle'),
description: t('socialUi.inviteDialog.sentDesc'),
});
setInviteeInput('');
setNote('');
@ -145,8 +147,8 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
setOpen(false);
} catch (err) {
toast({
title: 'Could not send invite',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.inviteDialog.couldNotSend'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -161,26 +163,26 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
size="sm"
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
>
<UserPlus className="h-3.5 w-3.5" /> Invite
<UserPlus className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.trigger')}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crew.name}
<UserPlus className="h-5 w-5 text-teal-400" /> {t('socialUi.inviteDialog.title', { crew: crew.name })}
</DialogTitle>
<DialogDescription>
Send an invite to someone's Nostr key, or share a link.
{t('socialUi.inviteDialog.description')}
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="send" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="send" className="gap-1.5">
<Send className="h-3.5 w-3.5" /> Send invite
<Send className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.sendTab')}
</TabsTrigger>
<TabsTrigger value="share" className="gap-1.5">
<Share2 className="h-3.5 w-3.5" /> Share link
<Share2 className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.shareTab')}
</TabsTrigger>
</TabsList>
@ -188,7 +190,7 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
<TabsContent value="send" className="space-y-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
Skater's npub
{t('socialUi.inviteDialog.npubLabel')}
</label>
<Input
value={inviteeInput}
@ -199,7 +201,7 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
spellCheck={false}
/>
{invalidKey && (
<p className="text-xs text-red-400">That doesn't look like a valid npub.</p>
<p className="text-xs text-red-400">{t('socialUi.inviteDialog.invalidNpub')}</p>
)}
</div>
@ -207,12 +209,12 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
Note <span className="font-normal text-gray-600">(optional)</span>
{t('socialUi.inviteDialog.noteLabel')} <span className="font-normal text-gray-600">{t('socialUi.inviteDialog.optional')}</span>
</label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="Come skate with us…"
placeholder={t('socialUi.inviteDialog.notePlaceholder')}
rows={2}
className="text-sm"
/>
@ -224,9 +226,9 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
<ShieldCheck className="h-4 w-4 text-teal-400" />
<div>
<Label htmlFor="invite-admin" className="text-sm font-bold text-white">
Invite as admin
{t('socialUi.inviteDialog.inviteAsAdmin')}
</Label>
<p className="text-[11px] text-gray-500">Can manage the crew &amp; invite others.</p>
<p className="text-[11px] text-gray-500">{t('socialUi.inviteDialog.inviteAsAdminDesc')}</p>
</div>
</div>
<Switch id="invite-admin" checked={asAdmin} onCheckedChange={setAsAdmin} />
@ -240,17 +242,17 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
className="w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Send invite
{t('socialUi.inviteDialog.sendButton')}
</Button>
<p className="text-center text-[11px] text-gray-500">
They'll get a notification to accept or decline. Membership only counts once they accept.
{t('socialUi.inviteDialog.sendFooter')}
</p>
</TabsContent>
{/* ── Share a link (for people not on Nostr yet) ── */}
<TabsContent value="share" className="space-y-4 pt-2">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Invite link</label>
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.inviteLink')}</label>
<div className="flex gap-2">
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
@ -260,15 +262,15 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Message</label>
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">{t('socialUi.inviteDialog.messageLabel')}</label>
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
Copy message
{t('socialUi.inviteDialog.copyMessage')}
</Button>
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
<Share2 className="h-4 w-4" /> Share
<Share2 className="h-4 w-4" /> {t('common.share')}
</Button>
</div>
</div>
@ -276,11 +278,11 @@ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInvi
{isFounder && (
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
<p className="mb-2 text-xs text-gray-400">
As founder you can also add a member's key directly, then they confirm.
{t('socialUi.inviteDialog.founderHint')}
</p>
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Add member in crew settings
<Edit className="h-3.5 w-3.5" /> {t('socialUi.inviteDialog.addMemberSettings')}
</Button>
</Link>
</div>

View file

@ -3,6 +3,7 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Users, X, Link2 } from 'lucide-react';
import { useCrews } from '@/hooks/useCrews';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { SKATE_CREW_KIND } from '@/lib/types';
import { cn } from '@/lib/utils';
@ -20,6 +21,7 @@ interface CrewLinkFieldProps {
* Lab profile (studio) to its crew so each surfaces the other.
*/
export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', accentBg = 'bg-teal-500' }: CrewLinkFieldProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: allCrews } = useCrews(200);
@ -35,18 +37,17 @@ export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', a
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className={cn('flex items-center gap-2', accentText)}>
<Link2 className="h-4 w-4" /> Linked Crew
<Link2 className="h-4 w-4" /> {t('socialUi.linkField.title')}
</Label>
<p className="mt-1 text-xs text-gray-500">
Run a crew too? Link it so people can jump between your studio and your crew. Optional.
Link this crew back to the profile from the crew editor and it'll show a green{' '}
<span className="font-bold text-green-400">Verified</span> badge on both.
{t('socialUi.linkField.description')}{' '}
<span className="font-bold text-green-400">{t('common.verified')}</span> {t('socialUi.linkField.descriptionRest')}
</p>
</div>
{myCrews.length === 0 ? (
<p className="text-xs text-gray-500 italic">
You don't have any crews yet. Create one on the Crews page to link it here.
{t('socialUi.linkField.none')}
</p>
) : (
<div className="flex flex-wrap gap-2">
@ -76,7 +77,7 @@ export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', a
{value && !myCrews.some((c) => coordFor(c.pubkey, c.identifier) === value) && (
<Badge variant="outline" className="gap-1 text-[10px] text-gray-400">
<Users className="h-3 w-3" /> Linked crew ({value.split(':')[2]})
<Users className="h-3 w-3" /> {t('socialUi.linkField.linkedCrew', { id: value.split(':')[2] })}
</Badge>
)}
</div>

View file

@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CREW_LOOKING_FOR_LABELS } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
import { Megaphone } from 'lucide-react';
/**
@ -8,15 +9,16 @@ import { Megaphone } from 'lucide-react';
* matching Lab providers can reach out. Slugs map to Lab scenes.
*/
export function CrewLookingForCard({ lookingFor }: { lookingFor: string[] }) {
const { t } = useTranslation();
if (!lookingFor || lookingFor.length === 0) return null;
return (
<Card className="border-2 border-fuchsia-500/30 bg-black/90">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Megaphone className="h-5 w-5 text-fuchsia-400" /> Open Call
<Megaphone className="h-5 w-5 text-fuchsia-400" /> {t('socialUi.lookingFor.title')}
</CardTitle>
<p className="text-xs text-gray-400">This crew is looking for:</p>
<p className="text-xs text-gray-400">{t('socialUi.lookingFor.subtitle')}</p>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">

View file

@ -6,6 +6,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
interface CrewNip05BadgeProps {
@ -23,6 +24,7 @@ interface CrewNip05BadgeProps {
* - `none`: renders nothing.
*/
export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) {
const { t } = useTranslation();
const { data, isLoading } = useCrewNip05(nip05, founderPubkey);
if (!nip05) return null;
@ -45,7 +47,7 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
</span>
</TooltipTrigger>
<TooltipContent>
This domain points the handle at a different Nostr key than the crew founder. Not verified.
{t('socialUi.nip05.mismatch')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -67,7 +69,7 @@ export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05Bad
</span>
</TooltipTrigger>
<TooltipContent>
Verified — the domain confirms this handle belongs to the crew's founder key.
{t('socialUi.nip05.verified')}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

@ -13,6 +13,7 @@ import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSendJoinRequest } from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { UserPlus, Loader2, Bell } from 'lucide-react';
import type { SkateCrew } from '@/lib/types';
@ -28,6 +29,7 @@ interface CrewRequestToJoinProps {
* an optional note.
*/
export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { mutateAsync: sendJoinRequest, isPending } = useSendJoinRequest();
const { toast } = useToast();
@ -47,15 +49,15 @@ export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
try {
await sendJoinRequest({ crew, note });
toast({
title: 'Request sent! 🛹',
description: `The ${crew.name} leadership will see it in their notifications.`,
title: t('socialUi.requestToJoin.sentTitle'),
description: t('socialUi.requestToJoin.sentDesc', { crew: crew.name }),
});
setOpen(false);
setNote('');
} catch (err) {
toast({
title: 'Could not send request',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.requestToJoin.couldNotSend'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -69,23 +71,23 @@ export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
size="sm"
className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10"
>
<UserPlus className="h-3.5 w-3.5" /> Request to join
<UserPlus className="h-3.5 w-3.5" /> {t('socialUi.requestToJoin.trigger')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crew.name}
<UserPlus className="h-5 w-5 text-teal-400" /> {t('socialUi.requestToJoin.title', { crew: crew.name })}
</DialogTitle>
<DialogDescription className="flex items-center gap-1.5">
<Bell className="h-3 w-3 text-teal-400" />
Notifies the crew's founder &amp; admins so they can add you.
{t('socialUi.requestToJoin.description')}
</DialogDescription>
</DialogHeader>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Say hi — where you skate, who you know, why you want in…"
placeholder={t('socialUi.requestToJoin.placeholder')}
rows={4}
maxLength={1000}
className="resize-none"
@ -98,11 +100,11 @@ export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sending…
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('socialUi.requestToJoin.sending')}
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" /> Send join request
<UserPlus className="mr-2 h-4 w-4" /> {t('socialUi.requestToJoin.sendButton')}
</>
)}
</Button>

View file

@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapButton } from '@/components/ZapButton';
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Target, Zap, Users, CheckCircle2 } from 'lucide-react';
@ -12,6 +13,7 @@ import { Target, Zap, Users, CheckCircle2 } from 'lucide-react';
* zap receipts, so anyone can audit it.
*/
export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
const { t } = useTranslation();
const { data: goal, isLoading } = useCrewGoal(goalId);
if (!goalId) return null;
@ -48,7 +50,7 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Target className={cn('h-5 w-5', reached ? 'text-green-400' : 'text-orange-400')} />
Crew Treasury
{t('socialUi.treasury.title')}
</CardTitle>
{goal.summary && <p className="text-xs text-gray-400">{goal.summary}</p>}
</CardHeader>
@ -61,10 +63,10 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
<div className="space-y-1.5">
<div className="flex items-end justify-between text-sm">
<span className={cn('font-black', reached ? 'text-green-400' : 'text-orange-400')}>
{goal.raisedSats.toLocaleString()} sats
{goal.raisedSats.toLocaleString()} {t('common.sats')}
</span>
<span className="text-xs text-gray-500">
of {goal.targetSats.toLocaleString()} goal
{t('socialUi.treasury.ofGoal', { target: goal.targetSats.toLocaleString() })}
</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-white/5">
@ -82,17 +84,19 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
<span className="flex items-center gap-1">
{reached ? (
<span className="flex items-center gap-1 font-bold text-green-400">
<CheckCircle2 className="h-3 w-3" /> Goal reached!
<CheckCircle2 className="h-3 w-3" /> {t('socialUi.treasury.goalReached')}
</span>
) : (
<span className="font-bold text-orange-300">{goal.percent}% funded</span>
<span className="font-bold text-orange-300">{t('socialUi.treasury.funded', { percent: goal.percent })}</span>
)}
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{goal.contributions} contribution{goal.contributions === 1 ? '' : 's'}
{daysLeft !== null && daysLeft > 0 && ` · ${daysLeft}d left`}
{closed && ' · closed'}
{goal.contributions === 1
? t('socialUi.treasury.contributionOne', { count: goal.contributions })
: t('socialUi.treasury.contributionMany', { count: goal.contributions })}
{daysLeft !== null && daysLeft > 0 && ` · ${t('socialUi.treasury.daysLeft', { days: daysLeft })}`}
{closed && ` · ${t('socialUi.treasury.closed')}`}
</span>
</div>
</div>
@ -106,7 +110,7 @@ export function CrewTreasuryCard({ goalId }: { goalId?: string }) {
/>
<p className="flex items-center justify-center gap-1 text-center text-[10px] text-gray-600">
<Zap className="h-2.5 w-2.5" />
Non-custodial · zaps go straight to the crew's Lightning wallet
{t('socialUi.treasury.nonCustodial')}
</p>
</div>
)}

View file

@ -7,6 +7,7 @@ import { useShops } from '@/hooks/useShops';
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
import { SPOT_TYPE_LABELS } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
export interface DestinationPick {
lat: number;
@ -26,9 +27,9 @@ interface Candidate {
}
const KIND_META: Record<Kind, { label: string; icon: typeof MapPin; className: string }> = {
spot: { label: 'Spot', icon: MapPin, className: 'text-[#ff6600]' },
shop: { label: 'Shop', icon: Store, className: 'text-emerald-400' },
merchant: { label: 'Bitcoin', icon: Bitcoin, className: 'text-[#f7931a]' },
spot: { label: 'placesUi.destination.spot', icon: MapPin, className: 'text-[#ff6600]' },
shop: { label: 'placesUi.destination.shop', icon: Store, className: 'text-emerald-400' },
merchant: { label: 'placesUi.destination.bitcoin', icon: Bitcoin, className: 'text-[#f7931a]' },
};
/**
@ -48,6 +49,7 @@ export function DestinationSearch({
onSelect: (pick: DestinationPick) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const { data: spots } = useSkateSpots(2000);
const { data: shops } = useShops(500);
@ -120,14 +122,14 @@ export function DestinationSearch({
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search spots, shops, Bitcoin merchants…"
placeholder={t('placesUi.destination.placeholder')}
className="h-10 border-white/15 bg-white/5 pl-9 text-sm text-white placeholder:text-gray-600"
/>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close search"
aria-label={t('placesUi.destination.closeSearch')}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
@ -137,12 +139,11 @@ export function DestinationSearch({
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{query.trim().length < 2 ? (
<p className="p-4 text-center text-xs text-gray-500">
Type at least 2 characters. Searches skate spots, skate shops, and Bitcoin
merchants{near ? ' near here' : ''}.
{near ? t('placesUi.destination.typeCharsNear') : t('placesUi.destination.typeChars')}
</p>
) : results.length === 0 ? (
<p className="p-4 text-center text-xs text-gray-500">
Nothing matches “{query.trim()}”.
{t('placesUi.destination.nothingMatches', { query: query.trim() })}
</p>
) : (
<div className="space-y-1">
@ -162,7 +163,7 @@ export function DestinationSearch({
{c.sub && <span className="block truncate text-[11px] text-gray-500">{c.sub}</span>}
</span>
<span className={cn('shrink-0 text-[10px] font-black uppercase tracking-widest', meta.className)}>
{meta.label}
{t(meta.label)}
</span>
</button>
);

View file

@ -28,6 +28,7 @@ import { useCrewNip05 } from '@/hooks/useCrewNip05';
import { SkateNip05Input } from '@/components/SkateNip05Input';
import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
import { useTranslation } from '@/contexts/language';
export const EditProfileForm: React.FC = () => {
const queryClient = useQueryClient();
@ -36,6 +37,7 @@ export const EditProfileForm: React.FC = () => {
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { t } = useTranslation();
// CLINK Offer (noffer1...) — a self-custodial Bitcoin tip code. Not part of
// the standard NostrMetadata schema, so we keep it in its own state and merge
@ -130,14 +132,14 @@ export const EditProfileForm: React.FC = () => {
const [[_, url]] = await uploadFile(file);
form.setValue(field, url);
toast({
title: 'Success',
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
title: t('settingsUi.editProfile.uploadSuccessTitle'),
description: t('settingsUi.editProfile.uploadSuccessDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }),
});
} catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: 'Error',
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
title: t('common.error'),
description: t('settingsUi.editProfile.uploadErrorDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }),
variant: 'destructive',
});
}
@ -146,8 +148,8 @@ export const EditProfileForm: React.FC = () => {
const onSubmit = async (values: NostrMetadata) => {
if (!user) {
toast({
title: 'Error',
description: 'You must be logged in to update your profile',
title: t('common.error'),
description: t('settingsUi.editProfile.mustBeLoggedIn'),
variant: 'destructive',
});
return;
@ -155,8 +157,8 @@ export const EditProfileForm: React.FC = () => {
if (nofferInvalid) {
toast({
title: 'Invalid CLINK code',
description: 'That doesn\'t look like a valid noffer1… code. Clear it or paste a valid one.',
title: t('settingsUi.editProfile.invalidClinkTitle'),
description: t('settingsUi.editProfile.invalidClinkDesc'),
variant: 'destructive',
});
return;
@ -164,8 +166,8 @@ export const EditProfileForm: React.FC = () => {
if (lud16Invalid) {
toast({
title: 'Invalid Lightning address',
description: 'Use an email-like address such as you@walletofsatoshi.com.',
title: t('settingsUi.editProfile.invalidLightningTitle'),
description: t('settingsUi.editProfile.invalidLightningDesc'),
variant: 'destructive',
});
return;
@ -173,8 +175,8 @@ export const EditProfileForm: React.FC = () => {
if (spInvalid) {
toast({
title: 'Invalid Silent Payment address',
description: 'A Silent Payment address starts with "sp1" and is quite long. Clear it or paste a valid one.',
title: t('settingsUi.editProfile.invalidSpTitle'),
description: t('settingsUi.editProfile.invalidSpDesc'),
variant: 'destructive',
});
return;
@ -182,8 +184,8 @@ export const EditProfileForm: React.FC = () => {
if (cordnInvalid) {
toast({
title: 'Invalid Cordn link',
description: 'Paste a full https://… Cordn contact link.',
title: t('settingsUi.editProfile.invalidCordnTitle'),
description: t('settingsUi.editProfile.invalidCordnDesc'),
variant: 'destructive',
});
return;
@ -278,14 +280,14 @@ export const EditProfileForm: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
toast({
title: 'Success',
description: 'Your profile has been updated',
title: t('settingsUi.editProfile.updateSuccessTitle'),
description: t('settingsUi.editProfile.updateSuccessDesc'),
});
} catch (error) {
console.error('Failed to update profile:', error);
toast({
title: 'Error',
description: 'Failed to publish profile. Check your relay connections in Settings > Relays',
title: t('common.error'),
description: t('settingsUi.editProfile.updateErrorDesc'),
variant: 'destructive',
});
}
@ -300,8 +302,8 @@ export const EditProfileForm: React.FC = () => {
{/* ── Section: Identity ── */}
<FormSection
icon={<UserRound className="h-4 w-4" />}
title="Identity"
description="How you show up across SkateSpots."
title={t('settingsUi.editProfile.identityTitle')}
description={t('settingsUi.editProfile.identityDesc')}
accent="from-[#ff0040] to-[#ff6600]"
>
<FormField
@ -309,9 +311,9 @@ export const EditProfileForm: React.FC = () => {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display name</FormLabel>
<FormLabel>{t('settingsUi.editProfile.displayName')}</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
<Input placeholder={t('settingsUi.editProfile.displayNamePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -323,9 +325,9 @@ export const EditProfileForm: React.FC = () => {
name="about"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormLabel>{t('settingsUi.editProfile.bio')}</FormLabel>
<FormControl>
<Textarea placeholder="A few words about you and your skating…" className="resize-none min-h-[88px]" {...field} />
<Textarea placeholder={t('settingsUi.editProfile.bioPlaceholder')} className="resize-none min-h-[88px]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -339,9 +341,9 @@ export const EditProfileForm: React.FC = () => {
render={({ field }) => (
<ImageUploadField
field={field}
label="Profile picture"
label={t('settingsUi.editProfile.profilePicture')}
placeholder="https://…/avatar.jpg"
description="Upload or paste a square image."
description={t('settingsUi.editProfile.pictureDesc')}
previewType="square"
onUpload={(file) => uploadPicture(file, 'picture')}
/>
@ -353,9 +355,9 @@ export const EditProfileForm: React.FC = () => {
render={({ field }) => (
<ImageUploadField
field={field}
label="Banner image"
label={t('settingsUi.editProfile.bannerImage')}
placeholder="https://…/banner.jpg"
description="A wide image for the top of your profile."
description={t('settingsUi.editProfile.bannerDesc')}
previewType="wide"
onUpload={(file) => uploadPicture(file, 'banner')}
/>
@ -368,7 +370,7 @@ export const EditProfileForm: React.FC = () => {
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
<FormLabel>{t('settingsUi.editProfile.website')}</FormLabel>
<FormControl>
<Input placeholder="https://yourwebsite.com" {...field} />
</FormControl>
@ -389,8 +391,8 @@ export const EditProfileForm: React.FC = () => {
{/* ── Section: Bitcoin & Payments ── */}
<FormSection
icon={<Bitcoin className="h-4 w-4" />}
title="Bitcoin & Payments"
description="Let people zap, tip and pay you directly — no middleman."
title={t('settingsUi.editProfile.bitcoinTitle')}
description={t('settingsUi.editProfile.bitcoinDesc')}
accent="from-amber-500 to-orange-500"
>
{/* Lightning address */}
@ -398,16 +400,16 @@ export const EditProfileForm: React.FC = () => {
id="profile-lud16"
icon={<Zap className="h-4 w-4" />}
iconClass="text-yellow-500 bg-yellow-500/10"
label="Lightning address"
label={t('settingsUi.editProfile.lightningAddress')}
value={lud16}
onChange={setLud16}
placeholder="you@yourdomain.com"
status={
lud16Invalid
? { kind: 'error', text: 'Use an email-like address, e.g. you@yourdomain.com.' }
? { kind: 'error', text: t('settingsUi.editProfile.lud16Invalid') }
: lud16Trimmed
? { kind: 'ok', text: 'Powers the “Zap” button on your profile.' }
: { kind: 'hint', text: 'Instant Bitcoin tips. Powers the “Zap” button on your profile.' }
? { kind: 'ok', text: t('settingsUi.editProfile.lud16Ok') }
: { kind: 'hint', text: t('settingsUi.editProfile.lud16Hint') }
}
/>
@ -416,16 +418,16 @@ export const EditProfileForm: React.FC = () => {
id="profile-sp"
icon={<Bitcoin className="h-4 w-4" />}
iconClass="text-orange-500 bg-orange-500/10"
label="Silent Payment address"
label={t('settingsUi.editProfile.silentPaymentAddress')}
value={silentPayment}
onChange={setSilentPayment}
placeholder="sp1q…"
status={
spInvalid
? { kind: 'error', text: 'Should start with “sp1” and be quite long.' }
? { kind: 'error', text: t('settingsUi.editProfile.spInvalid') }
: spValid
? { kind: 'ok', text: 'Valid — a reusable, private on-chain address.' }
: { kind: 'hint', text: 'Reusable static on-chain address (BIP-352). Share once; every payment is unlinkable.' }
? { kind: 'ok', text: t('settingsUi.editProfile.spOk') }
: { kind: 'hint', text: t('settingsUi.editProfile.spHint') }
}
/>
@ -434,16 +436,16 @@ export const EditProfileForm: React.FC = () => {
id="profile-noffer"
icon={<Zap className="h-4 w-4" />}
iconClass="text-indigo-500 bg-indigo-500/10"
label="CLINK tip code"
label={t('settingsUi.editProfile.clinkTipCode')}
value={noffer}
onChange={setNoffer}
placeholder="noffer1…"
status={
nofferInvalid
? { kind: 'error', text: 'That doesn’t look like a valid noffer1… code.' }
? { kind: 'error', text: t('settingsUi.editProfile.nofferInvalid') }
: nofferValid
? { kind: 'ok', text: 'Valid — a “Tip in Bitcoin” button will show on your profile.' }
: { kind: 'hint', text: 'Self-custodial CLINK code so anyone can tip you over Nostr — no middleman.' }
? { kind: 'ok', text: t('settingsUi.editProfile.nofferOk') }
: { kind: 'hint', text: t('settingsUi.editProfile.nofferHint') }
}
/>
</FormSection>
@ -451,16 +453,16 @@ export const EditProfileForm: React.FC = () => {
{/* ── Section: Skate profile ── */}
<FormSection
icon={<Award className="h-4 w-4" />}
title="Skate Profile"
description="Reppin’ your sponsors and how the crew reaches you."
title={t('settingsUi.editProfile.skateProfileTitle')}
description={t('settingsUi.editProfile.skateProfileDesc')}
accent="from-pink-500 to-fuchsia-500"
>
{/* Badges — sponsor/backer chips, each with an optional link */}
<div className="space-y-2.5">
<Label className="flex items-center gap-2 text-sm font-semibold">
<Award className="h-4 w-4 text-pink-500" />
Badges
<span className="text-xs font-normal text-muted-foreground">(label + optional link)</span>
{t('settingsUi.editProfile.badges')}
<span className="text-xs font-normal text-muted-foreground">{t('settingsUi.editProfile.badgesHint')}</span>
</Label>
<div className="space-y-2">
{badgeList.map((row, i) => (
@ -468,7 +470,7 @@ export const EditProfileForm: React.FC = () => {
<span className="text-pink-500/60 text-sm font-black w-4 text-center flex-shrink-0">•</span>
<Input
value={row.label}
placeholder={i === 0 ? 'e.g. Independent Trucks' : 'Badge label…'}
placeholder={i === 0 ? t('settingsUi.editProfile.badgeExample') : t('settingsUi.editProfile.badgeLabelPlaceholder')}
onChange={(e) => {
const next = [...badgeList];
next[i] = { ...next[i], label: e.target.value };
@ -478,7 +480,7 @@ export const EditProfileForm: React.FC = () => {
/>
<Input
value={row.url}
placeholder="https://… (optional)"
placeholder={t('settingsUi.editProfile.badgeUrlPlaceholder')}
onChange={(e) => {
const next = [...badgeList];
next[i] = { ...next[i], url: e.target.value };
@ -496,7 +498,7 @@ export const EditProfileForm: React.FC = () => {
const next = badgeList.filter((_, idx) => idx !== i);
setBadgeList(next.length ? next : [{ label: '', url: '' }]);
}}
aria-label="Remove badge"
aria-label={t('settingsUi.editProfile.removeBadge')}
disabled={badgeList.length === 1 && !row.label && !row.url}
>
<X className="h-4 w-4" />
@ -512,12 +514,11 @@ export const EditProfileForm: React.FC = () => {
onClick={() => setBadgeList([...badgeList, { label: '', url: '' }])}
>
<Plus className="h-3.5 w-3.5" />
Add badge
{t('settingsUi.editProfile.addBadge')}
</Button>
<p className="text-xs text-muted-foreground">
Sponsors, shops or people who back your skating — and anything else you want to show off. Each
becomes a clickable chip on your profile. Your{' '}
<span className="text-[#ccff00]">@skate-spots.org</span> member badge is added automatically.
{t('settingsUi.editProfile.badgesDescBefore')}{' '}
<span className="text-[#ccff00]">@skate-spots.org</span> {t('settingsUi.editProfile.badgesDescAfter')}
</p>
</div>
@ -526,16 +527,16 @@ export const EditProfileForm: React.FC = () => {
id="profile-cordn"
icon={<MessageSquareLock className="h-4 w-4" />}
iconClass="text-teal-500 bg-teal-500/10"
label="Coordinator"
label={t('settingsUi.editProfile.coordinator')}
value={cordn}
onChange={setCordn}
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
status={
cordnInvalid
? { kind: 'error', text: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https:// URL." }
? { kind: 'error', text: t('settingsUi.editProfile.cordnInvalid') }
: cordnTrimmed
? { kind: 'ok', text: 'Shown on your profile — others can add it in one tap.' }
: { kind: 'hint', text: 'The Cordn coordinator others use to message you. Private, encrypted, yours.' }
? { kind: 'ok', text: t('settingsUi.editProfile.cordnOk') }
: { kind: 'hint', text: t('settingsUi.editProfile.cordnHint') }
}
/>
{myCoordinator && (
@ -547,7 +548,7 @@ export const EditProfileForm: React.FC = () => {
onClick={() => setCordn(coordinatorAddUrl(myCoordinator))}
>
<Server className="mr-1.5 h-3.5 w-3.5" />
Use my coordinator
{t('settingsUi.editProfile.useMyCoordinator')}
</Button>
)}
</FormSection>
@ -555,8 +556,8 @@ export const EditProfileForm: React.FC = () => {
{/* ── Section: Account ── */}
<FormSection
icon={<Bot className="h-4 w-4" />}
title="Account"
description="Extra account settings."
title={t('settingsUi.editProfile.accountTitle')}
description={t('settingsUi.editProfile.accountDesc')}
accent="from-slate-500 to-gray-500"
>
<FormField
@ -565,8 +566,8 @@ export const EditProfileForm: React.FC = () => {
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-xl border p-4 bg-muted/20">
<div className="space-y-0.5">
<FormLabel className="text-base">Bot account</FormLabel>
<FormDescription>Mark this account as automated or a bot.</FormDescription>
<FormLabel className="text-base">{t('settingsUi.editProfile.botAccount')}</FormLabel>
<FormDescription>{t('settingsUi.editProfile.botAccountDesc')}</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
@ -580,9 +581,9 @@ export const EditProfileForm: React.FC = () => {
<div className="sticky bottom-0 -mx-1 px-1 py-3 bg-gradient-to-t from-background via-background to-transparent">
<Button type="submit" className="w-full h-11 font-bold" disabled={saving}>
{saving ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving…</>
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />{t('settingsUi.editProfile.saving')}</>
) : (
'Save Profile'
t('settingsUi.editProfile.saveProfile')
)}
</Button>
</div>
@ -666,6 +667,7 @@ const Nip05Field: React.FC<{
onChange: (v: string) => void;
pubkey: string | undefined;
}> = ({ value, onChange, pubkey }) => {
const { t } = useTranslation();
const trimmed = value.trim().toLowerCase();
const looksValid = /^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/.test(trimmed);
@ -682,19 +684,19 @@ const Nip05Field: React.FC<{
let status: { kind: 'hint' | 'ok' | 'error' | 'warn' | 'checking'; text: string };
if (!trimmed) {
status = { kind: 'hint', text: 'A verified handle like you@yourdomain.com — Nostr’s version of a blue check. Shows a ✓ badge on your profile and unlocks the “Verified” filter in the Skaters directory.' };
status = { kind: 'hint', text: t('settingsUi.editProfile.nip05HintEmpty') };
} else if (!looksValid) {
status = { kind: 'error', text: 'Use an email-like handle, e.g. you@yourdomain.com — no “https://” or spaces.' };
status = { kind: 'error', text: t('settingsUi.editProfile.nip05Invalid') };
} else if (isLoading) {
status = { kind: 'checking', text: 'Checking your domain…' };
status = { kind: 'checking', text: t('settingsUi.editProfile.nip05Checking') };
} else if (result?.status === 'verified') {
status = { kind: 'ok', text: 'Verified — your domain confirms this handle belongs to you. ✓' };
status = { kind: 'ok', text: t('settingsUi.editProfile.nip05Verified') };
} else if (result?.status === 'mismatch') {
status = { kind: 'error', text: 'Your domain maps this handle to a DIFFERENT key. Fix your .well-known/nostr.json so it points to this account.' };
status = { kind: 'error', text: t('settingsUi.editProfile.nip05Mismatch') };
} else if (result?.status === 'unreachable') {
status = { kind: 'warn', text: 'Couldn’t reach your domain’s /.well-known/nostr.json yet. Saved handles still work once your domain is set up correctly.' };
status = { kind: 'warn', text: t('settingsUi.editProfile.nip05Unreachable') };
} else {
status = { kind: 'hint', text: 'Enter your NIP-05 handle.' };
status = { kind: 'hint', text: t('settingsUi.editProfile.nip05Enter') };
}
return (
@ -703,7 +705,7 @@ const Nip05Field: React.FC<{
<span className="h-6 w-6 rounded-lg flex items-center justify-center text-[#00a3ff] bg-[#00a3ff]/10">
<BadgeCheck className="h-4 w-4" />
</span>
NIP-05 verified handle
{t('settingsUi.editProfile.nip05VerifiedHandle')}
<a
href="https://nostr.how/en/guides/get-verified"
target="_blank"
@ -711,7 +713,7 @@ const Nip05Field: React.FC<{
className="ml-auto inline-flex items-center gap-1 text-xs font-normal text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-3.5 w-3.5" />
How?
{t('settingsUi.editProfile.nip05How')}
</a>
</Label>
<div className="relative">
@ -763,8 +765,8 @@ const Nip05Field: React.FC<{
{/* Free SkateSpots handles — claim one here and it fills the field above. */}
<div className="space-y-2 rounded-lg border border-dashed border-muted-foreground/25 p-3">
<p className="text-xs text-muted-foreground">
No domain of your own? Claim a free{' '}
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> handle from SkateSpots.
{t('settingsUi.editProfile.freeHandleBefore')}{' '}
<span className="font-semibold text-foreground">@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.editProfile.freeHandleAfter')}
</p>
<SkateNip05Input
id="skate-nip05-claim"
@ -805,6 +807,7 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
onUpload,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
return (
<FormItem>
@ -817,11 +820,11 @@ const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
className={`group relative flex-shrink-0 overflow-hidden border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors bg-muted/30 flex items-center justify-center ${
previewType === 'square' ? 'h-16 w-16 rounded-full' : 'h-16 w-28 rounded-lg'
}`}
aria-label={`Upload ${label}`}
aria-label={t('settingsUi.editProfile.uploadLabel', { label })}
>
{field.value ? (
<>
<img src={field.value} alt={`${label} preview`} className="h-full w-full object-cover" />
<img src={field.value} alt={t('settingsUi.editProfile.previewLabel', { label })} className="h-full w-full object-cover" />
<span className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Upload className="h-4 w-4 text-white" />
</span>

View file

@ -2,6 +2,7 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
/**
* Shared failure state for data-driven pages.
@ -11,8 +12,8 @@ import { cn } from '@/lib/utils';
* unactionable. This keeps the failure honest and gives a retry path.
*/
export function ErrorState({
title = 'Couldn’t load this',
description = 'The network request failed. Check your connection and try again.',
title,
description,
onRetry,
className,
}: {
@ -21,16 +22,20 @@ export function ErrorState({
onRetry?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const heading = title ?? t('chromeUi.errorTitle');
const body = description ?? t('chromeUi.errorDescription');
return (
<Card className={cn('border-2 border-dashed border-red-500/30 bg-black/50', className)}>
<CardContent className="py-12 text-center">
<AlertTriangle className="mx-auto mb-4 h-12 w-12 text-red-500/60" />
<p className="mb-1 font-bold uppercase tracking-wide text-white">{title}</p>
<p className="mx-auto mb-5 max-w-sm text-sm text-gray-400">{description}</p>
<p className="mb-1 font-bold uppercase tracking-wide text-white">{heading}</p>
<p className="mx-auto mb-5 max-w-sm text-sm text-gray-400">{body}</p>
{onRetry && (
<Button onClick={onRetry} className="gap-2 bg-white/10 font-bold uppercase hover:bg-white/20">
<RefreshCw className="h-4 w-4" />
Try again
{t('chromeUi.tryAgain')}
</Button>
)}
</CardContent>

View file

@ -22,9 +22,25 @@ import { TrustBadge } from '@/components/TrustBadge';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
import { ReportDialog } from '@/components/ReportDialog';
import { useTranslation } from '@/contexts/language';
import type { SkateEvent } from '@/lib/types';
import { EVENT_TYPE_LABELS } from '@/lib/types';
/** Map event type keys to translation keys (types live in lib/types; labels translate at render). */
const EVENT_TYPE_KEYS: Record<string, string> = {
contest: 'placesUi.eventTypes.contest',
session: 'placesUi.eventTypes.session',
meetup: 'placesUi.eventTypes.meetup',
premiere: 'placesUi.eventTypes.premiere',
workshop: 'placesUi.eventTypes.workshop',
demo: 'placesUi.eventTypes.demo',
jam: 'placesUi.eventTypes.jam',
};
function eventTypeLabel(t: (key: string) => string, type: string): string {
return EVENT_TYPE_KEYS[type] ? t(EVENT_TYPE_KEYS[type]) : (EVENT_TYPE_LABELS[type] || type);
}
interface EventCardProps {
event: SkateEvent;
compact?: boolean;
@ -54,6 +70,7 @@ function formatTime(timestamp: number): string {
}
export function EventCard({ event, compact = false, goingCount: goingCountProp }: EventCardProps) {
const { t } = useTranslation();
const author = useAuthor(event.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
@ -169,7 +186,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
key={type}
className="bg-primary text-primary-foreground"
>
{EVENT_TYPE_LABELS[type] || type}
{eventTypeLabel(t, type)}
</Badge>
))}
</div>
@ -178,7 +195,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
<div className="absolute bottom-3 left-3 right-3 flex items-center gap-1.5 text-[11px] font-bold text-white/90">
<Sparkles className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
<span className="truncate">
<span className="text-white/60 uppercase tracking-wide">Presented by </span>
<span className="text-white/60 uppercase tracking-wide">{t('placesUi.sponsors.presentedBy')} </span>
{leadSponsor.name}
{sponsors.length > 1 && (
<span className="text-white/60"> +{sponsors.length - 1}</span>
@ -250,7 +267,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
{goingCount > 0 ? (
<span className="flex items-center gap-1.5 text-sm font-bold text-green-400">
<Users className="h-4 w-4" />
{goingCount} going
{goingCount} {t('placesUi.attendees.goingCount')}
</span>
) : (
<span />
@ -280,7 +297,7 @@ export function EventCard({ event, compact = false, goingCount: goingCountProp }
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
{t('placesUi.eventCard.report')}
</DropdownMenuItem>
)}
</DropdownMenuContent>

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
export type EventStatus = 'soon' | 'upcoming' | 'live' | 'ended';
@ -8,15 +9,17 @@ interface CountdownInfo {
label: string;
}
function computeStatus(start: number, end?: number): CountdownInfo {
type Translate = (key: string, params?: Record<string, string | number>) => string;
function computeStatus(start: number, end: number | undefined, t: Translate): CountdownInfo {
const now = Date.now() / 1000;
const effectiveEnd = end ?? start + 3 * 3600; // assume ~3h if no end
if (now >= start && now < effectiveEnd) {
return { status: 'live', label: 'Happening now' };
return { status: 'live', label: t('placesUi.countdown.happeningNow') };
}
if (now >= effectiveEnd) {
return { status: 'ended', label: 'Ended' };
return { status: 'ended', label: t('placesUi.countdown.ended') };
}
const diff = start - now;
@ -25,15 +28,15 @@ function computeStatus(start: number, end?: number): CountdownInfo {
const mins = Math.floor((diff % 3600) / 60);
if (days >= 1) {
return { status: days <= 3 ? 'soon' : 'upcoming', label: days === 1 ? 'Tomorrow' : `In ${days} days` };
return { status: days <= 3 ? 'soon' : 'upcoming', label: days === 1 ? t('placesUi.countdown.tomorrow') : t('placesUi.countdown.inDays', { count: days }) };
}
if (hours >= 1) {
return { status: 'soon', label: `In ${hours}h ${mins}m` };
return { status: 'soon', label: t('placesUi.countdown.inHoursMins', { hours, mins }) };
}
if (mins >= 1) {
return { status: 'soon', label: `In ${mins} min` };
return { status: 'soon', label: t('placesUi.countdown.inMins', { count: mins }) };
}
return { status: 'soon', label: 'Starting now' };
return { status: 'soon', label: t('placesUi.countdown.startingNow') };
}
/**
@ -50,6 +53,7 @@ export function EventCountdown({
className?: string;
}) {
const [tick, forceTick] = useState(0);
const { t } = useTranslation();
// Re-render periodically so the countdown stays fresh without a heavy timer.
useEffect(() => {
@ -59,7 +63,7 @@ export function EventCountdown({
// `tick` is a dependency so the label actually recomputes on each interval.
// eslint-disable-next-line react-hooks/exhaustive-deps
const info = useMemo(() => computeStatus(start, end), [start, end, tick]);
const info = useMemo(() => computeStatus(start, end, t), [start, end, tick]);
const styles: Record<EventStatus, string> = {
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',

View file

@ -1,6 +1,7 @@
import { ExternalLink, Sparkles } from 'lucide-react';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import { EVENT_SPONSOR_TIERS, type EventSponsor } from '@/lib/types';
function SponsorLogo({ sponsor, large }: { sponsor: EventSponsor; large?: boolean }) {
@ -59,6 +60,7 @@ export function EventSponsors({
sponsors: EventSponsor[];
className?: string;
}) {
const { t } = useTranslation();
if (!sponsors || sponsors.length === 0) return null;
const title = sponsors.filter((s) => s.tier === 'title');
@ -69,7 +71,7 @@ export function EventSponsors({
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[#ccff00]" />
<h3 className="text-xs font-black uppercase tracking-widest text-gray-400">
Presented by
{t('placesUi.sponsors.presentedBy')}
</h3>
</div>

View file

@ -12,19 +12,21 @@ import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { genUserName } from '@/lib/genUserName';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
const UPDATE_KIND_META: Record<
EventUpdateKind,
{ icon: typeof Info; label: string; color: string; ring: string }
> = {
update: { icon: Info, label: 'Update', color: 'text-sky-400', ring: 'bg-sky-500/15' },
reschedule: { icon: CalendarClock, label: 'New time', color: 'text-amber-400', ring: 'bg-amber-500/15' },
relocation: { icon: MapPin, label: 'New spot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
cancelled: { icon: XCircle, label: 'Cancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
update: { icon: Info, label: 'placesUi.updates.kindUpdate', color: 'text-sky-400', ring: 'bg-sky-500/15' },
reschedule: { icon: CalendarClock, label: 'placesUi.updates.kindNewTime', color: 'text-amber-400', ring: 'bg-amber-500/15' },
relocation: { icon: MapPin, label: 'placesUi.updates.kindNewSpot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
cancelled: { icon: XCircle, label: 'placesUi.updates.kindCancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
};
function UpdateItem({ update }: { update: EventUpdate }) {
const { t } = useTranslation();
const author = useAuthor(update.pubkey);
const name = author.data?.metadata?.name ?? genUserName(update.pubkey);
const pic = author.data?.metadata?.picture;
@ -54,7 +56,7 @@ function UpdateItem({ update }: { update: EventUpdate }) {
<span className="text-sm font-bold text-white">{name}</span>
{update.authorRole === 'admin' && (
<span className="rounded-full border border-yellow-500/40 bg-yellow-500/15 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide text-yellow-400">
Admin
{t('placesUi.updates.admin')}
</span>
)}
{update.updateKind !== 'update' && (
@ -65,7 +67,7 @@ function UpdateItem({ update }: { update: EventUpdate }) {
meta.color,
)}
>
{meta.label}
{t(meta.label)}
</span>
)}
<span className="text-[11px] text-gray-600">
@ -85,6 +87,7 @@ function UpdateItem({ update }: { update: EventUpdate }) {
* Posting p-tags every RSVPer so they get a notification.
*/
export function EventUpdates({ event }: { event: SkateEvent }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const coord = eventCoord(event);
@ -114,13 +117,13 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
setKind('update');
const reach = new Set([...going, ...tentative]).size;
toast({
title: 'Update posted',
description: reach > 0 ? `${reach} ${reach === 1 ? 'person' : 'people'} notified.` : 'Everyone will see it on the event.',
title: t('placesUi.updates.posted'),
description: reach > 0 ? t('placesUi.updates.notifiedCount', { count: reach }) : t('placesUi.updates.notifiedEveryone'),
});
} catch (err) {
toast({
title: 'Could not post update',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('placesUi.updates.postError'),
description: err instanceof Error ? err.message : t('placesUi.updates.tryAgain'),
variant: 'destructive',
});
}
@ -147,12 +150,11 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
)}
>
<Megaphone className="h-4 w-4" />
{isHost ? 'Post an update' : 'Post an admin update'}
{isHost ? t('placesUi.updates.postUpdate') : t('placesUi.updates.postAdminUpdate')}
</div>
{!isHost && (
<p className="mb-2 text-[11px] text-yellow-200/80">
You're posting as a SkateSpots admin. It'll be clearly labelled as an admin
notice, not from the host.
{t('placesUi.updates.adminNote')}
</p>
)}
<div className="mb-2 flex flex-wrap gap-1.5">
@ -173,7 +175,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
)}
>
<Icon className="h-3 w-3" />
{m.label}
{t(m.label)}
</button>
);
})}
@ -181,7 +183,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Rain — moved to Sunday 2pm. Meet at the back ledge…"
placeholder={t('placesUi.updates.placeholder')}
rows={3}
maxLength={500}
className="resize-none border-white/10 bg-black/40 text-sm"
@ -189,8 +191,8 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-gray-500">
{going.length + tentative.length > 0
? `Notifies ${new Set([...going, ...tentative]).size} RSVP${new Set([...going, ...tentative]).size === 1 ? '' : "'s"}`
: 'Visible to everyone on this event'}
? t('placesUi.updates.notifiesRsvps', { count: new Set([...going, ...tentative]).size })
: t('placesUi.updates.visibleToEveryone')}
</span>
<Button
size="sm"
@ -204,7 +206,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
)}
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Post
{t('placesUi.updates.post')}
</Button>
</div>
</div>
@ -213,7 +215,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
{/* Feed */}
{isLoading && !hasUpdates ? (
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading updates…
<Loader2 className="h-4 w-4 animate-spin" /> {t('placesUi.updates.loading')}
</div>
) : hasUpdates ? (
<div className="space-y-4">
@ -224,7 +226,7 @@ export function EventUpdates({ event }: { event: SkateEvent }) {
) : (
!canPost && (
<p className="text-sm text-muted-foreground">
No updates from the host yet. RSVP and you'll be notified if anything changes.
{t('placesUi.updates.noUpdates')}
</p>
)
)}

View file

@ -2,6 +2,7 @@ import { useMemo, useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Route, MapPin, Clock, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import type { SkateEvent, SkateTour } from '@/lib/types';
// ─── Types ──────────────────────────────────────────────────────────────────
@ -36,10 +37,29 @@ interface EventsCalendarProps {
// ─── Date helpers (local time) ────────────────────────────────────────────────
const DAY_MS = 86_400_000;
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
const WEEKDAY_KEYS = [
'placesUi.calendar.weekdays.sun',
'placesUi.calendar.weekdays.mon',
'placesUi.calendar.weekdays.tue',
'placesUi.calendar.weekdays.wed',
'placesUi.calendar.weekdays.thu',
'placesUi.calendar.weekdays.fri',
'placesUi.calendar.weekdays.sat',
];
const WEEKDAY_SHORT_KEYS = [
'placesUi.calendar.weekdaysShort.sun',
'placesUi.calendar.weekdaysShort.mon',
'placesUi.calendar.weekdaysShort.tue',
'placesUi.calendar.weekdaysShort.wed',
'placesUi.calendar.weekdaysShort.thu',
'placesUi.calendar.weekdaysShort.fri',
'placesUi.calendar.weekdaysShort.sat',
];
const MONTH_KEYS = [
'placesUi.calendar.months.january', 'placesUi.calendar.months.february', 'placesUi.calendar.months.march',
'placesUi.calendar.months.april', 'placesUi.calendar.months.may', 'placesUi.calendar.months.june',
'placesUi.calendar.months.july', 'placesUi.calendar.months.august', 'placesUi.calendar.months.september',
'placesUi.calendar.months.october', 'placesUi.calendar.months.november', 'placesUi.calendar.months.december',
];
/** Midnight (local) for a given date. */
@ -122,6 +142,7 @@ function formatDayLong(d: Date): string {
// ─── Component ────────────────────────────────────────────────────────────────
export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }: EventsCalendarProps) {
const { t } = useTranslation();
const today = useMemo(() => startOfDay(new Date()), []);
const [view, setView] = useState<Date>(() => {
const base = anchorUnix ? new Date(anchorUnix * 1000) : today;
@ -210,7 +231,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
<div className="flex items-center justify-between mb-4">
<div className="flex items-baseline gap-2 min-w-0">
<h2 className="text-lg md:text-2xl font-black uppercase tracking-tight truncate">
{MONTHS[view.getMonth()]}
{t(MONTH_KEYS[view.getMonth()])}
</h2>
<span className="text-gray-500 text-lg md:text-2xl font-black">{view.getFullYear()}</span>
</div>
@ -220,11 +241,11 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
onClick={goToToday}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
Today
{t('placesUi.calendar.today')}
</button>
<button
type="button"
aria-label="Previous month"
aria-label={t('placesUi.calendar.prevMonth')}
onClick={() => goToMonth(-1)}
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
@ -232,7 +253,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
</button>
<button
type="button"
aria-label="Next month"
aria-label={t('placesUi.calendar.nextMonth')}
onClick={() => goToMonth(1)}
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
@ -243,10 +264,10 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
{/* Weekday labels */}
<div className="grid grid-cols-7 gap-1 md:gap-1.5 mb-1 md:mb-2">
{WEEKDAYS.map((wd) => (
<div key={wd} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
<span className="md:hidden">{wd[0]}</span>
<span className="hidden md:inline">{wd}</span>
{WEEKDAY_KEYS.map((key, i) => (
<div key={key} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
<span className="md:hidden">{t(WEEKDAY_SHORT_KEYS[i])}</span>
<span className="hidden md:inline">{t(key)}</span>
</div>
))}
</div>
@ -265,7 +286,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
key={cell.getTime()}
type="button"
onClick={() => setSelected(startOfDay(cell))}
aria-label={`${formatDayLong(cell)}${has ? `, ${dayItems.length} ${show}` : ''}`}
aria-label={`${formatDayLong(cell)}${has ? `, ${t(show === 'events' ? 'placesUi.calendar.eventsOnDay' : 'placesUi.calendar.toursOnDay', { count: dayItems.length })}` : ''}`}
aria-pressed={isSelected}
className={cn(
'relative aspect-square rounded-lg md:rounded-xl border flex flex-col items-center justify-start pt-1 md:pt-1.5 transition-all active:scale-95',
@ -309,11 +330,13 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
{show === 'events' ? 'Event' : 'Tour'} day
{show === 'events' ? t('placesUi.calendar.eventDay') : t('placesUi.calendar.tourDay')}
</span>
<span>
<span className={cn('font-bold', accent.text)}>{totalThisMonth}</span>{' '}
{show === 'events' ? 'event' : 'tour'}{totalThisMonth === 1 ? '' : 's'} this month
{show === 'events'
? t('placesUi.calendar.eventsThisMonth')
: t('placesUi.calendar.toursThisMonth')}
</span>
</div>
</div>
@ -325,7 +348,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
? <CalendarIcon className={cn('h-4 w-4', accent.text)} />
: <Route className={cn('h-4 w-4', accent.text)} />}
<h3 className="text-sm font-black uppercase tracking-wide">
{sameDay(selected, today) ? 'Today' : formatDayLong(selected)}
{sameDay(selected, today) ? t('placesUi.calendar.today') : formatDayLong(selected)}
</h3>
</div>
@ -355,7 +378,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
{multiDay
? `${new Date(it.start * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })} – ${new Date((it.end as number) * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })}`
: it.allDay
? 'All day'
? t('placesUi.calendar.allDay')
: formatTime(it.start)}
</span>
{it.location && (
@ -367,7 +390,7 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
{it.kind === 'event' && (it.goingCount ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{it.goingCount} going
{it.goingCount} {t('placesUi.calendar.going')}
</span>
)}
</div>
@ -380,10 +403,10 @@ export function EventsCalendar({ events, tours, goingCounts, show, anchorUnix }:
) : (
<div className="rounded-xl border border-dashed border-white/10 py-8 px-4 text-center">
<p className="text-sm text-gray-500">
Nothing on this day.
{t('placesUi.calendar.nothingOnDay')}
</p>
<p className="mt-1 text-xs text-gray-600">
Tap a highlighted day to see what's on.
{t('placesUi.calendar.tapHighlighted')}
</p>
</div>
)}

View file

@ -3,14 +3,11 @@ import { Switch } from '@/components/ui/switch';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface FeaturedUpgradeProps {
/** Which platform fee this upgrade drives. */
type: Extract<FeeType, 'featuredEvent' | 'featuredTour'>;
/** Noun shown to the user, e.g. "event" or "tour". */
noun: string;
/** Where featured items appear, e.g. "events page" or "tours". */
placement: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
className?: string;
@ -25,14 +22,18 @@ interface FeaturedUpgradeProps {
*/
export function FeaturedUpgrade({
type,
noun,
placement,
enabled,
onEnabledChange,
className,
}: FeaturedUpgradeProps) {
const { t } = useTranslation();
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee(type);
const isEvent = type === 'featuredEvent';
const heading = t(isEvent ? 'placesUi.featured.headingEvent' : 'placesUi.featured.headingTour');
const body = t(isEvent ? 'placesUi.featured.bodyEvent' : 'placesUi.featured.bodyTour');
const context = t(isEvent ? 'placesUi.featured.contextEvent' : 'placesUi.featured.contextTour');
return (
<div
className={cn(
@ -48,17 +49,16 @@ export function FeaturedUpgrade({
</div>
<div className="min-w-0">
<p className="font-black uppercase tracking-wide text-sm text-[#ccff00]">
Feature this {noun} <span className="text-gray-500 font-normal normal-case">· optional</span>
{heading} <span className="text-gray-500 font-normal normal-case">· {t('placesUi.featured.optional')}</span>
</p>
<p className="text-xs text-gray-400 mt-0.5">
Free to post. Featured {noun}s get a <strong className="text-[#ccff00]">badge</strong> and sit
{' '}<strong className="text-[#ccff00]">at the top</strong> of {placement}.
{body}
</p>
</div>
</div>
{isActive ? (
<span className="flex items-center gap-1 text-[11px] font-bold text-green-400 flex-shrink-0">
<Check className="h-3.5 w-3.5" /> Active
<Check className="h-3.5 w-3.5" /> {t('placesUi.featured.active')}
</span>
) : (
<Switch
@ -72,8 +72,8 @@ export function FeaturedUpgrade({
{isActive && (
<p className="mt-2 text-xs text-gray-500">
{isPlatformOwner
? 'Platform owner — featured is always on.'
: `Featured active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining.`}
? t('placesUi.featured.platformOwner')
: t('placesUi.featured.daysRemaining', { count: daysRemaining })}
</p>
)}
</div>
@ -83,7 +83,7 @@ export function FeaturedUpgrade({
<PlatformFeePayment
type={type}
onSuccess={() => { /* hook auto-updates isActive */ }}
context={`Your ${noun} stays listed for free either way — featured just adds the badge & boost.`}
context={context}
/>
</div>
)}
@ -97,12 +97,5 @@ export function FeaturedEventUpgrade(props: {
onEnabledChange: (enabled: boolean) => void;
className?: string;
}) {
return (
<FeaturedUpgrade
type="featuredEvent"
noun="event"
placement="the events page"
{...props}
/>
);
return <FeaturedUpgrade type="featuredEvent" {...props} />;
}

View file

@ -11,6 +11,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { Users, UserPlus, UserMinus, Loader2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow: (pubkey: string) => void }) {
@ -18,6 +19,7 @@ function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow:
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const [isUnfollowing, setIsUnfollowing] = useState(false);
const { t } = useTranslation();
const handleUnfollow = async () => {
setIsUnfollowing(true);
@ -61,7 +63,7 @@ function FollowingUserCard({ pubkey, onUnfollow }: { pubkey: string; onUnfollow:
) : (
<UserMinus className="h-3 w-3" />
)}
Unfollow
{t('settingsUi.followList.unfollow')}
</Button>
</div>
);
@ -100,6 +102,7 @@ function FollowerUserCard({ pubkey }: { pubkey: string }) {
export function FollowListManager() {
const { user } = useCurrentUser();
const { t } = useTranslation();
const { data: following = [], isLoading: followingLoading } = useFollowing();
const { data: followers = [], isLoading: followersLoading } = useFollowers();
const { unfollow, followByIdentifier, isFollowing } = useFollowActions();
@ -123,10 +126,10 @@ export function FollowListManager() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-purple-400">
<Users className="h-5 w-5" />
Your Nostr Network
{t('settingsUi.followList.title')}
</CardTitle>
<CardDescription className="text-gray-300">
Manage who you follow. Following users helps build your Web of Trust and determines which spots show trust badges.
{t('settingsUi.followList.desc')}
</CardDescription>
</CardHeader>
<CardContent>
@ -134,11 +137,11 @@ export function FollowListManager() {
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="following" className="gap-2">
<UserPlus className="h-4 w-4" />
Following ({following.length})
{t('settingsUi.followList.following', { count: following.length })}
</TabsTrigger>
<TabsTrigger value="followers" className="gap-2">
<Users className="h-4 w-4" />
Followers ({followers.length})
{t('settingsUi.followList.followers', { count: followers.length })}
</TabsTrigger>
</TabsList>
@ -163,11 +166,11 @@ export function FollowListManager() {
) : (
<UserPlus className="h-4 w-4" />
)}
Follow
{t('settingsUi.followList.follow')}
</Button>
</div>
<p className="text-xs text-gray-500">
💡 Paste a Nostr pubkey (npub1...), NIP-05 address (user@domain.com), or hex pubkey
{t('settingsUi.followList.followHint')}
</p>
</form>
@ -199,9 +202,9 @@ export function FollowListManager() {
) : (
<div className="text-center py-8 border-2 border-dashed border-gray-700 rounded-lg">
<Users className="h-12 w-12 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400 mb-2">You're not following anyone yet</p>
<p className="text-gray-400 mb-2">{t('settingsUi.followList.notFollowing')}</p>
<p className="text-sm text-gray-500">
Follow skaters to see their spots with ✅ VERIFIED badges
{t('settingsUi.followList.notFollowingHint')}
</p>
</div>
)}
@ -210,8 +213,7 @@ export function FollowListManager() {
{following.length > 0 && (
<div className="p-4 rounded-lg bg-green-950/30 border border-green-500/30 text-sm">
<p className="text-green-400">
<strong>✅ Trust Badges Active:</strong> Spots created by people you follow will now show VERIFIED badges.
Spots by their friends will show TRUSTED badges.
<strong>{t('settingsUi.followList.trustActiveStrong')}</strong> {t('settingsUi.followList.trustActive')}
</p>
</div>
)}
@ -241,9 +243,9 @@ export function FollowListManager() {
) : (
<div className="text-center py-8 border-2 border-dashed border-gray-700 rounded-lg">
<Users className="h-12 w-12 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400 mb-2">No followers yet</p>
<p className="text-gray-400 mb-2">{t('settingsUi.followList.noFollowers')}</p>
<p className="text-sm text-gray-500">
Share your Nostr profile to gain followers!
{t('settingsUi.followList.noFollowersHint')}
</p>
</div>
)}
@ -253,20 +255,20 @@ export function FollowListManager() {
{/* Info Card */}
<Card className="bg-blue-950/30 border-2 border-blue-500/50 mt-6">
<CardHeader>
<CardTitle className="text-sm text-blue-400">💡 How Following Affects Trust Badges</CardTitle>
<CardTitle className="text-sm text-blue-400">{t('settingsUi.followList.howTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300">
<p>
<strong className="text-white">✅ VERIFIED (Green):</strong> Spots created by people you directly follow
<strong className="text-white">{t('settingsUi.followList.verifiedStrong')}</strong> {t('settingsUi.followList.verifiedDesc')}
</p>
<p>
<strong className="text-white">🟡 TRUSTED (Yellow):</strong> Spots created by people your friends follow (2 hops)
<strong className="text-white">{t('settingsUi.followList.trustedStrong')}</strong> {t('settingsUi.followList.trustedDesc')}
</p>
<p>
<strong className="text-white">⚪ Unverified:</strong> Spots by users with no connection to your network
<strong className="text-white">{t('settingsUi.followList.unverifiedStrong')}</strong> {t('settingsUi.followList.unverifiedDesc')}
</p>
<p className="text-xs text-blue-400 mt-4">
💡 Tip: Follow active skateboarders to see more verified spots and build your trust network!
{t('settingsUi.followList.tip')}
</p>
</CardContent>
</Card>

View file

@ -20,6 +20,7 @@ import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { toast } from '@/hooks/useToast';
import { PRO_LABEL_NAMESPACE, PRO_LABEL_VALUE, DAY_SECONDS } from '@/lib/sasquatchPro';
import { BadgeCheck, Loader2 } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
function decodeTarget(value: string): string | null {
const v = value.trim();
@ -41,6 +42,7 @@ export function GrantProCard() {
const [target, setTarget] = useState('');
const [days, setDays] = useState('30');
const [busy, setBusy] = useState(false);
const { t } = useTranslation();
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
if (!isOwner) return null;
@ -48,12 +50,12 @@ export function GrantProCard() {
const grant = async () => {
const pubkey = decodeTarget(target);
if (!pubkey) {
toast({ title: 'Enter a valid npub (or hex pubkey)', variant: 'destructive' });
toast({ title: t('settingsUi.grantPro.invalidNpub'), variant: 'destructive' });
return;
}
const n = Number(days);
if (!Number.isFinite(n) || n <= 0) {
toast({ title: 'Days must be a positive number', variant: 'destructive' });
toast({ title: t('settingsUi.grantPro.invalidDays'), variant: 'destructive' });
return;
}
if (!user?.signer) return;
@ -82,14 +84,17 @@ export function GrantProCard() {
);
if (!results.some((r) => r.status === 'fulfilled')) throw new Error('No relay accepted the grant.');
toast({
title: 'Pro granted ⚡',
description: `${nip19.npubEncode(pubkey).slice(0, 14)}… until ${new Date(expiry * 1000).toLocaleDateString()}`,
title: t('settingsUi.grantPro.grantedTitle'),
description: t('settingsUi.grantPro.grantedDesc', {
npub: `${nip19.npubEncode(pubkey).slice(0, 14)}…`,
date: new Date(expiry * 1000).toLocaleDateString(),
}),
});
setTarget('');
} catch (err) {
toast({
title: 'Grant failed',
description: err instanceof Error ? err.message : 'Try again.',
title: t('settingsUi.grantPro.failedTitle'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
} finally {
@ -102,20 +107,19 @@ export function GrantProCard() {
<CardContent className="py-3.5 space-y-2.5">
<p className="flex items-center gap-2 text-sm font-bold text-white">
<BadgeCheck className="h-4 w-4 text-[#ccff00]" />
Grant Sasquatch Pro
{t('settingsUi.grantPro.title')}
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
Owner
{t('settingsUi.grantPro.owner')}
</span>
</p>
<p className="text-xs text-muted-foreground">
Manual fallback for a verified payment the zap receipt missed. Publishes an owner-signed
grant; the user can't forge it.
{t('settingsUi.grantPro.desc')}
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="npub1… (or hex)"
placeholder={t('settingsUi.grantPro.targetPlaceholder')}
className="h-9 flex-1 border-white/10 bg-black/40 text-sm text-white"
spellCheck={false}
disabled={busy}
@ -134,7 +138,7 @@ export function GrantProCard() {
className="h-9 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <BadgeCheck className="h-4 w-4" />}
Grant
{t('settingsUi.grantPro.grant')}
</Button>
</div>
</CardContent>

View file

@ -11,6 +11,7 @@ import {
useEndorseJoinRequest,
} from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react';
@ -38,13 +39,14 @@ function EndorserAvatar({ pubkey }: { pubkey: string }) {
/** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */
function EndorserNames({ pubkeys }: { pubkeys: string[] }) {
const { t } = useTranslation();
const first = useAuthor(pubkeys[0]);
const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]);
const extra = pubkeys.length - 1;
return (
<span className="font-bold text-emerald-300">
{firstName}
{extra > 0 && <span className="font-normal text-emerald-400/70"> +{extra} more</span>}
{extra > 0 && <span className="font-normal text-emerald-400/70"> {t('socialUi.joinRequest.moreCount', { count: extra })}</span>}
</span>
);
}
@ -70,6 +72,7 @@ export function JoinRequestCard({
onResolved?: () => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const requester = useAuthor(request.requester);
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
@ -95,14 +98,14 @@ export function JoinRequestCard({
await approve({ request, crew });
setDone('approved');
toast({
title: `Added ${requesterName} to ${crew.name} 🛹`,
description: "They'll confirm from their side to appear as a member.",
title: t('socialUi.joinRequest.addedTitle', { name: requesterName, crew: crew.name }),
description: t('socialUi.joinRequest.addedDesc'),
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not add member',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.collectiveJoinRequest.couldNotAdd'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -113,14 +116,14 @@ export function JoinRequestCard({
await endorse({ request, crew });
setDone('endorsed');
toast({
title: `You vouched for ${requesterName} 🤝`,
description: `The founder of ${crew.name} will see your endorsement and can add them.`,
title: t('socialUi.joinRequest.vouchedTitle', { name: requesterName }),
description: t('socialUi.joinRequest.vouchedDesc', { crew: crew.name }),
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not endorse',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.joinRequest.couldNotEndorse'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -132,7 +135,7 @@ export function JoinRequestCard({
setDone('dismissed');
onResolved?.();
} catch {
toast({ title: 'Could not dismiss', variant: 'destructive' });
toast({ title: t('socialUi.collectiveJoinRequest.couldNotDismiss'), variant: 'destructive' });
}
};
@ -142,17 +145,17 @@ export function JoinRequestCard({
{done === 'approved' ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
{t('socialUi.collectiveJoinRequest.addedDone')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.collectiveJoinRequest.addedTo')} {crew.name}
</>
) : done === 'endorsed' ? (
<>
<ThumbsUp className="h-4 w-4 text-emerald-400" />
You vouched for <span className="font-bold text-white">{requesterName}</span> — founder will confirm
{t('socialUi.joinRequest.endorsedDoneA')} <span className="font-bold text-white">{requesterName}</span> {t('socialUi.joinRequest.endorsedDoneB')}
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Dismissed request from {requesterName}
{t('socialUi.collectiveJoinRequest.dismissedDone', { name: requesterName })}
</>
)}
</div>
@ -184,7 +187,7 @@ export function JoinRequestCard({
</Avatar>
{requesterName}
</Link>{' '}
wants to join{' '}
{t('socialUi.collectiveJoinRequest.wantsToJoin')}{' '}
<Link to={crewHref} className="font-black text-emerald-300 hover:underline">
{crew.name}
</Link>
@ -204,7 +207,7 @@ export function JoinRequestCard({
))}
</div>
<p className="min-w-0 flex-1 truncate text-[11px] text-emerald-200/90">
Vouched by <EndorserNames pubkeys={endorserPubkeys} />
{t('socialUi.joinRequest.vouchedBy')} <EndorserNames pubkeys={endorserPubkeys} />
</p>
</div>
)}
@ -218,12 +221,12 @@ export function JoinRequestCard({
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
{hasEndorsements ? 'Approve & add' : 'Add to crew'}
{hasEndorsements ? t('socialUi.joinRequest.approveAndAdd') : t('socialUi.joinRequest.addToCrew')}
</Button>
) : isAdmin ? (
iEndorsed ? (
<span className="flex flex-1 items-center gap-1.5 text-xs font-bold text-emerald-400/90">
<Clock className="h-3.5 w-3.5" /> Vouched — waiting on the founder
<Clock className="h-3.5 w-3.5" /> {t('socialUi.joinRequest.vouchedWaiting')}
</span>
) : (
<Button
@ -233,11 +236,11 @@ export function JoinRequestCard({
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{endorsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ThumbsUp className="h-3.5 w-3.5" />}
Vouch for them
{t('socialUi.joinRequest.vouchForThem')}
</Button>
)
) : (
<span className="flex-1 text-xs text-gray-500">Crew leadership only.</span>
<span className="flex-1 text-xs text-gray-500">{t('socialUi.joinRequest.leadershipOnly')}</span>
)}
<Button
size="sm"
@ -247,14 +250,14 @@ export function JoinRequestCard({
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
{t('socialUi.collectiveJoinRequest.dismiss')}
</Button>
</div>
{/* Micro-help so the admin path isn't a mystery. */}
{isAdmin && !iEndorsed && (
<p className="mt-1.5 text-[11px] leading-snug text-gray-500">
Admins can’t add members directly — vouching tells the founder you back this rider.
{t('socialUi.joinRequest.adminHelp')}
</p>
)}
</div>

View file

@ -173,6 +173,7 @@ function MobileHeaderUserMenu({
onAddAccount: () => void;
}) {
const author = useAuthor(pubkey);
const { t } = useTranslation();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
const avatar = metadata?.picture;
@ -194,7 +195,7 @@ function MobileHeaderUserMenu({
<UserStatusIndicator pubkey={pubkey} size="sm" />
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
<CheckCircle2 className="h-3 w-3 inline mr-1" />
In
{t('chromeUi.inBadge')}
</span>
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
@ -217,7 +218,7 @@ function MobileHeaderUserMenu({
<p className="font-bold text-sm truncate">{displayName}</p>
<p className="text-xs text-green-500 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Logged In
{t('chromeUi.loggedIn')}
</p>
</div>
</div>
@ -232,62 +233,62 @@ function MobileHeaderUserMenu({
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-green-400 font-bold">Set Status</span>
<span className="text-green-400 font-bold">{t('chromeUi.setStatus')}</span>
<span className="ml-auto text-xs text-green-500">🛹</span>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
<Plus className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00] font-medium">Add Spot</span>
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
View Profile
{t('nav.viewProfile')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
<Settings className="h-4 w-4" />
Settings
{t('nav.settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
<Database className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">My Data</span>
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-purple-400">My Stash</span>
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
<Wallet className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">Wallet</span>
<span className="text-orange-400">{t('nav.wallet')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">Leaderboards</span>
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">Marketplace</span>
<span className="text-orange-400">{t('nav.marketplace')}</span>
</Link>
</DropdownMenuItem>
{/* ── Accounts: switch between logged-in npubs + add another ── */}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[10px] font-black uppercase tracking-widest text-gray-500">
Accounts
{t('chromeUi.accounts')}
</div>
{otherUsers.map((account) => {
const name = account.metadata?.name ?? genUserName(account.pubkey);
@ -310,7 +311,7 @@ function MobileHeaderUserMenu({
className="flex items-center gap-2 cursor-pointer"
>
<Plus className="h-4 w-4 text-teal-400" />
<span className="text-teal-400">Add another account</span>
<span className="text-teal-400">{t('chromeUi.addAnotherAccount')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
@ -319,7 +320,7 @@ function MobileHeaderUserMenu({
className="flex items-center gap-2 cursor-pointer text-red-400"
>
<LogOut className="h-4 w-4" />
Log Out
{t('chromeUi.logOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -493,7 +494,7 @@ function MobileNav({
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
tabActive ? 'font-extrabold' : 'font-semibold',
)}>
{isLoggedIn ? 'Profile' : 'Log In'}
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
</span>
</Link>
);
@ -542,7 +543,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[#ccff00] focus:px-4 focus:py-2 focus:text-sm focus:font-black focus:uppercase focus:tracking-wide focus:text-black focus:shadow-lg"
>
Skip to content
{t('chromeUi.skipToContent')}
</a>
{/* Desktop Header */}
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
@ -618,14 +619,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
<DropdownMenuItem asChild>
<Link to="/skaters" className="flex items-center gap-2 cursor-pointer">
<PersonStanding className="h-4 w-4" />
Skaters
{t('nav.skaters')}
</Link>
</DropdownMenuItem>
{user && (
<DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(user.pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
View Profile
{t('nav.viewProfile')}
</Link>
</DropdownMenuItem>
)}
@ -639,7 +640,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
<DropdownMenuItem asChild>
<Link to="/glossary" className="flex items-center gap-2 cursor-pointer">
<BookOpen className="h-4 w-4" />
Glossary
{t('nav.glossary')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
@ -651,20 +652,20 @@ export function Layout({ children, noPadding }: LayoutProps) {
<DropdownMenuItem asChild>
<Link to="/card" className="flex items-center gap-2 cursor-pointer">
<CreditCard className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">Rider Card</span>
<span className="text-[#ccff00]">{t('nav.riderCard')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer">
<Heart className="h-4 w-4 text-[#ff0040]" />
<span className="text-[#ff0040]">Supporters ⚡</span>
<span className="text-[#ff0040]">{t('nav.supporters')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/newsletter" className="flex items-center gap-2 cursor-pointer">
<Radio className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">Newsletter</span>
<span className="text-[#ccff00]">{t('nav.newsletter')}</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
@ -684,7 +685,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
? "text-[#ccff00] bg-[#ccff00]/10"
: "text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
)}
title="My Collections"
title={t('chromeUi.myCollections')}
>
<Bookmark className="h-5 w-5" />
</Button>
@ -717,13 +718,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
variant="outline"
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
onClick={() => setShowStatusDialog(true)}
title="Set Your Status"
title={t('chromeUi.setYourStatus')}
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold">Status</span>
<span className="text-sm font-bold">{t('chromeUi.status')}</span>
</Button>
)}
{/* Notifications (crew invites & membership requests) */}
@ -739,14 +740,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
variant="ghost"
size="icon"
className="text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
title="View my profile"
title={t('chromeUi.viewMyProfile')}
>
<User className="h-5 w-5" />
</Button>
</Link>
)}
<Link to="/settings">
<Button variant="ghost" size="icon" title="Settings">
<Button variant="ghost" size="icon" title={t('nav.settings')}>
<Settings className="h-5 w-5" />
</Button>
</Link>
@ -801,7 +802,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 overflow-y-auto">
<SheetTitle className="sr-only">Menu</SheetTitle>
<SheetTitle className="sr-only">{t('chromeUi.menu')}</SheetTitle>
<div className="flex flex-col gap-6 mt-6 pb-8">
{/* Wallet Card - Mobile Menu */}
{user && (
@ -823,7 +824,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
</div>
<div>
<p className="font-bold text-sm">{t('wallet.cashu.title')}</p>
<p className="text-xs text-muted-foreground">Ecash</p>
<p className="text-xs text-muted-foreground">{t('chromeUi.ecash')}</p>
</div>
</div>
{hasCashuWalletHint() && (
@ -849,7 +850,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
))}
<NavLink
href="/skaters"
label="Skaters"
label={t('nav.skaters')}
icon={PersonStanding}
isActive={isActive('/skaters')}
onClick={() => setMobileMenuOpen(false)}
@ -873,7 +874,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{user && (
<NavLink
href={`/${nip19.npubEncode(user.pubkey)}`}
label="View Profile"
label={t('nav.viewProfile')}
icon={User}
isActive={false}
onClick={() => setMobileMenuOpen(false)}
@ -882,7 +883,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{user && (
<NavLink
href="/messages"
label="Messages"
label={t('nav.messages')}
icon={MessageCircle}
isActive={isActive('/messages')}
onClick={() => setMobileMenuOpen(false)}
@ -891,7 +892,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{user && (
<NavLink
href="/contacts"
label="Contacts"
label={t('nav.contacts')}
icon={Users}
isActive={isActive('/contacts')}
onClick={() => setMobileMenuOpen(false)}
@ -900,7 +901,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{user && (
<NavLink
href="/collections"
label="My Collections"
label={t('chromeUi.myCollections')}
icon={Bookmark}
isActive={isActive('/collections')}
onClick={() => setMobileMenuOpen(false)}
@ -909,7 +910,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{user && (
<NavLink
href="/stash"
label="My Stash"
label={t('chromeUi.myStash')}
icon={Package}
isActive={isActive('/stash')}
onClick={() => setMobileMenuOpen(false)}
@ -924,7 +925,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
/>
<NavLink
href="/glossary"
label="Glossary"
label={t('nav.glossary')}
icon={BookOpen}
isActive={isActive('/glossary')}
onClick={() => setMobileMenuOpen(false)}
@ -938,21 +939,21 @@ export function Layout({ children, noPadding }: LayoutProps) {
/>
<NavLink
href="/card"
label="Rider Card"
label={t('nav.riderCard')}
icon={CreditCard}
isActive={isActive('/card')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/supporters"
label="Supporters ⚡"
label={t('nav.supporters')}
icon={Heart}
isActive={isActive('/supporters')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/newsletter"
label="Newsletter"
label={t('nav.newsletter')}
icon={Radio}
isActive={isActive('/newsletter')}
onClick={() => setMobileMenuOpen(false)}
@ -974,7 +975,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
className="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 text-muted-foreground hover:bg-muted hover:text-foreground text-left"
>
<SlidersHorizontal className="h-5 w-5" />
<span className="font-medium">Customize nav bar</span>
<span className="font-medium">{t('chromeUi.customizeNavBar')}</span>
</button>
</div>
{/* Language Toggle in Mobile Menu */}
@ -998,7 +999,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
<p className="font-bold text-base truncate">{displayName}</p>
<p className="text-sm text-green-500 flex items-center gap-1.5">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Logged In
{t('chromeUi.loggedIn')}
</p>
</div>
</div>
@ -1015,7 +1016,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
}}
>
<LogOut className="h-5 w-5" />
Log Out
{t('chromeUi.logOut')}
</Button>
</>
) : (
@ -1023,9 +1024,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Not Logged In - Clear CTA */}
<div className="p-4 rounded-xl bg-gradient-to-br from-[#ff0040]/10 to-[#ff0040]/5 border border-[#ff0040]/20 text-center">
<User className="h-10 w-10 mx-auto mb-2 text-[#ff0040]/50" />
<p className="font-bold text-base mb-1">Not Logged In</p>
<p className="font-bold text-base mb-1">{t('chromeUi.notLoggedIn')}</p>
<p className="text-sm text-muted-foreground mb-4">
Log in to add spots, zap skaters, and save your favorites
{t('chromeUi.loggedOutPrompt')}
</p>
<LoginArea className="w-full justify-center" />
</div>
@ -1055,9 +1056,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
<div className="container px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<span className="flex items-center gap-2">
Made with <span className="text-red-500">❤️</span> for the skateboarding community
{t('chromeUi.madeWith')} <span className="text-red-500">❤️</span> {t('chromeUi.forSkateCommunity')}
<span className="inline-flex items-center rounded-full border border-[#ff0040]/50 bg-[#ff0040]/10 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-[#ff0040]">
Beta
{t('chromeUi.beta')}
</span>
</span>
<div className="flex items-center gap-4 text-xs text-gray-500">
@ -1065,28 +1066,28 @@ export function Layout({ children, noPadding }: LayoutProps) {
to="/newsletter"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Newsletter
{t('nav.newsletter')}
</Link>
<Link
to="/license"
className="hover:text-[#ccff00] transition-colors font-medium"
>
License
{t('chromeUi.license')}
</Link>
<Link
to="/privacy"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Privacy
{t('chromeUi.privacy')}
</Link>
<Link
to="/supporters"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Collaborators &amp; Friends
{t('chromeUi.collaborators')}
</Link>
<span className="flex items-center gap-1.5">
<span>Not sponsored by</span>
<span>{t('chromeUi.notSponsoredBy')}</span>
<span className="inline-flex items-center gap-1.5">
<img
src="https://opensats.org/favicon.ico"

View file

@ -6,6 +6,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { VisibilityPicker } from '@/components/ConfirmMembershipCard';
import { cn } from '@/lib/utils';
import type { MemberVisibility } from '@/lib/types';
@ -33,6 +34,7 @@ export function MemberVisibilityControl({
claimedMembers,
accentBg = 'bg-teal-500 hover:bg-teal-600',
}: Props) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const { mutateAsync: republish, isPending } = useConfirmMembership();
@ -64,16 +66,16 @@ export function MemberVisibilityControl({
try {
await republish({ kind, founderPubkey, identifier, collectiveName, visibility });
toast({
title: 'Visibility updated',
title: t('socialUi.memberVisibility.updatedTitle'),
description:
visibility === 'crew'
? 'You now appear only to crew-mates.'
: 'You now appear publicly in this crew.',
? t('socialUi.memberVisibility.updatedCrew')
: t('socialUi.memberVisibility.updatedPublic'),
});
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Please try again.',
title: t('socialUi.memberVisibility.couldNotUpdate'),
description: error instanceof Error ? error.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -89,7 +91,7 @@ export function MemberVisibilityControl({
<Globe className="h-4 w-4 text-gray-400" />
)}
<p className="text-xs font-black uppercase tracking-widest text-gray-400">
Your visibility in this crew
{t('socialUi.memberVisibility.title')}
</p>
</div>
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
@ -101,7 +103,7 @@ export function MemberVisibilityControl({
className={cn('w-full gap-2 font-bold', accentBg)}
>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Save visibility
{t('socialUi.memberVisibility.save')}
</Button>
)}
</CardContent>

View file

@ -40,6 +40,7 @@ import {
Zap,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useTranslation } from '@/contexts/language';
// Kind constants
const KINDS = {
@ -122,6 +123,7 @@ function EventRow({
event: NostrEvent;
onDelete: (event: NostrEvent) => void;
}) {
const { t } = useTranslation();
const config = KIND_CONFIG[event.kind] || { label: `Kind ${event.kind}`, icon: FileJson, color: 'text-gray-400', category: 'other' };
const Icon = config.icon;
@ -130,7 +132,7 @@ function EventRow({
// For reactions, show what was reacted to
if (event.kind === KINDS.REACTION) {
const emoji = event.content || '❤️';
return `${emoji} reaction`;
return t('settingsUi.myData.reactionTitle', { emoji });
}
// Check for title tag
@ -213,6 +215,7 @@ function SelectiveDeletionDialog({
onDeleteSelected: (events: NostrEvent[]) => Promise<void>;
isDeleting: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [selectedEvents, setSelectedEvents] = useState<Set<string>>(new Set());
const [filterKind, setFilterKind] = useState<number | 'all'>('all');
@ -271,15 +274,15 @@ function SelectiveDeletionDialog({
// Available filter tabs
const filterTabs: { kind: number | 'all'; label: string; icon: typeof MapPin; color: string }[] = [
{ kind: 'all', label: 'All', icon: Database, color: 'text-white' },
...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: 'Spots', icon: MapPin, color: 'text-[#ccff00]' }] : []),
...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: 'Events', icon: Calendar, color: 'text-[#ff0040]' }] : []),
...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: 'Shops', icon: Store, color: 'text-purple-400' }] : []),
...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: 'Listings', icon: ShoppingBag, color: 'text-orange-400' }] : []),
...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: 'Collections', icon: FolderHeart, color: 'text-pink-400' }] : []),
...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: 'Lab', icon: GraduationCap, color: 'text-green-400' }] : []),
...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: 'Posts', icon: MessageSquare, color: 'text-blue-400' }] : []),
...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: 'Reactions', icon: Heart, color: 'text-pink-400' }] : []),
{ kind: 'all', label: t('settingsUi.myData.all'), icon: Database, color: 'text-white' },
...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: t('settingsUi.myData.spots'), icon: MapPin, color: 'text-[#ccff00]' }] : []),
...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: t('settingsUi.myData.events'), icon: Calendar, color: 'text-[#ff0040]' }] : []),
...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: t('settingsUi.myData.shops'), icon: Store, color: 'text-purple-400' }] : []),
...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: t('settingsUi.myData.listings'), icon: ShoppingBag, color: 'text-orange-400' }] : []),
...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: t('settingsUi.myData.collections'), icon: FolderHeart, color: 'text-pink-400' }] : []),
...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: t('settingsUi.myData.lab'), icon: GraduationCap, color: 'text-green-400' }] : []),
...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: t('settingsUi.myData.posts'), icon: MessageSquare, color: 'text-blue-400' }] : []),
...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: t('settingsUi.myData.reactions'), icon: Heart, color: 'text-pink-400' }] : []),
];
// Handle special filter cases (Lab = Coach + Filmer, Posts = Post + Comment)
@ -301,17 +304,17 @@ function SelectiveDeletionDialog({
className="flex-1 border-red-500/50 text-red-400 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Request Deletion'}
{isDeleting ? t('settingsUi.myData.deleting') : t('settingsUi.myData.requestDeletion')}
</Button>
</DialogTrigger>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-white">
<Trash2 className="h-5 w-5 text-red-400" />
Select Content to Delete
{t('settingsUi.myData.selectContentTitle')}
</DialogTitle>
<DialogDescription className="text-gray-400">
Select the specific items you want to request deletion for.
{t('settingsUi.myData.selectContentDesc')}
</DialogDescription>
</DialogHeader>
@ -351,13 +354,13 @@ function SelectiveDeletionDialog({
<div className="flex items-center justify-between flex-shrink-0">
<div className="flex gap-2 text-xs">
<button onClick={selectAllVisible} className="text-[#ccff00] hover:underline">
Select All {filterKind !== 'all' ? 'Visible' : ''}
{filterKind !== 'all' ? t('settingsUi.myData.selectAllVisible') : t('settingsUi.myData.selectAll')}
</button>
<span className="text-gray-600">|</span>
<button onClick={selectNone} className="text-gray-400 hover:underline">Clear</button>
<button onClick={selectNone} className="text-gray-400 hover:underline">{t('common.clear')}</button>
</div>
<span className="text-xs text-gray-500">
{selectedCount} selected
{t('settingsUi.myData.selectedCount', { count: selectedCount })}
</span>
</div>
@ -365,7 +368,7 @@ function SelectiveDeletionDialog({
<div className="flex-1 overflow-y-auto space-y-1.5 pr-1 min-h-0">
{displayEvents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No items in this category
{t('settingsUi.myData.noItemsCategory')}
</div>
) : (
displayEvents.map((event) => {
@ -407,8 +410,7 @@ function SelectiveDeletionDialog({
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<p className="text-yellow-200 text-xs">
<strong>Important:</strong> Nostr is decentralized. Relays <em>should</em> honor deletion requests,
but some may not. This is not a guaranteed permanent deletion.
<strong>{t('settingsUi.myData.decentralizedStrong')}</strong> {t('settingsUi.myData.decentralizedNote')}
</p>
</div>
</div>
@ -420,7 +422,7 @@ function SelectiveDeletionDialog({
onClick={() => setOpen(false)}
className="border-gray-700"
>
Cancel
{t('common.cancel')}
</Button>
<Button
onClick={handleDelete}
@ -428,10 +430,10 @@ function SelectiveDeletionDialog({
className="bg-red-500 hover:bg-red-600 text-white"
>
{isDeleting ? (
'Deleting...'
t('settingsUi.myData.deleting')
) : (
<>
Delete {selectedCount} {selectedCount === 1 ? 'Item' : 'Items'}
{t('settingsUi.myData.deleteCount', { count: selectedCount, s: selectedCount === 1 ? '' : 's' })}
</>
)}
</Button>
@ -443,6 +445,7 @@ function SelectiveDeletionDialog({
export function MyDataTab() {
const { user } = useCurrentUser();
const { t } = useTranslation();
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
@ -526,8 +529,8 @@ export function MyDataTab() {
const handleExportData = () => {
if (!userEvents || userEvents.length === 0) {
toast({
title: 'No data to export',
description: 'You haven\'t published any events yet.',
title: t('settingsUi.myData.noDataTitle'),
description: t('settingsUi.myData.noDataDesc'),
variant: 'destructive',
});
return;
@ -554,8 +557,8 @@ export function MyDataTab() {
URL.revokeObjectURL(url);
toast({
title: 'Data exported!',
description: `Downloaded ${userEvents.length} events as JSON.`,
title: t('settingsUi.myData.exportedTitle'),
description: t('settingsUi.myData.exportedDesc', { count: userEvents.length }),
});
};
@ -579,16 +582,16 @@ export function MyDataTab() {
});
toast({
title: 'Deletion requested',
description: 'The deletion request has been published to relays.',
title: t('settingsUi.myData.deletionRequestedTitle'),
description: t('settingsUi.myData.deletionRequestedDesc'),
});
refetch();
} catch (error) {
console.error('Failed to delete event:', error);
toast({
title: 'Deletion failed',
description: 'Could not publish deletion request. Please try again.',
title: t('settingsUi.myData.deletionFailedTitle'),
description: t('settingsUi.myData.deletionFailedDesc'),
variant: 'destructive',
});
}
@ -627,8 +630,8 @@ export function MyDataTab() {
});
toast({
title: 'Deletion requested',
description: `Requested deletion of ${eventsToDelete.length} items. Note: Some relays may not honor this request.`,
title: t('settingsUi.myData.deletionRequestedTitle'),
description: t('settingsUi.myData.deletionRequestedMany', { count: eventsToDelete.length }),
});
queryClient.invalidateQueries({ queryKey: ['my-data'] });
@ -636,8 +639,8 @@ export function MyDataTab() {
} catch (error) {
console.error('Failed to delete events:', error);
toast({
title: 'Deletion failed',
description: 'Could not publish deletion request. Please try again.',
title: t('settingsUi.myData.deletionFailedTitle'),
description: t('settingsUi.myData.deletionFailedDesc'),
variant: 'destructive',
});
} finally {
@ -650,9 +653,9 @@ export function MyDataTab() {
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Login Required</h3>
<h3 className="text-lg font-medium mb-2">{t('settingsUi.myData.loginRequired')}</h3>
<p className="text-muted-foreground mb-6">
Log in to view and manage your data.
{t('settingsUi.myData.loginRequiredDesc')}
</p>
<LoginArea className="justify-center" />
</CardContent>
@ -669,10 +672,10 @@ export function MyDataTab() {
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-white">
<Database className="h-5 w-5 text-[#ccff00]" />
My Data
{t('settingsUi.myData.title')}
</CardTitle>
<CardDescription className="text-gray-300">
Your content on the Nostr network. You own this data - export it anytime or request deletion.
{t('settingsUi.myData.desc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 sm:space-y-6">
@ -680,17 +683,17 @@ export function MyDataTab() {
<div className="space-y-2 sm:space-y-3">
{/* Row 1: Main content */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<StatCard icon={MapPin} label="Spots" count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
<StatCard icon={Calendar} label="Events" count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
<StatCard icon={Store} label="Shops" count={stats.shops} color="text-purple-400" isLoading={isLoading} />
<StatCard icon={ShoppingBag} label="Listings" count={stats.listings} color="text-orange-400" isLoading={isLoading} />
<StatCard icon={MapPin} label={t('settingsUi.myData.spots')} count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
<StatCard icon={Calendar} label={t('settingsUi.myData.events')} count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
<StatCard icon={Store} label={t('settingsUi.myData.shops')} count={stats.shops} color="text-purple-400" isLoading={isLoading} />
<StatCard icon={ShoppingBag} label={t('settingsUi.myData.listings')} count={stats.listings} color="text-orange-400" isLoading={isLoading} />
</div>
{/* Row 2: Social & Lab */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<StatCard icon={MessageSquare} label="Posts" count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
<StatCard icon={GraduationCap} label="Lab" count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle="Coach/Filmer" />
<StatCard icon={Heart} label="Reactions" count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
<StatCard icon={Zap} label="Zaps" count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle="Received" />
<StatCard icon={MessageSquare} label={t('settingsUi.myData.posts')} count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
<StatCard icon={GraduationCap} label={t('settingsUi.myData.lab')} count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle={t('settingsUi.myData.coachFilmer')} />
<StatCard icon={Heart} label={t('settingsUi.myData.reactions')} count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
<StatCard icon={Zap} label={t('settingsUi.myData.zaps')} count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle={t('settingsUi.myData.received')} />
</div>
</div>
@ -702,7 +705,7 @@ export function MyDataTab() {
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
>
<Download className="h-4 w-4 mr-2" />
Export All Data
{t('settingsUi.myData.exportAll')}
</Button>
<SelectiveDeletionDialog
@ -718,7 +721,7 @@ export function MyDataTab() {
<Card className="bg-black/50 border-gray-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg text-white">Recent Activity</CardTitle>
<CardTitle className="text-lg text-white">{t('settingsUi.myData.recentActivity')}</CardTitle>
{userEvents && userEvents.length > 10 && (
<Button
variant="ghost"
@ -726,7 +729,7 @@ export function MyDataTab() {
onClick={() => setShowAllActivity(!showAllActivity)}
className="text-[#ccff00] hover:text-[#ccff00]/80"
>
{showAllActivity ? 'Show Less' : `View All (${userEvents.length})`}
{showAllActivity ? t('common.showLess') : t('settingsUi.myData.viewAll', { count: userEvents.length })}
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
)}
@ -742,9 +745,9 @@ export function MyDataTab() {
) : !userEvents?.length ? (
<div className="text-center py-8">
<Database className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400">No data published yet</p>
<p className="text-gray-400">{t('settingsUi.myData.noDataPublished')}</p>
<p className="text-gray-500 text-sm mt-1">
Start by <Link to="/create-spot" className="text-[#ccff00] hover:underline">adding a spot</Link>!
{t('settingsUi.myData.startBy')} <Link to="/create-spot" className="text-[#ccff00] hover:underline">{t('settingsUi.myData.addingASpot')}</Link>!
</p>
</div>
) : (
@ -760,29 +763,28 @@ export function MyDataTab() {
{/* Data Ownership Info */}
<Card className="bg-blue-950/30 border-blue-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-blue-400 text-base">About Your Data</CardTitle>
<CardTitle className="text-blue-400 text-base">{t('settingsUi.myData.aboutYourData')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-gray-300">
<p>
<strong className="text-white">You own your data.</strong> All content is signed with your cryptographic keys
and stored on Nostr relays. No company controls it.
<strong className="text-white">{t('settingsUi.myData.ownDataStrong')}</strong> {t('settingsUi.myData.ownData')}
</p>
<ul className="space-y-2 ml-4">
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Export anytime</strong> - Download all your events as JSON</span>
<span><strong>{t('settingsUi.myData.exportAnytimeStrong')}</strong> {t('settingsUi.myData.exportAnytime')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Portable</strong> - Use your keys in any Nostr app</span>
<span><strong>{t('settingsUi.myData.portableStrong')}</strong> {t('settingsUi.myData.portable')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Selective deletion</strong> - Choose exactly what to delete</span>
<span><strong>{t('settingsUi.myData.selectiveStrong')}</strong> {t('settingsUi.myData.selective')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-yellow-400">⚠</span>
<span><strong>Decentralized caveat</strong> - Some relays may retain data despite deletion requests</span>
<span><strong>{t('settingsUi.myData.caveatStrong')}</strong> {t('settingsUi.myData.caveat')}</span>
</li>
</ul>
</CardContent>

View file

@ -16,6 +16,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCrewmates } from '@/hooks/useCrewmates';
import { useFollowing } from '@/hooks/useFollowing';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { MessageCircle, Search, Users, UserCheck, ArrowRight, CheckCircle2 } from 'lucide-react';
@ -93,6 +94,7 @@ function PersonRow({
* paste-only flow so starting a DM feels like a real contacts screen.
*/
export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { crewmates, isLoading: crewmatesLoading } = useCrewmates();
@ -115,19 +117,19 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
group: 'crew',
hint:
cm.sharedCrews.length > 0
? `In your crew${cm.sharedCrews.length > 1 ? 's' : ''}: ${cm.sharedCrews.join(', ')}`
: 'Crew-mate',
? t('socialUi.newMessage.inCrew', { crews: cm.sharedCrews.join(', ') })
: t('socialUi.newMessage.crewmate'),
});
}
for (const pk of following ?? []) {
if (pk === user?.pubkey || seen.has(pk)) continue;
seen.add(pk);
list.push({ pubkey: pk, group: 'following', hint: 'Following' });
list.push({ pubkey: pk, group: 'following', hint: t('socialUi.newMessage.following') });
}
return list;
}, [crewmates, following, user?.pubkey]);
}, [crewmates, following, user?.pubkey, t]);
const crewCandidates = candidates.filter((c) => c.group === 'crew');
const followingCandidates = candidates.filter((c) => c.group === 'following');
@ -179,7 +181,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
}
goToConversation(pubkey);
} catch {
setRawError('That doesn\u2019t look like a valid npub or public key.');
setRawError(t('socialUi.newMessage.invalidKey'));
}
};
@ -192,10 +194,10 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<MessageCircle className="h-5 w-5 text-[#ff0040]" />
New Message
{t('socialUi.dms.newMessage')}
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
Search your people by name, or paste an npub to message anyone. Select two or more to start a group.
{t('socialUi.newMessage.description')}
</DialogDescription>
</DialogHeader>
@ -208,7 +210,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
setQuery(e.target.value);
setRawError('');
}}
placeholder="Search name or paste npub1..."
placeholder={t('socialUi.newMessage.searchPlaceholder')}
className="bg-gray-900 border-gray-700 text-white pl-9 h-11"
autoFocus
/>
@ -220,7 +222,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
onClick={handleOpenRaw}
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-11"
>
Message this key
{t('socialUi.newMessage.messageThisKey')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
@ -231,16 +233,16 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
<ScrollArea className="h-[min(50vh,360px)] pr-2">
{isLoading ? (
<div className="py-10 text-center text-sm text-gray-500">
Loading your people...
{t('socialUi.newMessage.loadingPeople')}
</div>
) : !hasAnyCandidates ? (
<div className="py-10 text-center px-4">
<Users className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-sm text-gray-400">
No crew-mates or follows yet.
{t('socialUi.newMessage.noPeople')}
</p>
<p className="text-xs text-gray-600 mt-1">
Paste someone&apos;s npub above to start a conversation.
{t('socialUi.newMessage.noPeopleHint')}
</p>
</div>
) : (
@ -248,7 +250,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
{crewCandidates.length > 0 && (
<div>
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-teal-400">
<Users className="h-3.5 w-3.5" /> Your crews
<Users className="h-3.5 w-3.5" /> {t('socialUi.newMessage.yourCrews')}
</p>
<div className="space-y-1">
{crewCandidates.map((c) => (
@ -266,7 +268,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
{followingCandidates.length > 0 && (
<div>
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400">
<UserCheck className="h-3.5 w-3.5" /> Following
<UserCheck className="h-3.5 w-3.5" /> {t('socialUi.newMessage.following')}
</p>
<div className="space-y-1">
{followingCandidates.map((c) => (
@ -288,12 +290,12 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
{selected.length > 0 && (
<div className="flex items-center gap-2 border-t border-white/5 pt-3">
<span className="text-xs text-gray-400">{selected.length} selected</span>
<span className="text-xs text-gray-400">{t('socialUi.newMessage.selected', { count: selected.length })}</span>
<Button
onClick={startChat}
className="ml-auto h-10 bg-[#ff0040] font-bold text-white hover:bg-[#ff0040]/90"
>
{selected.length > 1 ? `Start group (${selected.length})` : 'Message'}
{selected.length > 1 ? t('socialUi.newMessage.startGroup', { count: selected.length }) : t('socialUi.newMessage.message')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>

View file

@ -7,6 +7,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsletterArticle';
@ -18,6 +19,7 @@ import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsle
* untouched — this just mirrors the archive onto Nostr for subscribers/readers.
*/
export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[] }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { getWriteRelays } = useSkateRelays();
@ -42,7 +44,7 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
const ordered = [...missing].sort((a, b) => a.number - b.number);
for (const issue of ordered) {
try {
if (!user) throw new Error('Not logged in');
if (!user) throw new Error(t('walletUi.newsletter.notLoggedIn'));
const template = buildIssueArticle(issue);
const event = await user.signer.signEvent({
kind: template.kind,
@ -69,13 +71,13 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
if (failures.length === 0) {
toast({
title: `Backfilled ${published} issue${published === 1 ? '' : 's'} 🛹`,
description: 'The archive now lives on Nostr for subscribers and readers.',
title: t('walletUi.newsletter.backfilled', { count: published }),
description: t('walletUi.newsletter.backfilledDesc'),
});
} else {
toast({
title: `Published ${published}, ${failures.length} failed`,
description: `Retry to finish: #${failures.map((n) => String(n).padStart(3, '0')).join(', #')}.`,
title: t('walletUi.newsletter.partialBackfill', { published, failed: failures.length }),
description: t('walletUi.newsletter.retryFinish', { list: failures.map((n) => `#${String(n).padStart(3, '0')}`).join(', ') }),
variant: 'destructive',
});
setDone(false);
@ -85,7 +87,7 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
if (missing.length === 0) {
return (
<p className="mt-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-green-500/70">
<Check className="h-3 w-3" /> Archive synced to Nostr
<Check className="h-3 w-3" /> {t('walletUi.newsletter.archiveSynced')}
</p>
);
}
@ -96,12 +98,10 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
<div className="min-w-0">
<p className="text-xs font-bold text-yellow-200">
{missing.length} archive issue{missing.length === 1 ? '' : 's'} not on Nostr yet
{t('walletUi.newsletter.missingCount', { count: missing.length })}
</p>
<p className="mt-0.5 text-[11px] leading-snug text-gray-400">
Publish {missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ')} as
NIP-23 articles so subscribers and long-form readers can see the full archive. The page
keeps its rich layout regardless.
{t('walletUi.newsletter.backfillDesc', { list: missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ') })}
</p>
<Button
size="sm"
@ -116,7 +116,7 @@ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[]
) : (
<Archive className="h-3.5 w-3.5" />
)}
{running ? 'Publishing…' : done ? 'Done' : `Backfill ${missing.length} to Nostr`}
{running ? t('walletUi.newsletter.publishing') : done ? t('common.done') : t('walletUi.newsletter.backfill', { count: missing.length })}
</Button>
</div>
</div>

View file

@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
@ -35,6 +36,7 @@ function slugify(input: string): string {
* their notification bell's "News" tab and it appears on the /newsletter page.
*/
export function NewsletterComposer() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { getWriteRelays } = useSkateRelays();
@ -98,11 +100,11 @@ export function NewsletterComposer() {
),
);
if (results.every((r) => r.status === 'rejected')) {
throw new Error('Could not reach any relay — check your connection and try again.');
throw new Error(t('walletUi.newsletter.couldNotReachRelay'));
}
toast({
title: 'Issue published! 🛹',
description: 'Subscribers will see it in their notification bell.',
title: t('walletUi.newsletter.issuePublished'),
description: t('walletUi.newsletter.issuePublishedDesc'),
});
queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] });
setOpen(false);
@ -113,8 +115,8 @@ export function NewsletterComposer() {
setBody('');
} catch (err) {
toast({
title: 'Publish failed',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('walletUi.newsletter.publishFailed'),
description: err instanceof Error ? err.message : t('walletUi.newsletter.tryAgain'),
variant: 'destructive',
});
} finally {
@ -129,23 +131,22 @@ export function NewsletterComposer() {
variant="outline"
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<PenLine className="h-4 w-4" /> New issue
<PenLine className="h-4 w-4" /> {t('walletUi.newsletter.newIssue')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg w-[calc(100vw-2rem)] bg-black border-2 border-[#ccff00]/30">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Sparkles className="h-4 w-4 text-[#ccff00]" /> Publish a newsletter issue
<Sparkles className="h-4 w-4 text-[#ccff00]" /> {t('walletUi.newsletter.composerTitle')}
</DialogTitle>
<DialogDescription className="text-xs">
Published as a NIP-23 article (kind 30023) on the community relay. Subscribers get it in
their notification bell. Body supports Markdown.
{t('walletUi.newsletter.composerDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-400">Title</Label>
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.fieldTitle')}</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
@ -157,7 +158,7 @@ export function NewsletterComposer() {
<div>
<Label className="text-xs text-gray-400">
Identifier (slug) <span className="text-gray-600">· leave blank to auto-generate</span>
{t('walletUi.newsletter.identifier')} <span className="text-gray-600">· {t('walletUi.newsletter.leaveBlank')}</span>
</Label>
<Input
value={slug}
@ -167,12 +168,12 @@ export function NewsletterComposer() {
className="mt-1 font-mono text-sm"
/>
<p className="mt-1 text-[10px] text-gray-600">
Re-using an existing slug edits that issue instead of creating a new one.
{t('walletUi.newsletter.reusingSlug')}
</p>
</div>
<div>
<Label className="text-xs text-gray-400">Summary <span className="text-gray-600">· optional</span></Label>
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.summary')} <span className="text-gray-600">· {t('walletUi.newsletter.optional')}</span></Label>
<Input
value={summary}
onChange={(e) => setSummary(e.target.value)}
@ -183,7 +184,7 @@ export function NewsletterComposer() {
</div>
<div>
<Label className="text-xs text-gray-400">Cover image URL <span className="text-gray-600">· optional</span></Label>
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.coverImage')} <span className="text-gray-600">· {t('walletUi.newsletter.optional')}</span></Label>
<Input
value={image}
onChange={(e) => setImage(e.target.value)}
@ -193,14 +194,14 @@ export function NewsletterComposer() {
</div>
<div>
<Label className="text-xs text-gray-400">Body (Markdown)</Label>
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.bodyMarkdown')}</Label>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={'## What shipped\n\n- New thing one\n- New thing two\n\nRead more at **skate-spots.org**.'}
className="mt-1 min-h-[200px] font-mono text-sm leading-relaxed"
/>
<p className="mt-1 text-right text-[10px] text-gray-600">{body.length} characters</p>
<p className="mt-1 text-right text-[10px] text-gray-600">{t('walletUi.newsletter.characters', { count: body.length })}</p>
</div>
<Button
@ -209,7 +210,7 @@ export function NewsletterComposer() {
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
>
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Publish issue
{t('walletUi.newsletter.publishIssue')}
</Button>
</div>
</DialogContent>

View file

@ -10,6 +10,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ZapButton } from '@/components/ZapButton';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { NEWSLETTER_KIND, parseNewsletterIssue, type SkateNewsletterIssue } from '@/lib/types';
@ -100,6 +101,7 @@ function renderMarkdown(md: string) {
}
export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; identifier: string }) {
const { t } = useTranslation();
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const author = useAuthor(pubkey);
@ -163,11 +165,11 @@ export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; id
to="/newsletter"
className="mb-6 inline-flex items-center gap-2 text-sm text-gray-400 transition-colors hover:text-[#ccff00]"
>
<ArrowLeft className="h-4 w-4" /> All issues
<ArrowLeft className="h-4 w-4" /> {t('walletUi.newsletter.allIssues')}
</Link>
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
<Radio className="h-3 w-3" /> SkateSpots Newsletter
<Radio className="h-3 w-3" /> {t('walletUi.newsletter.brand')}
</div>
{issue.image && (
@ -203,7 +205,7 @@ export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; id
{/* Footer CTA: zap the issue + subscribe */}
<div className="mt-10 flex flex-col items-center gap-4 rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] p-6 text-center">
<p className="text-sm font-bold text-white">Dig the drop? Zap it and never miss the next one.</p>
<p className="text-sm font-bold text-white">{t('walletUi.newsletter.digTheDrop')}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<ZapButton target={issue.event} className="bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" />
<NewsletterSubscribeButton />

View file

@ -4,6 +4,7 @@ import { X, Radio, Pencil } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { NEWSLETTER_ISSUES } from '@/lib/newsletterIssues';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
/**
@ -41,6 +42,7 @@ function formatIssueNumber(n: number): string {
// ─── Floating "Latest Drop" pill ─────────────────────────────────────────────
function LatestDropPill() {
const { t } = useTranslation();
const location = useLocation();
const latest = NEWSLETTER_ISSUES[0];
const [dismissed, setDismissed] = useLocalStorage<number>(
@ -70,16 +72,16 @@ function LatestDropPill() {
>
<Radio className="h-3.5 w-3.5 text-[#ccff00] animate-pulse flex-shrink-0" />
<span className="text-xs font-black text-[#ccff00] uppercase tracking-wide whitespace-nowrap">
Drop {formatIssueNumber(latest.number)} is out
{t('walletUi.newsletter.dropIsOut', { number: formatIssueNumber(latest.number) })}
</span>
<span className="text-[10px] text-gray-500 hidden sm:inline">
· {age === 0 ? 'Today' : age === 1 ? 'Yesterday' : `${age}d ago`}
· {age === 0 ? t('time.today') : age === 1 ? t('time.yesterday') : t('walletUi.newsletter.daysAgo', { count: age })}
</span>
</Link>
<button
onClick={() => setDismissed(latest.number)}
className="flex items-center justify-center w-7 h-7 flex-shrink-0 rounded-full bg-black/98 border border-white/10 text-gray-500 hover:text-white hover:border-white/30 transition-colors touch-manipulation active:scale-95"
aria-label="Dismiss"
aria-label={t('walletUi.newsletter.dismiss')}
>
<X className="h-3 w-3" />
</button>
@ -90,6 +92,7 @@ function LatestDropPill() {
// ─── Monday admin reminder ────────────────────────────────────────────────────
function MondayAdminReminder() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const location = useLocation();
const [dismissed, setDismissed] = useLocalStorage<string>(
@ -121,10 +124,10 @@ function MondayAdminReminder() {
</div>
<div className="flex-1 min-w-0">
<p className="text-[#ccff00] font-black text-xs uppercase tracking-wide mb-1">
It's Monday
{t('walletUi.newsletter.itsMonday')}
</p>
<p className="text-gray-300 text-xs leading-relaxed mb-3">
Time to drop the weekly newsletter. One click and the AI writes it from the git log.
{t('walletUi.newsletter.mondayDesc')}
</p>
<a
href={WRITE_NEWSLETTER_URL}
@ -134,13 +137,13 @@ function MondayAdminReminder() {
onClick={() => setDismissed(todayKey)}
>
<Pencil className="h-3.5 w-3.5" />
Write Drop #{String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}
{t('walletUi.newsletter.writeDrop', { number: `#${String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}` })}
</a>
</div>
<button
onClick={() => setDismissed(todayKey)}
className="flex-shrink-0 text-gray-600 hover:text-gray-400 transition-colors"
aria-label="Dismiss reminder"
aria-label={t('walletUi.newsletter.dismissReminder')}
>
<X className="h-4 w-4" />
</button>

View file

@ -5,6 +5,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/**
@ -13,6 +14,7 @@ import { cn } from '@/lib/utils';
* login prompt instead.
*/
export function NewsletterSubscribeButton({ className }: { className?: string }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { isSubscribed, isLoading, setSubscribed, isUpdating } = useNewsletterSubscription();
const { toast } = useToast();
@ -21,7 +23,7 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
if (!user) {
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<p className="text-xs text-gray-500">Log in with Nostr to get new drops in your mailbox.</p>
<p className="text-xs text-gray-500">{t('walletUi.newsletter.loginPrompt')}</p>
<LoginArea className="max-w-60" />
</div>
);
@ -35,19 +37,19 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
setJustSubscribed(true);
setTimeout(() => setJustSubscribed(false), 2500);
toast({
title: "You're subscribed! 🛹",
description: 'New drops will land in your notification bell under "News".',
title: t('walletUi.newsletter.subscribedToast'),
description: t('walletUi.newsletter.subscribedToastDesc'),
});
} else {
toast({
title: 'Unsubscribed',
description: 'You can still read every issue here anytime.',
title: t('walletUi.newsletter.unsubscribed'),
description: t('walletUi.newsletter.unsubscribedDesc'),
});
}
} catch (err) {
toast({
title: 'Something went wrong',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('walletUi.addToCollection.somethingWrong'),
description: err instanceof Error ? err.message : t('walletUi.newsletter.tryAgain'),
variant: 'destructive',
});
}
@ -56,7 +58,7 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
if (isLoading) {
return (
<Button disabled className={cn('gap-2 h-11', className)}>
<Loader2 className="h-4 w-4 animate-spin" /> Checking…
<Loader2 className="h-4 w-4 animate-spin" /> {t('walletUi.newsletter.checking')}
</Button>
);
}
@ -82,8 +84,8 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
<BellOff className="hidden h-4 w-4 group-hover:block" />
</>
)}
<span className="group-hover:hidden">Subscribed</span>
<span className="hidden group-hover:inline">Unsubscribe</span>
<span className="group-hover:hidden">{t('walletUi.newsletter.subscribed')}</span>
<span className="hidden group-hover:inline">{t('walletUi.newsletter.unsubscribe')}</span>
</Button>
);
}
@ -98,7 +100,7 @@ export function NewsletterSubscribeButton({ className }: { className?: string })
)}
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
Subscribe to the newsletter
{t('walletUi.newsletter.subscribeCta')}
</Button>
);
}

View file

@ -4,6 +4,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface Nip05RequiredProps {
/** What the user is trying to do, e.g. "tour organizers", "sellers". */
@ -29,10 +30,11 @@ export function Nip05Required({
color = '#ef4444',
className,
}: Nip05RequiredProps) {
const { t } = useTranslation();
const providerList = (
<div className="space-y-2">
<p className="text-[11px] font-black uppercase tracking-widest text-gray-400">
Get a free / cheap NIP-05
{t('settingsUi.nip05Required.getFree')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{NIP05_PROVIDERS.map((p) => (
@ -58,7 +60,7 @@ export function Nip05Required({
: 'border-yellow-500/30 bg-yellow-500/15 text-yellow-400'
)}
>
{p.free ? 'Free' : 'Paid'}
{p.free ? t('common.free') : t('settingsUi.nip05Required.paid')}
</span>
</a>
))}
@ -77,11 +79,11 @@ export function Nip05Required({
<AlertTriangle className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color }} />
<div>
<h3 className="font-bold mb-1" style={{ color }}>
NIP-05 Verification Required
{t('settingsUi.nip05Required.requiredTitle')}
</h3>
<p className="text-sm text-gray-300">
{reason ??
`To prevent spam and build trust, ${role} need a verified Nostr address (NIP-05).`}
t('settingsUi.nip05Required.bannerReason', { role })}
</p>
</div>
</div>
@ -93,7 +95,7 @@ export function Nip05Required({
className="font-bold"
style={{ borderColor: `${color}55`, color }}
>
Add NIP-05 in Settings
{t('settingsUi.nip05Required.addInSettings')}
</Button>
</Link>
</CardContent>
@ -114,14 +116,14 @@ export function Nip05Required({
<ShieldCheck className="h-8 w-8" style={{ color }} />
</div>
<h2 className="text-xl font-bold mb-2" style={{ color }}>
NIP-05 Verification Required
{t('settingsUi.nip05Required.requiredTitle')}
</h2>
<p className="text-gray-300 mb-2">
{reason ??
`To prevent spam and build trust, ${role} must have NIP-05 verification.`}
t('settingsUi.nip05Required.gateReason', { role })}
</p>
<p className="text-gray-500 text-sm mb-6">
NIP-05 proves your identity by linking your Nostr profile to a domain — like{' '}
{t('settingsUi.nip05Required.explainer')}{' '}
<span className="text-gray-300">you@example.com</span>.
</p>
@ -129,7 +131,7 @@ export function Nip05Required({
<Link to="/settings?section=nip05">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
Update Your Profile
{t('settingsUi.nip05Required.updateProfile')}
</Button>
</Link>
</CardContent>

View file

@ -23,6 +23,7 @@ import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { NEWSLETTER_KIND, SKATE_CREW_KIND, type MemberVisibility, type SkateCrew, type SkateNewsletterIssue } from '@/lib/types';
@ -34,6 +35,7 @@ import { cn } from '@/lib/utils';
* can confirm (with a visibility choice) right from the bell.
*/
function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?: () => void }) {
const { t } = useTranslation();
const isPending = useIsPendingMember(crew);
const founder = useAuthor(crew.pubkey);
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
@ -56,12 +58,12 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
visibility,
});
setDone(true);
toast({ title: `You're in ${crew.name}! 🛹`, description: 'Membership confirmed.' });
toast({ title: t('socialUi.bell.youAreIn', { crew: crew.name }), description: t('socialUi.bell.membershipConfirmed') });
onResolved?.();
} catch (err) {
toast({
title: 'Could not confirm',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.bell.couldNotConfirm'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -82,18 +84,18 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
</Avatar>
{founderName}
</span>{' '}
added you to{' '}
{t('socialUi.bell.addedYouTo')}{' '}
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="font-black text-teal-300 hover:underline">
{crew.name}
</Link>
. Confirm to appear as a member.
. {t('socialUi.bell.confirmToAppear')}
</p>
{/* Visibility toggle */}
<div className="mt-2 grid grid-cols-2 gap-1.5">
{([
{ key: 'public', label: 'Public', icon: Globe },
{ key: 'crew', label: 'Crew only', icon: EyeOff },
{ key: 'public', label: t('socialUi.confirmMembership.public'), icon: Globe },
{ key: 'crew', label: t('socialUi.confirmMembership.crewOnly'), icon: EyeOff },
] as const).map(({ key, label, icon: Icon }) => {
const active = visibility === key;
return (
@ -122,7 +124,7 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
className="mt-2.5 h-8 w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Confirm membership
{t('socialUi.confirmMembership.confirmButton')}
</Button>
</div>
</div>
@ -140,6 +142,7 @@ function NewsletterItem({
unread: boolean;
onNavigate: () => void;
}) {
const { t } = useTranslation();
const naddr = nip19.naddrEncode({
kind: NEWSLETTER_KIND,
pubkey: issue.pubkey,
@ -174,7 +177,7 @@ function NewsletterItem({
</div>
{issue.summary && <p className="line-clamp-2 text-xs text-gray-400">{issue.summary}</p>}
<p className="mt-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-gray-600">
{date} · Read <ArrowRight className="h-2.5 w-2.5" />
{date} · {t('socialUi.bell.read')} <ArrowRight className="h-2.5 w-2.5" />
</p>
</div>
</Link>
@ -182,10 +185,11 @@ function NewsletterItem({
}
function EmptyState({ label }: { label: string }) {
const { t } = useTranslation();
return (
<SharedEmptyState
icon={Bell}
title="You're all caught up"
title={t('socialUi.bell.allCaughtUp')}
hint={label}
className="py-10"
/>
@ -203,6 +207,7 @@ function EmptyState({ label }: { label: string }) {
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
*/
export function NotificationBell({ className }: { className?: string }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
useCrewNotifications();
@ -286,8 +291,8 @@ export function NotificationBell({ className }: { className?: string }) {
badgeTotal > 0 && 'text-[#ccff00]',
className,
)}
title="Notifications"
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
title={t('socialUi.bell.notifications')}
aria-label={badgeTotal > 0 ? t('socialUi.bell.notificationsNew', { count: badgeTotal }) : t('socialUi.bell.notifications')}
>
{/* Soft glow behind the bell when there's something waiting */}
{badgeTotal > 0 && (
@ -305,7 +310,7 @@ export function NotificationBell({ className }: { className?: string }) {
{badgeTotal > 0 && (
<span
aria-live="polite"
aria-label={`${badgeTotal} unread notifications`}
aria-label={t('socialUi.bell.unreadCount', { count: badgeTotal })}
className="absolute -right-1 -top-1 flex min-w-[18px] items-center justify-center rounded-full bg-[#ccff00] px-1 text-[10px] font-black leading-[18px] text-black shadow-[0_0_8px_rgba(204,255,0,0.6)] ring-2 ring-black"
>
{badgeTotal > 9 ? '9+' : badgeTotal}
@ -324,7 +329,7 @@ export function NotificationBell({ className }: { className?: string }) {
<span className="relative flex h-6 w-6 items-center justify-center rounded-lg bg-teal-500/15">
<Bell className="h-3.5 w-3.5 text-teal-400" />
</span>
Notifications
{t('socialUi.bell.notifications')}
{badgeTotal > 0 && (
<span className="rounded-full bg-[#ccff00] px-1.5 text-[10px] font-black leading-4 text-black">
{badgeTotal > 99 ? '99+' : badgeTotal}
@ -337,11 +342,11 @@ export function NotificationBell({ className }: { className?: string }) {
onClick={markAllRead}
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-black uppercase tracking-wide text-gray-300 transition-all active:scale-95 hover:border-teal-400/40 hover:bg-teal-500/10 hover:text-teal-300"
>
<CheckCheck className="h-3 w-3" /> Mark read
<CheckCheck className="h-3 w-3" /> {t('socialUi.bell.markRead')}
</button>
) : (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-600">
All clear
{t('socialUi.bell.allClear')}
</span>
)}
</div>
@ -352,34 +357,34 @@ export function NotificationBell({ className }: { className?: string }) {
value="all"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
>
All
{t('socialUi.bell.tabAll')}
{badgeTotal > 0 && <span className="ml-1 text-teal-400">{badgeTotal}</span>}
</TabsTrigger>
<TabsTrigger
value="trades"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ff6600] data-[state=active]:bg-transparent data-[state=active]:text-[#ff6600]"
>
<Handshake className="mr-1 h-3 w-3" /> Trades
<Handshake className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabTrades')}
{tradeNotifs.length > 0 && <span className="ml-1 text-[#ff6600]">{tradeNotifs.length}</span>}
</TabsTrigger>
<TabsTrigger
value="social"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
>
<Heart className="mr-1 h-3 w-3" /> Social
<Heart className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabSocial')}
</TabsTrigger>
<TabsTrigger
value="news"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-[#ccff00] data-[state=active]:bg-transparent data-[state=active]:text-[#ccff00]"
>
<Radio className="mr-1 h-3 w-3" /> News
<Radio className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabNews')}
{unreadNewsletter > 0 && <span className="ml-1 text-[#ccff00]">{unreadNewsletter}</span>}
</TabsTrigger>
<TabsTrigger
value="crews"
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
>
<Users className="mr-1 h-3 w-3" /> Crews
<Users className="mr-1 h-3 w-3" /> {t('socialUi.bell.tabCrews')}
{crewTotal > 0 && <span className="ml-1 text-teal-400">{crewTotal}</span>}
</TabsTrigger>
</TabsList>
@ -389,11 +394,11 @@ export function NotificationBell({ className }: { className?: string }) {
<TabsContent value="all" className="m-0 p-3">
{isLoading && badgeTotal === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && (
<EmptyState label="Offers, trades, zaps, replies, newsletter drops and crew activity will show up here." />
<EmptyState label={t('socialUi.bell.emptyAll')} />
)}
<div className="space-y-2.5">
{/* Buying/selling steps first — they're the most time-sensitive. */}
@ -441,11 +446,11 @@ export function NotificationBell({ className }: { className?: string }) {
<TabsContent value="trades" className="m-0 p-3">
{tradeLoading && tradeNotifs.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Checking offers and trades…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingTrades')}
</div>
)}
{!tradeLoading && tradeNotifs.length === 0 && (
<EmptyState label="Offers, counter-offers and trade confirmations all land here — whether you're selling, buying or swapping." />
<EmptyState label={t('socialUi.bell.emptyTrades')} />
)}
<div className="space-y-2.5">
{tradeNotifs.map((n) => (
@ -463,11 +468,11 @@ export function NotificationBell({ className }: { className?: string }) {
<TabsContent value="social" className="m-0 p-3">
{activityLoading && activity.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!activityLoading && activity.length === 0 && (
<EmptyState label="Zaps, reactions, reposts, mentions, replies, event RSVPs and host updates all land here." />
<EmptyState label={t('socialUi.bell.emptySocial')} />
)}
<div className="space-y-2.5">
{activity.map((n) => (
@ -485,11 +490,11 @@ export function NotificationBell({ className }: { className?: string }) {
<TabsContent value="news" className="m-0 p-3">
{newsletterLoading && newsletterIssues.length === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading the latest drops…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.loadingDrops')}
</div>
)}
{!newsletterLoading && newsletterIssues.length === 0 && (
<EmptyState label="SkateSpots newsletter drops will land here. Subscribe on the Newsletter page." />
<EmptyState label={t('socialUi.bell.emptyNews')} />
)}
{!isSubscribed && newsletterIssues.length > 0 && (
<Link
@ -498,7 +503,7 @@ export function NotificationBell({ className }: { className?: string }) {
className="mb-2.5 flex items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/[0.06] p-3 text-xs font-bold text-[#ccff00] transition-colors hover:bg-[#ccff00]/10"
>
<Radio className="h-4 w-4 flex-shrink-0" />
<span className="flex-1">Subscribe to get new drops in your bell</span>
<span className="flex-1">{t('socialUi.bell.subscribeBanner')}</span>
<ArrowRight className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
)}
@ -518,11 +523,11 @@ export function NotificationBell({ className }: { className?: string }) {
<TabsContent value="crews" className="m-0 p-3">
{crewLoading && crewTotal === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!crewLoading && crewTotal === 0 && (
<EmptyState label="Crew invites, collective requests and membership requests will show up here." />
<EmptyState label={t('socialUi.bell.emptyCrews')} />
)}
<div className="space-y-2.5">
{joinRequests.map(({ request, crew, endorsements }) => (

View file

@ -13,6 +13,7 @@ import {
showOsNotification,
type OsPermission,
} from '@/lib/osNotify';
import { useTranslation } from '@/contexts/language';
/**
* Notification settings — the opt-in surface for OS notifications.
@ -29,6 +30,7 @@ export function NotificationSettingsCard() {
const push = usePushNotifications();
const background = useBackgroundDelivery();
const { toast } = useToast();
const { t } = useTranslation();
// When the category choices change, re-send them to whatever channel is on, so
// a closed-app push respects the toggles (the server filters by category).
@ -89,8 +91,8 @@ export function NotificationSettingsCard() {
const sendTest = () => {
void showOsNotification({
id: `test:${Date.now()}`,
title: 'SkateSpots notifications are on 🛹',
body: 'This is how a DM, zap or crew invite will look.',
title: t('settingsUi.notify.testTitle'),
body: t('settingsUi.notify.testBody'),
path: '/',
});
};
@ -108,9 +110,9 @@ export function NotificationSettingsCard() {
<Bell className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Enable notifications</p>
<p className="text-sm font-bold text-white">{t('settingsUi.notify.enableTitle')}</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Buzz this device when something needs you.
{t('settingsUi.notify.enableDesc')}
</p>
</div>
<Switch
@ -122,23 +124,21 @@ export function NotificationSettingsCard() {
{permission === 'loading' && (
<p className="flex items-center gap-2 border-t border-white/5 px-4 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Checking permission…
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('settingsUi.notify.checkingPermission')}
</p>
)}
{unsupported && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
This device doesn't support notifications here. Rebuild the Android app to
enable them on your phone.
{t('settingsUi.notify.unsupported')}
</p>
)}
{denied && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
Notifications are blocked for this site. Allow them in your browser/OS
settings, then try again.
{t('settingsUi.notify.denied')}
</p>
)}
</div>
@ -150,9 +150,9 @@ export function NotificationSettingsCard() {
<Radio className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Background notifications</p>
<p className="text-sm font-bold text-white">{t('settingsUi.notify.backgroundTitle')}</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Get pinged for messages and trades even when SkateSpots is closed.
{t('settingsUi.notify.backgroundDesc')}
</p>
</div>
{push.state === 'working' ? (
@ -172,7 +172,7 @@ export function NotificationSettingsCard() {
{push.state === 'unsupported' && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
This browser doesn&apos;t support background push. On mobile, add SkateSpots to your home screen.
{t('settingsUi.notify.pushUnsupported')}
</p>
)}
@ -186,10 +186,7 @@ export function NotificationSettingsCard() {
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<span>
To reach you while closed, a SkateSpots notification server gets a ping from your
relay when something arrives for your key and passes it to your phone&apos;s push
service. It sees only <em>that</em> your key has something — never who sent it or
what it says, and it can&apos;t decrypt your messages. Opt in per device.
{t('settingsUi.notify.pushPrivacy')}
</span>
</p>
</div>
@ -198,40 +195,39 @@ export function NotificationSettingsCard() {
{background.supported && (
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<div className="px-4 py-3.5">
<p className="text-sm font-bold text-white">Background delivery (Android)</p>
<p className="text-sm font-bold text-white">{t('settingsUi.notify.bgDeliveryTitle')}</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Check for new messages, offers and trades while the app is closed — no Google services.
{t('settingsUi.notify.bgDeliveryDesc')}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant={background.mode === 'off' ? 'default' : 'outline'} onClick={() => void background.disable()}>
Off
{t('settingsUi.notify.off')}
</Button>
<Button
size="sm"
variant={background.mode === 'standard' ? 'default' : 'outline'}
onClick={() => {
void background.enable('standard').catch((e) => {
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
toast({ title: t('settingsUi.notify.couldNotEnable'), description: e instanceof Error ? e.message : t('common.tryAgain'), variant: 'destructive' });
});
}}
>
Standard (~15 min)
{t('settingsUi.notify.standard')}
</Button>
<Button
size="sm"
variant={background.mode === 'fast' ? 'default' : 'outline'}
onClick={() => {
void background.enable('fast').catch((e) => {
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
toast({ title: t('settingsUi.notify.couldNotEnable'), description: e instanceof Error ? e.message : t('common.tryAgain'), variant: 'destructive' });
});
}}
>
Fast (~5 min)
{t('settingsUi.notify.fast')}
</Button>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">
Standard is best-effort — Android may defer it under Doze. Fast shows a persistent
notification and uses more battery. Nothing is decrypted on our server.
{t('settingsUi.notify.bgDeliveryNote')}
</p>
</div>
</div>
@ -240,16 +236,16 @@ export function NotificationSettingsCard() {
{/* Per-category toggles */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
Notify me about
{t('settingsUi.notify.notifyMeAbout')}
</p>
{NOTIFICATION_CATEGORIES.map(({ key, label, hint }) => (
{NOTIFICATION_CATEGORIES.map(({ key }) => (
<div
key={key}
className="flex items-center gap-3 border-b border-white/5 px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">{label}</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">{hint}</p>
<p className="text-sm font-bold text-white">{t(`settingsUi.notify.cat_${key}_label`)}</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">{t(`settingsUi.notify.cat_${key}_hint`)}</p>
</div>
<Switch
checked={prefs.enabled && granted && prefs[key]}
@ -270,16 +266,14 @@ export function NotificationSettingsCard() {
disabled={!prefs.enabled || !granted}
className="w-full"
>
Send a test notification
{t('settingsUi.notify.sendTest')}
</Button>
</div>
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<span>
Alerts are built on your device from data the app already has, and direct-message
alerts never decrypt your messages — so Amber is never asked to open one. With{' '}
<strong className="text-white">Background notifications</strong> off, a fully closed
app can&apos;t be reached, so alerts arrive next time you open SkateSpots.
{t('settingsUi.notify.footerBefore')}{' '}
<strong className="text-white">{t('settingsUi.notify.backgroundTitle')}</strong> {t('settingsUi.notify.footerAfter')}
</span>
</p>
</div>

View file

@ -20,12 +20,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useRegionBackfill, type BackfillResult } from '@/hooks/useRegionBackfill';
import { isAdminPubkey } from '@/lib/admins';
import { toast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
export function RegionBackfillCard() {
const { user } = useCurrentUser();
const isAdmin = isAdminPubkey(user?.pubkey);
const { candidates, isLoading, backfill, progress, isRunning } = useRegionBackfill(isAdmin);
const [summary, setSummary] = useState<BackfillResult | null>(null);
const { t } = useTranslation();
if (!isAdmin) return null;
@ -38,23 +40,23 @@ export function RegionBackfillCard() {
setSummary(result);
if (result.updated > 0) {
toast({
title: `Tagged ${result.updated} location${result.updated === 1 ? '' : 's'}`,
title: t('settingsUi.regionBackfill.taggedTitle', { count: result.updated, s: result.updated === 1 ? '' : 's' }),
description:
result.skipped > 0
? `${result.skipped} skipped (no coordinates or no geocoder match).`
: 'Region tags written to the community relay.',
? t('settingsUi.regionBackfill.taggedSkipped', { count: result.skipped })
: t('settingsUi.regionBackfill.taggedOk'),
});
} else {
toast({
title: 'Nothing to tag',
description: 'No pins could be geocoded. Try again later.',
title: t('settingsUi.regionBackfill.nothingTitle'),
description: t('settingsUi.regionBackfill.nothingDesc'),
variant: 'destructive',
});
}
} catch (err) {
toast({
title: 'Backfill failed',
description: err instanceof Error ? err.message : 'Try again.',
title: t('settingsUi.regionBackfill.failedTitle'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -65,29 +67,27 @@ export function RegionBackfillCard() {
<CardContent className="py-3.5 space-y-2.5">
<p className="flex items-center gap-2 text-sm font-bold text-white">
<MapPin className="h-4 w-4 text-[#ccff00]" />
Backfill region tags
{t('settingsUi.regionBackfill.title')}
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
Admin
{t('settingsUi.regionBackfill.admin')}
</span>
</p>
<p className="text-xs text-muted-foreground">
Pins made before geocoding existed have coordinates but no city/country tag, so the map
draws them while Community Stats can only say “Uncategorised”. This re-derives the tags
from each pin and republishes <strong>your own</strong> untagged spots &amp; shops.
{t('settingsUi.regionBackfill.descBefore')}{' '}
<strong>{t('settingsUi.regionBackfill.descStrong')}</strong>{' '}
{t('settingsUi.regionBackfill.descAfter')}
</p>
{isLoading ? (
<p className="text-xs text-muted-foreground">Scanning your spots…</p>
<p className="text-xs text-muted-foreground">{t('settingsUi.regionBackfill.scanning')}</p>
) : count === 0 ? (
<p className="text-xs text-[#ccff00]">
Every spot &amp; shop you own already has a region tag. 🎉 Any remaining
“Uncategorised” pins belong to other mappers — only their own keys can tag them.
{t('settingsUi.regionBackfill.allTagged')}
</p>
) : (
<>
<p className="text-xs text-muted-foreground">
{count} of your spot{count === 1 ? '' : 's'}/shops have no region tag. Your signer may
ask to approve each one.
{t('settingsUi.regionBackfill.countLine', { count, s: count === 1 ? '' : 's' })}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
@ -101,12 +101,12 @@ export function RegionBackfillCard() {
<MapPin className="h-4 w-4" />
)}
{isRunning && progress
? `Tagging ${progress.done}/${progress.total}…`
: 'Backfill region tags'}
? t('settingsUi.regionBackfill.tagging', { done: progress.done, total: progress.total })
: t('settingsUi.regionBackfill.run')}
</Button>
{summary && (
<span className="text-xs text-muted-foreground">
{summary.updated} updated · {summary.skipped} skipped · {summary.failed} failed
{t('settingsUi.regionBackfill.summary', { updated: summary.updated, skipped: summary.skipped, failed: summary.failed })}
</span>
)}
</div>

View file

@ -10,6 +10,7 @@ import { enableRelatr, getRelatrConfig, disableRelatr } from '@/hooks/useRelatrS
import { useNostr } from '@nostrify/react';
import { Shield, ExternalLink, Search, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useTranslation } from '@/contexts/language';
/**
* RelatrSettings component for configuring Relatr global trust score integration
@ -19,6 +20,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
*/
export function RelatrSettings() {
const { toast } = useToast();
const { t } = useTranslation();
const { nostr } = useNostr();
const config = getRelatrConfig();
@ -30,8 +32,8 @@ export function RelatrSettings() {
const handleAutoDiscover = async () => {
setIsSearching(true);
toast({
title: 'Searching for Relatr service...',
description: 'Querying Nostr for Relatr profiles',
title: t('settingsUi.relatr.searchingTitle'),
description: t('settingsUi.relatr.searchingDesc'),
});
try {
@ -58,8 +60,8 @@ export function RelatrSettings() {
) {
setPubkey(profile.pubkey);
toast({
title: 'Found Relatr service!',
description: `Service: ${metadata.name || 'Unknown'}`,
title: t('settingsUi.relatr.foundTitle'),
description: t('settingsUi.relatr.foundDesc', { name: metadata.name || t('settingsUi.relatr.unknown') }),
});
return;
}
@ -69,15 +71,15 @@ export function RelatrSettings() {
}
toast({
title: 'No Relatr service found',
description: 'Try entering the pubkey manually or check https://www.relatr.xyz/',
title: t('settingsUi.relatr.notFoundTitle'),
description: t('settingsUi.relatr.notFoundDesc'),
variant: 'destructive',
});
} catch (error) {
console.error('[RelatrSettings] Auto-discovery failed:', error);
toast({
title: 'Search failed',
description: 'Could not query Nostr for Relatr profiles',
title: t('settingsUi.relatr.searchFailedTitle'),
description: t('settingsUi.relatr.searchFailedDesc'),
variant: 'destructive',
});
} finally {
@ -88,8 +90,8 @@ export function RelatrSettings() {
const handleEnable = () => {
if (!pubkey || pubkey.length !== 64) {
toast({
title: 'Invalid pubkey',
description: 'Please enter a valid 64-character hex pubkey',
title: t('settingsUi.relatr.invalidPubkeyTitle'),
description: t('settingsUi.relatr.invalidPubkeyDesc'),
variant: 'destructive',
});
return;
@ -98,8 +100,8 @@ export function RelatrSettings() {
enableRelatr(pubkey, relay);
toast({
title: 'Relatr enabled!',
description: 'Global trust scores are now active.',
title: t('settingsUi.relatr.enabledTitle'),
description: t('settingsUi.relatr.enabledDesc'),
});
};
@ -107,8 +109,8 @@ export function RelatrSettings() {
disableRelatr();
toast({
title: 'Relatr disabled',
description: 'Trust scores are no longer being fetched.',
title: t('settingsUi.relatr.disabledTitle'),
description: t('settingsUi.relatr.disabledDesc'),
});
};
@ -117,25 +119,25 @@ export function RelatrSettings() {
<CardHeader>
<CardTitle className="text-xl font-black uppercase text-[#ccff00] flex items-center gap-2">
<Shield className="h-5 w-5" />
Relatr Trust Scores
{t('settingsUi.relatr.title')}
</CardTitle>
<CardDescription>
Global trust scores help identify reliable users and reduce spam
{t('settingsUi.relatr.desc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Status - Always visible */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-bold text-gray-300">Status</Label>
<Label className="text-sm font-bold text-gray-300">{t('settingsUi.relatr.status')}</Label>
<Badge variant={config.enabled ? 'default' : 'outline'} className="gap-2">
{config.enabled ? (
<>
<CheckCircle2 className="h-3 w-3" />
Active
{t('settingsUi.relatr.active')}
</>
) : (
'Disabled'
t('settingsUi.relatr.disabled')
)}
</Badge>
</div>
@ -146,11 +148,11 @@ export function RelatrSettings() {
<AlertDescription className="text-sm text-gray-300">
{config.enabled ? (
<>
<strong className="text-[#ccff00]">Relatr is active.</strong> Trust scores from ContextVM's global network analysis help identify reliable skaters and reduce spam.
<strong className="text-[#ccff00]">{t('settingsUi.relatr.activeStrong')}</strong> {t('settingsUi.relatr.activeDesc')}
</>
) : (
<>
<strong className="text-[#ccff00]">Relatr is disabled.</strong> Enable it to get trust scores from the global Nostr network.
<strong className="text-[#ccff00]">{t('settingsUi.relatr.disabledStrong')}</strong> {t('settingsUi.relatr.disabledHint')}
</>
)}
</AlertDescription>
@ -163,7 +165,7 @@ export function RelatrSettings() {
variant="outline"
className="w-full gap-2 border-red-500/50 text-red-400 hover:bg-red-500/10"
>
Disable Relatr
{t('settingsUi.relatr.disable')}
</Button>
) : (
<Button
@ -172,7 +174,7 @@ export function RelatrSettings() {
disabled={!pubkey || pubkey.length !== 64}
>
<Shield className="h-4 w-4" />
Enable Relatr
{t('settingsUi.relatr.enable')}
</Button>
)}
</div>
@ -185,7 +187,7 @@ export function RelatrSettings() {
className="w-full gap-2 text-gray-400 hover:text-white"
>
{isAdvancedOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
Advanced Settings
{t('settingsUi.relatr.advancedSettings')}
</Button>
</CollapsibleTrigger>
@ -193,12 +195,12 @@ export function RelatrSettings() {
{/* Service Pubkey Input */}
<div className="space-y-2">
<Label htmlFor="relatr-pubkey" className="text-sm font-bold text-gray-300">
Relatr Service Pubkey
{t('settingsUi.relatr.servicePubkey')}
</Label>
<div className="flex gap-2">
<Input
id="relatr-pubkey"
placeholder="Enter 64-character hex pubkey..."
placeholder={t('settingsUi.relatr.pubkeyPlaceholder')}
value={pubkey}
onChange={(e) => setPubkey(e.target.value)}
className="font-mono text-xs"
@ -212,18 +214,18 @@ export function RelatrSettings() {
className="gap-2 whitespace-nowrap"
>
<Search className="h-4 w-4" />
{isSearching ? 'Searching...' : 'Auto-Discover'}
{isSearching ? t('settingsUi.relatr.searching') : t('settingsUi.relatr.autoDiscover')}
</Button>
</div>
<p className="text-xs text-gray-500">
Default: ContextVM's official Relatr service (npub1dvmc...)
{t('settingsUi.relatr.pubkeyDefault')}
</p>
</div>
{/* Relay Input */}
<div className="space-y-2">
<Label htmlFor="relatr-relay" className="text-sm font-bold text-gray-300">
Relay (where Relatr publishes scores)
{t('settingsUi.relatr.relayLabel')}
</Label>
<Input
id="relatr-relay"
@ -233,7 +235,7 @@ export function RelatrSettings() {
className="font-mono text-xs"
/>
<p className="text-xs text-gray-500">
Default: wss://relay.contextvm.org (ContextVM's official relay)
{t('settingsUi.relatr.relayDefault')}
</p>
</div>
@ -244,12 +246,12 @@ export function RelatrSettings() {
className="w-full gap-2"
disabled={!pubkey || pubkey.length !== 64}
>
Apply Changes
{t('settingsUi.relatr.applyChanges')}
</Button>
{/* Documentation Links */}
<div className="pt-4 border-t border-gray-700 space-y-2">
<p className="text-sm font-bold text-gray-300">Learn More:</p>
<p className="text-sm font-bold text-gray-300">{t('settingsUi.relatr.learnMore')}</p>
<div className="flex flex-col gap-2">
<a
href="https://www.relatr.xyz/"
@ -258,7 +260,7 @@ export function RelatrSettings() {
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Relatr Website
{t('settingsUi.relatr.website')}
</a>
<a
href="https://github.com/ContextVM/relatr"
@ -267,7 +269,7 @@ export function RelatrSettings() {
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Relatr GitHub
{t('settingsUi.relatr.github')}
</a>
<a
href="https://github.com/nostr-protocol/nips/blob/master/85.md"
@ -276,7 +278,7 @@ export function RelatrSettings() {
className="text-sm text-[#ccff00] hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
NIP-85 Specification
{t('settingsUi.relatr.nip85Spec')}
</a>
</div>
</div>

View file

@ -20,6 +20,7 @@ import { useToast } from '@/hooks/useToast';
import { useRelayStatus } from '@/hooks/useRelayStatus';
import { isLocalRelay } from '@/lib/relayProbe';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface Relay {
url: string;
@ -66,9 +67,10 @@ function formatRelayUrl(url: string): string {
/** Live connection dot — green pulsing when connected, breathing while connecting, grey offline. */
function RelayStatusDot({ url }: { url: string }) {
const { status, reason } = useRelayStatus(url);
const { t } = useTranslation();
const label =
status === 'connected' ? 'Connected' : status === 'connecting' ? 'Connecting…' : 'Offline';
status === 'connected' ? t('settingsUi.relayList.connected') : status === 'connecting' ? t('settingsUi.relayList.connecting') : t('settingsUi.relayList.offline');
return (
<TooltipProvider>
@ -182,6 +184,7 @@ function ToggleChip({
/** Removable URL pills. */
function UrlChips({ urls, tone, onRemove }: { urls: string[]; tone: Tone; onRemove: (url: string) => void }) {
const { t } = useTranslation();
if (urls.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
@ -195,7 +198,7 @@ function UrlChips({ urls, tone, onRemove }: { urls: string[]; tone: Tone; onRemo
type="button"
onClick={() => onRemove(url)}
className="opacity-60 transition-opacity hover:opacity-100"
aria-label={`Remove ${url}`}
aria-label={t('settingsUi.relayList.removeUrl', { url })}
>
<X className="h-3 w-3" />
</button>
@ -221,6 +224,7 @@ function AddUrlRow({
tone?: Tone;
primary?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex gap-2">
<Input
@ -244,7 +248,7 @@ function AddUrlRow({
)}
>
<Plus className="h-4 w-4" />
Add
{t('settingsUi.relayList.add')}
</Button>
</div>
);
@ -255,6 +259,7 @@ export function RelayListManager() {
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const { toast } = useToast();
const { t } = useTranslation();
const [relays, setRelays] = useState<Relay[]>(config.relayMetadata.relays);
const [newRelayUrl, setNewRelayUrl] = useState('');
@ -323,10 +328,10 @@ export function RelayListManager() {
{ kind: 10002, content: '', tags },
{
onSuccess: () =>
toast({ title: 'Relay list published', description: 'Synced to Nostr (kind 10002).' }),
toast({ title: t('settingsUi.relayList.publishedTitle'), description: t('settingsUi.relayList.publishedDesc') }),
onError: (error) => {
console.error('Failed to publish relay list:', error);
toast({ title: 'Failed to publish relay list', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.publishFailedTitle'), variant: 'destructive' });
},
},
);
@ -335,15 +340,15 @@ export function RelayListManager() {
const handleAddRelay = () => {
if (!isValidRelayUrl(newRelayUrl)) {
toast({
title: 'Invalid relay URL',
description: 'Try something like wss://relay.example.com',
title: t('settingsUi.relayList.invalidUrlTitle'),
description: t('settingsUi.relayList.invalidUrlDesc'),
variant: 'destructive',
});
return;
}
const normalized = normalizeRelayUrl(newRelayUrl);
if (relays.some((r) => r.url === normalized)) {
toast({ title: 'Relay already in your list', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.alreadyInList'), variant: 'destructive' });
return;
}
const newRelays = [...relays, { url: normalized, read: true, write: true }];
@ -363,8 +368,8 @@ export function RelayListManager() {
if (!relay) return;
if (relay.read && !relay.write) {
toast({
title: 'Keep read or write on',
description: 'A relay needs at least one of read/write.',
title: t('settingsUi.relayList.keepReadWriteTitle'),
description: t('settingsUi.relayList.keepReadWriteDesc'),
variant: 'destructive',
});
return;
@ -379,8 +384,8 @@ export function RelayListManager() {
if (!relay) return;
if (!relay.read && relay.write) {
toast({
title: 'Keep read or write on',
description: 'A relay needs at least one of read/write.',
title: t('settingsUi.relayList.keepReadWriteTitle'),
description: t('settingsUi.relayList.keepReadWriteDesc'),
variant: 'destructive',
});
return;
@ -400,13 +405,13 @@ export function RelayListManager() {
const addCategoryRelay = (key: 'localRelays' | 'blockedRelays', value: string, reset: () => void) => {
if (!isValidRelayUrl(value)) {
toast({ title: 'Invalid relay URL', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.invalidUrlTitle'), variant: 'destructive' });
return;
}
const normalized = normalizeRelayUrl(value);
const existing = config.relayMetadata[key] ?? [];
if (existing.includes(normalized)) {
toast({ title: 'Relay already in this list', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.alreadyInThisList'), variant: 'destructive' });
return;
}
updateCategory(key, [...existing, normalized]);
@ -423,10 +428,10 @@ export function RelayListManager() {
publishEvent(
{ kind: 10050, content: '', tags: urls.map((url) => ['relay', url]) },
{
onSuccess: () => toast({ title: 'DM relays published', description: 'NIP-17 inbox updated.' }),
onSuccess: () => toast({ title: t('settingsUi.relayList.dmPublishedTitle'), description: t('settingsUi.relayList.dmPublishedDesc') }),
onError: (error) => {
console.error('Failed to publish DM relay list:', error);
toast({ title: 'Failed to publish DM relays', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.dmPublishFailedTitle'), variant: 'destructive' });
},
},
);
@ -442,12 +447,12 @@ export function RelayListManager() {
const addDmRelay = () => {
if (!isValidRelayUrl(newDmUrl)) {
toast({ title: 'Invalid relay URL', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.invalidUrlTitle'), variant: 'destructive' });
return;
}
const normalized = normalizeRelayUrl(newDmUrl);
if (dmRelays.includes(normalized)) {
toast({ title: 'Relay already in this list', variant: 'destructive' });
toast({ title: t('settingsUi.relayList.alreadyInThisList'), variant: 'destructive' });
return;
}
setDmRelays([...dmRelays, normalized]);
@ -459,18 +464,18 @@ export function RelayListManager() {
{/* NIP-65 */}
<Section
icon={Radio}
title="Relays (NIP-65)"
title={t('settingsUi.relayList.nip65Title')}
count={relays.length}
tone="lime"
description={
user
? 'Tap Read / Write to toggle. Published to Nostr as your kind 10002 list.'
: 'Tap Read / Write to toggle. Log in to publish your list to Nostr.'
? t('settingsUi.relayList.nip65DescUser')
: t('settingsUi.relayList.nip65DescLoggedOut')
}
>
{relays.length === 0 ? (
<p className="rounded-xl border border-dashed border-white/10 px-3 py-4 text-center text-xs text-muted-foreground">
No relays yet — add one below.
{t('settingsUi.relayList.noRelays')}
</p>
) : (
relays.map((relay) => (
@ -486,7 +491,7 @@ export function RelayListManager() {
</span>
{isLocalRelay(relay.url) && (
<span className="flex-shrink-0 rounded-full border border-[#ccff00]/25 bg-[#ccff00]/12 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-[0.15em] text-[#ccff00]">
Local
{t('settingsUi.relayList.local')}
</span>
)}
</div>
@ -495,15 +500,15 @@ export function RelayListManager() {
active={relay.read}
onClick={() => handleToggleRead(relay.url)}
icon={ArrowDownToLine}
label="Read"
hint="Fetch content from this relay"
label={t('settingsUi.relayList.read')}
hint={t('settingsUi.relayList.readHint')}
/>
<ToggleChip
active={relay.write}
onClick={() => handleToggleWrite(relay.url)}
icon={ArrowUpFromLine}
label="Write"
hint="Publish your events to this relay"
label={t('settingsUi.relayList.write')}
hint={t('settingsUi.relayList.writeHint')}
/>
</div>
</div>
@ -513,7 +518,7 @@ export function RelayListManager() {
onClick={() => handleRemoveRelay(relay.url)}
disabled={relays.length <= 1}
className="size-7 flex-shrink-0 rounded-full text-muted-foreground hover:bg-white/5 hover:text-destructive"
aria-label={`Remove ${relay.url}`}
aria-label={t('settingsUi.relayList.removeUrl', { url: relay.url })}
>
<X className="h-4 w-4" />
</Button>
@ -532,10 +537,10 @@ export function RelayListManager() {
{/* DM inbox */}
<Section
icon={Inbox}
title="DM inbox relays"
title={t('settingsUi.relayList.dmTitle')}
count={dmRelays.length}
tone="cyan"
description="Where you receive NIP-17 direct messages (kind 10050). Falls back to your write relays when empty."
description={t('settingsUi.relayList.dmDesc')}
>
<UrlChips urls={dmRelays} tone="cyan" onRemove={(u) => setDmRelays(dmRelays.filter((x) => x !== u))} />
<AddUrlRow
@ -550,10 +555,10 @@ export function RelayListManager() {
{/* Local */}
<Section
icon={HardDrive}
title="Local relays"
title={t('settingsUi.relayList.localTitle')}
count={localRelays.length}
tone="lime"
description="App-only (e.g. a LAN node). Used for reads and writes; never published."
description={t('settingsUi.relayList.localDesc')}
>
<UrlChips urls={localRelays} tone="lime" onRemove={(u) => removeCategoryRelay('localRelays', u)} />
<AddUrlRow
@ -568,10 +573,10 @@ export function RelayListManager() {
{/* Blocked */}
<Section
icon={Ban}
title="Blocked relays"
title={t('settingsUi.relayList.blockedTitle')}
count={blockedRelays.length}
tone="red"
description="Never connect to these — excluded from every read and write."
description={t('settingsUi.relayList.blockedDesc')}
>
<UrlChips urls={blockedRelays} tone="red" onRemove={(u) => removeCategoryRelay('blockedRelays', u)} />
<AddUrlRow

View file

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import {
useSendCollectiveJoinRequest,
useMyCollectiveJoinRequest,
@ -26,6 +27,7 @@ export function RequestToJoinCollective({
accentText?: string;
accentBorder?: string;
}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const [open, setOpen] = useState(false);
@ -47,13 +49,13 @@ export function RequestToJoinCollective({
setOpen(false);
setNote('');
toast({
title: 'Request sent 🤝',
description: `${profile.title} will see it in their notifications and can add you.`,
title: t('socialUi.requestCollective.sentTitle'),
description: t('socialUi.requestCollective.sentDesc', { name: profile.title }),
});
} catch (err) {
toast({
title: 'Could not send request',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('socialUi.requestCollective.couldNotSend'),
description: err instanceof Error ? err.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -64,19 +66,20 @@ export function RequestToJoinCollective({
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-3">
<Users className={`h-5 w-5 ${accentText}`} />
<h3 className="text-lg text-white uppercase font-black">Join this collective</h3>
<h3 className="text-lg text-white uppercase font-black">{t('socialUi.requestCollective.title')}</h3>
</div>
{alreadyRequested ? (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-300">
<Check className="h-4 w-4" />
Request sent — waiting on {profile.title}.
{t('socialUi.requestCollective.waiting', { name: profile.title })}
</div>
) : !open ? (
<>
<p className="text-sm text-gray-400 mb-3">
Ask the crew behind <span className="font-bold text-white">{profile.title}</span> to add
you to the roster. They’ll get a notification and can add you in one tap.
{t('socialUi.requestCollective.askA')}{' '}
<span className="font-bold text-white">{profile.title}</span>{' '}
{t('socialUi.requestCollective.askB')}
</p>
<Button
onClick={() => setOpen(true)}
@ -84,7 +87,7 @@ export function RequestToJoinCollective({
className="w-full gap-2 bg-white text-black font-black hover:bg-gray-200"
>
<Hand className="h-4 w-4" />
Request to join
{t('socialUi.requestToJoin.trigger')}
</Button>
</>
) : (
@ -92,7 +95,7 @@ export function RequestToJoinCollective({
<Textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, 500))}
placeholder="Optional: say why you'd be a good fit…"
placeholder={t('socialUi.requestCollective.placeholder')}
rows={3}
className="resize-none border-gray-700 bg-black/60 text-white placeholder:text-gray-600"
/>
@ -103,7 +106,7 @@ export function RequestToJoinCollective({
className="flex-1 gap-2 bg-white text-black font-black hover:bg-gray-200"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Hand className="h-4 w-4" />}
Send request
{t('socialUi.requestCollective.sendButton')}
</Button>
<Button
variant="outline"
@ -115,7 +118,7 @@ export function RequestToJoinCollective({
className="gap-1.5 border-gray-700 text-gray-400 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
Cancel
{t('common.cancel')}
</Button>
</div>
</div>

View file

@ -5,6 +5,7 @@ import { useToast } from '@/hooks/useToast';
import { useEventRsvps, useRsvp } from '@/hooks/useEventRsvps';
import { eventCoord } from '@/hooks/useEventRsvps';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import type { RsvpStatus, SkateEvent } from '@/lib/types';
const OPTIONS: {
@ -16,22 +17,22 @@ const OPTIONS: {
}[] = [
{
status: 'accepted',
label: "I'm Going",
shortLabel: 'Going',
label: 'placesUi.rsvp.going',
shortLabel: 'placesUi.rsvp.goingShort',
icon: Check,
activeClass: 'bg-green-500 border-green-500 text-black',
},
{
status: 'tentative',
label: 'Interested',
shortLabel: 'Maybe',
label: 'placesUi.rsvp.interested',
shortLabel: 'placesUi.rsvp.maybe',
icon: HelpCircle,
activeClass: 'bg-[#ccff00] border-[#ccff00] text-black',
},
{
status: 'declined',
label: "Can't make it",
shortLabel: "Can't",
label: 'placesUi.rsvp.cantMakeIt',
shortLabel: 'placesUi.rsvp.cant',
icon: X,
activeClass: 'bg-red-500/80 border-red-500 text-white',
},
@ -43,6 +44,7 @@ const OPTIONS: {
* is a no-op. Logged-out users see a login prompt instead.
*/
export function RsvpButtons({ event, className }: { event: SkateEvent; className?: string }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { toast } = useToast();
const coord = eventCoord(event);
@ -55,8 +57,8 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
return (
<div className={cn('rounded-xl border border-white/10 bg-white/[0.03] p-4 text-center', className)}>
<p className="mb-3 text-sm font-bold text-gray-300">
Are you going?{' '}
<span className="text-muted-foreground font-normal">Log in to RSVP.</span>
{t('placesUi.rsvp.areYouGoing')}{' '}
<span className="text-muted-foreground font-normal">{t('placesUi.rsvp.loginToRsvp')}</span>
</p>
<LoginArea className="w-full justify-center" />
</div>
@ -73,15 +75,15 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
});
const msg =
status === 'accepted'
? "You're going! 🛹"
? t('placesUi.rsvp.toastGoing')
: status === 'tentative'
? 'Marked as interested.'
: 'Marked as not going.';
? t('placesUi.rsvp.toastInterested')
: t('placesUi.rsvp.toastNotGoing');
toast({ title: msg });
} catch (err) {
toast({
title: 'Could not RSVP',
description: err instanceof Error ? err.message : 'Please try again.',
title: t('placesUi.rsvp.toastError'),
description: err instanceof Error ? err.message : t('placesUi.rsvp.tryAgain'),
variant: 'destructive',
});
}
@ -91,7 +93,7 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
<div className={cn('space-y-2', className)}>
{isHost && (
<p className="text-xs text-muted-foreground">
You're hosting this — but you can still set your status.
{t('placesUi.rsvp.hostNote')}
</p>
)}
<div className="grid grid-cols-3 gap-2">
@ -116,19 +118,19 @@ export function RsvpButtons({ event, className }: { event: SkateEvent; className
) : (
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">{shortLabel}</span>
<span className="hidden sm:inline">{t(label)}</span>
<span className="sm:hidden">{t(shortLabel)}</span>
</button>
);
})}
</div>
{(goingCount > 0 || tentativeCount > 0) && (
<p className="text-center text-xs text-muted-foreground">
<span className="font-bold text-green-400">{goingCount}</span> going
<span className="font-bold text-green-400">{goingCount}</span> {t('placesUi.rsvp.goingCount')}
{tentativeCount > 0 && (
<>
{' · '}
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> interested
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> {t('placesUi.rsvp.interestedCount')}
</>
)}
</p>

View file

@ -8,12 +8,14 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useNostrLogin } from '@nostrify/react/login';
import { toast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
const sanitizeFilename = (filename: string) => filename.replace(/[^a-z0-9_.-]/gi, '_');
export function SecuritySettings() {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { t } = useTranslation();
const [confirmed, setConfirmed] = useLocalStorage('backup-confirmed', false);
const [showWarning, setShowWarning] = useState(!confirmed);
@ -44,7 +46,7 @@ export function SecuritySettings() {
// gentle reminder stops nagging a user who has genuinely saved their login.
setConfirmed(true);
setShowWarning(false);
toast({ title: 'Copied!', description: 'Recovery code copied. Store it somewhere safe.' });
toast({ title: t('common.copied'), description: t('settingsUi.security.recoveryCopied') });
};
const handleDownloadRecovery = () => {
@ -64,9 +66,9 @@ export function SecuritySettings() {
// reminder stops for good.
setConfirmed(true);
setShowWarning(false);
toast({ title: 'Saved!', description: 'Recovery code downloaded. Keep it somewhere safe.' });
toast({ title: t('settingsUi.security.savedTitle'), description: t('settingsUi.security.recoveryDownloaded') });
} catch {
toast({ title: 'Download failed', description: 'Please copy it manually instead.', variant: 'destructive' });
toast({ title: t('settingsUi.security.downloadFailedTitle'), description: t('settingsUi.security.downloadFailedDesc'), variant: 'destructive' });
}
};
@ -75,9 +77,9 @@ export function SecuritySettings() {
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Login Required</h3>
<h3 className="text-lg font-medium mb-2">{t('settingsUi.security.loginRequired')}</h3>
<p className="text-muted-foreground">
Log in to access security settings.
{t('settingsUi.security.loginRequiredDesc')}
</p>
</CardContent>
</Card>
@ -88,8 +90,8 @@ export function SecuritySettings() {
setConfirmed(true);
setShowWarning(false);
toast({
title: 'Backup Confirmed! ✅',
description: 'Great! Your keys are safe.',
title: t('settingsUi.security.backupConfirmedTitle'),
description: t('settingsUi.security.backupConfirmedDesc'),
});
};
@ -101,19 +103,19 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-amber-400">
<KeyRound className="h-5 w-5" />
Your Recovery Code
{t('settingsUi.security.yourRecoveryCode')}
</CardTitle>
<CardDescription>
Didn't save it when you signed up? You can grab it here anytime.
{t('settingsUi.security.recoveryCodeDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{recoveryCode ? (
<>
<p className="text-sm text-gray-300">
This code is the <strong className="text-white">only</strong> way to log back in on a
new device or browser. Save it somewhere safe (a password manager, or written on paper).
Anyone who has it controls your account — never share it.
{t('settingsUi.security.recoveryCodeBefore')}{' '}
<strong className="text-white">{t('settingsUi.security.only')}</strong>{' '}
{t('settingsUi.security.recoveryCodeAfter')}
</p>
{!revealed ? (
@ -122,7 +124,7 @@ export function SecuritySettings() {
className="w-full bg-amber-500 hover:bg-amber-600 text-black font-bold"
>
<Eye className="h-4 w-4 mr-2" />
Reveal my recovery code
{t('settingsUi.security.revealRecovery')}
</Button>
) : (
<div className="space-y-3">
@ -138,7 +140,7 @@ export function SecuritySettings() {
onClick={handleCopyRecovery}
>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
{t('common.copy')}
</Button>
<Button
variant="outline"
@ -147,7 +149,7 @@ export function SecuritySettings() {
onClick={handleDownloadRecovery}
>
<Download className="h-3.5 w-3.5 mr-1.5" />
Save
{t('common.save')}
</Button>
<Button
variant="outline"
@ -166,13 +168,13 @@ export function SecuritySettings() {
<QRCodeCanvas value={recoveryCode} size={200} level="M" />
</div>
<p className="text-xs text-gray-500 text-center max-w-xs">
Scan with another Nostr app to log in. Keep this screen private.
{t('settingsUi.security.qrHint')}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-amber-300/80">⚠️ Never share this. Hide it when you're done.</p>
<p className="text-xs text-amber-300/80">{t('settingsUi.security.neverShareHide')}</p>
<Button
variant="ghost"
size="sm"
@ -180,7 +182,7 @@ export function SecuritySettings() {
onClick={() => { setRevealed(false); setShowQr(false); }}
>
<EyeOff className="h-3.5 w-3.5 mr-1.5" />
Hide
{t('settingsUi.security.hide')}
</Button>
</div>
</div>
@ -191,16 +193,16 @@ export function SecuritySettings() {
<Shield className="h-4 w-4 text-blue-400" />
<AlertDescription className="text-sm text-gray-300">
{loginType === 'x-amber' && (
<>You're logged in with <strong className="text-white">Amber</strong>. Your recovery code lives inside Amber — open the Amber app to back it up. SkateSpots never sees your key.</>
<>{t('settingsUi.security.amberNoteBefore')} <strong className="text-white">Amber</strong>{t('settingsUi.security.amberNoteAfter')}</>
)}
{loginType === 'bunker' && (
<>You're logged in with a <strong className="text-white">remote signer (bunker)</strong>. Your recovery code is held by your signer service, not SkateSpots. Back it up there.</>
<>{t('settingsUi.security.bunkerNoteBefore')} <strong className="text-white">{t('settingsUi.security.remoteSigner')}</strong>{t('settingsUi.security.bunkerNoteAfter')}</>
)}
{(loginType as string) === 'extension' && (
<>You're logged in with a <strong className="text-white">browser extension</strong>. Your recovery code lives in the extension — use its own backup/export option. SkateSpots never sees your key.</>
<>{t('settingsUi.security.extensionNoteBefore')} <strong className="text-white">{t('settingsUi.security.browserExtension')}</strong>{t('settingsUi.security.extensionNoteAfter')}</>
)}
{!loginType && (
<>Your recovery code is managed by your signer app, not SkateSpots. Back it up there.</>
<>{t('settingsUi.security.otherNote')}</>
)}
</AlertDescription>
</Alert>
@ -215,11 +217,10 @@ export function SecuritySettings() {
<AlertDescription>
<div className="space-y-3">
<p className="font-bold text-amber-400">
⚠️ Have you backed up your recovery phrase or secret key?
{t('settingsUi.security.backupWarningTitle')}
</p>
<p className="text-sm text-gray-300">
Your keys are the ONLY way to access your account. If you lose them, you lose everything -
there's no password reset!
{t('settingsUi.security.backupWarningBody')}
</p>
<Button
onClick={handleConfirmBackup}
@ -227,7 +228,7 @@ export function SecuritySettings() {
className="bg-amber-500 hover:bg-amber-600 text-black font-bold"
>
<CheckCircle className="h-4 w-4 mr-2" />
I've Backed Up My Keys
{t('settingsUi.security.iveBackedUp')}
</Button>
</div>
</AlertDescription>
@ -238,27 +239,26 @@ export function SecuritySettings() {
<Card className={confirmed ? "bg-green-950/20 border-2 border-green-500/30" : "bg-red-950/20 border-2 border-red-500/30"}>
<CardHeader>
<CardTitle className={confirmed ? "text-green-400" : "text-red-400"}>
{confirmed ? '✅ Backup Confirmed' : '⚠️ Backup Not Confirmed'}
{confirmed ? t('settingsUi.security.backupConfirmed') : t('settingsUi.security.backupNotConfirmed')}
</CardTitle>
<CardDescription>
{confirmed
? 'You\'ve confirmed that your keys are safely backed up.'
: 'Make sure to backup your recovery phrase or secret key!'}
? t('settingsUi.security.backupConfirmedStatusDesc')
: t('settingsUi.security.backupNotConfirmedDesc')}
</CardDescription>
</CardHeader>
{!confirmed && (
<CardContent>
<p className="text-sm text-gray-400 mb-4">
Haven't saved your recovery code yet? No problem — grab it from the
<strong className="text-amber-400"> Your Recovery Code</strong> section above, store it
somewhere safe, then confirm below.
{t('settingsUi.security.notSavedYetBefore')}
<strong className="text-amber-400"> {t('settingsUi.security.yourRecoveryCode')}</strong> {t('settingsUi.security.notSavedYetAfter')}
</p>
<Button
onClick={handleConfirmBackup}
variant="outline"
className="border-green-500/50 text-green-400 hover:bg-green-500/20"
>
Yes, I've Backed Up My Keys
{t('settingsUi.security.yesBackedUp')}
</Button>
</CardContent>
)}
@ -269,10 +269,10 @@ export function SecuritySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-[#ccff00]">
<Shield className="h-5 w-5" />
Key Security Best Practices
{t('settingsUi.security.bestPracticesTitle')}
</CardTitle>
<CardDescription>
How to keep your Nostr account safe
{t('settingsUi.security.bestPracticesDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -280,53 +280,53 @@ export function SecuritySettings() {
<div className="flex items-start gap-3">
<div className="text-2xl">✍️</div>
<div>
<p className="font-bold text-white">Write it down on paper</p>
<p className="text-gray-400">Physical backup is the safest. Store in a secure location like a safe.</p>
<p className="font-bold text-white">{t('settingsUi.security.bp1Title')}</p>
<p className="text-gray-400">{t('settingsUi.security.bp1Desc')}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">💾</div>
<div>
<p className="font-bold text-white">Multiple backups</p>
<p className="text-gray-400">Keep copies in different secure locations (home safe, bank deposit box, etc.)</p>
<p className="font-bold text-white">{t('settingsUi.security.bp2Title')}</p>
<p className="text-gray-400">{t('settingsUi.security.bp2Desc')}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">🔒</div>
<div>
<p className="font-bold text-white">Never share your keys</p>
<p className="text-gray-400">Real Nostr apps NEVER ask for your nsec or recovery phrase after initial login</p>
<p className="font-bold text-white">{t('settingsUi.security.bp3Title')}</p>
<p className="text-gray-400">{t('settingsUi.security.bp3Desc')}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">🚫</div>
<div>
<p className="font-bold text-white">Don't store in cloud unencrypted</p>
<p className="text-gray-400">Avoid Google Docs, email drafts, or plain text cloud storage</p>
<p className="font-bold text-white">{t('settingsUi.security.bp4Title')}</p>
<p className="text-gray-400">{t('settingsUi.security.bp4Desc')}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="text-2xl">🔐</div>
<div>
<p className="font-bold text-white">Consider using a browser extension</p>
<p className="text-gray-400">Alby, nos2x, or Flamingo keep your keys secure and never expose them to websites</p>
<p className="font-bold text-white">{t('settingsUi.security.bp5Title')}</p>
<p className="text-gray-400">{t('settingsUi.security.bp5Desc')}</p>
</div>
</div>
</div>
<div className="pt-4 border-t border-[#ccff00]/20">
<h4 className="font-bold text-[#ff0040] mb-2 uppercase text-sm">
⚠️ Important Reminders
{t('settingsUi.security.importantReminders')}
</h4>
<ul className="text-xs text-gray-400 space-y-1">
<li>• If you lose your keys, you lose your account permanently</li>
<li>• No company or person can reset your password - not even us</li>
<li>• Anyone with your keys has full control of your account</li>
<li>• Your keys control all your Nostr data across ALL apps</li>
<li>• {t('settingsUi.security.rem1')}</li>
<li>• {t('settingsUi.security.rem2')}</li>
<li>• {t('settingsUi.security.rem3')}</li>
<li>• {t('settingsUi.security.rem4')}</li>
</ul>
</div>
</CardContent>
@ -335,9 +335,9 @@ export function SecuritySettings() {
{/* Browser Extension & Mobile App Recommendations */}
<Card className="bg-black/90 border-2 border-purple-500/30">
<CardHeader>
<CardTitle className="text-purple-400">🔌 Recommended Nostr Signers</CardTitle>
<CardTitle className="text-purple-400">{t('settingsUi.security.recommendedSigners')}</CardTitle>
<CardDescription>
Use a Nostr signer for better security and convenience
{t('settingsUi.security.recommendedSignersDesc')}
</CardDescription>
</CardHeader>
<CardContent>
@ -356,7 +356,7 @@ export function SecuritySettings() {
<span className="text-xs px-2 py-0.5 bg-[#ccff00]/20 text-[#ccff00] rounded-full">Android</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Secure Nostr signer for Android. Keeps your keys safe on your phone and signs events without exposing them.
{t('settingsUi.security.amberDesc')}
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
@ -372,10 +372,10 @@ export function SecuritySettings() {
<div className="flex-1">
<div className="font-bold text-white flex items-center gap-2">
Alby
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">Recommended</span>
<span className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded-full">{t('settingsUi.security.recommended')}</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Full-featured extension with Lightning wallet, WebLN, and NWC support.
{t('settingsUi.security.albyDesc')}
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
@ -392,7 +392,7 @@ export function SecuritySettings() {
<div className="flex-1">
<div className="font-bold text-white">Flamingo</div>
<p className="text-xs text-gray-400 mt-1">
Simple, clean interface. Focuses on Nostr signing without wallet features.
{t('settingsUi.security.flamingoDesc')}
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
@ -409,7 +409,7 @@ export function SecuritySettings() {
<div className="flex-1">
<div className="font-bold text-white">nos2x</div>
<p className="text-xs text-gray-400 mt-1">
Minimal, lightweight extension for advanced users.
{t('settingsUi.security.nos2xDesc')}
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
@ -422,15 +422,14 @@ export function SecuritySettings() {
{/* Nostr Connect (Bunkers) */}
<Card className="bg-black/90 border-2 border-blue-500/30">
<CardHeader>
<CardTitle className="text-blue-400">☁️ Nostr Connect (Remote Signers)</CardTitle>
<CardTitle className="text-blue-400">{t('settingsUi.security.nostrConnectTitle')}</CardTitle>
<CardDescription>
Use a remote signer to access your account from any device
{t('settingsUi.security.nostrConnectDesc')}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-400 mb-4">
Nostr Connect (also called "bunkers") lets you store your keys on a secure server and sign in from any device.
Your keys never leave the server, but you can use your account anywhere!
{t('settingsUi.security.nostrConnectBody')}
</p>
<div className="space-y-3">
@ -444,10 +443,10 @@ export function SecuritySettings() {
<div className="flex-1">
<div className="font-bold text-white flex items-center gap-2">
nsec.app
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full">Popular</span>
<span className="text-xs px-2 py-0.5 bg-blue-500/20 text-blue-400 rounded-full">{t('common.popular')}</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Free, secure remote signer. Create bunker:// URI and paste when logging in.
{t('settingsUi.security.nsecAppDesc')}
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
@ -464,7 +463,7 @@ export function SecuritySettings() {
<div className="flex-1">
<div className="font-bold text-white">Nostrum</div>
<p className="text-xs text-gray-400 mt-1">
Another remote signer option with mobile support.
{t('settingsUi.security.nostrumDesc')}
</p>
</div>
<ExternalLink className="h-4 w-4 text-gray-500" />
@ -473,8 +472,7 @@ export function SecuritySettings() {
</div>
<div className="mt-4 p-3 rounded-lg bg-blue-950/30 border border-blue-500/30 text-sm text-gray-300">
<p><strong className="text-blue-400">💡 How to use:</strong> Create an account on one of these services,
get your bunker:// connection string, then paste it in the "Nostr Connect" tab when logging in.</p>
<p><strong className="text-blue-400">{t('settingsUi.security.howToUse')}</strong> {t('settingsUi.security.howToUseDesc')}</p>
</div>
</CardContent>
</Card>

View file

@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Button } from '@/components/ui/button';
import { Check, Copy, Share2, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { generateQRCode } from '@/lib/wallet/qrCodeGenerator';
interface ShareProfileDialogProps {
@ -21,6 +22,7 @@ interface ShareProfileDialogProps {
* sheet isn't available.
*/
export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: ShareProfileDialogProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [qr, setQr] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
@ -52,7 +54,7 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ title: 'Copy failed', description: 'Could not copy the link.', variant: 'destructive' });
toast({ title: t('walletUi.share.copyFailed'), description: t('walletUi.share.copyFailedDesc'), variant: 'destructive' });
}
};
@ -76,10 +78,10 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
<DialogHeader className="min-w-0">
<DialogTitle className="flex items-center gap-2 uppercase font-black">
<Share2 className="h-5 w-5 text-[#ff0040] flex-shrink-0" />
Share profile
{t('walletUi.share.title')}
</DialogTitle>
<DialogDescription className="text-gray-400">
Point a phone camera at the code to open {displayName}'s profile.
{t('walletUi.share.desc', { name: displayName })}
</DialogDescription>
</DialogHeader>
@ -87,7 +89,7 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
<div className="flex flex-col items-center gap-4 py-2 min-w-0 w-full">
<div className="rounded-2xl bg-white p-3 shadow-lg">
{qr ? (
<img src={qr} alt="Profile QR code" className="w-48 h-48 sm:w-52 sm:h-52" />
<img src={qr} alt={t('walletUi.share.qrAlt')} className="w-48 h-48 sm:w-52 sm:h-52" />
) : (
<div className="w-48 h-48 sm:w-52 sm:h-52 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
@ -112,12 +114,12 @@ export function ShareProfileDialog({ open, onOpenChange, npub, displayName }: Sh
<div className="grid grid-cols-1 gap-2 w-full">
<Button onClick={handleCopy} variant="outline" className="w-full border-white/15 font-bold">
{copied ? <><Check className="h-4 w-4 mr-2 text-[#ccff00]" />Copied!</> : <><Copy className="h-4 w-4 mr-2" />Copy link</>}
{copied ? <><Check className="h-4 w-4 mr-2 text-[#ccff00]" />{t('common.copied')}</> : <><Copy className="h-4 w-4 mr-2" />{t('walletUi.share.copyLink')}</>}
</Button>
{canNativeShare && (
<Button onClick={handleNativeShare} className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase">
<Share2 className="h-4 w-4 mr-2" />
Share…
{t('walletUi.share.shareEllipsis')}
</Button>
)}
</div>

View file

@ -2,6 +2,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Zap, Check, Circle, Camera, FileText, MapPin, Phone, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface ShopBountyCardProps {
// Current form state to calculate potential reward
@ -33,52 +34,53 @@ export function ShopBountyCard({
hasHours,
photoCount,
}: ShopBountyCardProps) {
const { t } = useTranslation();
const checks: QualityCheckItem[] = [
{
id: 'name',
label: 'Shop name',
label: t('placesUi.bounty.shopName'),
points: 15,
completed: hasName,
icon: MapPin,
},
{
id: 'location',
label: 'Address & coordinates',
label: t('placesUi.bounty.addressCoords'),
points: 20,
completed: hasAddress && hasCoordinates,
icon: MapPin,
},
{
id: 'photo1',
label: 'At least 1 photo',
label: t('placesUi.bounty.onePhoto'),
points: 15,
completed: photoCount >= 1,
icon: Camera,
},
{
id: 'photo3',
label: '3+ photos',
label: t('placesUi.bounty.threePhotos'),
points: 10,
completed: photoCount >= 3,
icon: Camera,
},
{
id: 'description',
label: 'Description',
label: t('placesUi.bounty.description'),
points: 15,
completed: hasDescription,
icon: FileText,
},
{
id: 'contact',
label: 'Contact info',
label: t('placesUi.bounty.contactInfo'),
points: 10,
completed: hasPhone || hasWebsite,
icon: Phone,
},
{
id: 'hours',
label: 'Opening hours',
label: t('placesUi.bounty.openingHours'),
points: 15,
completed: hasHours,
icon: Clock,
@ -105,10 +107,10 @@ export function ShopBountyCard({
};
const getScoreLabel = () => {
if (percentage >= 80) return 'Excellent!';
if (percentage >= 50) return 'Good';
if (percentage >= 30) return 'Basic';
return 'Getting started';
if (percentage >= 80) return t('placesUi.bounty.excellent');
if (percentage >= 50) return t('placesUi.bounty.good');
if (percentage >= 30) return t('placesUi.bounty.basic');
return t('placesUi.bounty.gettingStarted');
};
return (
@ -117,7 +119,7 @@ export function ShopBountyCard({
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ccff00]" />
<span className="text-sm font-bold uppercase text-gray-300">Earning Potential</span>
<span className="text-sm font-bold uppercase text-gray-300">{t('placesUi.bounty.earningPotential')}</span>
</div>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold', getScoreColor())}>
@ -174,7 +176,7 @@ export function ShopBountyCard({
{/* Tip */}
<div className="mt-4 pt-3 border-t border-gray-800">
<p className="text-[10px] text-gray-500">
💡 Higher quality = More zaps from the community!
{t('placesUi.bounty.tip')}
</p>
</div>
</CardContent>

View file

@ -9,6 +9,7 @@ import {
validateSkateNip05Name,
} from '@/lib/skateNip05';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
type FieldStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid' | 'error' | 'claimed';
@ -44,6 +45,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
className,
}) => {
const { checkAvailability, claim } = useSkateNip05();
const { t } = useTranslation();
const [status, setStatus] = useState<FieldStatus>('idle');
const [message, setMessage] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false);
@ -114,7 +116,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
id={id}
value={handle}
onChange={(e) => onHandleChange(e.target.value)}
placeholder="yourname"
placeholder={t('settingsUi.skateNip05.placeholder')}
spellCheck={false}
autoCapitalize="none"
autoComplete="off"
@ -132,19 +134,19 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
<p className="flex items-start gap-1.5 text-xs text-green-600 dark:text-green-400">
<BadgeCheck className="h-3.5 w-3.5 shrink-0 mt-px" />
<span>
<span className="font-semibold">{claimedHandle}@{SKATE_NIP05_DOMAIN}</span> is yours — save your profile to publish it.
<span className="font-semibold">{claimedHandle}@{SKATE_NIP05_DOMAIN}</span> {t('settingsUi.skateNip05.claimedSuffix')}
</span>
</p>
) : status === 'checking' ? (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Checking availability…
{t('settingsUi.skateNip05.checking')}
</p>
) : status === 'available' ? (
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
{validation.name}@{SKATE_NIP05_DOMAIN} is available
{t('settingsUi.skateNip05.available', { name: validation.name, domain: SKATE_NIP05_DOMAIN })}
</p>
<Button
type="button"
@ -153,7 +155,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
disabled={claiming || disabled}
className="h-7 rounded-full bg-[#ccff00] px-3 text-xs font-bold text-black hover:bg-[#ccff00]/90"
>
{claiming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Claim it'}
{claiming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('settingsUi.skateNip05.claim')}
</Button>
</div>
) : status === 'invalid' || status === 'taken' || status === 'error' ? (
@ -163,7 +165,7 @@ export const SkateNip05Input: React.FC<SkateNip05InputProps> = ({
</p>
) : (
<p className="text-xs text-muted-foreground">
Letters, numbers, dashes. Free, and shown as a ✓ badge on your profile.
{t('settingsUi.skateNip05.hint')}
</p>
)}
</div>

View file

@ -1,6 +1,7 @@
import { ArrowUpDown, Clock, TrendingUp, MapPin, Zap, Shield } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
export type SortOption =
| 'recent'
@ -19,29 +20,29 @@ interface SortDropdownProps {
const SORT_OPTIONS = {
recent: {
label: 'Most Recent',
labelKey: 'chromeUi.sort.recent',
descKey: 'chromeUi.sort.recentDesc',
icon: Clock,
description: 'Newest first',
},
trust: {
label: 'Highest Trust',
labelKey: 'chromeUi.sort.trust',
descKey: 'chromeUi.sort.trustDesc',
icon: Shield,
description: 'Most trusted creators',
},
proximity: {
label: 'Closest to Me',
labelKey: 'chromeUi.sort.proximity',
descKey: 'chromeUi.sort.proximityDesc',
icon: MapPin,
description: 'Nearest locations',
},
popular: {
label: 'Most Popular',
labelKey: 'chromeUi.sort.popular',
descKey: 'chromeUi.sort.popularDesc',
icon: TrendingUp,
description: 'Most comments/reactions',
},
zaps: {
label: 'Most Zapped',
labelKey: 'chromeUi.sort.zaps',
descKey: 'chromeUi.sort.zapsDesc',
icon: Zap,
description: 'Highest value received',
},
};
@ -51,6 +52,7 @@ export function SortDropdown({
className,
showProximity = false,
}: SortDropdownProps) {
const { t } = useTranslation();
const selectedOption = SORT_OPTIONS[value];
const Icon = selectedOption.icon;
@ -74,7 +76,7 @@ export function SortDropdown({
<SelectValue>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{selectedOption.label}
{t(selectedOption.labelKey)}
</span>
</SelectValue>
</SelectTrigger>
@ -88,8 +90,8 @@ export function SortDropdown({
<div className="flex items-center gap-2 w-full">
<OptionIcon className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium">{opt.label}</div>
<div className="text-xs text-muted-foreground">{opt.description}</div>
<div className="font-medium">{t(opt.labelKey)}</div>
<div className="text-xs text-muted-foreground">{t(opt.descKey)}</div>
</div>
</div>
</SelectItem>

View file

@ -4,7 +4,8 @@ import 'leaflet.markercluster';
import ngeohash from 'ngeohash';
import { useNavigate } from 'react-router-dom';
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types';
import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types';
import { formatPrice } from '@/lib/types';
import { spotTypeLabel } from '@/lib/spotTypeLabel';
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
import { isRecentlyVerified, isBoosted } from '@/hooks/useBtcMapPlaces';
@ -644,7 +645,7 @@ function createPopupContent(
spot.types.slice(0, 3).forEach((type) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-800';
badge.textContent = SPOT_TYPE_LABELS[type] || type;
badge.textContent = spotTypeLabel(t, type);
typesContainer.appendChild(badge);
});
container.appendChild(typesContainer);
@ -702,7 +703,7 @@ function createEnhancedPopupContent(
spot.types.slice(0, 3).forEach((type) => {
const badge = document.createElement('span');
badge.className = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold bg-gray-800 text-gray-200 border-gray-600';
badge.textContent = SPOT_TYPE_LABELS[type] || type;
badge.textContent = spotTypeLabel(t, type);
typesContainer.appendChild(badge);
});
spotSection.appendChild(typesContainer);

View file

@ -18,6 +18,9 @@ import { ReportDialog } from '@/components/ReportDialog';
import type { SkateTour, TourCategory } from '@/lib/types';
import { TOUR_CATEGORIES, TOUR_SKILL_LEVELS } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
type Translate = (key: string, params?: Record<string, string | number>) => string;
interface TourCardProps {
tour: SkateTour;
@ -39,16 +42,16 @@ function _getCategoryIcon(category: TourCategory) {
}
}
function formatDuration(duration?: number, unit?: string): string {
function formatDuration(duration: number | undefined, unit: string | undefined, t: Translate): string {
if (!duration) return '';
switch (unit) {
case 'hours':
return `${duration} hr${duration > 1 ? 's' : ''}`;
return t('placesUi.tourCard.durationHours', { count: duration });
case 'days':
return `${duration} day${duration > 1 ? 's' : ''}`;
return t('placesUi.tourCard.durationDays', { count: duration });
case 'weeks':
return duration >= 4 ? `${Math.floor(duration / 4)} month${Math.floor(duration / 4) > 1 ? 's' : ''}` : `${duration} week${duration > 1 ? 's' : ''}`;
return duration >= 4 ? t('placesUi.tourCard.durationMonths', { count: Math.floor(duration / 4) }) : t('placesUi.tourCard.durationWeeks', { count: duration });
default:
return `${duration}`;
}
@ -84,7 +87,8 @@ function formatDateRange(startDate?: number, endDate?: number, withTime = false)
* date. Returns null when there's no date or the tour is well in the past.
*/
function getCountdown(
tour: SkateTour
tour: SkateTour,
t: Translate,
): { label: string; color: string; live?: boolean } | null {
if (!tour.startDate) return null;
@ -95,13 +99,13 @@ function getCountdown(
// Currently running (has an end date and we're inside the window).
if (start <= nowSecs && end && nowSecs <= end) {
return { label: 'Happening now', color: '#ff0040', live: true };
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
}
// Already started but single-session / no end — treat same-day as live, else past.
if (start <= nowSecs) {
if (nowSecs - start < 12 * 60 * 60) {
return { label: 'Happening now', color: '#ff0040', live: true };
return { label: t('placesUi.tourCard.happeningNow'), color: '#ff0040', live: true };
}
return null;
}
@ -110,35 +114,36 @@ function getCountdown(
const days = Math.floor(diffSecs / 86400);
const hours = Math.floor(diffSecs / 3600);
if (hours < 1) return { label: 'Starting soon', color: accent, live: true };
if (hours < 24) return { label: `In ${hours}h`, color: accent };
if (days === 1) return { label: 'Tomorrow', color: accent };
if (days < 7) return { label: `In ${days} days`, color: accent };
if (hours < 1) return { label: t('placesUi.tourCard.startingSoon'), color: accent, live: true };
if (hours < 24) return { label: t('placesUi.tourCard.inHours', { count: hours }), color: accent };
if (days === 1) return { label: t('placesUi.tourCard.tomorrow'), color: accent };
if (days < 7) return { label: t('placesUi.tourCard.inDays', { count: days }), color: accent };
if (days < 30) {
const w = Math.round(days / 7);
return { label: `In ${w} week${w > 1 ? 's' : ''}`, color: accent };
return { label: t('placesUi.tourCard.inWeeks', { count: w }), color: accent };
}
const m = Math.round(days / 30);
return { label: `In ${m} month${m > 1 ? 's' : ''}`, color: accent };
return { label: t('placesUi.tourCard.inMonths', { count: m }), color: accent };
}
function getTourStatus(tour: SkateTour): { label: string; color: string } | null {
function getTourStatus(tour: SkateTour, t: Translate): { label: string; color: string } | null {
if (tour.category !== 'destruction') return null;
const now = Date.now() / 1000;
if (tour.startDate && now < tour.startDate) {
return { label: 'Registration Open', color: '#ccff00' };
return { label: t('placesUi.tourCard.registrationOpen'), color: '#ccff00' };
} else if (tour.startDate && tour.endDate && now >= tour.startDate && now <= tour.endDate) {
return { label: 'Active', color: '#ff0040' };
return { label: t('placesUi.tourCard.active'), color: '#ff0040' };
} else if (tour.endDate && now > tour.endDate) {
return { label: 'Completed', color: '#888' };
return { label: t('placesUi.tourCard.completed'), color: '#888' };
}
return null;
}
export function TourCard({ tour, compact = false }: TourCardProps) {
const { t } = useTranslation();
const author = useAuthor(tour.pubkey);
const { user } = useCurrentUser();
const metadata = author.data?.metadata;
@ -147,13 +152,13 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
const categoryInfo = TOUR_CATEGORIES[tour.category];
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const tourStatus = getTourStatus(tour);
const countdown = getCountdown(tour);
const tourStatus = getTourStatus(tour, t);
const countdown = getCountdown(tour, t);
const isOwner = user?.pubkey === tour.pubkey;
// Get location text based on category
const locationText = tour.category === 'destruction' || tour.category === 'international'
? tour.countries?.join(', ') || tour.cities?.join(', ') || 'Worldwide'
? tour.countries?.join(', ') || tour.cities?.join(', ') || t('placesUi.tourCard.worldwide')
: tour.location || tour.cities?.join(', ');
if (compact) {
@ -228,7 +233,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
{tour.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" style={{ color: categoryInfo.color }} />
{formatDuration(tour.duration, tour.durationUnit)}
{formatDuration(tour.duration, tour.durationUnit, t)}
</span>
)}
{locationText && (
@ -329,7 +334,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
{tour.prizePool && (
<Badge className="bg-orange-500/90 backdrop-blur-sm shadow-lg font-bold text-xs">
<Trophy className="h-3 w-3 mr-1" />
{tour.prizePool.toLocaleString()} sats
{t('placesUi.tourCard.prizePool', { amount: tour.prizePool.toLocaleString() })}
</Badge>
)}
</div>
@ -381,19 +386,19 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
{tour.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" style={{ color: categoryInfo.color }} />
{formatDuration(tour.duration, tour.durationUnit)}
{formatDuration(tour.duration, tour.durationUnit, t)}
</span>
)}
{tour.maxGroupSize && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
Max {tour.maxGroupSize}
{t('placesUi.tourCard.maxGroup', { count: tour.maxGroupSize })}
</span>
)}
{tour.maxTeams && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4 text-[#ff6600]" />
{tour.maxTeams} teams max
{t('placesUi.tourCard.teamsMax', { count: tour.maxTeams })}
</span>
)}
{skillInfo && (
@ -425,16 +430,16 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
{tour.category === 'destruction' && tour.entryFee ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
Entry: {tour.entryFee.toLocaleString()} sats
{t('placesUi.tourCard.entry', { amount: tour.entryFee.toLocaleString() })}
</Badge>
) : tour.price ? (
<Badge variant="outline" className="border-orange-500/50 text-orange-400">
<Zap className="h-3 w-3 mr-1" />
{tour.price.toLocaleString()} sats
{t('placesUi.tourCard.price', { amount: tour.price.toLocaleString() })}
</Badge>
) : (
<Badge variant="outline" className="border-green-500/50 text-green-400">
Contact for price
{t('placesUi.tourCard.contactForPrice')}
</Badge>
)}
</div>
@ -463,7 +468,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
className="text-red-500 focus:text-red-500"
>
<Flag className="h-4 w-4 mr-2" />
Report
{t('placesUi.eventCard.report')}
</DropdownMenuItem>
)}
</DropdownMenuContent>

View file

@ -3,6 +3,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Shield, CheckCircle2, AlertCircle } from 'lucide-react';
import { useTrustScore, getTrustBadgeConfig } from '@/hooks/useTrustScore';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface TrustBadgeProps {
pubkey: string | undefined;
@ -22,6 +23,7 @@ interface TrustBadgeProps {
* - 0: Unknown/unverified
*/
export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }: TrustBadgeProps) {
const { t } = useTranslation();
const { data: trustScore, isLoading } = useTrustScore(pubkey);
const badge = getTrustBadgeConfig(trustScore);
@ -42,12 +44,12 @@ export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }:
};
const getTooltipText = (score: number | undefined) => {
if (score === undefined) return 'Trust score unavailable';
if (score === 100) return 'This is your content';
if (score >= 90) return 'You follow this user';
if (score >= 60) return 'Friend of a friend (2 hops)';
if (score >= 30) return 'NIP-05 verified user';
return 'Unknown user';
if (score === undefined) return t('walletUi.trust.unavailable');
if (score === 100) return t('walletUi.trust.yourContent');
if (score >= 90) return t('walletUi.trust.following');
if (score >= 60) return t('walletUi.trust.friendOfFriend');
if (score >= 30) return t('walletUi.trust.nip05');
return t('walletUi.trust.unknown');
};
return (
@ -68,7 +70,7 @@ export function TrustBadge({ pubkey, className, showLabel = true, size = 'sm' }:
<div className="space-y-1">
<p className="font-bold">{getTooltipText(trustScore)}</p>
<p className="text-xs text-muted-foreground">
Trust scores help identify reliable contributors in the skateboarding community.
{t('walletUi.trust.help')}
</p>
</div>
</TooltipContent>

View file

@ -11,6 +11,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface TrustFilterProps {
value: TrustFilterLevel;
@ -22,35 +23,36 @@ interface TrustFilterProps {
const TRUST_LEVELS = [
{
value: 'all' as const,
label: 'All Users',
description: 'Show content from everyone',
labelKey: 'chromeUi.trust.all',
descKey: 'chromeUi.trust.allDesc',
icon: Users,
color: 'text-gray-400',
},
{
value: 'verified' as const,
label: 'Verified',
description: 'You follow them or they are verified (trust ≥90)',
labelKey: 'chromeUi.trust.verified',
descKey: 'chromeUi.trust.verifiedDesc',
icon: CheckCircle2,
color: 'text-green-500',
},
{
value: 'trusted' as const,
label: 'Trusted',
description: 'Friends-of-friends or NIP-05 verified (trust ≥60)',
labelKey: 'chromeUi.trust.trusted',
descKey: 'chromeUi.trust.trustedDesc',
icon: Shield,
color: 'text-yellow-500',
},
{
value: 'following' as const,
label: 'Following Only',
description: 'Only show content from people you follow',
labelKey: 'chromeUi.trust.following',
descKey: 'chromeUi.trust.followingDesc',
icon: CheckCircle2,
color: 'text-green-500',
},
];
export function TrustFilter({ value, onChange, className, compact = false }: TrustFilterProps) {
const { t } = useTranslation();
const currentLevel = TRUST_LEVELS.find((l) => l.value === value) || TRUST_LEVELS[0];
const Icon = currentLevel.icon;
@ -64,16 +66,16 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
className={cn("gap-2", className)}
>
<Icon className="h-4 w-4" />
Trust Filter
{t('chromeUi.trustFilter')}
{value !== 'all' && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{value === 'verified' ? '≥90' : value === 'trusted' ? '≥60' : 'Following'}
{value === 'verified' ? '≥90' : value === 'trusted' ? '≥60' : t('chromeUi.trust.followingShort')}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel>Filter by Trust Level</DropdownMenuLabel>
<DropdownMenuLabel>{t('chromeUi.filterByTrustLevel')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{TRUST_LEVELS.map((level) => (
<DropdownMenuCheckboxItem
@ -84,9 +86,9 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
>
<div className="flex items-center gap-2 mb-1">
<level.icon className={cn("h-4 w-4", level.color)} />
<span className="font-bold">{level.label}</span>
<span className="font-bold">{t(level.labelKey)}</span>
</div>
<p className="text-xs text-muted-foreground pl-6">{level.description}</p>
<p className="text-xs text-muted-foreground pl-6">{t(level.descKey)}</p>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
@ -98,7 +100,7 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
<div className={cn("space-y-3", className)}>
<label className="text-sm font-bold text-gray-300 uppercase tracking-wide flex items-center gap-2">
<Shield className="h-4 w-4 text-[#ccff00]" />
Trust Filter
{t('chromeUi.trustFilter')}
</label>
<div className="grid gap-2">
{TRUST_LEVELS.map((level) => {
@ -118,8 +120,8 @@ export function TrustFilter({ value, onChange, className, compact = false }: Tru
>
<Icon className={cn("h-5 w-5 flex-shrink-0 mt-0.5", isActive ? level.color : "text-gray-500")} />
<div className="flex-1">
<div className="font-bold text-white mb-1">{level.label}</div>
<p className="text-xs text-gray-400">{level.description}</p>
<div className="font-bold text-white mb-1">{t(level.labelKey)}</div>
<p className="text-xs text-gray-400">{t(level.descKey)}</p>
</div>
{isActive && (
<CheckCircle2 className="h-5 w-5 text-[#ccff00] flex-shrink-0" />

View file

@ -1,4 +1,5 @@
import { useUserStatus, SKATING_STATUSES } from '@/hooks/useUserStatus';
import { useTranslation } from '@/contexts/language';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
@ -16,6 +17,7 @@ export function UserStatusIndicator({
showLabel = false,
size = 'sm',
}: UserStatusIndicatorProps) {
const { t } = useTranslation();
const { data: status, isLoading } = useUserStatus(pubkey);
if (isLoading || !status) return null;
@ -78,12 +80,12 @@ export function UserStatusIndicator({
<p className="font-bold">{label}</p>
{status.linkedSpot && (
<p className="text-xs text-muted-foreground mt-1">
📍 At a spot
{t('socialUi.userStatus.atSpot')}
</p>
)}
{status.expiration && (
<p className="text-xs text-muted-foreground mt-1">
⏱️ Auto-clears in {Math.round((status.expiration - Date.now() / 1000) / 60)} min
{t('socialUi.userStatus.autoClears', { min: Math.round((status.expiration - Date.now() / 1000) / 60) })}
</p>
)}
</div>

View file

@ -14,6 +14,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useVouches, useVouchFor } from '@/hooks/useVouches';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
@ -62,6 +63,7 @@ export function VouchCard({
accentText = 'text-teal-400',
accentBg = 'bg-teal-500 hover:bg-teal-600',
}: VouchCardProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data, isLoading } = useVouches(coord);
const { mutateAsync: vouch, isPending } = useVouchFor();
@ -81,15 +83,15 @@ export function VouchCard({
try {
await vouch({ coord, providerName, retract: data.viewerVouched });
toast({
title: data.viewerVouched ? 'Vouch removed' : 'Vouched!',
title: data.viewerVouched ? t('socialUi.vouch.removedTitle') : t('socialUi.vouch.vouchedTitle'),
description: data.viewerVouched
? `You no longer vouch for ${providerName}.`
: `You vouched for ${providerName}. The scene trusts its own.`,
? t('socialUi.vouch.removedDesc', { name: providerName })
: t('socialUi.vouch.vouchedDesc', { name: providerName }),
});
} catch (error) {
toast({
title: 'Could not vouch',
description: error instanceof Error ? error.message : 'Please try again.',
title: t('socialUi.vouch.couldNotVouch'),
description: error instanceof Error ? error.message : t('common.tryAgain'),
variant: 'destructive',
});
}
@ -100,31 +102,30 @@ export function VouchCard({
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-gray-400">
<HeartHandshake className={cn('h-4 w-4', accentText)} /> Scene Vouches
<HeartHandshake className={cn('h-4 w-4', accentText)} /> {t('socialUi.vouch.title')}
</p>
{tier === 'verified' && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-400">
<ShieldCheck className="h-3 w-3" /> Scene-verified
<ShieldCheck className="h-3 w-3" /> {t('socialUi.vouch.sceneVerified')}
</span>
)}
{tier === 'vouched' && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-bold text-amber-400">
<Users className="h-3 w-3" /> Vouched
<Users className="h-3 w-3" /> {t('socialUi.vouch.vouched')}
</span>
)}
{tier === 'new' && (
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
New · unvouched
{t('socialUi.vouch.newUnvouched')}
</span>
)}
</div>
{isLoading ? (
<p className="text-xs text-gray-500">Checking the scene…</p>
<p className="text-xs text-gray-500">{t('socialUi.vouch.checking')}</p>
) : data.count === 0 ? (
<p className="text-xs leading-relaxed text-gray-500">
No vouches yet. Vouches from skaters in your circles are the best signal that
someone's the real deal — not a random who wandered in.
{t('socialUi.vouch.empty')}
</p>
) : (
<div className="space-y-2">
@ -135,12 +136,14 @@ export function VouchCard({
))}
</div>
<span className="text-sm font-bold text-gray-300">
{data.count} vouch{data.count !== 1 ? 'es' : ''}
{data.count === 1
? t('socialUi.vouch.countOne', { count: data.count })
: t('socialUi.vouch.countMany', { count: data.count })}
</span>
</div>
{data.trustedVouchers > 0 && (
<p className="text-xs text-green-500/80">
🛡 {data.trustedVouchers} from skaters in your web of trust
🛡 {t('socialUi.vouch.trusted', { count: data.trustedVouchers })}
</p>
)}
</div>
@ -162,12 +165,12 @@ export function VouchCard({
) : (
<HeartHandshake className="h-4 w-4" />
)}
{data.viewerVouched ? 'Remove vouch' : `Vouch for ${providerName.split(' ')[0]}`}
{data.viewerVouched ? t('socialUi.vouch.remove') : t('socialUi.vouch.vouchFor', { name: providerName.split(' ')[0] })}
</Button>
)}
{isOwner && (
<p className="text-center text-[11px] text-gray-600">
Skaters who know your work can vouch for you here.
{t('socialUi.vouch.ownerNote')}
</p>
)}
</CardContent>

View file

@ -12,6 +12,7 @@ import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useTranslation } from '@/contexts/language';
import type { NWCConnection, NWCInfo } from '@/hooks/useNWC';
import type { WebLNProvider } from "@webbtc/webln-types";
@ -26,19 +27,21 @@ const AddWalletContent = forwardRef<HTMLDivElement, {
setAlias: (value: string) => void;
connectionUri: string;
setConnectionUri: (value: string) => void;
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => (
}>(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => {
const { t } = useTranslation();
return (
<div className="space-y-4 px-4" ref={ref}>
<div>
<Label htmlFor="alias">Wallet Name (optional)</Label>
<Label htmlFor="alias">{t('walletUi.modal.walletName')}</Label>
<Input
id="alias"
placeholder="My Lightning Wallet"
placeholder={t('walletUi.modal.walletNamePlaceholder')}
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
</div>
<div>
<Label htmlFor="connection-uri">Connection URI</Label>
<Label htmlFor="connection-uri">{t('walletUi.modal.connectionUri')}</Label>
<Textarea
id="connection-uri"
placeholder="nostr+walletconnect://..."
@ -52,18 +55,17 @@ const AddWalletContent = forwardRef<HTMLDivElement, {
<div className="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<ShieldAlert className="h-4 w-4 flex-shrink-0 text-amber-500 mt-0.5" />
<div className="space-y-1 text-xs text-amber-200/90">
<p className="font-semibold text-amber-300">Set a spending limit first</p>
<p className="font-semibold text-amber-300">{t('walletUi.modal.spendLimitTitle')}</p>
<p>
This string lets SkateSpots pay Lightning invoices from your wallet. In
your wallet app, give this connection a small <strong>daily budget</strong>{' '}
(e.g. a few thousand sats) and enable only <strong>pay_invoice</strong>.
Never paste a connection with an unlimited budget. You can revoke it in
your wallet any time.
{t('walletUi.modal.spendLimitBody1')} <strong>{t('walletUi.modal.dailyBudget')}</strong>{' '}
{t('walletUi.modal.spendLimitBody2')} <strong>pay_invoice</strong>.{' '}
{t('walletUi.modal.spendLimitBody3')}
</p>
</div>
</div>
</div>
));
);
});
AddWalletContent.displayName = 'AddWalletContent';
// Recommended wallets for NWC
@ -92,7 +94,9 @@ const WalletContent = forwardRef<HTMLDivElement, {
handleSetActive,
handleRemoveConnection,
setAddDialogOpen
}, ref) => (
}, ref) => {
const { t } = useTranslation();
return (
<div className="space-y-4 sm:space-y-6 px-0 sm:px-4 pb-4" ref={ref}>
{/* NWC First - Primary Option */}
<div className="space-y-3">
@ -100,7 +104,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
<h3 className="font-medium text-sm sm:text-base">Nostr Wallet Connect</h3>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Wallet
{t('walletUi.modal.addWallet')}
</Button>
</div>
@ -116,10 +120,10 @@ const WalletContent = forwardRef<HTMLDivElement, {
<WalletMinimal className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{connection.alias || info?.alias || 'Lightning Wallet'}
{connection.alias || info?.alias || t('walletUi.modal.lightningWallet')}
</p>
<p className="text-xs text-muted-foreground">
NWC Connection
{t('walletUi.modal.nwcConnection')}
</p>
</div>
</div>
@ -150,11 +154,11 @@ const WalletContent = forwardRef<HTMLDivElement, {
<div className="text-center py-4 px-3 border border-dashed rounded-lg">
<WalletMinimal className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-3">
Paste your NWC connection string to send zaps
{t('walletUi.modal.pasteNwc')}
</p>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Connect Wallet
{t('walletUi.modal.connectWallet')}
</Button>
</div>
)}
@ -168,14 +172,14 @@ const WalletContent = forwardRef<HTMLDivElement, {
<div className="flex items-center gap-2 sm:gap-3">
<Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs sm:text-sm font-medium">Browser Extension (WebLN)</p>
<p className="text-[10px] sm:text-xs text-muted-foreground">Auto-detected if installed</p>
<p className="text-xs sm:text-sm font-medium">{t('walletUi.modal.browserExtension')}</p>
<p className="text-[10px] sm:text-xs text-muted-foreground">{t('walletUi.modal.autoDetected')}</p>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{webln && <CheckCircle className="h-3 w-3 sm:h-4 sm:w-4 text-green-600" />}
<Badge variant={webln ? "default" : "secondary"} className="text-[10px] sm:text-xs">
{webln ? "Ready" : "Not Found"}
{webln ? t('walletUi.modal.ready') : t('walletUi.modal.notFound')}
</Badge>
</div>
</div>
@ -186,7 +190,7 @@ const WalletContent = forwardRef<HTMLDivElement, {
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">Get NWC from:</h4>
<h4 className="text-sm font-medium text-muted-foreground">{t('walletUi.modal.getNwcFrom')}</h4>
<div className="grid gap-2">
{RECOMMENDED_WALLETS.map((wallet) => (
<a
@ -210,10 +214,12 @@ const WalletContent = forwardRef<HTMLDivElement, {
</>
)}
</div>
));
);
});
WalletContent.displayName = 'WalletContent';
export function WalletModal({ children, className }: WalletModalProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
@ -238,8 +244,8 @@ export function WalletModal({ children, className }: WalletModalProps) {
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
title: t('walletUi.modal.connectionRequired'),
description: t('walletUi.modal.connectionRequiredDesc'),
variant: 'destructive',
});
return;
@ -265,8 +271,8 @@ export function WalletModal({ children, className }: WalletModalProps) {
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
title: t('walletUi.modal.activeChanged'),
description: t('walletUi.modal.activeChangedDesc'),
});
};
@ -285,9 +291,9 @@ export function WalletModal({ children, className }: WalletModalProps) {
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">Connect NWC Wallet</DialogTitle>
<DialogTitle className="text-base sm:text-lg">{t('walletUi.modal.connectNwc')}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Enter your connection string from a compatible wallet.
{t('walletUi.modal.connectNwcDesc')}
</DialogDescription>
</DialogHeader>
<AddWalletContent
@ -302,7 +308,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? 'Connecting...' : 'Connect'}
{isConnecting ? t('walletUi.modal.connecting') : t('walletUi.modal.connect')}
</Button>
</DialogFooter>
</DialogContent>
@ -317,7 +323,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
Wallet Settings
{t('walletUi.modal.walletSettings')}
</Button>
)}
</DrawerTrigger>
@ -326,15 +332,15 @@ export function WalletModal({ children, className }: WalletModalProps) {
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="absolute right-4 top-4">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{t('common.close')}</span>
</Button>
</DrawerClose>
<DrawerTitle className="flex items-center justify-center gap-2 pt-2">
<Wallet className="h-5 w-5" />
Lightning Wallet
{t('walletUi.modal.lightningWallet')}
</DrawerTitle>
<DrawerDescription>
Connect your lightning wallet to send zaps instantly.
{t('walletUi.modal.connectDesc')}
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto">
@ -346,9 +352,9 @@ export function WalletModal({ children, className }: WalletModalProps) {
<Drawer open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Connect NWC Wallet</DrawerTitle>
<DrawerTitle>{t('walletUi.modal.connectNwc')}</DrawerTitle>
<DrawerDescription>
Enter your connection string from a compatible wallet.
{t('walletUi.modal.connectNwcDesc')}
</DrawerDescription>
</DrawerHeader>
<AddWalletContent
@ -363,7 +369,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
disabled={isConnecting || !connectionUri.trim()}
className="w-full"
>
{isConnecting ? 'Connecting...' : 'Connect'}
{isConnecting ? t('walletUi.modal.connecting') : t('walletUi.modal.connect')}
</Button>
</div>
</DrawerContent>
@ -379,7 +385,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
{children || (
<Button variant="outline" size="sm" className={className}>
<Wallet className="h-4 w-4 mr-2" />
Wallet Settings
{t('walletUi.modal.walletSettings')}
</Button>
)}
</DialogTrigger>
@ -387,10 +393,10 @@ export function WalletModal({ children, className }: WalletModalProps) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Wallet className="h-5 w-5" />
Lightning Wallet
{t('walletUi.modal.lightningWallet')}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Connect your lightning wallet to send zaps instantly.
{t('walletUi.modal.connectDesc')}
</DialogDescription>
</DialogHeader>
<WalletContent {...walletContentProps} />

View file

@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button';
import { Cloud, CloudOff } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface WeatherToggleProps {
enabled: boolean;
@ -9,6 +10,7 @@ interface WeatherToggleProps {
}
export function WeatherToggle({ enabled, onToggle, className }: WeatherToggleProps) {
const { t } = useTranslation();
return (
<Button
variant={enabled ? 'default' : 'outline'}
@ -23,12 +25,12 @@ export function WeatherToggle({ enabled, onToggle, className }: WeatherTogglePro
{enabled ? (
<>
<Cloud className="h-4 w-4" />
<span className="hidden sm:inline">Weather On</span>
<span className="hidden sm:inline">{t('chromeUi.weatherOn')}</span>
</>
) : (
<>
<CloudOff className="h-4 w-4" />
<span className="hidden sm:inline">Weather Off</span>
<span className="hidden sm:inline">{t('chromeUi.weatherOff')}</span>
</>
)}
</Button>

View file

@ -1,23 +1,24 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useZapLeaderboard } from '@/hooks/useZapLeaderboard';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Award, Crown, Sparkles, Trophy, Zap } from 'lucide-react';
interface BadgeRule {
id: string;
label: string;
hint: string;
labelKey: string;
hintKey: string;
Icon: typeof Zap;
earned: (count: number, sats: number, rank: number | null) => boolean;
}
const BADGES: BadgeRule[] = [
{ id: 'first', label: 'First Zap', hint: 'Send your first zap', Icon: Sparkles, earned: (c) => c >= 1 },
{ id: 'regular', label: 'Regular', hint: 'Send 10 zaps', Icon: Zap, earned: (c) => c >= 10 },
{ id: 'generous', label: 'Generous', hint: 'Send 10,000 sats', Icon: Award, earned: (_c, s) => s >= 10_000 },
{ id: 'patron', label: 'Patron', hint: 'Send 100,000 sats', Icon: Crown, earned: (_c, s) => s >= 100_000 },
{ id: 'top10', label: 'Top 10', hint: 'Reach the top 10 supporters', Icon: Trophy, earned: (_c, _s, r) => r !== null && r <= 10 },
{ id: 'first', labelKey: 'walletUi.zapBadges.first', hintKey: 'walletUi.zapBadges.firstHint', Icon: Sparkles, earned: (c) => c >= 1 },
{ id: 'regular', labelKey: 'walletUi.zapBadges.regular', hintKey: 'walletUi.zapBadges.regularHint', Icon: Zap, earned: (c) => c >= 10 },
{ id: 'generous', labelKey: 'walletUi.zapBadges.generous', hintKey: 'walletUi.zapBadges.generousHint', Icon: Award, earned: (_c, s) => s >= 10_000 },
{ id: 'patron', labelKey: 'walletUi.zapBadges.patron', hintKey: 'walletUi.zapBadges.patronHint', Icon: Crown, earned: (_c, s) => s >= 100_000 },
{ id: 'top10', labelKey: 'walletUi.zapBadges.top10', hintKey: 'walletUi.zapBadges.top10Hint', Icon: Trophy, earned: (_c, _s, r) => r !== null && r <= 10 },
];
/**
@ -26,6 +27,7 @@ const BADGES: BadgeRule[] = [
* published, so this adds no events and no spam to the network.
*/
export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className?: string }) {
const { t } = useTranslation();
const since = useMemo(() => Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60, []);
const { data, isLoading } = useZapLeaderboard(since);
@ -47,15 +49,15 @@ export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ccff00]">
<Zap className="h-5 w-5" />
Zap Badges
{t('walletUi.zapBadges.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-gray-400">
<span className="font-bold text-[#ccff00]">{stats.count.toLocaleString()}</span> zaps ·{' '}
<span className="font-bold text-[#ccff00]">{stats.sats.toLocaleString()}</span> sats sent
<span className="font-bold text-[#ccff00]">{stats.count.toLocaleString()}</span> {t('walletUi.zapBadges.zaps')} ·{' '}
<span className="font-bold text-[#ccff00]">{stats.sats.toLocaleString()}</span> {t('walletUi.zapBadges.satsSent')}
{stats.rank !== null && (
<> · rank <span className="font-bold text-white">#{stats.rank}</span></>
<> · {t('walletUi.zapBadges.rank')} <span className="font-bold text-white">#{stats.rank}</span></>
)}
</p>
@ -65,7 +67,7 @@ export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className
return (
<div
key={b.id}
title={b.hint}
title={t(b.hintKey)}
className={cn(
'flex flex-col items-center gap-1 rounded-lg border-2 px-2 py-3 text-center',
earned ? 'border-[#ccff00]/60 bg-[#ccff00]/10' : 'border-white/10 bg-black/40 opacity-45',
@ -78,14 +80,14 @@ export function ZapBadgesCard({ pubkey, className }: { pubkey: string; className
earned ? 'text-white' : 'text-gray-500',
)}
>
{b.label}
{t(b.labelKey)}
</span>
</div>
);
})}
</div>
<p className="text-[10px] text-gray-500">Based on the last 30 days of zaps.</p>
<p className="text-[10px] text-gray-500">{t('walletUi.zapBadges.footnote')}</p>
</CardContent>
</Card>
);

View file

@ -3,6 +3,7 @@ import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { Zap } from 'lucide-react';
import type { Event } from 'nostr-tools';
@ -19,6 +20,7 @@ export function ZapButton({
showCount = true,
zapData: externalZapData
}: ZapButtonProps) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: author } = useAuthor(target?.pubkey || '');
const { webln, activeNWC } = useWallet();
@ -54,7 +56,7 @@ export function ZapButton({
return (
<span
className={`flex items-center gap-1 ${className}`}
title={`${zapCount.toLocaleString()} zap${zapCount === 1 ? '' : 's'}`}
title={t('walletUi.zap.count', { count: zapCount.toLocaleString() })}
>
<Zap className="h-4 w-4" />
<span className="text-xs">{totalSats.toLocaleString()}</span>
@ -72,7 +74,7 @@ export function ZapButton({
) : showCount && totalSats > 0 ? (
`${totalSats.toLocaleString()}`
) : (
'Zap'
t('zaps.zapButton')
)}
</span>
</div>

View file

@ -12,6 +12,7 @@ import { useWallet } from '@/hooks/useWallet';
import { useCrewZap, splitAmount, type CrewZapResult } from '@/hooks/useCrewZap';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
import { Zap, Check, Copy, Loader2, X, Users, AlertCircle } from 'lucide-react';
@ -25,6 +26,7 @@ interface ZapCrewDialogProps {
}
function RecipientRow({ result }: { result: CrewZapResult }) {
const { t } = useTranslation();
const author = useAuthor(result.pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(result.pubkey);
@ -33,7 +35,7 @@ function RecipientRow({ result }: { result: CrewZapResult }) {
const copyInvoice = async () => {
if (!result.invoice) return;
await navigator.clipboard.writeText(result.invoice);
toast({ title: 'Invoice copied', description: 'Paste it into any Lightning wallet' });
toast({ title: t('walletUi.zapCrew.invoiceCopied'), description: t('walletUi.zapCrew.invoiceCopiedDesc') });
};
return (
@ -52,28 +54,30 @@ function RecipientRow({ result }: { result: CrewZapResult }) {
}
function StatusPill({ result, onCopy }: { result: CrewZapResult; onCopy: () => void }) {
const { t } = useTranslation();
switch (result.status) {
case 'success':
return <span className="flex items-center gap-1 text-[10px] font-bold text-green-400"><Check className="h-3 w-3" /> Sent</span>;
return <span className="flex items-center gap-1 text-[10px] font-bold text-green-400"><Check className="h-3 w-3" /> {t('walletUi.zapCrew.sent')}</span>;
case 'resolving':
case 'paying':
return <span className="flex items-center gap-1 text-[10px] text-teal-400"><Loader2 className="h-3 w-3 animate-spin" /> {result.status === 'paying' ? 'Paying' : 'Resolving'}</span>;
return <span className="flex items-center gap-1 text-[10px] text-teal-400"><Loader2 className="h-3 w-3 animate-spin" /> {result.status === 'paying' ? t('walletUi.zapCrew.paying') : t('walletUi.zapCrew.resolving')}</span>;
case 'skipped':
return <span className="text-[10px] text-gray-500" title={result.error}>Skipped</span>;
return <span className="text-[10px] text-gray-500" title={result.error}>{t('walletUi.zapCrew.skipped')}</span>;
case 'failed':
return result.invoice ? (
<button type="button" onClick={onCopy} className="flex items-center gap-1 text-[10px] font-bold text-amber-400 hover:text-amber-300">
<Copy className="h-3 w-3" /> Copy invoice
<Copy className="h-3 w-3" /> {t('walletUi.zapCrew.copyInvoice')}
</button>
) : (
<span className="flex items-center gap-1 text-[10px] text-red-400" title={result.error}><AlertCircle className="h-3 w-3" /> Failed</span>
<span className="flex items-center gap-1 text-[10px] text-red-400" title={result.error}><AlertCircle className="h-3 w-3" /> {t('walletUi.zapCrew.failed')}</span>
);
default:
return <span className="text-[10px] text-gray-600">Waiting</span>;
return <span className="text-[10px] text-gray-600">{t('walletUi.zapCrew.waiting')}</span>;
}
}
export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { preferredMethod } = useWallet();
@ -95,7 +99,7 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
const handleZap = () => {
if (numAmount <= 0 || payable.length === 0) return;
zapCrew(payable, numAmount, comment || `Zapped the ${crewName} crew! 🤘`);
zapCrew(payable, numAmount, comment || t('walletUi.zapCrew.defaultComment', { crew: crewName }));
};
const handleOpenChange = (o: boolean) => {
@ -117,21 +121,21 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-teal-400" /> Zap the Crew
<Users className="h-5 w-5 text-teal-400" /> {t('walletUi.zapCrew.title')}
</DialogTitle>
<DialogDescription>
Split a zap evenly across {payable.length} crew {payable.length === 1 ? 'member' : 'members'} with a Lightning address.
{t('walletUi.zapCrew.desc', { count: payable.length })}
</DialogDescription>
</DialogHeader>
{payable.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No other crew members to zap right now.
{t('walletUi.zapCrew.noMembers')}
</div>
) : !started ? (
<div className="space-y-4">
<div>
<Label className="text-xs uppercase tracking-wide text-muted-foreground">Total amount</Label>
<Label className="text-xs uppercase tracking-wide text-muted-foreground">{t('walletUi.zapCrew.totalAmount')}</Label>
<ToggleGroup
type="single"
value={String(amount)}
@ -149,22 +153,22 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Custom total"
placeholder={t('walletUi.zapCrew.customTotal')}
className="mt-2 bg-gray-800 border-gray-700"
/>
</div>
{numAmount > 0 && (
<p className="text-xs text-muted-foreground text-center">
≈ <span className="font-bold text-teal-400">{perHead[0]?.toLocaleString()}</span> sats each
{perHead.length > 1 && perHead[0] !== perHead[perHead.length - 1] && ' (rounded)'}
≈ <span className="font-bold text-teal-400">{perHead[0]?.toLocaleString()}</span> {t('walletUi.zapCrew.each')}
{perHead.length > 1 && perHead[0] !== perHead[perHead.length - 1] && ` ${t('walletUi.zapCrew.rounded')}`}
</p>
)}
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={`Zapped the ${crewName} crew! 🤘`}
placeholder={t('walletUi.zapCrew.defaultComment', { crew: crewName })}
rows={2}
className="bg-gray-800 border-gray-700 resize-none text-sm"
/>
@ -172,13 +176,13 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
{preferredMethod === 'manual' && (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
No wallet connected — you'll get an invoice per member to pay manually. Connect a wallet for one-tap splitting.
{t('walletUi.zapCrew.noWallet')}
</p>
)}
<Button onClick={handleZap} disabled={numAmount <= 0} className="w-full bg-teal-500 hover:bg-teal-600 font-bold">
<Zap className="h-4 w-4 mr-2" />
Zap {numAmount.toLocaleString()} sats across {payable.length}
{t('walletUi.zapCrew.zapAcross', { amount: numAmount.toLocaleString(), count: payable.length })}
</Button>
</div>
) : (
@ -192,22 +196,22 @@ export function ZapCrewDialog({ crewName, recipients, children }: ZapCrewDialogP
{allDone && (
<div className="rounded-lg border border-gray-700 bg-black/40 p-3 text-center">
<p className="text-sm font-bold text-white">
{doneCount > 0 ? `⚡ Sent to ${doneCount}/${payable.length}` : 'No auto-payments completed'}
{doneCount > 0 ? t('walletUi.zapCrew.sentTo', { done: doneCount, total: payable.length }) : t('walletUi.zapCrew.none')}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{results.some((r) => r.status === 'failed' && r.invoice)
? 'Copy any pending invoices to finish paying manually.'
: 'Thanks for supporting the crew!'}
? t('walletUi.zapCrew.copyPending')
: t('walletUi.zapCrew.thanks')}
</p>
<Button variant="outline" size="sm" onClick={() => handleOpenChange(false)} className="mt-3 border-gray-700">
<X className="h-3.5 w-3.5 mr-1" /> Close
<X className="h-3.5 w-3.5 mr-1" /> {t('common.close')}
</Button>
</div>
)}
{isRunning && (
<p className="flex items-center justify-center gap-1.5 text-xs text-teal-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Paying the crew…
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('walletUi.zapCrew.payingCrew')}
</p>
)}
</div>

View file

@ -33,6 +33,7 @@ import { useWallet } from '@/hooks/useWallet';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useCashu } from '@/hooks/wallet/useCashu';
import { WalletModal } from '@/components/WalletModal';
import { useTranslation } from '@/contexts/language';
import type { Event } from 'nostr-tools';
// qrcode imported lazily in async function to avoid canvas crash at module load
import type { WebLNProvider } from "@webbtc/webln-types";
@ -75,16 +76,18 @@ interface ZapContentProps {
// Wallet Setup Screen - shown when user has no wallet connected
const WalletSetupContent = forwardRef<HTMLDivElement, {
onContinue: () => void;
}>(({ onContinue }, ref) => (
}>(({ onContinue }, ref) => {
const { t } = useTranslation();
return (
<div className="px-4 py-6 space-y-6" ref={ref}>
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/20 flex items-center justify-center">
<Wallet className="h-8 w-8 text-orange-400" />
</div>
<div>
<h3 className="font-bold text-lg mb-2">Connect Your Wallet First</h3>
<h3 className="font-bold text-lg mb-2">{t('walletUi.zap.connectFirst')}</h3>
<p className="text-sm text-muted-foreground">
To send zaps (Bitcoin tips), you need to connect a Lightning wallet.
{t('walletUi.zap.connectFirstDesc')}
</p>
</div>
</div>
@ -94,9 +97,9 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
<div className="flex items-start gap-3">
<div className="text-2xl">💡</div>
<div className="flex-1">
<p className="text-sm font-medium mb-1">Quick Setup (30 seconds)</p>
<p className="text-sm font-medium mb-1">{t('walletUi.zap.quickSetup')}</p>
<p className="text-xs text-muted-foreground">
Connect your existing Lightning wallet with Nostr Wallet Connect (NWC). No extra accounts needed.
{t('walletUi.zap.quickSetupDesc')}
</p>
</div>
</div>
@ -107,7 +110,7 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
<WalletModal>
<Button className="w-full gap-2 bg-orange-500 hover:bg-orange-600" size="lg">
<Wallet className="h-4 w-4" />
Connect Wallet with NWC
{t('walletUi.zap.connectNwc')}
</Button>
</WalletModal>
@ -119,7 +122,7 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
>
<Button variant="outline" className="w-full gap-2" size="lg">
<ExternalLink className="h-4 w-4" />
Don't have a wallet? Learn how
{t('walletUi.zap.noWallet')}
</Button>
</a>
@ -129,19 +132,20 @@ const WalletSetupContent = forwardRef<HTMLDivElement, {
className="w-full text-muted-foreground hover:text-foreground"
size="sm"
>
Skip for now (manual invoice)
{t('walletUi.zap.skip')}
</Button>
</div>
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-gray-300">
<strong className="text-yellow-400">ℹ️ Note:</strong> You can still create an invoice without connecting a wallet, but you'll need to copy it manually to pay.
<strong className="text-yellow-400">{t('walletUi.zap.note')}</strong> {t('walletUi.zap.noteDesc')}
</p>
</CardContent>
</Card>
</div>
));
);
});
WalletSetupContent.displayName = 'WalletSetupContent';
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
@ -164,7 +168,9 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
setPaymentMethod,
cashuBalance,
hasCashu,
}, ref) => (
}, ref) => {
const { t } = useTranslation();
return (
<div ref={ref}>
{invoice ? (
<div className="flex flex-col h-full min-h-0">
@ -183,7 +189,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
{qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="Lightning Invoice QR Code"
alt={t('walletUi.zap.qrAlt')}
className="w-full h-auto aspect-square max-w-full object-contain"
/>
) : (
@ -195,7 +201,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
{/* Invoice input */}
<div className="space-y-2 mt-4">
<Label htmlFor="invoice">Lightning Invoice</Label>
<Label htmlFor="invoice">{t('walletUi.zap.lightningInvoice')}</Label>
<div className="flex gap-2 min-w-0">
<Input
id="invoice"
@ -232,7 +238,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
size="lg"
>
<Zap className="h-4 w-4 mr-2" />
{isZapping ? "Processing..." : "Pay with WebLN"}
{isZapping ? t('walletUi.zap.processing') : t('walletUi.zap.payWithWebln')}
</Button>
)}
@ -243,11 +249,11 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
size="lg"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Lightning Wallet
{t('walletUi.zap.openInWallet')}
</Button>
<div className="text-xs sm:text-[.65rem] text-muted-foreground text-center">
Scan the QR code or copy the invoice to pay with any Lightning wallet.
{t('walletUi.zap.scanHint')}
</div>
</div>
</div>
@ -272,13 +278,13 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
<span className="text-xl">🥜</span>
</div>
<div>
<p className="text-sm font-bold text-orange-400">Cashu Wallet</p>
<p className="text-xs text-muted-foreground">Pay instantly with your built-in wallet</p>
<p className="text-sm font-bold text-orange-400">{t('walletUi.cashu.title')}</p>
<p className="text-xs text-muted-foreground">{t('walletUi.zap.cashuInstant')}</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-bold text-orange-400">{cashuBalance.toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground uppercase">sats available</p>
<p className="text-[10px] text-muted-foreground uppercase">{t('walletUi.zap.satsAvailable')}</p>
</div>
</div>
</CardContent>
@ -340,21 +346,21 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<span className="text-xs text-muted-foreground">{t('walletUi.zap.or')}</span>
<div className="h-px flex-1 bg-muted" />
</div>
<Input
ref={inputRef}
id="custom-amount"
type="number"
placeholder="Custom amount"
placeholder={t('walletUi.zap.customAmount')}
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full text-sm"
/>
<Textarea
id="custom-comment"
placeholder="Add a comment (optional)"
placeholder={t('walletUi.zap.addComment')}
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none text-sm"
@ -378,8 +384,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
<Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3">
<p className="text-xs text-yellow-200">
⚠️ Insufficient Cashu balance. You need {(numericAmount - cashuBalance).toLocaleString()} more sats.
Switch to Lightning or top up your Cashu wallet.
{t('walletUi.zap.insufficientBanner', { amount: (numericAmount - cashuBalance).toLocaleString() })}
</p>
</CardContent>
</Card>
@ -399,11 +404,11 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
size="default"
>
{isZapping ? (
'Creating invoice...'
t('walletUi.zap.creatingInvoice')
) : (
<>
{paymentMethod === 'cashu' ? '🥜' : <Zap className="h-4 w-4 mr-2" />}
Zap {amount} sats {paymentMethod === 'cashu' ? 'with Cashu' : ''}
{paymentMethod === 'cashu' ? t('walletUi.zap.zapAmountCashu', { amount }) : t('walletUi.zap.zapAmount', { amount })}
</>
)}
</Button>
@ -411,10 +416,12 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
</>
)}
</div>
));
);
});
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
@ -440,9 +447,9 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
useEffect(() => {
if (target) {
setComment('Zapped on SkateSpots! 🛹');
setComment(t('walletUi.zap.defaultComment'));
}
}, [target]);
}, [target, t]);
// Pick the most useful default payment method when the dialog opens: if the
// user has Cashu funds but no connected Lightning wallet, default to Cashu so
@ -500,8 +507,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({
title: 'Invoice copied',
description: 'Lightning invoice copied to clipboard',
title: t('walletUi.zap.invoiceCopied'),
description: t('walletUi.zap.invoiceCopiedDesc'),
});
setTimeout(() => setCopied(false), 2000);
}
@ -544,8 +551,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
if (!Number.isFinite(finalAmount) || finalAmount <= 0) {
toast({
variant: 'destructive',
title: 'Enter an amount',
description: 'Choose a preset or type a valid number of sats.',
title: t('walletUi.zap.enterAmount'),
description: t('walletUi.zap.enterAmountDesc'),
});
return;
}
@ -559,16 +566,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
if (!hasCashu || !coco) {
toast({
variant: 'destructive',
title: 'Cashu wallet unavailable',
description: 'Your Cashu wallet is not ready. Switch to Lightning or open your wallet first.',
title: t('walletUi.zap.cashuUnavailable'),
description: t('walletUi.zap.cashuUnavailableDesc'),
});
return;
}
if (finalAmount > cashuBalance) {
toast({
variant: 'destructive',
title: 'Insufficient Cashu balance',
description: `You need ${(finalAmount - cashuBalance).toLocaleString()} more sats. Switch to Lightning or top up.`,
title: t('walletUi.zap.insufficientCashu'),
description: t('walletUi.zap.insufficientCashuDesc', { amount: (finalAmount - cashuBalance).toLocaleString() }),
});
return;
}
@ -585,8 +592,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
toast({
title: 'Zap sent! 🥜',
description: `Zapped ${finalAmount.toLocaleString()} sats from your Cashu wallet.`,
title: t('walletUi.zap.zapSentCashu'),
description: t('walletUi.zap.zapSentCashuDesc', { amount: finalAmount.toLocaleString() }),
});
setInvoice(null);
setOpen(false);
@ -594,8 +601,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
console.error('Cashu payment error:', error);
toast({
variant: 'destructive',
title: 'Cashu payment failed',
description: error instanceof Error ? error.message : 'Failed to pay with your Cashu wallet.',
title: t('walletUi.zap.cashuFailed'),
description: error instanceof Error ? error.message : t('walletUi.zap.cashuFailedDesc'),
});
} finally {
setIsZapping(false);
@ -694,20 +701,20 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
className="absolute right-4 top-4"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{t('common.close')}</span>
</Button>
</DrawerClose>
<DrawerTitle className="text-lg break-words pt-2">
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
{showWalletSetup ? t('walletUi.zap.titleConnect') : invoice ? t('walletUi.zap.titlePayment') : t('walletUi.zap.titleZap')}
</DrawerTitle>
<DrawerDescription className="text-sm break-words text-center">
{showWalletSetup ? (
'Connect your Lightning wallet to send zaps'
t('walletUi.zap.descConnect')
) : invoice ? (
'Pay with Bitcoin Lightning Network'
t('walletUi.zap.descPayment')
) : (
'Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!'
t('walletUi.zap.descZap')
)}
</DrawerDescription>
</DrawerHeader>
@ -733,16 +740,16 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-[425px] max-h-[90vh] max-h-[90dvh] overflow-hidden p-4 sm:p-6" data-testid="zap-modal">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg break-words">
{showWalletSetup ? 'Connect Wallet' : invoice ? 'Lightning Payment' : 'Send a Zap'}
{showWalletSetup ? t('walletUi.zap.titleConnect') : invoice ? t('walletUi.zap.titlePayment') : t('walletUi.zap.titleZap')}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-center break-words">
{showWalletSetup ? (
'Connect your Lightning wallet to send zaps'
t('walletUi.zap.descConnect')
) : invoice ? (
'Pay with Bitcoin Lightning Network'
t('walletUi.zap.descPayment')
) : (
<>
Zaps are small Bitcoin payments that support creators. Consider sending a zap!
{t('walletUi.zap.descZapShort')}
</>
)}
</DialogDescription>

View file

@ -13,6 +13,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx'
import { WalletModal } from '@/components/WalletModal';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language';
interface AccountSwitcherProps {
onAddAccountClick: () => void;
@ -20,6 +21,7 @@ interface AccountSwitcherProps {
export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
const { currentUser, otherUsers, setLogin, removeLogin } = useLoggedInAccounts();
const { t } = useTranslation();
if (!currentUser) return null;
@ -55,7 +57,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56 p-2 animate-scale-in'>
<div className='font-medium text-sm px-2 py-1.5'>Switch Account</div>
<div className='font-medium text-sm px-2 py-1.5'>{t('settingsUi.accountSwitcher.switchAccount')}</div>
{otherUsers.map((user) => (
<DropdownMenuItem
key={user.id}
@ -84,7 +86,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
onSelect={(e) => e.preventDefault()}
>
<Wallet className='w-4 h-4' />
<span>Wallet Settings</span>
<span>{t('settingsUi.accountSwitcher.walletSettings')}</span>
</DropdownMenuItem>
</WalletModal>
<DropdownMenuItem
@ -92,14 +94,14 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
>
<UserPlus className='w-4 h-4' />
<span>Add another account</span>
<span>{t('settingsUi.accountSwitcher.addAccount')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => removeLogin(currentUser.id)}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
>
<LogOut className='w-4 h-4' />
<span>Log out</span>
<span>{t('settingsUi.accountSwitcher.logout')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -19,6 +19,7 @@
import { useEffect, useRef, useState } from 'react';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import {
isAmberCallback,
getAmberCallbackResult,
@ -32,6 +33,7 @@ import {
export function AmberCallbackHandler() {
const login = useLoginActions();
const { toast } = useToast();
const { t } = useTranslation();
const hasProcessed = useRef(false);
const [debugInfo, setDebugInfo] = useState<string | null>(null);
@ -57,7 +59,7 @@ export function AmberCallbackHandler() {
hasProcessed.current = true;
// Show debug info temporarily
setDebugInfo('Processing Amber login...');
setDebugInfo(t('settingsUi.amberCallback.processing'));
// Process async to allow IndexedDB lookup
processAmberCallback();
@ -65,7 +67,7 @@ export function AmberCallbackHandler() {
async function processAmberCallback() {
const result = getAmberCallbackResult();
setDebugInfo(`Got result: ${result ? result.substring(0, 16) + '...' : 'null'}`);
setDebugInfo(t('settingsUi.amberCallback.gotResult', { result: result ? result.substring(0, 16) + '...' : 'null' }));
// Clean up URL immediately to prevent re-processing on refresh
cleanAmberCallbackUrl();
@ -73,10 +75,10 @@ export function AmberCallbackHandler() {
if (!result) {
// User cancelled or Amber failed
clearAmberPendingOperation();
setDebugInfo('No result from Amber');
setDebugInfo(t('settingsUi.amberCallback.noResultDebug'));
toast({
title: 'Amber',
description: 'No result received from Amber. Operation may have been cancelled.',
description: t('settingsUi.amberCallback.noResultToast'),
variant: 'destructive',
});
return;
@ -96,23 +98,23 @@ export function AmberCallbackHandler() {
// Check if result looks like a pubkey (login callback)
if (pubkeyRegex.test(result)) {
console.log('[AmberCallback] Result looks like pubkey, treating as login callback');
setDebugInfo('Valid pubkey! Logging in...');
setDebugInfo(t('settingsUi.amberCallback.validPubkey'));
try {
login.amber(result.toLowerCase());
setDebugInfo('Login successful!');
setDebugInfo(t('settingsUi.amberCallback.loginSuccessful'));
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
title: t('settingsUi.amberCallback.loggedInTitle'),
description: t('settingsUi.amberCallback.amberLoginDesc'),
});
// Clear debug after success
setTimeout(() => setDebugInfo(null), 2000);
return;
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
setDebugInfo(`Login error: ${error}`);
setDebugInfo(t('settingsUi.amberCallback.loginErrorDebug', { error: String(error) }));
toast({
title: 'Login failed',
description: 'Failed to complete Amber login. Please try again.',
title: t('settingsUi.amberCallback.loginFailedTitle'),
description: t('settingsUi.amberCallback.loginFailedDesc'),
variant: 'destructive',
});
return;
@ -120,10 +122,10 @@ export function AmberCallbackHandler() {
}
// Not a pubkey - can't determine what operation this was for
setDebugInfo(`Invalid result format: ${result.substring(0, 20)}...`);
setDebugInfo(t('settingsUi.amberCallback.invalidResultDebug', { result: result.substring(0, 20) }));
toast({
title: 'Amber',
description: 'Session data was lost. Please try again.',
description: t('settingsUi.amberCallback.sessionLostToast'),
variant: 'destructive',
});
return;
@ -140,15 +142,15 @@ export function AmberCallbackHandler() {
clearAmberPendingOperation();
console.log('[AmberCallback] Login successful!');
toast({
title: 'Logged in',
description: 'Successfully logged in with Amber!',
title: t('settingsUi.amberCallback.loggedInTitle'),
description: t('settingsUi.amberCallback.amberLoginDesc'),
});
} catch (error) {
console.error('[AmberCallback] Amber login failed:', error);
clearAmberPendingOperation();
toast({
title: 'Login failed',
description: 'Failed to complete Amber login. Please try again.',
title: t('settingsUi.amberCallback.loginFailedTitle'),
description: t('settingsUi.amberCallback.loginFailedDesc'),
variant: 'destructive',
});
}
@ -156,8 +158,8 @@ export function AmberCallbackHandler() {
console.warn('[AmberCallback] Invalid pubkey format:', result);
clearAmberPendingOperation();
toast({
title: 'Login failed',
description: 'Invalid pubkey received from Amber.',
title: t('settingsUi.amberCallback.loginFailedTitle'),
description: t('settingsUi.amberCallback.invalidPubkeyDesc'),
variant: 'destructive',
});
}
@ -180,8 +182,8 @@ export function AmberCallbackHandler() {
sessionStorage.setItem('amber_signed_event', JSON.stringify(signedEvent));
clearAmberPendingOperation();
toast({
title: 'Event signed',
description: 'Your event has been signed by Amber.',
title: t('settingsUi.amberCallback.eventSignedTitle'),
description: t('settingsUi.amberCallback.eventSignedDesc'),
});
// Reload the page to re-submit the event
// This is a workaround since we can't resolve the original promise
@ -190,8 +192,8 @@ export function AmberCallbackHandler() {
console.error('Failed to reconstruct signed event from Amber result');
clearAmberPendingOperation();
toast({
title: 'Signing failed',
description: 'Failed to process signed event from Amber.',
title: t('settingsUi.amberCallback.signingFailedTitle'),
description: t('settingsUi.amberCallback.signingFailedDesc'),
variant: 'destructive',
});
}
@ -206,8 +208,8 @@ export function AmberCallbackHandler() {
sessionStorage.setItem('amber_crypto_result', result);
clearAmberPendingOperation();
toast({
title: 'Operation complete',
description: `${pendingOp.type.replace('_', ' ')} completed.`,
title: t('settingsUi.amberCallback.operationCompleteTitle'),
description: t('settingsUi.amberCallback.operationCompleteDesc', { op: pendingOp.type.replace('_', ' ') }),
});
break;
}
@ -218,7 +220,7 @@ export function AmberCallbackHandler() {
}
}
}
}, [login, toast]);
}, [login, toast, t]);
// Show debug overlay when processing Amber callback
if (debugInfo) {

View file

@ -9,6 +9,7 @@ import SignupDialog from './SignupDialog';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AccountSwitcher } from './AccountSwitcher';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
export interface LoginAreaProps {
className?: string;
@ -18,6 +19,7 @@ export interface LoginAreaProps {
export function LoginArea({ className, compact }: LoginAreaProps) {
const { currentUser } = useLoggedInAccounts();
const { t } = useTranslation();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
@ -37,7 +39,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
onClick={() => setSignupDialogOpen(true)}
size="icon"
className='h-9 w-9 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
title="Sign Up"
title={t('settingsUi.loginArea.signUp')}
>
<UserPlus className='w-4 h-4' />
</Button>
@ -46,7 +48,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
variant="outline"
size="icon"
className="h-9 w-9 rounded-full border border-gray-600 hover:border-[#ff0040]"
title="Log In"
title={t('settingsUi.loginArea.logIn')}
>
<User className='w-4 h-4' />
</Button>
@ -59,7 +61,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
className='flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold transition-all flex-1 sm:flex-none'
>
<UserPlus className='w-4 h-4' />
<span>Sign Up</span>
<span>{t('settingsUi.loginArea.signUp')}</span>
</Button>
<Button
onClick={() => setLoginDialogOpen(true)}
@ -67,7 +69,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
className="flex items-center gap-2 px-5 py-2.5 rounded-full font-medium transition-all border-2 border-gray-600 hover:border-[#ff0040] flex-1 sm:flex-none"
>
<User className='w-4 h-4' />
<span className='truncate'>Log In</span>
<span className='truncate'>{t('settingsUi.loginArea.logIn')}</span>
</Button>
</div>
)}

View file

@ -21,6 +21,7 @@ import { useNostr } from '@nostrify/react';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { startNostrConnect, nostrConnectBunkerUri, type NostrConnectHandle } from '@/lib/nostrConnect';
import { decryptNcryptsec } from '@/lib/ncryptsec';
import { useTranslation } from '@/contexts/language';
interface LoginDialogProps {
@ -31,6 +32,7 @@ interface LoginDialogProps {
}
export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [bunkerUri, setBunkerUri] = useState('');
const [bunkerError, setBunkerError] = useState('');
@ -84,12 +86,12 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const pubkey = await getAmberPublicKeyViaPlugin();
completeAmberLogin(pubkey);
} catch (e: unknown) {
const msg = (e as Error)?.message ?? 'Amber login failed';
const msg = (e as Error)?.message ?? t('settingsUi.loginDialog.amberLoginFailed');
// Distinguish: user cancelled vs Amber not installed vs other error
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('denied')) {
setAmberError('Login cancelled. Tap "Open Amber" to try again.');
setAmberError(t('settingsUi.loginDialog.amberCancelled'));
} else if (msg.toLowerCase().includes('not found') || msg.toLowerCase().includes('no activity') || msg.toLowerCase().includes('resolve')) {
setAmberError('Amber not found. Make sure Amber is installed from the ZapStore.');
setAmberError(t('settingsUi.loginDialog.amberNotFound'));
} else {
setAmberError(msg);
}
@ -167,10 +169,10 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
try {
login.amber(pubkey);
setAmberStep('done');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedAmber') });
setTimeout(() => { onLogin(); onClose(); }, 800);
} catch (e: unknown) {
setAmberError((e as Error)?.message ?? 'Login failed');
setAmberError((e as Error)?.message ?? t('settingsUi.loginDialog.loginFailed'));
setAmberStep('error');
}
};
@ -195,7 +197,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
}
} catch { /* fall through */ }
}
setPubkeyError('Paste your hex pubkey (64 chars) or npub from Amber');
setPubkeyError(t('settingsUi.loginDialog.pubkeyError'));
};
/**
@ -216,13 +218,13 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
.then(async ({ signerPubkey }) => {
const uri = nostrConnectBunkerUri(signerPubkey, session.relays, session.secret);
await login.bunker(uri);
toast({ title: '🛹 Logged in!', description: 'Paired with your signer.' });
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.pairedSigner') });
onLogin();
onClose();
})
.catch((e: unknown) => {
setConnectState('error');
setConnectError((e as Error)?.message ?? 'Could not connect to your signer.');
setConnectError((e as Error)?.message ?? t('settingsUi.loginDialog.connectFailed'));
});
};
@ -240,11 +242,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
try {
const nsec = decryptNcryptsec(ncryptsecInput, ncryptsecPassword);
login.nsec(nsec);
toast({ title: '🛹 Logged in!', description: 'Opened your encrypted key.' });
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.openedEncrypted') });
onLogin();
onClose();
} catch (e: unknown) {
setNsecError((e as Error)?.message ?? 'Could not open that key.');
setNsecError((e as Error)?.message ?? t('settingsUi.loginDialog.openKeyFailed'));
}
};
@ -284,33 +286,33 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const handleBunkerLogin = async () => {
const uri = bunkerUri.trim();
if (!uri.startsWith('bunker://')) { setBunkerError('Must start with bunker://'); return; }
if (!uri.startsWith('bunker://')) { setBunkerError(t('settingsUi.loginDialog.bunkerPrefix')); return; }
setIsLoading(true);
setBunkerError('');
try {
await login.bunker(uri);
toast({ title: '🛹 Logged in!', description: 'Connected via bunker!' });
toast({ title: t('settingsUi.loginDialog.loggedInTitle'), description: t('settingsUi.loginDialog.connectedBunker') });
onLogin(); onClose();
} catch (e: unknown) {
setBunkerError(`Failed: ${(e as Error)?.message ?? 'Check your bunker URI'}`);
setBunkerError(t('settingsUi.loginDialog.bunkerFailed', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkBunkerUri') }));
} finally { setIsLoading(false); }
};
const handleNsecLogin = () => {
const val = nsecInput.trim();
if (!val.startsWith('nsec1')) { setNsecError('Must start with nsec1'); return; }
if (!val.startsWith('nsec1')) { setNsecError(t('settingsUi.loginDialog.nsecPrefix')); return; }
try {
login.nsec(val);
toast({ title: '🛹 Logged in!' });
toast({ title: t('settingsUi.loginDialog.loggedInTitle') });
onLogin(); onClose();
} catch (e: unknown) {
setNsecError(`Invalid nsec: ${(e as Error)?.message ?? 'Check your key'}`);
setNsecError(t('settingsUi.loginDialog.invalidNsec', { error: (e as Error)?.message ?? t('settingsUi.loginDialog.checkKey') }));
}
};
const handleExtensionLogin = async () => {
try { await login.extension(); onLogin(); onClose(); }
catch { toast({ title: 'No extension found', description: 'Install Alby or another NIP-07 extension.', variant: 'destructive' }); }
catch { toast({ title: t('settingsUi.loginDialog.noExtensionTitle'), description: t('settingsUi.loginDialog.noExtensionDesc'), variant: 'destructive' }); }
};
return (
@ -318,10 +320,10 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<DialogContent className={cn("w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1')}>
<DialogTitle className="text-center font-black uppercase tracking-wide text-xl">
Welcome to SkateSpots
{t('settingsUi.loginDialog.welcomeTitle')}
</DialogTitle>
<DialogDescription className="text-center text-sm text-gray-400">
Sign up or log in to continue
{t('settingsUi.loginDialog.welcomeSubtitle')}
</DialogDescription>
</DialogHeader>
@ -332,18 +334,18 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='relative z-10 text-center space-y-3'>
<div className='flex justify-center items-center gap-2'>
<Sparkles className='w-5 h-5 text-[#ccff00]' />
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>New here?</span>
<span className='font-bold text-[#ccff00] uppercase tracking-wide'>{t('settingsUi.loginDialog.newHere')}</span>
</div>
<p className='text-xs text-gray-300'>Create a free account. No email required.</p>
<p className='text-xs text-gray-300'>{t('settingsUi.loginDialog.createFree')}</p>
<Button onClick={() => { onClose(); onSignup?.(); }} className='w-full rounded-full py-3 font-bold bg-[#ccff00] text-black hover:bg-[#ccff00]/90 uppercase'>
<UserPlus className='w-4 h-4 mr-2' />Create Account
<UserPlus className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.createAccount')}
</Button>
</div>
</div>
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>Or log in</span>
<span className='px-3 text-xs text-gray-500'>{t('settingsUi.loginDialog.orLogIn')}</span>
<div className='flex-1 border-t border-gray-800' />
</div>
@ -370,7 +372,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
{amberStep === 'done' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
<Check className='w-10 h-10 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
<p className='text-sm text-green-400 font-bold'>{t('settingsUi.loginDialog.loggedIn')}</p>
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
</div>
)}
@ -382,17 +384,16 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<Smartphone className='w-7 h-7 text-[#ccff00]' />
</div>
<div>
<p className='text-sm text-white font-bold'>Login with Amber</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.loginWithAmber')}</p>
<p className='text-xs text-gray-400 mt-1'>
Your key stays inside Amber. One tap — approve in Amber and you're
back here, signed in. Nothing to copy.
{t('settingsUi.loginDialog.amberIdleDesc')}
</p>
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={handleAmberLogin}
>
<Smartphone className="w-4 h-4 mr-2" />Continue with Amber
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.continueWithAmber')}
</Button>
<a
href="https://github.com/greenart7c3/Amber"
@ -400,7 +401,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
rel="noopener noreferrer"
className="block text-[11px] text-gray-600 hover:text-[#ccff00]"
>
Don't have Amber? Get it →
{t('settingsUi.loginDialog.noAmberGetIt')}
</a>
</div>
)}
@ -413,10 +414,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' />
</div>
<div>
<p className='text-sm text-white font-bold'>Waiting for Amber…</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.waitingAmberTitle')}</p>
<p className='text-xs text-gray-400 mt-1'>
Approve the request in Amber. You'll be signed in automatically
the moment you come back.
{t('settingsUi.loginDialog.waitingAmberDesc')}
</p>
</div>
<Button
@ -424,13 +424,13 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
className='w-full rounded-full py-2.5 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10 font-bold'
onClick={handleAmberLogin}
>
<Smartphone className="w-4 h-4 mr-2" />Open Amber again
<Smartphone className="w-4 h-4 mr-2" />{t('settingsUi.loginDialog.openAmberAgain')}
</Button>
<button
className='w-full text-[11px] text-gray-600 hover:text-gray-400 py-1'
onClick={() => setShowManualPaste(true)}
>
Having trouble? Enter your key manually
{t('settingsUi.loginDialog.havingTrouble')}
</button>
</div>
)}
@ -441,10 +441,10 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className='text-center space-y-1'>
<Smartphone className='w-8 h-8 mx-auto text-[#ccff00]' />
<p className='text-sm text-white font-bold'>Enter your public key</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.enterPublicKey')}</p>
<p className='text-xs text-gray-400'>
Only if the automatic flow didn't work. In Amber: tap your profile →
{' '}<strong className='text-white'>Copy public key</strong>, then paste it here.
{t('settingsUi.loginDialog.enterPublicKeyDescBefore')}{' '}
<strong className='text-white'>{t('settingsUi.loginDialog.copyPublicKey')}</strong>, {t('settingsUi.loginDialog.enterPublicKeyDescAfter')}
</p>
</div>
{amberStep === 'error' && amberError && (
@ -465,11 +465,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
disabled={!pubkeyInput.trim()}
onClick={handlePubkeySubmit}
>
<Check className='w-4 h-4 mr-2' />Connect
<Check className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.connect')}
</Button>
<Button variant='ghost' className='w-full text-xs text-gray-600 h-auto py-1'
onClick={() => { setAmberStep('idle'); setShowManualPaste(false); setPubkeyInput(''); setPubkeyError(''); setAmberError(''); }}>
← Back
{t('settingsUi.loginDialog.back')}
</Button>
</div>
)}
@ -484,9 +484,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
<div className='text-center'>
<QrCode className='w-10 h-10 mx-auto mb-2 text-blue-400' />
<p className='text-sm text-white font-bold'>Sign in with your phone</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.signInWithPhone')}</p>
<p className='text-xs text-gray-400 mt-1'>
Scan with a signer app (Amber, nsec.app). Your key never leaves your phone.
{t('settingsUi.loginDialog.signInWithPhoneDesc')}
</p>
</div>
@ -495,7 +495,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
onClick={handleNostrConnect}
>
<QrCode className='w-4 h-4 mr-2' />Show pairing code
<QrCode className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.showPairingCode')}
</Button>
)}
@ -505,7 +505,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<QRCodeCanvas value={connectSession.uri} size={196} level='M' />
</div>
<p className='flex items-center justify-center gap-2 text-center text-xs text-gray-400'>
<Loader2 className='w-3.5 h-3.5 animate-spin' /> Waiting for your signer…
<Loader2 className='w-3.5 h-3.5 animate-spin' /> {t('settingsUi.loginDialog.waitingSigner')}
</p>
<div className='flex gap-2'>
<Button
@ -519,15 +519,15 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
}}
>
{copiedUri
? <><Check className='w-3.5 h-3.5 mr-1' />Copied</>
: <><Copy className='w-3.5 h-3.5 mr-1' />Copy link</>}
? <><Check className='w-3.5 h-3.5 mr-1' />{t('common.copied')}</>
: <><Copy className='w-3.5 h-3.5 mr-1' />{t('settingsUi.loginDialog.copyLink')}</>}
</Button>
<Button
variant='outline'
className='flex-1 rounded-full text-xs border-white/15 text-gray-400 hover:bg-white/5'
onClick={cancelNostrConnect}
>
Cancel
{t('common.cancel')}
</Button>
</div>
</div>
@ -540,7 +540,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
className='w-full rounded-full py-2.5 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase'
onClick={handleNostrConnect}
>
Try again
{t('common.tryAgain')}
</Button>
</div>
)}
@ -548,26 +548,26 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>or paste a bunker URL</span>
<span className='px-3 text-xs text-gray-500'>{t('settingsUi.loginDialog.orPasteBunker')}</span>
<div className='flex-1 border-t border-gray-800' />
</div>
<div className='p-5 rounded-xl bg-black/50 border-2 border-blue-500/30 space-y-4'>
<div className='text-center'>
<Cloud className='w-10 h-10 mx-auto mb-2 text-blue-400' />
<p className='text-sm text-white font-bold'>Bunker (NIP-46)</p>
<p className='text-xs text-gray-400 mt-1'>Paste your <code className='text-blue-300'>bunker://</code> URI.</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.bunkerTitle')}</p>
<p className='text-xs text-gray-400 mt-1'>{t('settingsUi.loginDialog.bunkerPasteBefore')} <code className='text-blue-300'>bunker://</code> {t('settingsUi.loginDialog.bunkerPasteAfter')}</p>
</div>
<div className='text-xs text-gray-300 bg-black/40 rounded-lg p-3 space-y-1 border border-gray-800'>
<p><strong className='text-blue-300'>Amber:</strong> Settings → NIP-46 Bunker → Create → Copy</p>
<p><strong className='text-blue-300'>nsec.app:</strong> Connect app → Copy bunker URI</p>
<p><strong className='text-blue-300'>Amber:</strong> {t('settingsUi.loginDialog.amberInstructions')}</p>
<p><strong className='text-blue-300'>nsec.app:</strong> {t('settingsUi.loginDialog.nsecAppInstructions')}</p>
</div>
<div className='space-y-2'>
<Input placeholder='bunker://...' value={bunkerUri} onChange={e => { setBunkerUri(e.target.value); setBunkerError(''); }} className='bg-black/60 border-blue-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} />
{bunkerError && <p className="text-xs text-red-400">{bunkerError}</p>}
</div>
<Button className='w-full rounded-full py-3 bg-blue-500 hover:bg-blue-600 text-white font-bold uppercase' disabled={isLoading || !bunkerUri.trim()} onClick={handleBunkerLogin}>
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}Connect
{isLoading ? <Loader2 className='w-4 h-4 animate-spin mr-2' /> : <Cloud className='w-4 h-4 mr-2' />}{t('settingsUi.loginDialog.connect')}
</Button>
<div className='flex gap-2 justify-center'>
<a href="https://nsec.app" target="_blank" rel="noopener noreferrer" className="text-xs px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full hover:bg-blue-500/30">nsec.app →</a>
@ -581,11 +581,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='p-5 rounded-xl bg-black/50 border-2 border-yellow-500/30 space-y-4'>
<div className='text-center'>
<KeyRound className='w-10 h-10 mx-auto mb-2 text-yellow-400' />
<p className='text-sm text-white font-bold'>Secret Key</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.secretKeyTitle')}</p>
<p className='text-xs text-gray-400 mt-1'>
{nsecMode === 'raw'
? 'Paste your Nostr secret key directly.'
: 'Open a password-protected key backup.'}
? t('settingsUi.loginDialog.secretKeyRawDesc')
: t('settingsUi.loginDialog.secretKeyEncDesc')}
</p>
</div>
@ -601,7 +601,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
nsecMode === m ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white',
)}
>
{m === 'raw' ? 'Raw nsec' : 'Encrypted (NIP-49)'}
{m === 'raw' ? t('settingsUi.loginDialog.rawNsec') : t('settingsUi.loginDialog.encryptedNip49')}
</button>
))}
</div>
@ -609,22 +609,21 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
{nsecMode === 'raw' ? (
<>
<div className='p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30'>
<p className='text-xs text-yellow-300'>⚠️ Only use this on a trusted device. Never share your nsec.</p>
<p className='text-xs text-yellow-300'>{t('settingsUi.loginDialog.nsecWarning')}</p>
</div>
<div className='space-y-2'>
<Input type="password" placeholder='nsec1...' value={nsecInput} onChange={e => { setNsecInput(e.target.value); setNsecError(''); }} className='bg-black/60 border-yellow-500/30 text-white placeholder:text-gray-600 text-xs font-mono' autoComplete='off' autoCorrect='off' spellCheck={false} />
{nsecError && <p className="text-xs text-red-400">{nsecError}</p>}
</div>
<Button className='w-full rounded-full py-3 bg-yellow-500 hover:bg-yellow-600 text-black font-bold uppercase' disabled={!nsecInput.trim()} onClick={handleNsecLogin}>
<KeyRound className='w-4 h-4 mr-2' />Login
<KeyRound className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.login')}
</Button>
</>
) : (
<>
<div className='p-3 rounded-lg bg-green-500/10 border border-green-500/30'>
<p className='text-xs text-green-300'>
Safer: the key stays encrypted until you enter your password, so the
backup is safe to keep in cloud storage or a password manager.
{t('settingsUi.loginDialog.encSaferDesc')}
</p>
</div>
<div className='space-y-2'>
@ -637,7 +636,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
/>
<Input
type='password'
placeholder='Password'
placeholder={t('settingsUi.loginDialog.password')}
value={ncryptsecPassword}
onChange={e => { setNcryptsecPassword(e.target.value); setNsecError(''); }}
onKeyDown={e => { if (e.key === 'Enter' && ncryptsecInput.trim() && ncryptsecPassword) handleNcryptsecLogin(); }}
@ -651,7 +650,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
disabled={!ncryptsecInput.trim() || !ncryptsecPassword}
onClick={handleNcryptsecLogin}
>
<Unlock className='w-4 h-4 mr-2' />Open encrypted key
<Unlock className='w-4 h-4 mr-2' />{t('settingsUi.loginDialog.openEncryptedKey')}
</Button>
</>
)}
@ -663,11 +662,11 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ff0040]/30 space-y-4'>
<Shield className='w-10 h-10 mx-auto text-[#ff0040]' />
<div>
<p className='text-sm text-white font-bold'>Browser Extension (NIP-07)</p>
<p className='text-xs text-gray-400 mt-1'>Best for desktop. Works with Alby, nos2x, Flamingo.</p>
<p className='text-sm text-white font-bold'>{t('settingsUi.loginDialog.browserExtensionTitle')}</p>
<p className='text-xs text-gray-400 mt-1'>{t('settingsUi.loginDialog.browserExtensionDesc')}</p>
</div>
<Button className='w-full rounded-full py-3 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold uppercase' onClick={handleExtensionLogin}>
Login with Extension
{t('settingsUi.loginDialog.loginWithExtension')}
</Button>
</div>
</TabsContent>

View file

@ -9,6 +9,7 @@ import { useLoginActions } from '@/hooks/useLoginActions';
import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import { generateMnemonicPhrase, privateKeyFromMnemonic, mnemonicToWords } from '@/lib/nip06';
import { useTranslation } from '@/contexts/language';
// qrcode imported lazily in async function
interface MnemonicSignupDialogProps {
@ -22,6 +23,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
onClose,
onSwitchToNsec
}) => {
const { t } = useTranslation();
const [step, setStep] = useState<'generate' | 'verify' | 'complete'>('generate');
const [mnemonic, setMnemonic] = useState('');
const [words, setWords] = useState<string[]>([]);
@ -46,8 +48,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
setVerifyIndices(indices.sort((a, b) => a - b));
toast({
title: 'Recovery Phrase Generated! 🎉',
description: 'Write down these 12 words in order. They\'re the key to your account!',
title: t('settingsUi.mnemonic.generatedTitle'),
description: t('settingsUi.mnemonic.generatedDesc'),
});
};
@ -66,13 +68,13 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
document.body.removeChild(a);
toast({
title: 'Recovery Phrase Saved!',
description: 'Keep this file in a secure location.',
title: t('settingsUi.mnemonic.savedTitle'),
description: t('settingsUi.mnemonic.savedDesc'),
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the file. Please copy manually.',
title: t('settingsUi.mnemonic.downloadFailedTitle'),
description: t('settingsUi.mnemonic.downloadFailedDesc'),
variant: 'destructive',
});
}
@ -82,8 +84,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
const handleCopy = () => {
navigator.clipboard.writeText(mnemonic);
toast({
title: 'Copied to clipboard!',
description: 'Your recovery phrase has been copied.',
title: t('settingsUi.mnemonic.copiedTitle'),
description: t('settingsUi.mnemonic.copiedDesc'),
});
};
@ -102,7 +104,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
win.document.write(`
<html>
<head>
<title>Recovery Phrase QR Code</title>
<title>${t('settingsUi.mnemonic.qrWindowTitle')}</title>
<style>
body {
display: flex;
@ -121,11 +123,11 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
</style>
</head>
<body>
<h1>🔐 Your Recovery Phrase QR Code</h1>
<img src="${qrDataUrl}" alt="Recovery Phrase QR Code" />
<p>⚠️ Keep this QR code private and secure!</p>
<h1>🔐 ${t('settingsUi.mnemonic.qrHeading')}</h1>
<img src="${qrDataUrl}" alt="${t('settingsUi.mnemonic.qrAlt')}" />
<p>⚠️ ${t('settingsUi.mnemonic.qrWarning')}</p>
<button onclick="window.print()" style="margin-top: 1rem; padding: 0.5rem 1rem; font-size: 16px; cursor: pointer;">
Print QR Code
${t('settingsUi.mnemonic.qrPrint')}
</button>
</body>
</html>
@ -134,8 +136,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
}
} catch {
toast({
title: 'QR Code failed',
description: 'Could not generate QR code.',
title: t('settingsUi.mnemonic.qrFailedTitle'),
description: t('settingsUi.mnemonic.qrFailedDesc'),
variant: 'destructive',
});
}
@ -162,8 +164,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
setStep('complete');
toast({
title: 'Account Created! 🎉',
description: 'You\'re all set! Your recovery phrase is secure.',
title: t('settingsUi.mnemonic.accountCreatedTitle'),
description: t('settingsUi.mnemonic.accountCreatedDesc'),
});
// Mark signup as completed
@ -174,15 +176,15 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
}, 2000);
} catch {
toast({
title: 'Login Failed',
description: 'Failed to create account. Please try again.',
title: t('settingsUi.mnemonic.loginFailedTitle'),
description: t('settingsUi.mnemonic.loginFailedDesc'),
variant: 'destructive',
});
}
} else {
toast({
title: 'Verification Failed',
description: 'The words don\'t match. Please check and try again.',
title: t('settingsUi.mnemonic.verifyFailedTitle'),
description: t('settingsUi.mnemonic.verifyFailedDesc'),
variant: 'destructive',
});
}
@ -202,8 +204,8 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
<DialogHeader>
<DialogTitle className="text-2xl font-black uppercase text-center">
<span className="neon-orange">Create Account with</span>{' '}
<span className="neon-lime">Recovery Phrase</span>
<span className="neon-orange">{t('settingsUi.mnemonic.titlePrefix')}</span>{' '}
<span className="neon-lime">{t('settingsUi.mnemonic.titleSuffix')}</span>
</DialogTitle>
</DialogHeader>
@ -215,25 +217,24 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<CardContent className="p-8 text-center space-y-4">
<div className="text-6xl mb-4">🔑</div>
<h3 className="text-xl font-bold text-[#ccff00]">
12-Word Recovery Phrase
{t('settingsUi.mnemonic.twelveWordTitle')}
</h3>
<p className="text-gray-400 text-sm max-w-md mx-auto">
Your recovery phrase is like a master password. Write it down on paper and keep it safe.
You'll need it to access your account from other devices or if you clear your browser data.
{t('settingsUi.mnemonic.twelveWordDesc')}
</p>
<Button
onClick={handleGenerate}
className="w-full max-w-xs bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 font-bold uppercase"
size="lg"
>
Generate Recovery Phrase
{t('settingsUi.mnemonic.generate')}
</Button>
<div className="pt-4">
<button
onClick={onSwitchToNsec}
className="text-xs text-gray-500 hover:text-gray-300 underline"
>
Use advanced nsec key instead
{t('settingsUi.mnemonic.useNsecInstead')}
</button>
</div>
</CardContent>
@ -244,7 +245,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<Card className="bg-black/50 border-[#ccff00]/30">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-[#ccff00] uppercase">Your Recovery Phrase</h3>
<h3 className="font-bold text-[#ccff00] uppercase">{t('settingsUi.mnemonic.yourPhrase')}</h3>
<div className="flex gap-2">
<Button
variant="ghost"
@ -290,7 +291,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Copy className="h-4 w-4 mr-2" />
Copy
{t('common.copy')}
</Button>
<Button
onClick={handleDownload}
@ -299,7 +300,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<Download className="h-4 w-4 mr-2" />
Download
{t('settingsUi.mnemonic.download')}
</Button>
<Button
onClick={handleQR}
@ -307,7 +308,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
size="sm"
className="border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10"
>
QR Code
{t('settingsUi.mnemonic.qrCode')}
</Button>
</div>
</CardContent>
@ -317,14 +318,14 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<Card className="bg-[#ff0040]/10 border-2 border-[#ff0040]/50">
<CardContent className="p-6">
<h3 className="font-bold text-[#ff0040] mb-3 uppercase flex items-center gap-2">
<span>⚠️</span> Critical Security Warning
<span>⚠️</span> {t('settingsUi.mnemonic.criticalWarning')}
</h3>
<ul className="space-y-2 text-sm text-gray-300">
<li>✍️ <strong>Write it down</strong> on paper and store it safely</li>
<li>🔒 <strong>Never share</strong> your recovery phrase with anyone</li>
<li>💾 <strong>Keep multiple backups</strong> in different secure locations</li>
<li>❌ <strong>Anyone with this phrase</strong> controls your account forever</li>
<li>🚫 <strong>If you lose it</strong>, you lose your account permanently</li>
<li>✍️ <strong>{t('settingsUi.mnemonic.warnWriteStrong')}</strong> {t('settingsUi.mnemonic.warnWrite')}</li>
<li>🔒 <strong>{t('settingsUi.mnemonic.warnNeverShareStrong')}</strong> {t('settingsUi.mnemonic.warnNeverShare')}</li>
<li>💾 <strong>{t('settingsUi.mnemonic.warnBackupsStrong')}</strong> {t('settingsUi.mnemonic.warnBackups')}</li>
<li>❌ <strong>{t('settingsUi.mnemonic.warnAnyoneStrong')}</strong> {t('settingsUi.mnemonic.warnAnyone')}</li>
<li>🚫 <strong>{t('settingsUi.mnemonic.warnLoseStrong')}</strong> {t('settingsUi.mnemonic.warnLose')}</li>
</ul>
</CardContent>
</Card>
@ -335,7 +336,7 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
size="lg"
>
I've Saved It Securely
{t('settingsUi.mnemonic.savedSecurely')}
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</>
@ -349,23 +350,23 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<Card className="bg-black/50 border-[#ccff00]/30">
<CardContent className="p-6">
<h3 className="font-bold text-[#ccff00] mb-4 uppercase text-center">
Verify Your Recovery Phrase
{t('settingsUi.mnemonic.verifyTitle')}
</h3>
<p className="text-gray-400 text-sm mb-6 text-center">
To make sure you've saved it correctly, please enter these words:
{t('settingsUi.mnemonic.verifyDesc')}
</p>
<div className="space-y-4 max-w-md mx-auto">
{verifyIndices.map((idx) => (
<div key={idx} className="space-y-2">
<label className="text-sm text-gray-300 font-mono">
Word #{idx + 1}
{t('settingsUi.mnemonic.wordLabel', { n: idx + 1 })}
</label>
<input
type="text"
value={verifyInputs[idx] || ''}
onChange={(e) => setVerifyInputs({ ...verifyInputs, [idx]: e.target.value })}
placeholder={`Enter word #${idx + 1}`}
placeholder={t('settingsUi.mnemonic.wordPlaceholder', { n: idx + 1 })}
className="w-full px-4 py-3 rounded-lg bg-gray-900 border-2 border-[#ccff00]/20 text-white placeholder-gray-600 focus:border-[#ccff00]/50 focus:outline-none font-mono"
/>
</div>
@ -380,14 +381,14 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
variant="outline"
className="flex-1 border-gray-600 text-gray-400 hover:bg-gray-800"
>
Go Back
{t('settingsUi.mnemonic.goBack')}
</Button>
<Button
onClick={handleVerify}
disabled={!verifyIndices.every((idx) => verifyInputs[idx]?.trim())}
className="flex-1 bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase"
>
Verify & Create Account
{t('settingsUi.mnemonic.verifyCreate')}
</Button>
</div>
</div>
@ -400,10 +401,10 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<CardContent className="p-8 text-center space-y-4">
<div className="text-6xl mb-2">✅</div>
<h3 className="text-2xl font-black uppercase text-[#ccff00]">
Account Created!
{t('settingsUi.mnemonic.completeTitle')}
</h3>
<p className="text-gray-300">
Your Nostr account is ready to go!
{t('settingsUi.mnemonic.completeDesc')}
</p>
<CheckCircle className="h-12 w-12 mx-auto text-[#00ff00]" />
</CardContent>
@ -417,25 +418,24 @@ const MnemonicSignupDialog: React.FC<MnemonicSignupDialogProps> = ({
<Shield className="h-5 w-5 text-blue-400" />
</div>
<div>
<h4 className="font-bold text-blue-400">Recommended: Set Up a Signer App</h4>
<p className="text-xs text-gray-400">Keep your keys extra secure</p>
<h4 className="font-bold text-blue-400">{t('settingsUi.mnemonic.signerTitle')}</h4>
<p className="text-xs text-gray-400">{t('settingsUi.mnemonic.signerSubtitle')}</p>
</div>
</div>
<p className="text-sm text-gray-300">
For better security, use a dedicated signer app instead of pasting your recovery phrase everywhere.
Signer apps keep your keys safe while letting you use any Nostr app.
{t('settingsUi.mnemonic.signerDesc')}
</p>
<Link to="/signers" onClick={onClose}>
<Button className="w-full bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 border border-blue-500/50">
<ExternalLink className="h-4 w-4 mr-2" />
Learn About Signer Apps
{t('settingsUi.mnemonic.signerCta')}
</Button>
</Link>
<button
onClick={onClose}
className="w-full text-xs text-gray-500 hover:text-gray-400 py-2"
>
Skip for now
{t('settingsUi.mnemonic.skip')}
</button>
</CardContent>
</Card>

View file

@ -21,6 +21,7 @@ import { SkateNip05Input } from '@/components/SkateNip05Input';
import { useSkateNip05 } from '@/hooks/useSkateNip05';
import { deriveSkateNip05Handle, validateSkateNip05Name } from '@/lib/skateNip05';
import { encryptNsec } from '@/lib/ncryptsec';
import { useTranslation } from '@/contexts/language';
interface SignupDialogProps {
isOpen: boolean;
@ -41,12 +42,13 @@ const sanitizeFilename = (filename: string) => {
const STEP_ORDER = ['welcome', 'profile', 'done'] as const;
type SignupStep = typeof STEP_ORDER[number];
const STEP_LABELS: Record<SignupStep, string> = {
welcome: 'Choose',
profile: 'Profile',
done: 'Save key',
welcome: 'settingsUi.signupDialog.stepChoose',
profile: 'settingsUi.signupDialog.stepProfile',
done: 'settingsUi.signupDialog.stepSaveKey',
};
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete, onUseAmber }) => {
const { t } = useTranslation();
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const [step, setStep] = useState<SignupStep>('welcome');
const [nsec, setNsec] = useState('');
@ -92,8 +94,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
return true;
} catch {
toast({
title: 'Something went wrong',
description: "Couldn't create your account. Please try again.",
title: t('settingsUi.signupDialog.somethingWrong'),
description: t('settingsUi.signupDialog.createFailed'),
variant: 'destructive',
});
return false;
@ -127,13 +129,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setBackupConfirmed(true);
toast({
title: 'Secret Key Saved!',
description: 'Your key has been safely stored.',
title: t('settingsUi.signupDialog.keySavedTitle'),
description: t('settingsUi.signupDialog.keySavedDesc'),
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
title: t('settingsUi.signupDialog.downloadFailedTitle'),
description: t('settingsUi.signupDialog.downloadFailedDesc'),
variant: 'destructive',
});
}
@ -161,11 +163,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setBackupAck(true);
setBackupConfirmed(true);
toast({
title: 'Encrypted backup saved 🔐',
description: 'Keep the file AND the password — you need both to log back in.',
title: t('settingsUi.signupDialog.encSavedTitle'),
description: t('settingsUi.signupDialog.encSavedDesc'),
});
} catch (e) {
setEncError((e as Error)?.message ?? 'Could not encrypt that key.');
setEncError((e as Error)?.message ?? t('settingsUi.signupDialog.encFailed'));
}
};
@ -181,8 +183,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
// Validate file type
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file for your avatar.',
title: t('settingsUi.signupDialog.invalidFileTitle'),
description: t('settingsUi.signupDialog.invalidFileDesc'),
variant: 'destructive',
});
return;
@ -191,8 +193,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Avatar image must be smaller than 5MB.',
title: t('settingsUi.signupDialog.fileTooLargeTitle'),
description: t('settingsUi.signupDialog.fileTooLargeDesc'),
variant: 'destructive',
});
return;
@ -205,14 +207,14 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
if (url) {
setProfileData(prev => ({ ...prev, picture: url }));
toast({
title: 'Avatar uploaded!',
description: 'Your avatar has been uploaded successfully.',
title: t('settingsUi.signupDialog.avatarUploadedTitle'),
description: t('settingsUi.signupDialog.avatarUploadedDesc'),
});
}
} catch {
toast({
title: 'Upload failed',
description: 'Failed to upload avatar. Please try again.',
title: t('settingsUi.signupDialog.uploadFailedTitle'),
description: t('settingsUi.signupDialog.uploadFailedDesc'),
variant: 'destructive',
});
}
@ -224,8 +226,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
const trimmedName = profileData.name.trim();
if (!skipProfile && !trimmedName) {
toast({
title: 'Display name required',
description: 'Pick a display name so other skaters know who you are. You can change it anytime.',
title: t('settingsUi.signupDialog.nameRequiredTitle'),
description: t('settingsUi.signupDialog.nameRequiredDesc'),
variant: 'destructive',
});
return;
@ -248,8 +250,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setClaimedNip05(claimResult.nip05);
} else {
toast({
title: "Couldn't reserve that name",
description: `${claimResult.error} You can claim one later in Settings.`,
title: t('settingsUi.signupDialog.reserveFailedTitle'),
description: t('settingsUi.signupDialog.reserveFailedDesc', { error: claimResult.error }),
});
}
}
@ -269,8 +271,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
});
toast({
title: 'Profile Created!',
description: 'Your profile has been set up.',
title: t('settingsUi.signupDialog.profileCreatedTitle'),
description: t('settingsUi.signupDialog.profileCreatedDesc'),
});
}
@ -278,8 +280,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setStep('done');
} catch {
toast({
title: 'Profile Setup Failed',
description: 'Your account was created but profile setup failed. You can update it later.',
title: t('settingsUi.signupDialog.profileFailedTitle'),
description: t('settingsUi.signupDialog.profileFailedDesc'),
variant: 'destructive',
});
@ -296,8 +298,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
}, 600);
} else {
toast({
title: 'Welcome!',
description: 'Your account is ready.',
title: t('settingsUi.signupDialog.welcomeToastTitle'),
description: t('settingsUi.signupDialog.welcomeToastDesc'),
});
}
};
@ -305,17 +307,17 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
const getTitle = () => {
if (step === 'welcome') return (
<span className="flex items-center justify-center gap-2">
Join SkateSpots
{t('settingsUi.signupDialog.titleWelcome')}
</span>
);
if (step === 'profile') return (
<span className="flex items-center justify-center gap-2">
Set Up Your Profile
{t('settingsUi.signupDialog.titleProfile')}
</span>
);
return (
<span className="flex items-center justify-center gap-2">
Welcome!
{t('settingsUi.signupDialog.titleDone')}
</span>
);
};
@ -392,7 +394,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
i === idx ? 'text-white' : 'text-gray-600'
)}
>
{STEP_LABELS[s]}
{t(STEP_LABELS[s])}
</span>
{i < STEP_ORDER.length - 1 && (
<span className={cn('h-px w-3 sm:w-5', i < idx ? 'bg-[#00ff00]/50' : 'bg-white/10')} />
@ -413,11 +415,10 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
🛹
</div>
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
Create your free account
{t('settingsUi.signupDialog.createFreeAccount')}
</h3>
<p className='text-sm text-gray-300'>
No email, no phone, no password. Your account is a Nostr key — choose
where it lives.
{t('settingsUi.signupDialog.createFreeAccountDesc')}
</p>
</div>
</div>
@ -427,17 +428,16 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
{onUseAmber && isAndroid && (
<div className='rounded-2xl border-2 border-[#ccff00]/50 bg-black/40 p-4 text-left space-y-3'>
<div className='flex items-center gap-2'>
<span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>Recommended</span>
<span className='rounded-full bg-[#ccff00] px-2 py-0.5 text-[10px] font-black uppercase text-black'>{t('settingsUi.signupDialog.recommended')}</span>
</div>
<div className='flex items-start gap-3'>
<div className='mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#ccff00]/15'>
<Smartphone className='h-5 w-5 text-[#ccff00]' />
</div>
<div>
<p className='font-bold text-white'>Sign up with Amber</p>
<p className='font-bold text-white'>{t('settingsUi.signupDialog.signUpWithAmber')}</p>
<p className='text-xs text-gray-400'>
Your key is created and stored inside the Amber app — SkateSpots
never touches it. Most secure. One tap to approve.
{t('settingsUi.signupDialog.signUpWithAmberDesc')}
</p>
</div>
</div>
@ -446,7 +446,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={() => { onClose(); onUseAmber(); }}
>
<Smartphone className='mr-2 h-4 w-4' />
Continue with Amber
{t('settingsUi.signupDialog.continueWithAmber')}
</Button>
</div>
)}
@ -454,7 +454,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
{onUseAmber && isAndroid && (
<div className='relative flex items-center'>
<div className='flex-1 border-t border-gray-800' />
<span className='px-3 text-xs text-gray-500'>or the quick way</span>
<span className='px-3 text-xs text-gray-500'>{t('settingsUi.signupDialog.orQuickWay')}</span>
<div className='flex-1 border-t border-gray-800' />
</div>
)}
@ -466,10 +466,9 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<Key className='h-5 w-5 text-gray-300' />
</div>
<div>
<p className='font-bold text-white'>Quick account on this device</p>
<p className='font-bold text-white'>{t('settingsUi.signupDialog.quickAccount')}</p>
<p className='text-xs text-gray-400'>
We generate a key that lives on this device. Fastest start — we'll
help you save a backup right after.
{t('settingsUi.signupDialog.quickAccountDesc')}
</p>
</div>
</div>
@ -479,14 +478,14 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={handleCreateAccount}
>
<Sparkles className='mr-2 h-4 w-4' />
Create quick account
{t('settingsUi.signupDialog.createQuickAccount')}
</Button>
{/* Reassurance: effortless + safe, no jargon. Deliberately does
NOT promise "nothing to remember" — step 3 asks them to save
the key, and the copy must not contradict that. */}
<p className='flex items-center justify-center gap-1.5 text-[11px] text-gray-500'>
<CheckCircle className='h-3 w-3 text-[#00ff00]/70' />
Takes 5 seconds · lives on this device · we'll help you back it up next
{t('settingsUi.signupDialog.quickReassurance')}
</p>
</div>
@ -494,11 +493,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
leads; elsewhere point at signer apps/extensions honestly. */}
<p className='text-xs text-gray-500'>
{onUseAmber && isAndroid ? (
<>Prefer another signer?{' '}</>
<>{t('settingsUi.signupDialog.preferAnotherSigner')}{' '}</>
) : (
<>On iOS or desktop? A signer app or browser extension is the most secure option —{' '}</>
<>{t('settingsUi.signupDialog.iosDesktopNudge')}{' '}</>
)}
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>See all options</Link>
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>{t('settingsUi.signupDialog.seeAllOptions')}</Link>
</p>
</div>
)}
@ -528,11 +527,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<div className='relative z-10 space-y-2'>
<p className='text-base font-semibold'>
You're in! 🛹 Pick a display name so skaters know who you are
{t('settingsUi.signupDialog.youreInPickName')}
</p>
<p className='text-sm text-muted-foreground'>
You can change it anytime later. Bio and avatar are optional.
{t('settingsUi.signupDialog.changeAnytime')}
</p>
</div>
</div>
@ -543,7 +542,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<div className='flex items-center justify-center gap-3'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
<span className='text-sm font-medium text-blue-700 dark:text-blue-300'>
Publishing your profile...
{t('settingsUi.signupDialog.publishingProfile')}
</span>
</div>
</div>
@ -553,13 +552,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<div className={`space-y-4 text-left ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
<div className='space-y-2'>
<label htmlFor='profile-name' className='text-sm font-medium'>
Display Name <span className='text-red-500'>*</span>
{t('settingsUi.signupDialog.displayName')} <span className='text-red-500'>*</span>
</label>
<Input
id='profile-name'
value={profileData.name}
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
placeholder='e.g. Tony, gnarlyGrind, or your crew name'
placeholder={t('settingsUi.signupDialog.displayNamePlaceholder')}
className='rounded-lg'
maxLength={64}
required
@ -567,13 +566,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
disabled={isPublishing}
/>
<p className='text-xs text-muted-foreground'>
Required — how you'll show up across SkateSpots. Change it anytime.
{t('settingsUi.signupDialog.displayNameHint')}
</p>
</div>
<div className='space-y-2'>
<label htmlFor='skate-nip05' className='text-sm font-medium'>
Your SkateSpots name
{t('settingsUi.signupDialog.skateName')}
</label>
<SkateNip05Input
id='skate-nip05'
@ -588,19 +587,19 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
disabled={isPublishing}
/>
<p className='text-xs text-muted-foreground'>
Free and optional — a verified handle that shows a ✓ on your profile.
{t('settingsUi.signupDialog.skateNameHint')}
</p>
</div>
<div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'>
Bio
{t('settingsUi.signupDialog.bio')}
</label>
<Textarea
id='profile-about'
value={profileData.about}
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
placeholder='Tell others about yourself...'
placeholder={t('settingsUi.signupDialog.bioPlaceholder')}
className='rounded-lg resize-none'
rows={3}
disabled={isPublishing}
@ -609,7 +608,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<div className='space-y-2'>
<label htmlFor='profile-picture' className='text-sm font-medium'>
Avatar
{t('settingsUi.signupDialog.avatar')}
</label>
<div className='flex gap-2'>
<Input
@ -634,7 +633,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={() => avatarFileInputRef.current?.click()}
disabled={isUploading || isPublishing}
className='rounded-lg shrink-0'
title='Upload avatar image'
title={t('settingsUi.signupDialog.uploadAvatar')}
>
{isUploading ? (
<div className='w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin' />
@ -656,19 +655,19 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
{isPublishing ? (
<>
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
Saving...
{t('settingsUi.signupDialog.saving')}
</>
) : (
<>
<User className='w-4 h-4 mr-2' />
Save & Start Skating
{t('settingsUi.signupDialog.saveAndStart')}
</>
)}
</Button>
{!profileData.name.trim() && !isPublishing && (
<p className='text-center text-xs text-muted-foreground'>
Add a display name to continue.
{t('settingsUi.signupDialog.addNameToContinue')}
</p>
)}
@ -677,7 +676,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
show an approval prompt. Explaining it first avoids the "why
is it asking me to sign?" moment mid-signup. */}
<p className='text-center text-[11px] text-muted-foreground/80'>
Saving publishes your profile to Nostr — your signer may ask you to approve it once.
{t('settingsUi.signupDialog.savingPublishesNote')}
</p>
</div>
</div>
@ -692,8 +691,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<div className='flex items-center gap-3 rounded-xl bg-[#00ff00]/10 border border-[#00ff00]/30 px-4 py-3'>
<CheckCircle className='h-6 w-6 shrink-0 text-[#00ff00]' />
<div>
<p className='font-black uppercase text-[#ccff00] leading-tight'>You're in! 🛹</p>
<p className='text-xs text-gray-400'>One quick thing before you drop in…</p>
<p className='font-black uppercase text-[#ccff00] leading-tight'>{t('settingsUi.signupDialog.youreIn')}</p>
<p className='text-xs text-gray-400'>{t('settingsUi.signupDialog.oneQuickThing')}</p>
</div>
</div>
@ -706,10 +705,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<Key className='h-5 w-5 text-amber-400' />
</div>
<div className='flex-1'>
<h4 className='font-black uppercase text-amber-300 leading-tight'>Save your login key</h4>
<h4 className='font-black uppercase text-amber-300 leading-tight'>{t('settingsUi.signupDialog.saveLoginKey')}</h4>
<p className='text-xs text-amber-100/80 leading-snug mt-1'>
This is the <span className='font-bold text-amber-200'>only</span> way back into your account —
no email, no password reset. Save it now and you can log in on any device, anywhere you skate.
{t('settingsUi.signupDialog.saveLoginKeyBefore')}{' '}
<span className='font-bold text-amber-200'>{t('settingsUi.signupDialog.only')}</span>{' '}
{t('settingsUi.signupDialog.saveLoginKeyAfter')}
</p>
</div>
</div>
@ -720,7 +720,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={() => setShowNsec(true)}
>
<Shield className='h-4 w-4 mr-2' />
Reveal &amp; save my key
{t('settingsUi.signupDialog.revealSaveKey')}
</Button>
) : (
<div className='space-y-2'>
@ -735,11 +735,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={() => {
navigator.clipboard.writeText(nsec);
setBackupConfirmed(true);
toast({ title: 'Copied!', description: 'Recovery code copied. Keep it somewhere safe.' });
toast({ title: t('common.copied'), description: t('settingsUi.signupDialog.recoveryCopied') });
}}
>
<Key className='h-3.5 w-3.5 mr-1.5' />
Copy
{t('common.copy')}
</Button>
<Button
size='sm'
@ -752,15 +752,15 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={downloadKey}
>
{keySecured === 'downloaded' ? (
<><CheckCircle className='h-3.5 w-3.5 mr-1.5' /> Saved</>
<><CheckCircle className='h-3.5 w-3.5 mr-1.5' /> {t('settingsUi.signupDialog.saved')}</>
) : (
<><Download className='h-3.5 w-3.5 mr-1.5' /> Download</>
<><Download className='h-3.5 w-3.5 mr-1.5' /> {t('settingsUi.signupDialog.download')}</>
)}
</Button>
</div>
<p className='flex items-start gap-1.5 text-[11px] text-amber-300/80 leading-snug'>
<Shield className='h-3.5 w-3.5 shrink-0 mt-px' />
Treat it like the keys to your board — anyone with this code controls your account. Never share it.
{t('settingsUi.signupDialog.treatLikeBoardKeys')}
</p>
</div>
)}
@ -774,21 +774,22 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
className='flex w-full items-center justify-between text-left'
>
<span className='text-[11px] font-black uppercase tracking-wide text-amber-300'>
{showEncBackup ? 'Hide encrypted backup' : 'Save an encrypted backup'}
{showEncBackup ? t('settingsUi.signupDialog.hideEncBackup') : t('settingsUi.signupDialog.saveEncBackup')}
</span>
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 text-amber-300 transition-transform', showEncBackup && 'rotate-180')} />
</button>
{showEncBackup && (
<div className='space-y-2'>
<p className='text-[11px] leading-snug text-amber-100/70'>
A password-protected copy — safe to keep in cloud storage or a password
manager. You'll need the file <span className='font-bold text-amber-200'>and</span> the password.
{t('settingsUi.signupDialog.encBackupBefore')}{' '}
<span className='font-bold text-amber-200'>{t('settingsUi.signupDialog.and')}</span>{' '}
{t('settingsUi.signupDialog.encBackupAfter')}
</p>
<Input
type='password'
value={encPassword}
onChange={(e) => { setEncPassword(e.target.value); setEncError(''); }}
placeholder='Choose a password'
placeholder={t('settingsUi.signupDialog.choosePassword')}
className='rounded-lg'
autoComplete='new-password'
/>
@ -800,7 +801,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={downloadEncryptedKey}
>
<Download className='h-3.5 w-3.5 mr-1.5' />
Save encrypted backup
{t('settingsUi.signupDialog.saveEncBackupCta')}
</Button>
</div>
)}
@ -824,10 +825,10 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
if (on) setBackupConfirmed(true);
}}
className='mt-0.5'
aria-label="I've saved my recovery code"
aria-label={t('settingsUi.signupDialog.savedCodeAria')}
/>
<span className='text-xs text-gray-300 leading-snug'>
I've saved my recovery code somewhere safe — a password manager, or written down offline.
{t('settingsUi.signupDialog.savedCodeLabel')}
</span>
</label>
@ -842,7 +843,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={handleFinishComplete}
>
<LogIn className='h-4 w-4 mr-2' />
Let's skate
{t('settingsUi.signupDialog.letsSkate')}
</Button>
{!backupAck && (
@ -850,13 +851,13 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onClick={handleFinishComplete}
className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'
>
I'll save it later from Settings
{t('settingsUi.signupDialog.saveLater')}
</button>
)}
<Link to='/signers' onClick={handleFinishComplete} className='block'>
<button className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'>
Advanced: use a signer app instead
{t('settingsUi.signupDialog.advancedSigner')}
</button>
</Link>
</div>

View file

@ -3,6 +3,7 @@ import { Key, FileText, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
import { useTranslation } from '@/contexts/language';
interface SignupMethodDialogProps {
isOpen: boolean;
@ -17,16 +18,17 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
onSelectMnemonic,
onSelectNsec
}) => {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl bg-gradient-to-br from-gray-900 to-black border-2 border-[#ccff00]/30">
<DialogHeader>
<DialogTitle className="text-3xl font-black uppercase text-center mb-2">
<span className="neon-orange">Choose Your</span>{' '}
<span className="neon-lime">Signup Method</span>
<span className="neon-orange">{t('settingsUi.signupMethod.titlePrefix')}</span>{' '}
<span className="neon-lime">{t('settingsUi.signupMethod.titleSuffix')}</span>
</DialogTitle>
<p className="text-center text-gray-400 text-sm">
Both methods are secure. Choose what works best for you!
{t('settingsUi.signupMethod.subtitle')}
</p>
</DialogHeader>
@ -38,42 +40,42 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
<div className="flex items-center justify-between">
<FileText className="h-12 w-12 text-[#ccff00]" />
<span className="text-xs font-bold uppercase px-2 py-1 bg-[#ccff00]/20 text-[#ccff00] rounded-full">
Recommended
{t('settingsUi.signupMethod.recommended')}
</span>
</div>
<div>
<h3 className="text-2xl font-black uppercase text-[#ccff00] mb-2">
Recovery Phrase
{t('settingsUi.signupMethod.mnemonicTitle')}
</h3>
<p className="text-sm text-gray-400 mb-3">
12 easy-to-remember words. Industry standard used by Bitcoin, Ethereum, and most crypto wallets.
{t('settingsUi.signupMethod.mnemonicDesc')}
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Easy to write down on paper</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF1')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Works with other Nostr apps (NIP-06)</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF2')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Can generate multiple accounts</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF3')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#00ff00] text-lg">✓</span>
<span className="text-xs text-gray-300">Familiar to crypto users</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.mnemonicF4')}</span>
</div>
</div>
<Button
className="w-full bg-gradient-to-r from-[#ccff00] to-[#00ff00] hover:from-[#ccff00]/90 hover:to-[#00ff00]/90 text-black font-bold uppercase group-hover:scale-105 transition-transform"
>
Use Recovery Phrase
{t('settingsUi.signupMethod.useMnemonic')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
@ -86,42 +88,42 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
<div className="flex items-center justify-between">
<Key className="h-12 w-12 text-[#ff0040]" />
<span className="text-xs font-bold uppercase px-2 py-1 bg-[#ff0040]/20 text-[#ff0040] rounded-full">
Advanced
{t('settingsUi.signupMethod.advanced')}
</span>
</div>
<div>
<h3 className="text-2xl font-black uppercase text-[#ff0040] mb-2">
Secret Key (nsec)
{t('settingsUi.signupMethod.nsecTitle')}
</h3>
<p className="text-sm text-gray-400 mb-3">
Raw cryptographic key. More technical but gives you direct access to your Nostr identity.
{t('settingsUi.signupMethod.nsecDesc')}
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">Direct cryptographic control</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF1')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">Compatible with all Nostr apps</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF2')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">Single string to backup</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF3')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-[#ff6600] text-lg">✓</span>
<span className="text-xs text-gray-300">No derivation layer</span>
<span className="text-xs text-gray-300">{t('settingsUi.signupMethod.nsecF4')}</span>
</div>
</div>
<Button
className="w-full bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:from-[#ff0040]/90 hover:to-[#ff6600]/90 text-white font-bold uppercase group-hover:scale-105 transition-transform"
>
Use Secret Key
{t('settingsUi.signupMethod.useNsec')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
@ -130,7 +132,7 @@ const SignupMethodDialog: React.FC<SignupMethodDialogProps> = ({
<div className="text-center mt-4">
<p className="text-xs text-gray-500">
🔒 Both methods are equally secure. Choose based on your preference!
{t('settingsUi.signupMethod.footer')}
</p>
</div>
</DialogContent>

View file

@ -3,6 +3,7 @@ import { Lock, ShieldCheck, Server, ExternalLink, Settings } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
@ -30,6 +31,7 @@ export function CrewCoordinatorCard({
crew: SkateCrew;
isFounder: boolean;
}) {
const { t } = useTranslation();
const { coordinator } = crew;
// Which relay the messages actually travel over — a `wss://` coordinator URL
@ -47,36 +49,35 @@ export function CrewCoordinatorCard({
<Card className="border-2 border-teal-500/25 bg-gradient-to-br from-teal-500/[0.06] to-black/90">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-teal-400" /> Private Crew Chat
<Lock className="h-5 w-5 text-teal-400" /> {t('socialUi.crewCoordinator.privateChat')}
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-teal-300">
<ShieldCheck className="h-3 w-3" /> On
<ShieldCheck className="h-3 w-3" /> {t('socialUi.crewCoordinator.on')}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm leading-relaxed text-gray-400">
The <span className="font-semibold text-gray-200">Chat</span> tab is{' '}
<span className="font-bold text-teal-300">end-to-end encrypted</span> for the founder and all
listed members — nothing to set up. Messages are sealed to each member's key (NIP-17), so only
your crew can read them.
{t('socialUi.crewCoordinator.defaultA')}{' '}
<span className="font-semibold text-gray-200">{t('socialUi.crewCoordinator.chatWord')}</span> {t('socialUi.crewCoordinator.defaultB')}{' '}
<span className="font-bold text-teal-300">{t('socialUi.crewCoordinator.e2ee')}</span> {t('socialUi.crewCoordinator.defaultC')}
</p>
{isFounder && (
<p className="text-[11px] leading-relaxed text-gray-600">
Advanced: run your own{' '}
{t('socialUi.crewCoordinator.advancedA')}{' '}
<a
href="https://github.com/Cordn-msg/cordn"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 underline underline-offset-2 hover:text-teal-300"
>
Cordn coordinator
{t('socialUi.crewCoordinator.cordnCoordinator')}
</a>
?{' '}
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1 font-semibold text-gray-400 hover:text-teal-300"
>
<Settings className="h-3 w-3" /> Route chat through it
<Settings className="h-3 w-3" /> {t('socialUi.crewCoordinator.routeThrough')}
</Link>
</p>
)}
@ -89,9 +90,9 @@ export function CrewCoordinatorCard({
<Card className="border-2 border-emerald-500/30 bg-gradient-to-br from-emerald-500/[0.08] to-black/90">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat
<Lock className="h-5 w-5 text-emerald-400" /> {t('socialUi.crewCoordinator.privateChat')}
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<Server className="h-3 w-3" /> Coordinator
<Server className="h-3 w-3" /> {t('socialUi.crewCoordinator.coordinator')}
</span>
</CardTitle>
</CardHeader>
@ -102,23 +103,22 @@ export function CrewCoordinatorCard({
{endpoint && (
<p className="text-[11px] leading-relaxed text-gray-500">
Delivered over{' '}
{t('socialUi.crewCoordinator.deliveredOver')}{' '}
<span className="font-mono text-gray-400">{urlDomain(endpoint.relayUrl) || endpoint.relayUrl}</span>
{overPublicRelay ? ' — the shared ContextVM relay.' : " — your crew's own relay."}
{overPublicRelay ? ` ${t('socialUi.crewCoordinator.sharedRelay')}` : ` ${t('socialUi.crewCoordinator.ownRelay')}`}
</p>
)}
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
<Lock className="h-3.5 w-3.5" /> Private chat is live
<Lock className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.chatLive')}
</p>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
The <span className="font-semibold text-gray-300">Chat</span> tab routes through this
coordinator: messages are end-to-end encrypted (NIP-44, sealed to each member), so it only
ever handles sealed traffic.{' '}
{t('socialUi.crewCoordinator.routesA')}{' '}
<span className="font-semibold text-gray-300">{t('socialUi.crewCoordinator.chatWord')}</span> {t('socialUi.crewCoordinator.routesB')}{' '}
{overPublicRelay
? 'Heads up: it is addressed over the shared ContextVM relay, so the ciphertext and its metadata (which crew, and when) are publicly visible — just not readable. Point the crew at a private wss:// relay to keep even that private.'
: "Nothing crew-specific leaves your crew's own relay."}
? t('socialUi.crewCoordinator.publicWarning')
: t('socialUi.crewCoordinator.ownRelaySafe')}
</p>
</div>
@ -127,7 +127,7 @@ export function CrewCoordinatorCard({
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-400 hover:text-emerald-300"
>
<Settings className="h-3.5 w-3.5" /> Manage coordinator
<Settings className="h-3.5 w-3.5" /> {t('socialUi.crewCoordinator.manage')}
</Link>
)}
</CardContent>
@ -136,6 +136,7 @@ export function CrewCoordinatorCard({
}
function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) {
const { t } = useTranslation();
const safe = sanitizeUrl(coordinator.url);
return (
<div className="flex items-center gap-3 rounded-xl border border-emerald-500/20 bg-black/40 p-3">
@ -144,7 +145,7 @@ function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator })
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-black uppercase tracking-widest text-emerald-400/80">
Coordinator
{t('socialUi.crewCoordinator.coordinator')}
</p>
<p className="truncate text-sm font-bold text-white">{urlDomain(coordinator.url)}</p>
</div>
@ -154,7 +155,7 @@ function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator })
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-emerald-500/40 hover:text-emerald-300"
title={`Open ${urlDomain(coordinator.url)}`}
title={t('socialUi.crewCoordinator.open', { domain: urlDomain(coordinator.url) })}
>
<ExternalLink className="h-4 w-4" />
</a>
@ -164,6 +165,7 @@ function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator })
}
function CoordinatorOperator({ pubkey }: { pubkey: string }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
const pic = author.data?.metadata?.picture;
@ -179,7 +181,7 @@ function CoordinatorOperator({ pubkey }: { pubkey: string }) {
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">Coordinator run by</p>
<p className="text-[10px] font-black uppercase tracking-widest text-gray-500">{t('socialUi.crewCoordinator.runBy')}</p>
<p className="truncate text-sm font-bold text-gray-200">{name}</p>
</div>
</Link>

View file

@ -18,6 +18,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CrewActivityPulse } from '@/components/CrewBadge';
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { crewAccent, crewBadge, type CrewActivity } from '@/lib/crewHelpers';
import { CREW_FOCUS_LABELS, CREW_VIBE_LABELS, type CrewFocus, type SkateCrew } from '@/lib/types';
@ -63,6 +64,7 @@ export function CrewHero({
confirmedCount?: number;
actions?: React.ReactNode;
}) {
const { t } = useTranslation();
const accent = crewAccent(crew);
const founder = useAuthor(crew.pubkey);
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
@ -188,14 +190,14 @@ export function CrewHero({
)}
{crew.founded && (
<span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4 shrink-0" /> Est. {crew.founded}
<Calendar className="h-4 w-4 shrink-0" /> {t('socialUi.hero.established', { year: crew.founded })}
</span>
)}
{/* Team leader inline */}
<Link
to={`/${npub}`}
className="group flex items-center gap-1.5 transition-colors hover:text-white"
title={`Team leader: ${founderName}`}
title={t('socialUi.hero.teamLeader', { name: founderName })}
>
<Avatar className="h-5 w-5 border border-white/20">
<AvatarImage src={founderPic} alt={founderName} />
@ -203,7 +205,7 @@ export function CrewHero({
</Avatar>
<span className="inline-flex items-center gap-1">
<ShieldCheck className="h-3.5 w-3.5" style={{ color: accent.hex }} />
Led by <span className="font-bold text-gray-200 group-hover:text-white">{founderName}</span>
{t('socialUi.hero.ledBy')} <span className="font-bold text-gray-200 group-hover:text-white">{founderName}</span>
</span>
</Link>
</div>

View file

@ -4,6 +4,7 @@ import { Crown, ShieldCheck, MessageSquare, Check } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { crewAccent } from '@/lib/crewHelpers';
@ -27,6 +28,7 @@ function LeaderCard({
confirmed?: boolean;
skateRoles?: CrewRole[];
}) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(pubkey);
@ -37,7 +39,7 @@ function LeaderCard({
const isLeader = role === 'leader';
const RoleIcon = isLeader ? Crown : ShieldCheck;
const roleLabel = isLeader ? 'Team Leader' : 'Captain';
const roleLabel = isLeader ? t('socialUi.leadership.teamLeader') : t('socialUi.leadership.captain');
return (
<div
@ -86,7 +88,7 @@ function LeaderCard({
</Link>
{confirmed && !isLeader && (
<p className="flex items-center gap-1 text-[11px] font-bold text-green-500">
<Check className="h-3 w-3" /> Verified member
<Check className="h-3 w-3" /> {t('socialUi.leadership.verifiedMember')}
</p>
)}
{skateRoles && skateRoles.length > 0 && (
@ -108,7 +110,7 @@ function LeaderCard({
<Link
to={`/messages/${npub}`}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-gray-400 transition-colors hover:border-white/20 hover:text-white"
title={`Message ${name}`}
title={t('socialUi.leadership.messageName', { name })}
>
<MessageSquare className="h-4 w-4" />
</Link>
@ -139,6 +141,7 @@ export function CrewLeadership({
/** Skate roles per member (pubkey → roles) for role chips. */
rolesByPubkey?: Map<string, CrewRole[]>;
}) {
const { t } = useTranslation();
const accent = crewAccent(crew);
// Admins are always a subset of members; founder is never listed as an admin card.
const captains = crew.admins.filter((pk) => pk !== crew.pubkey);
@ -148,7 +151,7 @@ export function CrewLeadership({
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Crown className="h-5 w-5" style={{ color: accent.hex }} />
Leadership
{t('socialUi.leadership.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2.5">
@ -162,7 +165,9 @@ export function CrewLeadership({
{captains.length > 0 && (
<>
<p className="px-1 pt-1 text-[11px] font-black uppercase tracking-widest text-gray-500">
{captains.length} Captain{captains.length === 1 ? '' : 's'}
{captains.length === 1
? t('socialUi.leadership.captainsOne', { count: captains.length })
: t('socialUi.leadership.captainsMany', { count: captains.length })}
</p>
{captains.map((pk) => (
<LeaderCard
@ -178,7 +183,7 @@ export function CrewLeadership({
)}
<p className="pt-1 text-[11px] leading-relaxed text-gray-500">
The Team Leader runs the roster; Captains help invite, host jams and keep the crew tidy.
{t('socialUi.leadership.footer')}
</p>
</CardContent>
</Card>

View file

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import { Store, ChevronRight, Loader2, BadgeCheck } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useSkateShop } from '@/hooks/useSkateShops';
import { useTranslation } from '@/contexts/language';
import { parseAddrCoord } from '@/lib/types';
/**
@ -12,6 +13,7 @@ import { parseAddrCoord } from '@/lib/types';
* dangling claim.
*/
export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefined }) {
const { t } = useTranslation();
const coord = sponsorRef ? parseAddrCoord(sponsorRef) : null;
const { data: shop, isLoading } = useSkateShop(coord?.pubkey, coord?.identifier);
@ -21,7 +23,7 @@ export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefin
return (
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/25 bg-amber-500/[0.05] px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-amber-400" />
<span className="text-sm text-gray-400">Loading sponsor…</span>
<span className="text-sm text-gray-400">{t('socialUi.sponsorBanner.loading')}</span>
</div>
);
}
@ -48,7 +50,7 @@ export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefin
<div className="relative min-w-0 flex-1">
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
<BadgeCheck className="h-3 w-3" /> Sponsored by
<BadgeCheck className="h-3 w-3" /> {t('socialUi.sponsorBanner.sponsoredBy')}
</p>
<p className="truncate text-base font-black text-white">{shop.name}</p>
{shop.location && (

View file

@ -3,6 +3,7 @@ import { Store, Search, X, Check, Loader2, MapPin } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useSkateShops } from '@/hooks/useSkateShops';
import { useTranslation } from '@/contexts/language';
import { SKATE_SHOP_KIND, parseAddrCoord, type SkateShop } from '@/lib/types';
import { cn } from '@/lib/utils';
@ -18,6 +19,7 @@ export function CrewSponsorPicker({
value: string;
onChange: (coord: string) => void;
}) {
const { t } = useTranslation();
const { data: shops, isLoading } = useSkateShops(200);
const [query, setQuery] = useState('');
@ -60,7 +62,7 @@ export function CrewSponsorPicker({
</Avatar>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
<Check className="h-3 w-3" /> Sponsor selected
<Check className="h-3 w-3" /> {t('socialUi.sponsorPicker.selected')}
</p>
<p className="truncate text-sm font-black text-white">{selected.name}</p>
{selected.location && (
@ -72,7 +74,7 @@ export function CrewSponsorPicker({
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-gray-400">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin text-amber-400" /> : <Store className="h-4 w-4 text-amber-400" />}
<span className="truncate">
{hasValueButUnresolved ? 'Sponsor set (shop loading…)' : 'Sponsor set'}
{hasValueButUnresolved ? t('socialUi.sponsorPicker.setLoading') : t('socialUi.sponsorPicker.set')}
</span>
</div>
)}
@ -80,7 +82,7 @@ export function CrewSponsorPicker({
type="button"
onClick={() => onChange('')}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-red-500/40 hover:text-red-400"
aria-label="Clear sponsor"
aria-label={t('socialUi.sponsorPicker.clearAria')}
>
<X className="h-4 w-4" />
</button>
@ -95,18 +97,18 @@ export function CrewSponsorPicker({
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search skate shops by name or city…"
placeholder={t('socialUi.sponsorPicker.searchPlaceholder')}
className="bg-gray-800 border-gray-700 pl-9 text-sm"
/>
</div>
{isLoading ? (
<div className="flex items-center gap-2 px-1 py-3 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading shops…
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.sponsorPicker.loading')}
</div>
) : results.length === 0 ? (
<p className="px-1 py-3 text-sm text-gray-500">
{query ? 'No shops match — try a different search.' : 'No shops found yet.'}
{query ? t('socialUi.sponsorPicker.noMatch') : t('socialUi.sponsorPicker.none')}
</p>
) : (
<div className="max-h-56 space-y-1.5 overflow-y-auto pr-1">

View file

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Play, Film, ExternalLink, Instagram } from 'lucide-react';
import { parseCrewVideo, youtubeThumb, type ParsedCrewVideo } from '@/lib/videoEmbed';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
/**
@ -25,6 +26,7 @@ export function CrewVideoSection({
crewName: string;
accentHex?: string;
}) {
const { t } = useTranslation();
const parsed = parseCrewVideo(videoUrl);
if (!parsed) return null;
@ -44,10 +46,10 @@ export function CrewVideoSection({
</div>
<div>
<h2 className="text-lg font-black uppercase leading-none text-white" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
The Part
{t('socialUi.video.title')}
</h2>
<p className="text-[11px] font-bold uppercase tracking-widest" style={{ color: accentHex }}>
{parsed.source === 'youtube' ? 'Full video · montage' : 'Latest clip'}
{parsed.source === 'youtube' ? t('socialUi.video.fullVideo') : t('socialUi.video.latestClip')}
</p>
</div>
</div>
@ -58,7 +60,7 @@ export function CrewVideoSection({
className="flex items-center gap-1.5 rounded-full border border-white/10 px-3 py-1.5 text-xs font-bold text-gray-300 transition-colors hover:border-white/20 hover:text-white"
>
{parsed.source === 'instagram' ? <Instagram className="h-3.5 w-3.5" /> : <ExternalLink className="h-3.5 w-3.5" />}
Watch
{t('socialUi.video.watch')}
</a>
</div>
@ -83,6 +85,7 @@ function YouTubeFacade({
crewName: string;
accentHex: string;
}) {
const { t } = useTranslation();
const [playing, setPlaying] = useState(false);
if (playing) {
@ -90,7 +93,7 @@ function YouTubeFacade({
<div className="relative aspect-video w-full overflow-hidden rounded-2xl bg-black">
<iframe
src={`${video.embedUrl}&autoplay=1`}
title={`${crewName} — video`}
title={t('socialUi.video.iframeTitle', { crew: crewName })}
className="absolute inset-0 h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
@ -106,12 +109,12 @@ function YouTubeFacade({
type="button"
onClick={() => setPlaying(true)}
className="group relative block aspect-video w-full overflow-hidden rounded-2xl bg-black"
aria-label={`Play ${crewName} video`}
aria-label={t('socialUi.video.playAria', { crew: crewName })}
>
{video.id && (
<img
src={youtubeThumb(video.id)}
alt={`${crewName} video thumbnail`}
alt={t('socialUi.video.thumbAlt', { crew: crewName })}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
@ -133,11 +136,12 @@ function YouTubeFacade({
/** Instagram permalink embed. */
function InstagramEmbed({ video }: { video: ParsedCrewVideo }) {
const { t } = useTranslation();
return (
<div className="mx-auto w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-black">
<iframe
src={video.embedUrl}
title="Instagram clip"
title={t('socialUi.video.instagramTitle')}
className="h-[560px] w-full"
allow="encrypted-media; picture-in-picture"
referrerPolicy="strict-origin-when-cross-origin"

View file

@ -16,6 +16,7 @@ import {
Users,
Shield,
} from 'lucide-react';
import { useTranslation } from '@/contexts/language';
interface RelayEducationProps {
onComplete: () => void;
@ -23,6 +24,7 @@ interface RelayEducationProps {
}
export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
const { t } = useTranslation();
const [currentStep, setCurrentStep] = useState(1);
const totalSteps = 3;
@ -47,10 +49,10 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wide">
Understanding Nostr
{t('settingsUi.relayEdu.understandingNostr')}
</span>
<span className="text-xs text-gray-500">
Step {currentStep} of {totalSteps}
{t('settingsUi.relayEdu.stepOf', { current: currentStep, total: totalSteps })}
</span>
</div>
<Progress value={(currentStep / totalSteps) * 100} className="h-1.5" />
@ -63,9 +65,9 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-purple-500/20 mb-4">
<Globe className="h-8 w-8 text-purple-500" />
</div>
<h2 className="text-xl font-black uppercase mb-2">Understanding Relays</h2>
<h2 className="text-xl font-black uppercase mb-2">{t('settingsUi.relayEdu.step1Title')}</h2>
<p className="text-gray-400 text-sm">
Think of relays like skate shops that share news about spots
{t('settingsUi.relayEdu.step1Desc')}
</p>
</div>
@ -75,7 +77,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="w-12 h-12 rounded-lg bg-red-500/20 flex items-center justify-center">
<span className="text-2xl">🛹</span>
</div>
<span className="text-xs text-gray-500">You</span>
<span className="text-xs text-gray-500">{t('settingsUi.relayEdu.you')}</span>
</div>
<ChevronRight className="h-5 w-5 text-gray-600" />
@ -89,7 +91,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<Store className="h-5 w-5 text-purple-500" />
</div>
</div>
<span className="text-xs text-gray-500">Relays</span>
<span className="text-xs text-gray-500">{t('settingsUi.relayEdu.relays')}</span>
</div>
<ChevronRight className="h-5 w-5 text-gray-600" />
@ -98,7 +100,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="w-12 h-12 rounded-lg bg-green-500/20 flex items-center justify-center">
<Users className="h-6 w-6 text-green-500" />
</div>
<span className="text-xs text-gray-500">Skaters</span>
<span className="text-xs text-gray-500">{t('settingsUi.relayEdu.skaters')}</span>
</div>
</div>
@ -106,22 +108,22 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-green-400">No company owns your account</span>
<p className="text-gray-400 text-xs mt-0.5">Your identity lives on multiple relay servers</p>
<span className="font-bold text-green-400">{t('settingsUi.relayEdu.s1Good1')}</span>
<p className="text-gray-400 text-xs mt-0.5">{t('settingsUi.relayEdu.s1Good1Desc')}</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-green-400">You control where your data lives</span>
<p className="text-gray-400 text-xs mt-0.5">Your posts sync across relays you choose — pick your own, or run one yourself</p>
<span className="font-bold text-green-400">{t('settingsUi.relayEdu.s1Good2')}</span>
<p className="text-gray-400 text-xs mt-0.5">{t('settingsUi.relayEdu.s1Good2Desc')}</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div>
<span className="font-bold text-amber-400">Relay operators can see public posts</span>
<p className="text-gray-400 text-xs mt-0.5">Think of it like posting on a bulletin board</p>
<span className="font-bold text-amber-400">{t('settingsUi.relayEdu.s1Warn')}</span>
<p className="text-gray-400 text-xs mt-0.5">{t('settingsUi.relayEdu.s1WarnDesc')}</p>
</div>
</div>
</div>
@ -135,9 +137,9 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-500/20 mb-4">
<Shield className="h-8 w-8 text-blue-500" />
</div>
<h2 className="text-xl font-black uppercase mb-2">Public vs Private</h2>
<h2 className="text-xl font-black uppercase mb-2">{t('settingsUi.relayEdu.step2Title')}</h2>
<p className="text-gray-400 text-sm">
Know what's visible and what's encrypted
{t('settingsUi.relayEdu.step2Desc')}
</p>
</div>
@ -146,24 +148,24 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-center gap-2 mb-3">
<Unlock className="h-5 w-5 text-red-400" />
<span className="font-bold text-red-400 uppercase text-sm">Public (Anyone Can See)</span>
<span className="font-bold text-red-400 uppercase text-sm">{t('settingsUi.relayEdu.publicTitle')}</span>
</div>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Spots you add to the map</span>
<span>{t('settingsUi.relayEdu.pub1')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Reviews and comments</span>
<span>{t('settingsUi.relayEdu.pub2')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Your profile info (name, picture)</span>
<span>{t('settingsUi.relayEdu.pub3')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-400">•</span>
<span>Who you follow</span>
<span>{t('settingsUi.relayEdu.pub4')}</span>
</div>
</div>
</div>
@ -172,16 +174,16 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-2 mb-3">
<Lock className="h-5 w-5 text-green-400" />
<span className="font-bold text-green-400 uppercase text-sm">Private (Encrypted)</span>
<span className="font-bold text-green-400 uppercase text-sm">{t('settingsUi.relayEdu.privateTitle')}</span>
</div>
<div className="space-y-2 text-sm text-gray-300">
<div className="flex items-center gap-2">
<span className="text-green-400">•</span>
<span>Direct messages</span>
<span>{t('settingsUi.relayEdu.priv1')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-400">•</span>
<span>Wallet communications</span>
<span>{t('settingsUi.relayEdu.priv2')}</span>
</div>
</div>
</div>
@ -189,8 +191,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="p-3 rounded-lg bg-gray-800/50 border border-gray-700">
<p className="text-xs text-gray-400 text-center">
<span className="text-amber-400 font-bold">Note:</span> Posts may be stored permanently on relays.
Think before you post - just like real life!
<span className="text-amber-400 font-bold">{t('settingsUi.relayEdu.noteStrong')}</span> {t('settingsUi.relayEdu.note')}
</p>
</div>
</div>
@ -203,9 +204,9 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/20 mb-4">
<Key className="h-8 w-8 text-amber-500" />
</div>
<h2 className="text-xl font-black uppercase mb-2">Your Keys = Your Account</h2>
<h2 className="text-xl font-black uppercase mb-2">{t('settingsUi.relayEdu.step3Title')}</h2>
<p className="text-gray-400 text-sm">
This is the most important thing to understand
{t('settingsUi.relayEdu.step3Desc')}
</p>
</div>
@ -213,24 +214,23 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30">
<h3 className="font-bold text-amber-400 mb-2 flex items-center gap-2">
<Key className="h-4 w-4" />
Your Secret Key (nsec)
{t('settingsUi.relayEdu.secretKeyTitle')}
</h3>
<p className="text-sm text-gray-300 mb-3">
Think of it like your skateboard - never lend it to anyone.
It proves you own your account.
{t('settingsUi.relayEdu.secretKeyDesc')}
</p>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2 text-red-400">
<span className="font-bold">⚠️</span>
<span>If lost, your account can't be recovered</span>
<span>{t('settingsUi.relayEdu.s3Bad1')}</span>
</div>
<div className="flex items-start gap-2 text-red-400">
<span className="font-bold">⚠️</span>
<span>No "forgot password" - nobody can reset it</span>
<span>{t('settingsUi.relayEdu.s3Bad2')}</span>
</div>
<div className="flex items-start gap-2 text-green-400">
<span className="font-bold">✓</span>
<span>Back it up and store it safely</span>
<span>{t('settingsUi.relayEdu.s3Good')}</span>
</div>
</div>
</div>
@ -238,17 +238,17 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
<div className="p-4 rounded-lg bg-purple-500/10 border border-purple-500/30">
<h3 className="font-bold text-purple-400 mb-2 flex items-center gap-2">
<Globe className="h-4 w-4" />
Your Public Key (npub)
{t('settingsUi.relayEdu.publicKeyTitle')}
</h3>
<p className="text-sm text-gray-300">
This is like your username - share it freely so others can find you!
{t('settingsUi.relayEdu.publicKeyDesc')}
</p>
</div>
</div>
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 text-center">
<p className="text-sm text-green-400 font-bold">
🔐 You're in control - no company can lock you out!
{t('settingsUi.relayEdu.inControl')}
</p>
</div>
</div>
@ -264,7 +264,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
Back
{t('common.back')}
</Button>
) : onSkip ? (
<Button
@ -272,7 +272,7 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
onClick={onSkip}
className="text-gray-500"
>
Skip
{t('settingsUi.relayEdu.skip')}
</Button>
) : (
<div />
@ -290,12 +290,12 @@ export function RelayEducation({ onComplete, onSkip }: RelayEducationProps) {
>
{currentStep === totalSteps ? (
<>
Got It!
{t('settingsUi.relayEdu.gotIt')}
<CheckCircle2 className="h-4 w-4" />
</>
) : (
<>
Next
{t('common.next')}
<ChevronRight className="h-4 w-4" />
</>
)}

View file

@ -41,6 +41,7 @@ import { QRScannerModal } from '@/components/wallet/QRScannerModal';
import { MintPrivacyManager } from '@/components/wallet/MintPrivacyManager';
import { RecoveryPhraseDialog } from '@/components/wallet/RecoveryPhraseDialog';
import { isNativePlatform } from '@/lib/platform';
import { useTranslation } from '@/contexts/language';
import { Smartphone } from 'lucide-react';
/**
@ -62,6 +63,7 @@ export function CashuWalletCard() {
}
function CashuWalletBrowserNotice() {
const { t } = useTranslation();
return (
<Card className="relative overflow-hidden border-orange-500/30 bg-gradient-to-br from-orange-500/[0.07] via-background to-yellow-500/[0.05]">
{/* Ambient glow */}
@ -75,13 +77,13 @@ function CashuWalletBrowserNotice() {
</div>
<div className="min-w-0">
<CardTitle className="text-base font-black tracking-tight flex items-center gap-2">
Built-in Ecash Wallet
{t('walletUi.cashu.browser.title')}
<Badge className="border-0 bg-orange-500/15 text-[10px] font-bold uppercase tracking-wider text-orange-400">
App only
{t('walletUi.cashu.browser.appOnly')}
</Badge>
</CardTitle>
<CardDescription className="text-xs mt-0.5">
Self-custodial Cashu ecash, powered by Bitcoin Lightning
{t('walletUi.cashu.browser.desc')}
</CardDescription>
</div>
</div>
@ -95,11 +97,9 @@ function CashuWalletBrowserNotice() {
<Smartphone className="h-4 w-4 text-orange-400" />
</div>
<div className="space-y-1.5">
<p className="text-sm font-bold leading-tight">Unlock the full wallet in the SkateSpots app</p>
<p className="text-sm font-bold leading-tight">{t('walletUi.cashu.browser.unlockTitle')}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
The built-in ecash wallet lets you mint, send and redeem Cashu tokens right at the
spot. It lives in the native app, where it can talk to mints directly — browsers
sandbox those connections for your security.
{t('walletUi.cashu.browser.unlockDesc')}
</p>
</div>
</div>
@ -111,12 +111,10 @@ function CashuWalletBrowserNotice() {
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-amber-500/15">
<Zap className="h-3.5 w-3.5 text-amber-500" />
</span>
Zap &amp; tip — works in your browser now
{t('walletUi.cashu.browser.zapTipTitle')}
</p>
<p className="mt-2 text-xs text-muted-foreground leading-relaxed">
Sending sats to spots, events, coaches and crews works right here. Just connect a
Lightning wallet below — Nostr Wallet Connect or a browser extension like Alby. No
Cashu mint needed.
{t('walletUi.cashu.browser.zapTipDesc')}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{['Nostr Wallet Connect', 'Alby / WebLN', 'Lightning address'].map((label) => (
@ -136,6 +134,7 @@ function CashuWalletBrowserNotice() {
}
function CashuWalletCardNative() {
const { t } = useTranslation();
const {
coco,
repositories,
@ -227,12 +226,12 @@ function CashuWalletCardNative() {
if (coco) {
try {
await coco.wallet.receive(result.value);
toast({ title: 'Token received', description: 'Cashu token redeemed successfully!' });
toast({ title: t('walletUi.cashu.tokenReceived'), description: t('walletUi.cashu.tokenReceivedDesc') });
} catch (err) {
toast({
variant: 'destructive',
title: 'Receive failed',
description: err instanceof Error ? err.message : 'Unknown error'
title: t('walletUi.cashu.receiveFailed'),
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
});
}
}
@ -248,41 +247,41 @@ function CashuWalletCardNative() {
// Recognized so the user gets an honest answer instead of "unknown".
toast({
variant: 'destructive',
title: 'Cashu request not supported yet',
description: 'This wallet can’t pay a Cashu (creq) request yet — ask for a Lightning invoice or address instead.',
title: t('walletUi.cashu.requestUnsupported'),
description: t('walletUi.cashu.requestUnsupportedDesc'),
});
break;
case 'bitcoin_address':
// Recognized, but this is an ecash/Lightning wallet — no on-chain send.
toast({
variant: 'destructive',
title: 'On-chain not supported here',
description: 'That’s a Bitcoin address. This wallet only sends Cashu tokens and Lightning payments.',
title: t('walletUi.cashu.onchainUnsupported'),
description: t('walletUi.cashu.onchainUnsupportedDesc'),
});
break;
case 'npub':
toast({
variant: 'destructive',
title: 'That’s a Nostr profile',
description: 'Scan a Cashu token, Lightning invoice, or Lightning address to pay.',
title: t('walletUi.cashu.nostrProfile'),
description: t('walletUi.cashu.nostrProfileDesc'),
});
break;
case 'clink_offer':
case 'clink_debit':
toast({
variant: 'destructive',
title: 'Payment code not supported yet',
description: 'This wallet can’t pay CLINK codes yet. Use a Lightning invoice or address instead.',
title: t('walletUi.cashu.clinkUnsupported'),
description: t('walletUi.cashu.clinkUnsupportedDesc'),
});
break;
default:
toast({
variant: 'destructive',
title: 'Unrecognized QR code',
description: 'Scan a Cashu token, Lightning invoice, or Lightning address.',
title: t('walletUi.cashu.unrecognizedQr'),
description: t('walletUi.cashu.unrecognizedQrDesc'),
});
}
}, [coco, setTokenInput, setLightningInvoice, toast]);
}, [coco, setTokenInput, setLightningInvoice, toast, t]);
// Handle paying a Lightning address
const [isPayingAddress, setIsPayingAddress] = useState(false);
@ -292,7 +291,7 @@ function CashuWalletCardNative() {
try {
const amount = parseInt(lightningAddressAmount);
if (isNaN(amount) || amount <= 0) {
toast({ variant: 'destructive', title: 'Invalid amount', description: 'Please enter a valid amount' });
toast({ variant: 'destructive', title: t('walletUi.cashu.invalidAmount'), description: t('walletUi.cashu.invalidAmountDesc') });
return;
}
@ -305,25 +304,25 @@ function CashuWalletCardNative() {
const meltQuote = await coco.quotes.createMeltQuote(activeMintUrl, invoice);
await coco.quotes.payMeltQuote(activeMintUrl, meltQuote.quote);
toast({ title: 'Payment sent', description: `Sent ${amount} sats to ${lightningAddress}` });
toast({ title: t('walletUi.cashu.paymentSent'), description: t('walletUi.cashu.paymentSentDesc', { amount, address: lightningAddress }) });
setLightningAddress('');
setLightningAddressAmount('');
} catch (err) {
toast({
variant: 'destructive',
title: 'Payment failed',
description: err instanceof Error ? err.message : 'Unknown error'
title: t('walletUi.cashu.paymentFailed'),
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
});
} finally {
setIsPayingAddress(false);
}
}, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast]);
}, [lightningAddress, lightningAddressAmount, coco, activeMintUrl, getLightningInvoice, toast, t]);
// Copy to clipboard helper
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
toast({ title: 'Copied', description: `${label} copied to clipboard` });
toast({ title: t('walletUi.cashu.copied'), description: t('walletUi.cashu.copiedDesc', { label }) });
} catch (err) {
console.error('Failed to copy:', err);
}
@ -336,9 +335,9 @@ function CashuWalletCardNative() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-orange-500" />
Cashu Wallet
{t('walletUi.cashu.title')}
</CardTitle>
<CardDescription>Initializing wallet...</CardDescription>
<CardDescription>{t('walletUi.cashu.initializing')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
@ -356,7 +355,7 @@ function CashuWalletCardNative() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
Wallet Error
{t('walletUi.cashu.walletError')}
</CardTitle>
</CardHeader>
<CardContent>
@ -377,21 +376,21 @@ function CashuWalletCardNative() {
<CardHeader className="p-4">
<CardTitle className="text-base font-bold flex items-center gap-2">
<Wallet className="h-5 w-5 text-orange-500" />
Cashu Wallet
{t('walletUi.cashu.title')}
</CardTitle>
<CardDescription className="text-xs">
Built-in ecash wallet — no external setup required
{t('walletUi.cashu.desc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
{/* Balance Display — compact on mobile since BalanceHero already shows it */}
<div className="flex items-center justify-between py-3 px-4 rounded-xl bg-orange-500/10 border border-orange-500/20">
<div>
<div className="text-[10px] text-orange-400/80 uppercase tracking-widest font-bold mb-0.5">Balance</div>
<div className="text-[10px] text-orange-400/80 uppercase tracking-widest font-bold mb-0.5">{t('walletUi.cashu.balance')}</div>
<div
className="text-2xl font-black tabular-nums leading-none"
aria-live="polite"
aria-label={`Wallet balance ${totalBalance.toLocaleString()} sats`}
aria-label={t('walletUi.cashu.balanceAria', { amount: totalBalance.toLocaleString() })}
>
{totalBalance.toLocaleString()}
<span className="text-sm font-semibold text-muted-foreground ml-1.5">sats</span>
@ -408,10 +407,10 @@ function CashuWalletCardNative() {
{/* Active Mint Selector */}
{mints.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Active Mint</Label>
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{t('walletUi.cashu.activeMint')}</Label>
<Select value={activeMintUrl || undefined} onValueChange={setActiveMintUrl}>
<SelectTrigger className="h-11">
<SelectValue placeholder="Select a mint" />
<SelectValue placeholder={t('walletUi.cashu.selectMint')} />
</SelectTrigger>
<SelectContent>
{mints.map((mint) => (
@ -434,11 +433,11 @@ function CashuWalletCardNative() {
<TabsList className="grid w-full grid-cols-2 h-11 rounded-xl">
<TabsTrigger value="receive" className="gap-2 rounded-lg h-full font-bold text-sm data-[state=active]:bg-green-600 data-[state=active]:text-white">
<ArrowDownCircle className="h-4 w-4" />
Receive
{t('walletUi.cashu.receive')}
</TabsTrigger>
<TabsTrigger value="send" className="gap-2 rounded-lg h-full font-bold text-sm data-[state=active]:bg-red-600 data-[state=active]:text-white">
<ArrowUpCircle className="h-4 w-4" />
Send
{t('walletUi.cashu.send')}
</TabsTrigger>
</TabsList>
@ -446,12 +445,12 @@ function CashuWalletCardNative() {
<TabsContent value="receive" className="space-y-4 mt-4">
{/* Lightning Invoice */}
<div className="space-y-3">
<Label className="text-sm font-semibold">⚡ Receive via Lightning</Label>
<Label className="text-sm font-semibold">{t('walletUi.cashu.receiveViaLightning')}</Label>
<div className="flex flex-col gap-2">
<Input
type="number"
inputMode="numeric"
placeholder="Amount in sats"
placeholder={t('walletUi.cashu.amountSats')}
value={quoteAmount}
onChange={(e) => setQuoteAmount(e.target.value)}
className="h-11 min-w-0"
@ -461,7 +460,7 @@ function CashuWalletCardNative() {
disabled={!activeMintUrl || !quoteAmount || isGeneratingInvoice}
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
>
{isGeneratingInvoice ? 'Generating…' : 'Generate Invoice'}
{isGeneratingInvoice ? t('walletUi.cashu.generating') : t('walletUi.cashu.generateInvoice')}
</Button>
</div>
</div>
@ -471,14 +470,14 @@ function CashuWalletCardNative() {
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.cashu.or')}</span>
</div>
</div>
{/* Cashu Token */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">🥜 Receive Cashu Token</Label>
<Label className="text-sm font-semibold">{t('walletUi.cashu.receiveCashu')}</Label>
<Button
variant="outline"
size="sm"
@ -486,12 +485,12 @@ function CashuWalletCardNative() {
onClick={() => setShowQRScanner(true)}
>
<ScanLine className="h-4 w-4" />
Scan QR
{t('walletUi.cashu.scanQr')}
</Button>
</div>
<div className="space-y-2">
<Input
placeholder="Paste Cashu token (cashu...)"
placeholder={t('walletUi.cashu.pasteCashu')}
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
className="font-mono text-xs h-11"
@ -501,7 +500,7 @@ function CashuWalletCardNative() {
disabled={!tokenInput || isReceivingToken}
className="w-full h-11 bg-green-600 hover:bg-green-700 font-bold"
>
{isReceivingToken ? 'Receiving…' : 'Receive Token'}
{isReceivingToken ? t('walletUi.cashu.receiving') : t('walletUi.cashu.receiveToken')}
</Button>
</div>
</div>
@ -511,12 +510,12 @@ function CashuWalletCardNative() {
<TabsContent value="send" className="space-y-4 mt-4">
{/* Send Token */}
<div className="space-y-3">
<Label className="text-sm font-semibold">🥜 Create Cashu Token</Label>
<Label className="text-sm font-semibold">{t('walletUi.cashu.createCashu')}</Label>
<div className="flex flex-col gap-2">
<Input
type="number"
inputMode="numeric"
placeholder="Amount in sats"
placeholder={t('walletUi.cashu.amountSats')}
value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)}
className="min-w-0 h-11"
@ -526,7 +525,7 @@ function CashuWalletCardNative() {
disabled={!activeMintUrl || !sendAmount || isCreatingToken}
className="h-11 bg-orange-500 hover:bg-orange-600 font-bold"
>
{isCreatingToken ? 'Creating…' : 'Create Token'}
{isCreatingToken ? t('walletUi.cashu.creating') : t('walletUi.cashu.createToken')}
</Button>
</div>
</div>
@ -536,14 +535,14 @@ function CashuWalletCardNative() {
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.cashu.or')}</span>
</div>
</div>
{/* Pay Lightning Invoice */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold">⚡ Pay Lightning Invoice</Label>
<Label className="text-sm font-semibold">{t('walletUi.cashu.payInvoice')}</Label>
<Button
variant="outline"
size="sm"
@ -551,12 +550,12 @@ function CashuWalletCardNative() {
onClick={() => setShowQRScanner(true)}
>
<ScanLine className="h-4 w-4" />
Scan QR
{t('walletUi.cashu.scanQr')}
</Button>
</div>
<div className="space-y-2">
<Input
placeholder="Paste Lightning invoice (lnbc...)"
placeholder={t('walletUi.cashu.pasteInvoice')}
value={lightningInvoice}
onChange={(e) => setLightningInvoice(e.target.value)}
className="font-mono text-xs h-11"
@ -567,7 +566,7 @@ function CashuWalletCardNative() {
className="w-full h-11 bg-blue-600 hover:bg-blue-700 font-bold"
>
<Zap className="h-4 w-4 mr-2" />
{isProcessingPayment ? 'Processing...' : 'Pay Invoice'}
{isProcessingPayment ? t('walletUi.cashu.processing') : t('walletUi.cashu.payInvoiceBtn')}
</Button>
</div>
</div>
@ -577,13 +576,13 @@ function CashuWalletCardNative() {
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.cashu.or')}</span>
</div>
</div>
{/* Pay Lightning Address */}
<div className="space-y-3">
<Label className="text-sm font-semibold">📧 Pay Lightning Address</Label>
<Label className="text-sm font-semibold">{t('walletUi.cashu.payAddress')}</Label>
<div className="space-y-2">
<Input
placeholder="user@domain.com"
@ -594,7 +593,7 @@ function CashuWalletCardNative() {
<Input
type="number"
inputMode="numeric"
placeholder="Amount in sats"
placeholder={t('walletUi.cashu.amountSats')}
value={lightningAddressAmount}
onChange={(e) => setLightningAddressAmount(e.target.value)}
className="h-11"
@ -604,7 +603,7 @@ function CashuWalletCardNative() {
disabled={!lightningAddress || !lightningAddressAmount || isResolvingAddress || isPayingAddress}
className="w-full h-11 bg-purple-600 hover:bg-purple-700 font-bold"
>
{isResolvingAddress || isPayingAddress ? 'Sending…' : 'Send to Address'}
{isResolvingAddress || isPayingAddress ? t('walletUi.cashu.sending') : t('walletUi.cashu.sendToAddress')}
</Button>
</div>
</div>
@ -616,7 +615,7 @@ function CashuWalletCardNative() {
<div className="space-y-3">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<History className="h-3.5 w-3.5" />
Recent Activity
{t('walletUi.cashu.recentActivity')}
</Label>
<div className="space-y-2 max-h-52 overflow-y-auto scroll-container">
{history.slice(0, 5).map((entry) => (
@ -632,10 +631,10 @@ function CashuWalletCardNative() {
)}
<div className="min-w-0">
<div className="text-sm font-semibold leading-tight">
{entry.type === 'mint' && 'Received ⚡ Lightning'}
{entry.type === 'melt' && 'Sent ⚡ Lightning'}
{entry.type === 'send' && 'Sent 🥜 Token'}
{entry.type === 'receive' && 'Received 🥜 Token'}
{entry.type === 'mint' && t('walletUi.cashu.receivedLightning')}
{entry.type === 'melt' && t('walletUi.cashu.sentLightning')}
{entry.type === 'send' && t('walletUi.cashu.sentToken')}
{entry.type === 'receive' && t('walletUi.cashu.receivedToken')}
</div>
<div className="text-xs text-muted-foreground">
{new Date(entry.timestamp).toLocaleDateString()}
@ -662,19 +661,19 @@ function CashuWalletCardNative() {
<CardHeader className="px-4 pt-4 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-5 w-5 text-amber-500" />
What is Cashu?
{t('walletUi.cashu.whatIsCashu')}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
<p className="text-muted-foreground text-sm leading-relaxed">
Cashu is digital cash backed by Bitcoin Lightning. Think of it as Bitcoin tokens you hold in your browser.
{t('walletUi.cashu.whatIsCashuDesc')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
{[
'Works offline at skate spots',
'Instant peer-to-peer payments',
"Privacy-focused (mints can't track you)",
'Always redeemable for Lightning sats',
t('walletUi.cashu.benefitOffline'),
t('walletUi.cashu.benefitInstant'),
t('walletUi.cashu.benefitPrivacy'),
t('walletUi.cashu.benefitRedeemable'),
].map((item) => (
<div key={item} className="flex items-start gap-2 p-2.5 bg-muted/40 rounded-lg">
<span className="text-green-500 flex-shrink-0 mt-0.5">✅</span>
@ -689,7 +688,7 @@ function CashuWalletCardNative() {
className="inline-flex items-center gap-2 text-sm text-primary hover:underline font-medium"
>
<ExternalLink className="h-4 w-4" />
Find mints at mintradar.pedani.eu
{t('walletUi.cashu.findMints')}
</a>
</CardContent>
</Card>
@ -712,22 +711,22 @@ function CashuWalletCardNative() {
<CardHeader className="px-4 pt-4 pb-3">
<CardTitle className="flex items-center gap-2 text-amber-600 text-base">
<Shield className="h-5 w-5" />
Backup Your Wallet
{t('walletUi.cashu.backupTitle')}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
<p className="text-muted-foreground text-sm leading-relaxed">
Your wallet is secured with a 12-word recovery phrase. Save it safely — you'll need it to restore your balance if you clear browser data.
{t('walletUi.cashu.backupDesc')}
</p>
<Button
onClick={() => setShowRecoveryPhrase(true)}
variant="outline"
className="w-full h-11 border-amber-500/50 text-amber-600 hover:bg-amber-500/10 font-bold"
>
View Recovery Phrase
{t('walletUi.cashu.viewRecovery')}
</Button>
<p className="text-xs text-muted-foreground italic">
This is like your Nostr nsec — keep it secret and backed up!
{t('walletUi.cashu.backupNote')}
</p>
</CardContent>
</Card>
@ -737,18 +736,18 @@ function CashuWalletCardNative() {
<Dialog open={showInvoiceModal} onOpenChange={setShowInvoiceModal}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">Receive {currentInvoice?.amount} sats</DialogTitle>
<DialogTitle className="text-base sm:text-lg">{t('walletUi.cashu.receiveSats', { amount: currentInvoice?.amount ?? 0 })}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Pay this invoice from any Lightning wallet
{t('walletUi.cashu.payInvoiceHint')}
</DialogDescription>
</DialogHeader>
{currentInvoice && (
<div className="space-y-3 sm:space-y-4">
<div className="flex justify-center p-2 sm:p-4 bg-white rounded-lg">
<img src={currentInvoice.qrCodeUrl} alt="Invoice QR Code" className="max-w-full max-h-[40vh] h-auto object-contain" />
<img src={currentInvoice.qrCodeUrl} alt={t('walletUi.cashu.invoiceQrAlt')} className="max-w-full max-h-[40vh] h-auto object-contain" />
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Lightning Invoice</Label>
<Label className="text-xs text-muted-foreground">{t('walletUi.cashu.lightningInvoice')}</Label>
<div className="flex gap-2">
<Input
value={currentInvoice.invoice}
@ -758,14 +757,14 @@ function CashuWalletCardNative() {
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(currentInvoice.invoice, 'Invoice')}
onClick={() => copyToClipboard(currentInvoice.invoice, t('walletUi.cashu.invoiceLabel'))}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<p className="text-xs sm:text-sm text-amber-600">
⏳ Waiting for payment...
{t('walletUi.cashu.waiting')}
</p>
</div>
)}
@ -776,20 +775,20 @@ function CashuWalletCardNative() {
<Dialog open={!!sentToken} onOpenChange={(open) => !open && setSentToken(null)}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">Token Ready to Send</DialogTitle>
<DialogTitle className="text-base sm:text-lg">{t('walletUi.cashu.tokenReady')}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Share this token with the recipient
{t('walletUi.cashu.shareToken')}
</DialogDescription>
</DialogHeader>
{sentToken && (
<div className="space-y-3 sm:space-y-4">
{sentTokenQRCode && (
<div className="flex justify-center p-2 sm:p-4 bg-white rounded-lg">
<img src={sentTokenQRCode} alt="Token QR Code" className="max-w-full max-h-[40vh] h-auto object-contain" />
<img src={sentTokenQRCode} alt={t('walletUi.cashu.tokenQrAlt')} className="max-w-full max-h-[40vh] h-auto object-contain" />
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Cashu Token</Label>
<Label className="text-xs text-muted-foreground">{t('walletUi.cashu.cashuToken')}</Label>
<div className="space-y-2">
<Input
value={sentToken}
@ -800,11 +799,11 @@ function CashuWalletCardNative() {
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(sentToken, 'Token')}
onClick={() => copyToClipboard(sentToken, t('walletUi.cashu.tokenLabel'))}
className="flex-1"
>
<Copy className="h-4 w-4 mr-2" />
Copy Token
{t('walletUi.cashu.copyToken')}
</Button>
</div>
</div>
@ -812,7 +811,7 @@ function CashuWalletCardNative() {
<Alert className="border-amber-500/50 bg-amber-500/5">
<AlertCircle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-xs sm:text-sm">
Anyone with this token can claim it - treat it like cash!
{t('walletUi.cashu.tokenWarning')}
</AlertDescription>
</Alert>
</div>

View file

@ -6,6 +6,7 @@ import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { Handshake, Loader2, Check } from 'lucide-react';
/**
@ -13,6 +14,7 @@ import { Handshake, Loader2, Check } from 'lucide-react';
* buyers can lock escrow funds to them. Native-only; renders nothing otherwise.
*/
export function EscrowKeyCard() {
const { t } = useTranslation();
const escrow = useCashuEscrow();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey);
@ -29,13 +31,13 @@ export function EscrowKeyCard() {
try {
await escrow.advertiseEscrowKey();
toast({
title: 'Escrow key published',
description: 'Buyers can now lock escrow funds to you.',
title: t('walletUi.escrow.published'),
description: t('walletUi.escrow.publishedDesc'),
});
} catch (e) {
toast({
title: 'Could not publish key',
description: e instanceof Error ? e.message : 'Something went wrong.',
title: t('walletUi.escrow.failed'),
description: e instanceof Error ? e.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
} finally {
@ -48,17 +50,16 @@ export function EscrowKeyCard() {
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
<Handshake className="h-5 w-5" />
Escrow
{t('walletUi.escrow.title')}
</CardTitle>
<CardDescription>
Advertise a public key so buyers can lock funds to you for a trade. Funds
are refundable to the buyer if the deal falls through.
{t('walletUi.escrow.desc')}
</CardDescription>
</CardHeader>
<CardContent>
{advertised ? (
<Badge className="gap-1 bg-[#ccff00]/15 text-[#ccff00] border border-[#ccff00]/30">
<Check className="h-3 w-3" /> Escrow key active
<Check className="h-3 w-3" /> {t('walletUi.escrow.active')}
</Badge>
) : (
<Button
@ -67,7 +68,7 @@ export function EscrowKeyCard() {
className="gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Handshake className="h-4 w-4" />}
Publish escrow key
{t('walletUi.escrow.publish')}
</Button>
)}
</CardContent>

View file

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Shield, Eye, AlertTriangle, ChevronDown, ChevronUp, Nut, Zap, Globe, Lock, CheckCircle2, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface LightningPrivacyTipsProps {
className?: string;
@ -11,6 +12,7 @@ interface LightningPrivacyTipsProps {
}
export function LightningPrivacyTips({ className, defaultExpanded = false }: LightningPrivacyTipsProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
@ -22,7 +24,7 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-5 w-5 text-blue-500" />
Privacy Tips
{t('walletUi.privacy.title')}
</CardTitle>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
{isExpanded ? (
@ -34,7 +36,7 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
</div>
{!isExpanded && (
<CardDescription className="text-xs">
Learn how to protect your financial privacy
{t('walletUi.privacy.subtitle')}
</CardDescription>
)}
</CardHeader>
@ -47,24 +49,24 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2 mb-2">
<Nut className="h-5 w-5 text-orange-500" />
<span className="font-bold text-sm text-green-400">CASHU (Recommended)</span>
<span className="font-bold text-sm text-green-400">{t('walletUi.privacy.cashuRecommended')}</span>
</div>
<div className="space-y-1.5 text-xs text-gray-300">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span>Anonymous transactions</span>
<span>{t('walletUi.privacy.anon')}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span>Blind signatures hide activity</span>
<span>{t('walletUi.privacy.blind')}</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span>No IP exposure</span>
<span>{t('walletUi.privacy.noIp')}</span>
</div>
<div className="flex items-center gap-2 text-amber-400">
<AlertTriangle className="h-3 w-3" />
<span>Trust mint with funds</span>
<span>{t('walletUi.privacy.trustMint')}</span>
</div>
</div>
</div>
@ -73,24 +75,24 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-center gap-2 mb-2">
<Zap className="h-5 w-5 text-amber-500" />
<span className="font-bold text-sm text-amber-400">LIGHTNING ADDRESS</span>
<span className="font-bold text-sm text-amber-400">{t('walletUi.privacy.lightningAddress')}</span>
</div>
<div className="space-y-1.5 text-xs text-gray-300">
<div className="flex items-center gap-2 text-amber-400">
<Eye className="h-3 w-3" />
<span>Can be linked to identity</span>
<span>{t('walletUi.privacy.linked')}</span>
</div>
<div className="flex items-center gap-2 text-amber-400">
<Eye className="h-3 w-3" />
<span>Payment history traceable</span>
<span>{t('walletUi.privacy.traceable')}</span>
</div>
<div className="flex items-center gap-2 text-amber-400">
<Globe className="h-3 w-3" />
<span>IP visible when invoicing</span>
<span>{t('walletUi.privacy.ipVisible')}</span>
</div>
<div className="flex items-center gap-2 text-green-400">
<Lock className="h-3 w-3" />
<span>Self-custody possible</span>
<span>{t('walletUi.privacy.selfCustody')}</span>
</div>
</div>
</div>
@ -100,35 +102,35 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
<div className="space-y-2">
<h4 className="font-bold text-sm flex items-center gap-2">
<Info className="h-4 w-4 text-blue-500" />
Best Practices
{t('walletUi.privacy.bestPractices')}
</h4>
<div className="space-y-2 text-xs">
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
<span className="text-blue-400 font-bold">1.</span>
<div>
<span className="font-medium text-gray-200">Use Cashu for receiving tips</span>
<p className="text-gray-500 mt-0.5">Built-in Cashu wallet is the most private way to receive zaps</p>
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip1Title')}</span>
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip1Desc')}</p>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
<span className="text-blue-400 font-bold">2.</span>
<div>
<span className="font-medium text-gray-200">Use a separate wallet for SkateSpots</span>
<p className="text-gray-500 mt-0.5">Don't use your main savings wallet - keep app funds separate</p>
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip2Title')}</span>
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip2Desc')}</p>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
<span className="text-blue-400 font-bold">3.</span>
<div>
<span className="font-medium text-gray-200">Consider using VPN or Tor</span>
<p className="text-gray-500 mt-0.5">Hides your IP address when making transactions</p>
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip3Title')}</span>
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip3Desc')}</p>
</div>
</div>
<div className="flex items-start gap-2 p-2 rounded bg-gray-800/50">
<span className="text-blue-400 font-bold">4.</span>
<div>
<span className="font-medium text-gray-200">Avoid identity-linked Lightning addresses</span>
<p className="text-gray-500 mt-0.5">Use pseudonymous addresses not tied to your real name</p>
<span className="font-medium text-gray-200">{t('walletUi.privacy.tip4Title')}</span>
<p className="text-gray-500 mt-0.5">{t('walletUi.privacy.tip4Desc')}</p>
</div>
</div>
</div>
@ -138,8 +140,8 @@ export function LightningPrivacyTips({ className, defaultExpanded = false }: Lig
<Alert className="border-green-500/30 bg-green-500/5">
<Nut className="h-4 w-4 text-orange-500" />
<AlertDescription className="text-xs">
<span className="font-bold text-green-400">Pro tip:</span> Receive payments to your Cashu wallet first,
then periodically move larger amounts to your personal wallet using a VPN for the transfer.
<span className="font-bold text-green-400">{t('walletUi.privacy.proTip')}</span>{' '}
{t('walletUi.privacy.proTipDesc')}
</AlertDescription>
</Alert>
</CardContent>
@ -156,32 +158,33 @@ export function LightningAddressPrivacyWarning({
onUseCashu?: () => void;
onContinue: () => void;
}) {
const { t } = useTranslation();
return (
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30 space-y-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-bold text-amber-400 text-sm">Privacy Note</h4>
<h4 className="font-bold text-amber-400 text-sm">{t('walletUi.privacy.warnTitle')}</h4>
<p className="text-xs text-gray-300 mt-1">
Lightning addresses can potentially link your SkateSpots activity to your real identity.
{t('walletUi.privacy.warnDesc')}
</p>
</div>
</div>
<div className="space-y-2 text-xs text-gray-400">
<p className="font-medium">For better privacy:</p>
<p className="font-medium">{t('walletUi.privacy.betterPrivacy')}</p>
<ul className="space-y-1 ml-4">
<li className="flex items-center gap-2">
<span className="text-amber-400">•</span>
Use a dedicated wallet for this app
{t('walletUi.privacy.bullet1')}
</li>
<li className="flex items-center gap-2">
<span className="text-amber-400">•</span>
Consider using Cashu wallet instead
{t('walletUi.privacy.bullet2')}
</li>
<li className="flex items-center gap-2">
<span className="text-amber-400">•</span>
Use VPN/Tor for transactions
{t('walletUi.privacy.bullet3')}
</li>
</ul>
</div>
@ -195,7 +198,7 @@ export function LightningAddressPrivacyWarning({
className="flex-1 border-green-500/50 text-green-400 hover:bg-green-500/10"
>
<Nut className="h-4 w-4 mr-1" />
Use Cashu Instead
{t('walletUi.privacy.useCashu')}
</Button>
)}
<Button
@ -204,7 +207,7 @@ export function LightningAddressPrivacyWarning({
onClick={onContinue}
className="flex-1"
>
I Understand
{t('walletUi.privacy.understand')}
</Button>
</div>
</div>

View file

@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { isNativePlatform } from '@/lib/platform';
interface Mint {
@ -56,18 +57,18 @@ const COINOS = {
const LNBITS = {
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
name: 'LNbits Legend',
description: 'Community mint on LNbits',
description: 'walletUi.mintPrivacy.mintLnbitsDesc',
};
const RECOMMENDED_MINTS_WEB = [
{ ...COINOS, description: 'Web-friendly mint (recommended for browsers)' },
{ ...COINOS, description: 'walletUi.mintPrivacy.mintCoinosWebDesc' },
LNBITS,
{ ...MINIBITS, description: '⚠ Native app only — not reachable from web browsers' },
{ ...MINIBITS, description: 'walletUi.mintPrivacy.mintMinibitsWebDesc' },
];
const RECOMMENDED_MINTS_NATIVE = [
{ ...MINIBITS, description: 'SkateSpots mint — recommended' },
{ ...COINOS, description: 'Web-friendly mint' },
{ ...MINIBITS, description: 'walletUi.mintPrivacy.mintMinibitsNativeDesc' },
{ ...COINOS, description: 'walletUi.mintPrivacy.mintCoinosDesc' },
LNBITS,
];
@ -81,6 +82,7 @@ export function MintPrivacyManager({
totalBalance,
className,
}: MintPrivacyManagerProps) {
const { t } = useTranslation();
const RECOMMENDED_MINTS = isNativePlatform() ? RECOMMENDED_MINTS_NATIVE : RECOMMENDED_MINTS_WEB;
const [showPrivacyTips, setShowPrivacyTips] = useState(false);
const [showAddMint, setShowAddMint] = useState(false);
@ -98,14 +100,14 @@ export function MintPrivacyManager({
// Privacy score based on distribution
const getPrivacyScore = () => {
if (mints.length === 0) return { score: 0, label: 'No Mints', color: 'text-gray-500' };
if (mints.length === 1) return { score: 30, label: 'Single Mint', color: 'text-red-400' };
if (mints.length === 0) return { score: 0, label: t('walletUi.mintPrivacy.noMints'), color: 'text-gray-500' };
if (mints.length === 1) return { score: 30, label: t('walletUi.mintPrivacy.singleMint'), color: 'text-red-400' };
// Check if funds are well distributed
const maxPercentage = Math.max(...mintDistribution.map(m => m.percentage));
if (maxPercentage > 80) return { score: 50, label: 'Concentrated', color: 'text-amber-400' };
if (maxPercentage > 60) return { score: 70, label: 'Moderate', color: 'text-yellow-400' };
return { score: 90, label: 'Well Distributed', color: 'text-green-400' };
if (maxPercentage > 80) return { score: 50, label: t('walletUi.mintPrivacy.concentrated'), color: 'text-amber-400' };
if (maxPercentage > 60) return { score: 70, label: t('walletUi.mintPrivacy.moderate'), color: 'text-yellow-400' };
return { score: 90, label: t('walletUi.mintPrivacy.wellDistributed'), color: 'text-green-400' };
};
const privacyScore = getPrivacyScore();
@ -115,7 +117,7 @@ export function MintPrivacyManager({
// Basic validation
if (!mintUrl.startsWith('https://')) {
toast({ variant: 'destructive', title: 'Invalid URL', description: 'Mint URL must start with https://' });
toast({ variant: 'destructive', title: t('walletUi.mintPrivacy.invalidUrl'), description: t('walletUi.mintPrivacy.invalidUrlDesc') });
return;
}
@ -124,12 +126,12 @@ export function MintPrivacyManager({
await onAddMint(mintUrl.trim());
setMintUrl('');
setShowAddMint(false);
toast({ title: 'Mint Added', description: 'New mint added successfully' });
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedDesc') });
} catch (err) {
toast({
variant: 'destructive',
title: 'Failed to add mint',
description: err instanceof Error ? err.message : 'Unknown error'
title: t('walletUi.mintPrivacy.failedAdd'),
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
});
} finally {
setIsAddingMint(false);
@ -160,7 +162,7 @@ export function MintPrivacyManager({
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-5 w-5 text-purple-500" />
Mint Privacy
{t('walletUi.mintPrivacy.title')}
</CardTitle>
<Badge
variant="outline"
@ -170,7 +172,7 @@ export function MintPrivacyManager({
</Badge>
</div>
<CardDescription className="text-xs">
Using multiple mints improves privacy by preventing any single mint from seeing all your transactions
{t('walletUi.mintPrivacy.desc')}
</CardDescription>
</CardHeader>
@ -178,7 +180,7 @@ export function MintPrivacyManager({
{/* Distribution Visualization */}
{mints.length > 0 && totalBalance > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Fund Distribution</Label>
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.distribution')}</Label>
<div className="h-3 rounded-full bg-gray-800 overflow-hidden flex">
{mintDistribution.map((item, index) => (
<div
@ -220,15 +222,14 @@ export function MintPrivacyManager({
<Alert className="border-amber-500/30 bg-amber-500/5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<AlertDescription className="text-xs">
Using a single mint means that mint can see all your transaction timing and amounts.
Consider adding more mints for better privacy.
{t('walletUi.mintPrivacy.singleWarning')}
</AlertDescription>
</Alert>
)}
{/* Mint List */}
<div className="space-y-2">
<Label className="text-xs font-medium">Your Mints ({mints.length})</Label>
<Label className="text-xs font-medium">{t('walletUi.mintPrivacy.yourMints', { count: mints.length })}</Label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{mints.map((mint) => (
<div
@ -251,7 +252,7 @@ export function MintPrivacyManager({
{activeMintUrl === mint.url && (
<Badge variant="secondary" className="text-[10px]">
<Star className="h-2.5 w-2.5 mr-0.5" />
Default
{t('walletUi.mintPrivacy.default')}
</Badge>
)}
</div>
@ -279,7 +280,7 @@ export function MintPrivacyManager({
onClick={() => setShowAddMint(true)}
>
<Plus className="h-4 w-4" />
Add Mint
{t('walletUi.mintPrivacy.addMint')}
</Button>
{/* Auto Distribution Toggle — not yet implemented, so it's shown as a
@ -288,16 +289,16 @@ export function MintPrivacyManager({
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 opacity-70">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Auto-distribute funds</Label>
<Label className="text-sm font-medium">{t('walletUi.mintPrivacy.autoDistribute')}</Label>
<Badge variant="outline" className="text-[10px] text-amber-400 border-amber-500/30">
Soon
{t('walletUi.mintPrivacy.soon')}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Spread received funds across multiple mints
{t('walletUi.mintPrivacy.autoDesc')}
</p>
</div>
<Switch checked={false} disabled aria-label="Auto-distribute funds (coming soon)" />
<Switch checked={false} disabled aria-label={t('walletUi.mintPrivacy.autoAria')} />
</div>
{/* Privacy Tips Toggle */}
@ -308,7 +309,7 @@ export function MintPrivacyManager({
>
<span className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-500" />
Privacy Tips
{t('walletUi.privacy.title')}
</span>
{showPrivacyTips ? (
<ChevronUp className="h-4 w-4" />
@ -321,23 +322,23 @@ export function MintPrivacyManager({
<div className="space-y-2 text-xs p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Use 2-3 different mints minimum for better privacy</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip1')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Don't keep all funds in one mint - spread them out</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip2')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Consider mint jurisdiction (different countries = different legal access)</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip3')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-blue-400 font-bold">•</span>
<span className="text-gray-300">Mints see timing of transactions but NOT who you pay (blind signatures)</span>
<span className="text-gray-300">{t('walletUi.mintPrivacy.tip4')}</span>
</div>
<div className="mt-2 pt-2 border-t border-blue-500/20">
<p className="text-gray-500 italic">
Future features: Mint rotation and privacy scoring - stay tuned!
{t('walletUi.mintPrivacy.future')}
</p>
</div>
</div>
@ -351,7 +352,7 @@ export function MintPrivacyManager({
className="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
Find more mints at mintradar.pedani.eu
{t('walletUi.mintPrivacy.findMore')}
</a>
</CardContent>
</Card>
@ -360,16 +361,16 @@ export function MintPrivacyManager({
<Dialog open={showAddMint} onOpenChange={setShowAddMint}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Mint</DialogTitle>
<DialogTitle>{t('walletUi.mintPrivacy.addMint')}</DialogTitle>
<DialogDescription>
Add a new Cashu mint to your wallet
{t('walletUi.mintPrivacy.addMintDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Manual Entry */}
<div className="space-y-2">
<Label className="text-sm">Mint URL</Label>
<Label className="text-sm">{t('walletUi.mintPrivacy.mintUrl')}</Label>
<div className="flex gap-2">
<Input
placeholder="https://mint.example.com"
@ -381,7 +382,7 @@ export function MintPrivacyManager({
onClick={handleAddMint}
disabled={!mintUrl || isAddingMint}
>
{isAddingMint ? 'Adding...' : 'Add'}
{isAddingMint ? t('walletUi.mintPrivacy.adding') : t('walletUi.mintPrivacy.add')}
</Button>
</div>
</div>
@ -391,13 +392,13 @@ export function MintPrivacyManager({
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or choose from list</span>
<span className="bg-background px-2 text-muted-foreground">{t('walletUi.mintPrivacy.orChoose')}</span>
</div>
</div>
{/* Recommended Mints */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Recommended Mints</Label>
<Label className="text-xs text-muted-foreground">{t('walletUi.mintPrivacy.recommended')}</Label>
{RECOMMENDED_MINTS.map((mint) => {
const isAdded = mints.some(m => m.url.toLowerCase() === mint.url.toLowerCase());
return (
@ -413,10 +414,10 @@ export function MintPrivacyManager({
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">{mint.name}</span>
</div>
<p className="text-xs text-muted-foreground ml-6">{mint.description}</p>
<p className="text-xs text-muted-foreground ml-6">{t(mint.description)}</p>
</div>
{isAdded ? (
<Badge variant="secondary" className="text-xs">Added</Badge>
<Badge variant="secondary" className="text-xs">{t('walletUi.mintPrivacy.added')}</Badge>
) : (
<Button
size="sm"
@ -425,12 +426,12 @@ export function MintPrivacyManager({
setIsAddingMint(true);
try {
await onAddMint(mint.url);
toast({ title: 'Mint Added', description: `${mint.name} added successfully` });
toast({ title: t('walletUi.mintPrivacy.mintAdded'), description: t('walletUi.mintPrivacy.mintAddedNamed', { name: mint.name }) });
} catch (err) {
toast({
variant: 'destructive',
title: 'Failed',
description: err instanceof Error ? err.message : 'Unknown error'
title: t('walletUi.mintPrivacy.failed'),
description: err instanceof Error ? err.message : t('walletUi.cashu.unknownError')
});
} finally {
setIsAddingMint(false);
@ -450,9 +451,7 @@ export function MintPrivacyManager({
<Alert className="border-amber-500/30 bg-amber-500/5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<AlertDescription className="text-xs">
<span className="font-bold text-amber-400">Trust Warning:</span> Mints are custodial - they hold your funds.
Only use mints you trust. If a mint disappears, your funds may be lost.
Spreading funds across multiple mints reduces risk.
<span className="font-bold text-amber-400">{t('walletUi.mintPrivacy.trustWarning')}</span> {t('walletUi.mintPrivacy.trustWarningDesc')}
</AlertDescription>
</Alert>
</div>
@ -463,9 +462,9 @@ export function MintPrivacyManager({
<Dialog open={!!mintToRemove} onOpenChange={(open) => !open && setMintToRemove(null)}>
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove Mint?</DialogTitle>
<DialogTitle>{t('walletUi.mintPrivacy.removeTitle')}</DialogTitle>
<DialogDescription>
Are you sure you want to remove {mintToRemove && getCleanMintLabel(mintToRemove)}?
{t('walletUi.mintPrivacy.removeDesc', { name: mintToRemove ? getCleanMintLabel(mintToRemove) : '' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@ -473,17 +472,16 @@ export function MintPrivacyManager({
<Alert className="border-red-500/30 bg-red-500/5">
<AlertTriangle className="h-4 w-4 text-red-500" />
<AlertDescription className="text-xs">
<span className="font-bold text-red-400">Warning:</span> This mint has {(balances[mintToRemove.url] || 0).toLocaleString()} sats.
Withdraw your funds before removing!
<span className="font-bold text-red-400">{t('walletUi.mintPrivacy.warning')}</span> {t('walletUi.mintPrivacy.withdrawWarning', { amount: (balances[mintToRemove.url] || 0).toLocaleString() })}
</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setMintToRemove(null)}>
Cancel
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleConfirmRemove}>
Remove Mint
{t('walletUi.mintPrivacy.removeMint')}
</Button>
</div>
</div>

View file

@ -6,6 +6,7 @@ import { Textarea } from '@/components/ui/textarea';
import { useNWC } from '@/hooks/useNWCContext';
import { useToast } from '@/hooks/useToast';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import {
ArrowDownToLine,
@ -26,6 +27,7 @@ import {
* pay-invoice (send). It renders nothing when no NWC connection is active.
*/
export function NwcWalletCard() {
const { t } = useTranslation();
const { getActiveConnection, getBalance, makeInvoice, sendPayment } = useNWC();
const { toast } = useToast();
const connection = getActiveConnection();
@ -49,14 +51,14 @@ export function NwcWalletCard() {
setBalance(await getBalance(connection));
} catch (e) {
toast({
title: 'Could not read balance',
description: e instanceof Error ? e.message : 'Your wallet did not respond.',
title: t('walletUi.nwc.readBalanceFailed'),
description: e instanceof Error ? e.message : t('walletUi.nwc.readBalanceFailedDesc'),
variant: 'destructive',
});
} finally {
setLoadingBalance(false);
}
}, [connection, getBalance, toast]);
}, [connection, getBalance, toast, t]);
useEffect(() => {
void refreshBalance();
@ -66,7 +68,7 @@ export function NwcWalletCard() {
if (!connection) return;
const sats = Number(receiveAmount);
if (!Number.isFinite(sats) || sats <= 0) {
toast({ title: 'Enter an amount in sats', variant: 'destructive' });
toast({ title: t('walletUi.nwc.enterAmount'), variant: 'destructive' });
return;
}
setCreating(true);
@ -77,8 +79,8 @@ export function NwcWalletCard() {
setQr(await QRCode.toDataURL(inv.toUpperCase(), { width: 256, margin: 1 }));
} catch (e) {
toast({
title: 'Could not create invoice',
description: e instanceof Error ? e.message : 'Try again in a moment.',
title: t('walletUi.nwc.createInvoiceFailed'),
description: e instanceof Error ? e.message : t('walletUi.nwc.createInvoiceFailedDesc'),
variant: 'destructive',
});
} finally {
@ -94,13 +96,13 @@ export function NwcWalletCard() {
try {
await sendPayment(connection, inv);
const sats = (() => { try { return getSatoshisFromBolt11(inv); } catch { return 0; } })();
toast({ title: 'Payment sent', description: sats > 0 ? `Paid ${sats.toLocaleString()} sats.` : 'Paid.' });
toast({ title: t('walletUi.nwc.paymentSent'), description: sats > 0 ? t('walletUi.nwc.paidAmount', { amount: sats.toLocaleString() }) : t('walletUi.nwc.paid') });
setPayInput('');
void refreshBalance();
} catch (e) {
toast({
title: 'Payment failed',
description: e instanceof Error ? e.message : 'The wallet rejected the payment.',
title: t('walletUi.cashu.paymentFailed'),
description: e instanceof Error ? e.message : t('walletUi.nwc.paymentRejected'),
variant: 'destructive',
});
} finally {
@ -130,17 +132,17 @@ export function NwcWalletCard() {
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
<Zap className="h-5 w-5" />
Lightning Wallet
{t('walletUi.nwc.title')}
</CardTitle>
<p className="text-xs text-gray-500">
{connection.alias || 'NWC wallet'} · NIP-47
{connection.alias || t('walletUi.nwc.noConnection')} · NIP-47
</p>
</CardHeader>
<CardContent className="space-y-5">
{/* Balance */}
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500">Balance</p>
<p className="text-xs uppercase tracking-wide text-gray-500">{t('walletUi.cashu.balance')}</p>
<p className="text-2xl font-black text-white">
{loadingBalance && balance === null ? '…' : balance === null ? '—' : balance.toLocaleString()}
<span className="ml-1 text-sm font-bold text-gray-400">sats</span>
@ -149,7 +151,7 @@ export function NwcWalletCard() {
<Button
variant="ghost"
size="icon"
aria-label="Refresh balance"
aria-label={t('walletUi.nwc.refresh')}
onClick={() => refreshBalance()}
disabled={loadingBalance}
className="text-gray-400 hover:text-white"
@ -161,14 +163,14 @@ export function NwcWalletCard() {
{/* Receive */}
<div className="space-y-2">
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
<ArrowDownToLine className="h-3.5 w-3.5 text-[#ccff00]" /> Receive
<ArrowDownToLine className="h-3.5 w-3.5 text-[#ccff00]" /> {t('walletUi.cashu.receive')}
</p>
<div className="flex gap-2">
<Input
value={receiveAmount}
onChange={(e) => setReceiveAmount(e.target.value)}
inputMode="numeric"
placeholder="Amount in sats"
placeholder={t('walletUi.cashu.amountSats')}
className="h-11 border-gray-700 bg-gray-900 text-white"
/>
<Button
@ -176,16 +178,16 @@ export function NwcWalletCard() {
disabled={creating}
className="h-11 shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create invoice'}
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : t('walletUi.nwc.createInvoice')}
</Button>
</div>
{invoice && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
{qr && <img src={qr} alt="Lightning invoice QR code" className="mx-auto h-48 w-48 rounded-lg bg-white p-2" />}
{qr && <img src={qr} alt={t('walletUi.nwc.invoiceQrAlt')} className="mx-auto h-48 w-48 rounded-lg bg-white p-2" />}
<p className="break-all font-mono text-[11px] text-gray-400">{invoice}</p>
<Button variant="outline" onClick={copyInvoice} className="w-full gap-2 border-white/15">
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copied' : 'Copy invoice'}
{copied ? t('common.copied') : t('walletUi.nwc.copyInvoice')}
</Button>
</div>
)}
@ -194,18 +196,18 @@ export function NwcWalletCard() {
{/* Send */}
<div className="space-y-2">
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
<ArrowUpFromLine className="h-3.5 w-3.5 text-[#ff6600]" /> Send
<ArrowUpFromLine className="h-3.5 w-3.5 text-[#ff6600]" /> {t('walletUi.cashu.send')}
</p>
<Textarea
value={payInput}
onChange={(e) => setPayInput(e.target.value)}
placeholder="Paste a Lightning invoice (lnbc…)"
placeholder={t('walletUi.nwc.pasteInvoice')}
rows={2}
className="resize-none border-gray-700 bg-gray-900 font-mono text-xs text-white"
/>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{paySats > 0 ? `Amount: ${paySats.toLocaleString()} sats` : 'Paste an invoice to see the amount'}
{paySats > 0 ? t('walletUi.nwc.amount', { amount: paySats.toLocaleString() }) : t('walletUi.nwc.pasteToSee')}
</span>
<Button
onClick={handlePay}
@ -213,7 +215,7 @@ export function NwcWalletCard() {
className="ml-auto h-10 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
>
{paying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
Pay
{t('walletUi.nwc.pay')}
</Button>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useQRCodeScanner, classifyQRCode, type QRScanResult } from '@/hooks/useQRCodeScanner';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { ScanLine, Clipboard, X } from 'lucide-react';
interface QRScannerModalProps {
@ -16,6 +17,7 @@ export function QRScannerModal({
onClose,
onResult,
}: QRScannerModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const { isScanning, error, startScanning, stopScanning } = useQRCodeScanner();
@ -33,8 +35,8 @@ export function QRScannerModal({
if (!text || !text.trim()) {
toast({
variant: 'destructive',
title: 'Clipboard empty',
description: 'No text found in clipboard'
title: t('walletUi.qr.clipboardEmpty'),
description: t('walletUi.qr.clipboardEmptyDesc')
});
return;
}
@ -45,8 +47,8 @@ export function QRScannerModal({
console.error('Error pasting from clipboard:', err);
toast({
variant: 'destructive',
title: 'Paste failed',
description: 'Could not read from clipboard'
title: t('walletUi.qr.pasteFailed'),
description: t('walletUi.qr.pasteFailedDesc')
});
}
};
@ -82,10 +84,10 @@ export function QRScannerModal({
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<ScanLine className="h-5 w-5" />
Scan QR Code
{t('walletUi.qr.title')}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Position the QR code within the camera view to scan
{t('walletUi.qr.desc')}
</DialogDescription>
</DialogHeader>
@ -120,7 +122,7 @@ export function QRScannerModal({
size="sm"
>
<Clipboard className="h-4 w-4" />
Paste from Clipboard
{t('walletUi.qr.paste')}
</Button>
<Button
onClick={() => {
@ -132,12 +134,12 @@ export function QRScannerModal({
className="text-sm"
>
<X className="h-4 w-4 mr-2" />
Cancel
{t('common.cancel')}
</Button>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground text-center mt-1 sm:mt-2">
Scan a Cashu token, Lightning invoice, or Lightning address
{t('walletUi.qr.hint')}
</p>
</DialogContent>
</Dialog>

View file

@ -8,6 +8,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { Shield, Eye, EyeOff, Copy, Check } from 'lucide-react';
@ -47,6 +48,7 @@ export function RecoveryPhraseDialog({
requireConfirm = false,
onConfirm,
}: RecoveryPhraseDialogProps) {
const { t } = useTranslation();
const [revealed, setRevealed] = useState(false);
const [copied, setCopied] = useState(false);
const { toast } = useToast();
@ -76,10 +78,10 @@ export function RecoveryPhraseDialog({
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
toast({ title: 'Copied', description: 'Recovery phrase copied — paste it somewhere safe, then clear your clipboard.' });
toast({ title: t('common.copied'), description: t('walletUi.recovery.copiedDesc') });
setTimeout(() => setCopied(false), 2500);
} catch {
toast({ variant: 'destructive', title: 'Copy failed', description: 'Please write the words down instead.' });
toast({ variant: 'destructive', title: t('walletUi.recovery.copyFailed'), description: t('walletUi.recovery.copyFailedDesc') });
}
};
@ -94,10 +96,9 @@ export function RecoveryPhraseDialog({
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/15">
<Shield className="h-6 w-6 text-amber-500" />
</div>
<DialogTitle className="text-center text-lg font-black">Recovery Phrase</DialogTitle>
<DialogTitle className="text-center text-lg font-black">{t('walletUi.recovery.title')}</DialogTitle>
<DialogDescription className="text-center text-xs leading-relaxed">
These 12 words are the only way to restore your ecash balance. Write them down in order
and store them offline — never share them with anyone.
{t('walletUi.recovery.desc')}
</DialogDescription>
</DialogHeader>
@ -134,9 +135,9 @@ export function RecoveryPhraseDialog({
<Eye className="h-5 w-5 text-amber-500" />
</span>
<span className="text-xs font-bold uppercase tracking-wide text-amber-400">
Tap to reveal
{t('walletUi.recovery.tapReveal')}
</span>
<span className="text-[10px] text-muted-foreground">Make sure nobody's watching</span>
<span className="text-[10px] text-muted-foreground">{t('walletUi.recovery.noWatching')}</span>
</button>
)}
</div>
@ -149,7 +150,7 @@ export function RecoveryPhraseDialog({
onClick={() => setRevealed((v) => !v)}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{revealed ? 'Hide' : 'Reveal'}
{revealed ? t('walletUi.recovery.hide') : t('walletUi.recovery.reveal')}
</Button>
<Button
variant="outline"
@ -158,12 +159,12 @@ export function RecoveryPhraseDialog({
disabled={!revealed}
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copied' : 'Copy'}
{copied ? t('common.copied') : t('common.copy')}
</Button>
</div>
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
Writing it on paper is safest — copying to your clipboard can leak to other apps.
{t('walletUi.recovery.paperHint')}
</p>
{/* Mandatory first-time backup confirmation. Disabled until the user has
@ -175,7 +176,7 @@ export function RecoveryPhraseDialog({
disabled={!revealed}
>
<Check className="h-4 w-4" />
{revealed ? "I've written it down" : 'Reveal the words first'}
{revealed ? t('walletUi.recovery.written') : t('walletUi.recovery.revealFirst')}
</Button>
)}
</DialogContent>

View file

@ -10,6 +10,7 @@ import {
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface WalletComparisonGuideProps {
className?: string;
@ -17,6 +18,7 @@ interface WalletComparisonGuideProps {
}
export function WalletComparisonGuide({ className, defaultExpanded = false }: WalletComparisonGuideProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
@ -30,7 +32,7 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
<div className="flex items-center gap-2">
<span className="text-xl">🛹</span>
<span className="font-bold text-sm">
{isExpanded ? 'Wallet Guide (Skater Edition)' : 'Quick Guide'}
{isExpanded ? t('walletUi.guide.fullGuide') : t('walletUi.guide.quickGuide')}
</span>
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
@ -48,21 +50,21 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
<div className="flex items-center gap-2">
<Nut className="h-4 w-4 text-orange-500" />
<span className="text-gray-300">
<span className="font-bold text-orange-400">Cashu</span> = Privacy + Convenience
<span className="text-gray-500"> (mint holds funds)</span>
<span className="font-bold text-orange-400">Cashu</span> = {t('walletUi.guide.cashuSummary')}{' '}
<span className="text-gray-500"> {t('walletUi.guide.mintHoldsFunds')}</span>
</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
<span className="text-gray-300">
<span className="font-bold text-yellow-400">Lightning</span> = Control + Security
<span className="text-gray-500"> (you hold keys)</span>
<span className="font-bold text-yellow-400">Lightning</span> = {t('walletUi.guide.lightningSummary')}{' '}
<span className="text-gray-500"> {t('walletUi.guide.youHoldKeys')}</span>
</span>
</div>
<div className="flex items-start gap-2 mt-3 p-2 rounded-lg bg-green-500/10 border border-green-500/20">
<Lightbulb className="h-4 w-4 text-green-500 flex-shrink-0 mt-0.5" />
<span className="text-xs text-green-400">
Use both: Cashu for tips, Lightning for savings
{t('walletUi.guide.useBoth')}
</span>
</div>
</div>
@ -75,32 +77,32 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
<div className="p-4 rounded-lg bg-orange-500/10 border border-orange-500/20">
<div className="flex items-center gap-2 mb-2">
<Nut className="h-5 w-5 text-orange-500" />
<h3 className="font-bold text-orange-400">CASHU WALLET</h3>
<h3 className="font-bold text-orange-400">{t('walletUi.guide.cashuTitle')}</h3>
</div>
<p className="text-sm text-gray-400 italic mb-3">
Like cash in your pocket at the skatepark
{t('walletUi.guide.cashuTagline')}
</p>
<ul className="space-y-1.5 text-sm text-gray-300">
<li className="flex items-start gap-2">
<span className="text-orange-400">•</span>
<span>Quick to pull out for a tip</span>
<span>{t('walletUi.guide.cashuBullet1')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-orange-400">•</span>
<span>Don't carry your life savings here</span>
<span>{t('walletUi.guide.cashuBullet2')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-orange-400">•</span>
<span>If the mint disappears, funds could be lost</span>
<span>{t('walletUi.guide.cashuBullet3')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-orange-400">•</span>
<span>Nobody knows how much you have (privacy!)</span>
<span>{t('walletUi.guide.cashuBullet4')}</span>
</li>
</ul>
<div className="mt-3 pt-3 border-t border-orange-500/20">
<p className="text-xs text-orange-300">
<span className="font-bold">Perfect for:</span> Session snacks, zapping clips, small tips
<span className="font-bold">{t('walletUi.guide.perfectFor')}</span> {t('walletUi.guide.cashuPerfectFor')}
</p>
</div>
</div>
@ -109,32 +111,32 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<div className="flex items-center gap-2 mb-2">
<Zap className="h-5 w-5 text-yellow-500" />
<h3 className="font-bold text-yellow-400">LIGHTNING WALLET</h3>
<h3 className="font-bold text-yellow-400">{t('walletUi.guide.lightningTitle')}</h3>
</div>
<p className="text-sm text-gray-400 italic mb-3">
Like your bank account - you're in control
{t('walletUi.guide.lightningTagline')}
</p>
<ul className="space-y-1.5 text-sm text-gray-300">
<li className="flex items-start gap-2">
<span className="text-yellow-400">•</span>
<span>Secure, you control the keys</span>
<span>{t('walletUi.guide.lightningBullet1')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-yellow-400">•</span>
<span>Takes a moment to set up</span>
<span>{t('walletUi.guide.lightningBullet2')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-yellow-400">•</span>
<span>Full transaction history</span>
<span>{t('walletUi.guide.lightningBullet3')}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-yellow-400">•</span>
<span>Better for bigger amounts</span>
<span>{t('walletUi.guide.lightningBullet4')}</span>
</li>
</ul>
<div className="mt-3 pt-3 border-t border-yellow-500/20">
<p className="text-xs text-yellow-300">
<span className="font-bold">Perfect for:</span> Gear purchases, savings, big zaps
<span className="font-bold">{t('walletUi.guide.perfectFor')}</span> {t('walletUi.guide.lightningPerfectFor')}
</p>
</div>
</div>
@ -143,31 +145,31 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-5 w-5 text-green-500" />
<h3 className="font-bold text-green-400">PRO TIPS</h3>
<h3 className="font-bold text-green-400">{t('walletUi.guide.proTips')}</h3>
</div>
<ul className="space-y-2 text-sm text-gray-300">
<li className="flex items-start gap-2">
<span className="text-green-400 font-bold">1.</span>
<span>
<span className="font-medium text-white">Use both wallets</span> - Keep session money in Cashu, savings in Lightning
<span className="font-medium text-white">{t('walletUi.guide.tip1Title')}</span> - {t('walletUi.guide.tip1Desc')}
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 font-bold">2.</span>
<span>
<span className="font-medium text-white">Try different wallets</span> - Experiment to find what works for you. Each has different features and trade-offs
<span className="font-medium text-white">{t('walletUi.guide.tip2Title')}</span> - {t('walletUi.guide.tip2Desc')}
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 font-bold">3.</span>
<span>
<span className="font-medium text-white">Better privacy</span> - Using multiple wallets means no single app sees all your activity
<span className="font-medium text-white">{t('walletUi.guide.tip3Title')}</span> - {t('walletUi.guide.tip3Desc')}
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-400 font-bold">4.</span>
<span>
<span className="font-medium text-white">Gain experience</span> - The more tools you know, the more options you have. Start small, learn as you go
<span className="font-medium text-white">{t('walletUi.guide.tip4Title')}</span> - {t('walletUi.guide.tip4Desc')}
</span>
</li>
</ul>
@ -176,8 +178,7 @@ export function WalletComparisonGuide({ className, defaultExpanded = false }: Wa
{/* Bottom Summary */}
<div className="text-center p-3 rounded-lg bg-gray-800/50 border border-gray-700">
<p className="text-xs text-gray-400">
🎯 <span className="font-bold text-white">TL;DR:</span> There's no "best" wallet - it depends on your needs.
Try both, use what feels right, and don't keep all your sats in one place!
🎯 <span className="font-bold text-white">{t('walletUi.guide.tldr')}</span> {t('walletUi.guide.tldrDesc')}
</p>
</div>
</div>

View file

@ -60,13 +60,13 @@ export const NAV_CATALOG: NavTab[] = [
{ id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar },
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
{ id: 'mints', href: '/mints', label: 'Mints', icon: Coins },
{ id: 'app-shop', href: '/app-shop', label: 'App Shop', icon: ShoppingBag },
{ id: 'trades', href: '/marketplace', label: 'Marketplace', icon: Handshake },
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
{ id: 'messages', href: '/messages', label: 'Messages', icon: MessageCircle, authOnly: true },
{ id: 'mints', href: '/mints', labelKey: 'nav.mints', label: 'Mints', icon: Coins },
{ id: 'app-shop', href: '/app-shop', labelKey: 'nav.appShop', label: 'App Shop', icon: ShoppingBag },
{ id: 'trades', href: '/marketplace', labelKey: 'nav.trades', label: 'Marketplace', icon: Handshake },
{ id: 'stash', href: '/stash', labelKey: 'nav.stash', label: 'Stash', icon: Package, authOnly: true },
{ id: 'collections', href: '/collections', labelKey: 'nav.collections', label: 'Collections', icon: Bookmark, authOnly: true },
{ id: 'contacts', href: '/contacts', labelKey: 'nav.contacts', label: 'Contacts', icon: Users, authOnly: true },
{ id: 'messages', href: '/messages', labelKey: 'nav.messages', label: 'Messages', icon: MessageCircle, authOnly: true },
{ id: 'stats', href: '/stats', labelKey: 'nav.stats', label: 'Stats', icon: BarChart3 },
];

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

31
src/lib/spotTypeLabel.ts Normal file
View file

@ -0,0 +1,31 @@
import { SPOT_TYPE_LABELS } from '@/lib/types';
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* Spot-type keys that have a translated label under `chromeUi.spotType.*`.
* Anything else (feature tags like `wallrides`, legacy values) falls back to
* the English label so map popups never show a raw tag string.
*/
const TRANSLATED_SPOT_TYPES = new Set([
'street',
'transition',
'park',
'plaza',
'diy',
'bowl',
'vert',
'indoor',
'skatepark',
'miniramp',
]);
/**
* Localised label for a spot type/category key. Translates the ten spot-type
* keys; every other key keeps its English `SPOT_TYPE_LABELS` label (or the key
* itself as a last resort).
*/
export function spotTypeLabel(t: Translate, key: string): string {
if (TRANSLATED_SPOT_TYPES.has(key)) return t(`chromeUi.spotType.${key}`);
return SPOT_TYPE_LABELS[key] ?? key;
}

View file

@ -19,11 +19,13 @@ import {
Smartphone,
MessageCircle,
} from 'lucide-react';
import { useTranslation } from '@/contexts/language';
export default function AboutPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'About - SkateSpots',
description: 'Learn about SkateSpots and our mission to build a decentralized skateboarding community.',
title: t('settingsUi.about.seoTitle'),
description: t('settingsUi.about.seoDescription'),
});
const { user } = useCurrentUser();
@ -32,19 +34,19 @@ export default function AboutPage() {
const copyNpub = () => {
navigator.clipboard.writeText('npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx');
toast({
title: 'Copied!',
description: 'npub copied to clipboard. Paste it in your favorite Nostr client to send a DM.',
title: t('common.copied'),
description: t('settingsUi.about.npubCopied'),
});
};
// Bug report template for DM
const bugReportTemplate = `🐛 Bug Report
const bugReportTemplate = `${t('settingsUi.about.bugReport')}
What happened:
${t('settingsUi.about.bugWhatHappened')}
What I expected:
${t('settingsUi.about.bugExpected')}
Steps to reproduce:
${t('settingsUi.about.bugSteps')}
Device/Browser: ${navigator.userAgent.includes('Mobile') ? 'Mobile' : 'Desktop'} - ${navigator.userAgent.split(' ').slice(-2).join(' ')}
@ -66,11 +68,11 @@ Version: v0.1.0`;
fontFamily: 'Impact, "Arial Black", sans-serif',
letterSpacing: '0.05em'
}}>
<span className="neon-red">ABOUT</span>{' '}
<span className="neon-red">{t('settingsUi.about.aboutWord')}</span>{' '}
<span className="gradient-text">SKATESPOTS</span>
</h1>
<p className="text-xl text-gray-300 max-w-2xl mx-auto mb-6">
Connect with local skate scenes worldwide. <strong className="text-[#ff0040]">Skater-owned data</strong>, community-run relays.
{t('settingsUi.about.heroBefore')} <strong className="text-[#ff0040]">{t('settingsUi.about.skaterOwned')}</strong>{t('settingsUi.about.heroAfter')}
</p>
<Link to="/stats">
<Button
@ -78,7 +80,7 @@ Version: v0.1.0`;
className="gap-2 border-2 border-[#ccff00] text-[#ccff00] hover:bg-[#ccff00]/10"
>
<BarChart3 className="h-4 w-4" />
View Community Stats
{t('settingsUi.about.viewStats')}
</Button>
</Link>
</div>
@ -89,17 +91,14 @@ Version: v0.1.0`;
<div className="flex items-start gap-4 mb-4">
<Heart className="h-8 w-8 text-[#ff0040] flex-shrink-0" />
<div>
<h2 className="text-2xl font-black uppercase text-white mb-3">Our Mission</h2>
<h2 className="text-2xl font-black uppercase text-white mb-3">{t('settingsUi.about.missionTitle')}</h2>
<p className="text-gray-300 leading-relaxed">
SkateSpots helps skateboarders discover spots, connect with pros at The Lab, and organize sessions -
all without corporate control. The real skating happens offline, this tool just connects you to it.
{t('settingsUi.about.mission1')}
</p>
</div>
</div>
<p className="text-gray-300 leading-relaxed">
Skateboarding knowledge should live with the community, not locked in corporate apps.
That's why SkateSpots runs on Nostr - a relay-based system where skaters control their own
data, choose which relays store it, and can even run their own. No single company owns the map.
{t('settingsUi.about.mission2')}
</p>
</CardContent>
</Card>
@ -110,37 +109,37 @@ Version: v0.1.0`;
<div className="flex items-start gap-4 mb-6">
<Globe className="h-8 w-8 text-[#ccff00] flex-shrink-0" />
<div>
<h2 className="text-2xl font-black uppercase text-white mb-3">Why Nostr?</h2>
<h2 className="text-2xl font-black uppercase text-white mb-3">{t('settingsUi.about.whyNostr')}</h2>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-black/50 border border-[#ff0040]/20">
<Shield className="h-6 w-6 text-[#ff0040] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-2">Community-Controlled</h3>
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why1Title')}</h3>
<p className="text-sm text-gray-400">
Your spots live on community relays. Skaters run the servers, not corporations.
{t('settingsUi.about.why1Desc')}
</p>
</div>
<div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
<Users className="h-6 w-6 text-[#ccff00] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-2">You Own Your Data</h3>
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why2Title')}</h3>
<p className="text-sm text-gray-400">
Your Nostr identity is yours. Take it to any Nostr app - no lock-in.
{t('settingsUi.about.why2Desc')}
</p>
</div>
<div className="p-4 rounded-lg bg-black/50 border border-[#ff6600]/20">
<Zap className="h-6 w-6 text-[#ff6600] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-2">Bitcoin Integration</h3>
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why3Title')}</h3>
<p className="text-sm text-gray-400">
Zap creators directly with Bitcoin. No platform taking 30%.
{t('settingsUi.about.why3Desc')}
</p>
</div>
<div className="p-4 rounded-lg bg-black/50 border border-[#ccff00]/20">
<Code className="h-6 w-6 text-[#ccff00] mb-2" />
<h3 className="font-bold text-white uppercase text-sm mb-2">Open Source</h3>
<h3 className="font-bold text-white uppercase text-sm mb-2">{t('settingsUi.about.why4Title')}</h3>
<p className="text-sm text-gray-400">
Built in the open. Anyone can contribute or fork the code.
{t('settingsUi.about.why4Desc')}
</p>
</div>
</div>
@ -151,13 +150,13 @@ Version: v0.1.0`;
<div className="grid md:grid-cols-1 gap-6 max-w-md mx-auto">
<Card className="bg-black/90 border-2 border-[#ccff00]/50">
<CardContent className="p-6 text-center">
<h3 className="font-black uppercase text-[#ccff00] mb-4">Get Involved</h3>
<h3 className="font-black uppercase text-[#ccff00] mb-4">{t('settingsUi.about.getInvolved')}</h3>
<p className="text-gray-300 mb-4 text-sm">
SkateSpots is open source and community-driven. Contribute code, share spots, or help others get started.
{t('settingsUi.about.getInvolvedDesc')}
</p>
<Link to="/onboarding">
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase w-full">
Get Started
{t('common.getStarted')}
</Button>
</Link>
</CardContent>
@ -170,15 +169,13 @@ Version: v0.1.0`;
<div className="flex items-start gap-4">
<AlertCircle className="h-6 w-6 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-yellow-500 uppercase mb-2">Beta Testing</h3>
<h3 className="font-bold text-yellow-500 uppercase mb-2">{t('settingsUi.about.betaTitle')}</h3>
<p className="text-sm text-gray-300 mb-4">
Dear User! Thank you for beta testing SkateSpots. Please note that the app is still
in beta, and there may be bugs or security issues. If you encounter any problems
or want to report a bug/issue, please reach out!
{t('settingsUi.about.betaDesc')}
</p>
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
<div className="flex-1 bg-black/30 rounded-lg p-3 border border-yellow-500/20 min-w-0">
<p className="text-xs text-gray-400 mb-1">Give us feedback:</p>
<p className="text-xs text-gray-400 mb-1">{t('settingsUi.about.giveFeedback')}</p>
<code className="text-xs text-yellow-400 break-all">
npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
</code>
@ -190,7 +187,7 @@ Version: v0.1.0`;
className="border-yellow-500/50 text-yellow-500 hover:bg-yellow-500/10"
onClick={copyNpub}
>
Copy npub
{t('settingsUi.about.copyNpub')}
</Button>
{user ? (
<Link to={bugReportUrl}>
@ -198,7 +195,7 @@ Version: v0.1.0`;
size="sm"
className="bg-yellow-500 text-black hover:bg-yellow-400"
>
Report Bug
{t('settingsUi.about.reportBug')}
</Button>
</Link>
) : (
@ -211,7 +208,7 @@ Version: v0.1.0`;
size="sm"
className="bg-yellow-500 text-black hover:bg-yellow-400"
>
View Profile
{t('settingsUi.about.viewProfile')}
</Button>
</a>
)}
@ -229,10 +226,10 @@ Version: v0.1.0`;
<div className="flex-1 text-center sm:text-left">
<div className="flex items-center gap-2 justify-center sm:justify-start mb-2">
<Smartphone className="h-5 w-5 text-[#ccff00]" />
<h3 className="font-black uppercase text-[#ccff00]">Download the App</h3>
<h3 className="font-black uppercase text-[#ccff00]">{t('settingsUi.about.downloadApp')}</h3>
</div>
<p className="text-sm text-gray-300">
Install SkateSpots on Android via Zapstore — the open-source, Bitcoin-native app store.
{t('settingsUi.about.downloadAppDesc')}
</p>
</div>
<a
@ -243,7 +240,7 @@ Version: v0.1.0`;
>
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase px-6">
<Download className="h-4 w-4" />
Get on Zapstore
{t('settingsUi.about.getOnZapstore')}
</Button>
</a>
</div>
@ -255,19 +252,19 @@ Version: v0.1.0`;
<CardContent className="p-6">
<h3 className="font-black uppercase text-white mb-4 flex items-center gap-2">
<Heart className="h-5 w-5 text-[#ff0040]" />
Credits
{t('settingsUi.about.credits')}
</h3>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
{[
{ label: 'Powered by', name: 'Nostr', url: 'https://github.com/nostr-protocol/nostr', desc: 'Decentralized protocol' },
{ label: 'Crew chat via', name: 'Cordn', url: 'https://cordn.net/', desc: 'End-to-end encrypted group chat' },
{ label: 'Ecash via', name: 'Cashu', url: 'https://cashu.space/', desc: 'Chaumian ecash for Lightning' },
{ label: 'Wallet connect via', name: 'NWC', url: 'https://docs.nwc.dev/', desc: 'Nostr Wallet Connect (NIP-47)' },
{ label: 'Signing on Android with', name: 'Amber', url: 'https://github.com/greenart7c3/Amber', desc: 'Nostr signer for Android' },
{ label: 'Bitcoin map data from', name: 'BTCMap', url: 'https://btcmap.org/', desc: 'Places that accept Bitcoin' },
{ label: 'Crowdfunding', name: 'Angor', url: 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z', desc: 'Decentralized Bitcoin crowdfunding' },
{ label: 'Distributed on', name: 'Zapstore', url: 'https://zapstore.dev/apps/org.skatespots.app', desc: 'Open-source app store' },
{ label: 'Trust & crew chat via', name: 'ContextVM', url: 'https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530', desc: 'Relatr trust scores & encrypted crew-chat coordination' },
{ label: t('settingsUi.about.creditPoweredBy'), name: 'Nostr', url: 'https://github.com/nostr-protocol/nostr', desc: t('settingsUi.about.creditNostr') },
{ label: t('settingsUi.about.creditCrewChat'), name: 'Cordn', url: 'https://cordn.net/', desc: t('settingsUi.about.creditCordn') },
{ label: t('settingsUi.about.creditEcash'), name: 'Cashu', url: 'https://cashu.space/', desc: t('settingsUi.about.creditCashu') },
{ label: t('settingsUi.about.creditWalletConnect'), name: 'NWC', url: 'https://docs.nwc.dev/', desc: t('settingsUi.about.creditNwc') },
{ label: t('settingsUi.about.creditSigning'), name: 'Amber', url: 'https://github.com/greenart7c3/Amber', desc: t('settingsUi.about.creditAmber') },
{ label: t('settingsUi.about.creditMapData'), name: 'BTCMap', url: 'https://btcmap.org/', desc: t('settingsUi.about.creditBtcmap') },
{ label: t('settingsUi.about.creditCrowdfunding'), name: 'Angor', url: 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z', desc: t('settingsUi.about.creditAngor') },
{ label: t('settingsUi.about.creditDistributed'), name: 'Zapstore', url: 'https://zapstore.dev/apps/org.skatespots.app', desc: t('settingsUi.about.creditZapstore') },
{ label: t('settingsUi.about.creditTrust'), name: 'ContextVM', url: 'https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530', desc: t('settingsUi.about.creditContextvm') },
].map((item) => (
<a
key={item.name}
@ -293,9 +290,9 @@ Version: v0.1.0`;
<CardContent className="p-6 flex flex-col sm:flex-row items-center gap-5">
<div className="text-5xl flex-shrink-0">🦧</div>
<div className="flex-1 text-center sm:text-left">
<h3 className="text-xl font-black text-white uppercase mb-1">Still have questions?</h3>
<h3 className="text-xl font-black text-white uppercase mb-1">{t('settingsUi.about.stillQuestions')}</h3>
<p className="text-gray-400 text-sm">
The Sasquatch is our AI guide — powered by live SkateSpots data. Ask him about spots, events, coaches, gear, or how anything works.
{t('settingsUi.about.stillQuestionsDesc')}
</p>
</div>
<Button
@ -303,23 +300,23 @@ Version: v0.1.0`;
className="bg-amber-500 hover:bg-amber-400 text-black font-bold gap-2 flex-shrink-0 rounded-full px-6"
>
<MessageCircle className="h-4 w-4" />
Ask The Sasquatch
{t('settingsUi.about.askSasquatch')}
</Button>
</CardContent>
</Card>
{/* Powered By */}
<div className="text-center mt-8 pb-6 px-4">
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">Powered by</p>
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">{t('settingsUi.about.poweredBy')}</p>
<div className="flex flex-wrap items-center justify-center gap-2 md:gap-3">
<a href="https://github.com/nostr-protocol/nostr" target="_blank" rel="noopener noreferrer"
className="text-[#ccff00] hover:text-[#ccff00]/80 font-black text-lg md:text-xl uppercase">
Nostr
</a>
<span className="text-gray-600">•</span>
<span className="text-[#ff0040] font-black text-lg md:text-xl uppercase">Decentralized</span>
<span className="text-[#ff0040] font-black text-lg md:text-xl uppercase">{t('settingsUi.about.decentralized')}</span>
<span className="text-gray-600">•</span>
<span className="text-orange-400 font-black text-lg md:text-xl uppercase">Community-Owned</span>
<span className="text-orange-400 font-black text-lg md:text-xl uppercase">{t('settingsUi.about.communityOwned')}</span>
</div>
</div>
</div>

View file

@ -46,6 +46,7 @@ import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { contentFlags } from '@/lib/spotQuality';
import {
@ -75,15 +76,15 @@ import NotFound from '@/pages/NotFound';
type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab';
const TABS: Array<{ id: QueueKind; label: string }> = [
{ id: 'reports', label: 'Reports' },
{ id: 'spots', label: 'Spots' },
{ id: 'shops', label: 'Shops' },
{ id: 'events', label: 'Events' },
{ id: 'tours', label: 'Tours' },
{ id: 'gear', label: 'Gear' },
{ id: 'crews', label: 'Crews' },
{ id: 'lab', label: 'Lab' },
const TABS: Array<{ id: QueueKind; labelKey: string }> = [
{ id: 'reports', labelKey: 'walletUi.admin.tabReports' },
{ id: 'spots', labelKey: 'walletUi.admin.tabSpots' },
{ id: 'shops', labelKey: 'walletUi.admin.tabShops' },
{ id: 'events', labelKey: 'walletUi.admin.tabEvents' },
{ id: 'tours', labelKey: 'walletUi.admin.tabTours' },
{ id: 'gear', labelKey: 'walletUi.admin.tabGear' },
{ id: 'crews', labelKey: 'walletUi.admin.tabCrews' },
{ id: 'lab', labelKey: 'walletUi.admin.tabLab' },
];
/** One reviewable item, flattened from whatever kind it came from. */
@ -117,6 +118,7 @@ function QueueList({
confirmHide?: boolean;
confirmHideNote?: string;
}) {
const { t } = useTranslation();
const { hideContent, isPending: hiding } = useAdminHide();
const { confirmContent, isPending: confirming } = useAdminVerify();
const { clearVerdict, isPending: clearing } = useAdminClear();
@ -209,7 +211,7 @@ function QueueList({
<CardContent className="space-y-3 py-12 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="text-sm text-gray-400">
Nothing to review — nothing of this kind trips the junk signals.
{t('walletUi.admin.nothingToReview')}
</p>
</CardContent>
</Card>
@ -253,28 +255,27 @@ function QueueList({
onClick={() => setShowActioned((v) => !v)}
>
<EyeOff className="h-3.5 w-3.5" />
{showActioned ? 'Hide actioned' : `Show actioned (${actioned.length})`}
{showActioned ? t('walletUi.admin.hideActioned') : t('walletUi.admin.showActioned', { count: actioned.length })}
</Button>
{actioned.length > 1 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="gap-1.5 border-white/20" disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
Undo all {actioned.length}
{t('walletUi.admin.undoAll', { count: actioned.length })}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undo every verdict in this tab?</AlertDialogTitle>
<AlertDialogTitle>{t('walletUi.admin.undoAllTitle')}</AlertDialogTitle>
<AlertDialogDescription>
This sets each item back to neutral — not hidden, not vouched. It publishes one
public label per item, so with Amber you'll get {actioned.length} approval screens.
{t('walletUi.admin.undoAllDesc', { count: actioned.length })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => void undoAll()}>
Undo {actioned.length}
{t('walletUi.admin.undoCount', { count: actioned.length })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -319,13 +320,13 @@ function QueueList({
{hidden && (
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
<EyeOff className="mr-1 h-3 w-3" />
Hidden
{t('walletUi.admin.hidden')}
</Badge>
)}
{verified && (
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<BadgeCheck className="mr-1 h-3 w-3" />
Confirmed
{t('walletUi.admin.confirmed')}
</Badge>
)}
<span className="truncate">{row.title || '(untitled)'}</span>
@ -401,7 +402,7 @@ function QueueList({
}
>
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
Undo
{t('walletUi.admin.undo')}
</Button>
) : (
<>
@ -418,7 +419,7 @@ function QueueList({
}
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
Confirm
{t('walletUi.admin.confirm')}
</Button>
{confirmHide ? (
<AlertDialog>
@ -430,29 +431,29 @@ function QueueList({
disabled={hiding}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
{t('walletUi.admin.hide')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hide this for everyone?</AlertDialogTitle>
<AlertDialogTitle>{t('walletUi.admin.hideForEveryone')}</AlertDialogTitle>
<AlertDialogDescription>
{confirmHideNote ??
'This removes the item from browse and search for every SkateSpots user. It is a public, reversible label — you can Undo it from this queue.'}
t('walletUi.admin.hideForEveryoneDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
void act(
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
'Could not hide',
{ title: t('walletUi.admin.hidden'), description: t('walletUi.admin.hiddenDesc') },
t('walletUi.admin.couldNotHide'),
)
}
>
Hide it
{t('walletUi.admin.hideIt')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -466,13 +467,13 @@ function QueueList({
onClick={() =>
void act(
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
'Could not hide',
{ title: t('walletUi.admin.hidden'), description: t('walletUi.admin.hiddenDesc') },
t('walletUi.admin.couldNotHide'),
)
}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
{t('walletUi.admin.hide')}
</Button>
)}
{row.kind === SKATE_SPOT_KIND && (
@ -485,7 +486,7 @@ function QueueList({
}
>
<Flag className="h-3.5 w-3.5" />
Not a skate spot
{t('walletUi.admin.notASpot')}
</Button>
)}
</>
@ -493,7 +494,7 @@ function QueueList({
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={row.href}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
{t('walletUi.admin.lookAtIt')}
</Link>
</Button>
</div>
@ -654,6 +655,7 @@ function LabQueue() {
}
export default function AdminReviewPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('reports');
@ -666,25 +668,25 @@ export default function AdminReviewPage() {
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
<PageHeader
icon={ShieldAlert}
title="Review queue"
title={t('walletUi.admin.title')}
accent="#ff6600"
subtitle="Spots, shops, events, tours and gear that trip the junk signals. Hide and Confirm are soft, reversible labels; reports rank first. Nothing is deleted."
subtitle={t('walletUi.admin.subtitle')}
/>
<div className="mb-4 flex flex-wrap gap-1.5">
{TABS.map((t) => (
{TABS.map((tab) => (
<button
key={t.id}
key={tab.id}
type="button"
onClick={() => setKind(t.id)}
onClick={() => setKind(tab.id)}
className={
kind === t.id
kind === tab.id
? 'rounded-full border border-[#ff6600]/60 bg-[#ff6600]/15 px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-[#ff6600]'
: 'rounded-full border border-white/12 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400 hover:text-white'
}
aria-pressed={kind === t.id}
aria-pressed={kind === tab.id}
>
{t.label}
{t(tab.labelKey)}
</button>
))}
</div>

View file

@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/contexts/language';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
/** Where "request an item" goes: an encrypted DM straight to the shop owner. */
@ -41,35 +42,36 @@ const REQUEST_HREF = `/messages/${SHOP_OWNER_NPUB}`;
*/
const FREE_PRINT = [
{
title: 'Sticker',
titleKey: 'walletUi.appShop.sticker',
size: '55 × 86 mm',
tip: 'Black die-cut. Print on sticker paper, cut along the black edge.',
tipKey: 'walletUi.appShop.stickerTip',
file: '/images/print/skatespots-sticker.svg',
},
{
title: 'Poster',
titleKey: 'walletUi.appShop.poster',
size: 'A3 · 297 × 420 mm',
tip: 'Prints on A3; shrink to A4 (50%) and it still scans fine.',
tipKey: 'walletUi.appShop.posterTip',
file: '/images/print/skatespots-poster.svg',
},
];
/** What we're working on. Not products, not prices — just intent. */
const PLANNED = [
{ icon: Shirt, label: 'Tees & hoodies' },
{ icon: Package, label: 'Decks' },
{ icon: Shirt, labelKey: 'walletUi.appShop.tees' },
{ icon: Package, labelKey: 'walletUi.appShop.decks' },
// 'Sticker packs' is rendered separately in the grid, because it is the one
// entry that already contains something real: the free printables.
{ icon: Hammer, label: 'Shop tools' },
{ icon: Hammer, labelKey: 'walletUi.appShop.shopTools' },
];
const PAY_CHIPS = [
{ icon: Zap, label: 'Lightning' },
{ icon: Coins, label: 'Cashu' },
{ icon: MapPin, label: 'Local handover' },
{ icon: Zap, labelKey: 'walletUi.appShop.lightning' },
{ icon: Coins, labelKey: 'walletUi.appShop.cashu' },
{ icon: MapPin, labelKey: 'walletUi.appShop.localHandover' },
];
export default function AppShopPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'App Shop — SkateSpots',
description:
@ -80,9 +82,9 @@ export default function AppShopPage() {
<div className="container mx-auto max-w-4xl px-4 py-8 pb-24 md:pb-14">
<PageHeader
icon={ShoppingBag}
title="App Shop"
title={t('walletUi.appShop.title')}
accent="#ccff00"
subtitle="SkateSpots merch for the people who keep the scene rolling."
subtitle={t('walletUi.appShop.subtitle')}
/>
{/* ── Hero ─────────────────────────────────────────────────────────── */}
@ -93,7 +95,7 @@ export default function AppShopPage() {
<div className="relative">
<span className="inline-flex items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
<Sparkles className="h-3 w-3" />
Coming soon
{t('walletUi.appShop.comingSoon')}
</span>
<h2
@ -101,19 +103,16 @@ export default function AppShopPage() {
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
Wear the
{t('walletUi.appShop.wearThe')}
</span>
<br />
<span className="text-white">Scene</span>
<span className="text-white">{t('walletUi.appShop.scene')}</span>
</h2>
<p className="mt-4 max-w-xl text-sm leading-relaxed text-gray-300 sm:text-base">
Merch for the people who keep this thing rolling — sold{' '}
<strong className="font-bold text-white">locally, on request</strong>. You message
me, we sort the details, and you pay in{' '}
<strong className="font-bold text-[#f7931a]">sats</strong> — Lightning or Cashu —
when you collect it. No cards, no accounts, no checkout robot, no middleman
skimming the drop.
{t('walletUi.appShop.heroDesc1')}{' '}
<strong className="font-bold text-white">{t('walletUi.appShop.locally')}</strong>{t('walletUi.appShop.heroDesc2')}{' '}
<strong className="font-bold text-[#f7931a]">sats</strong>{t('walletUi.appShop.heroDesc3')}
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
@ -123,22 +122,22 @@ export default function AppShopPage() {
>
<Link to={REQUEST_HREF}>
<MessageCircle className="h-4 w-4" />
Request an item
{t('walletUi.appShop.requestItem')}
</Link>
</Button>
<span className="text-[11px] text-gray-500">
Opens a private, encrypted DM with me.
{t('walletUi.appShop.dmHint')}
</span>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{PAY_CHIPS.map(({ icon: Icon, label }) => (
{PAY_CHIPS.map(({ icon: Icon, labelKey }) => (
<span
key={label}
key={labelKey}
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-white/[0.04] px-3 py-1.5 text-[11px] font-bold text-gray-300 backdrop-blur-sm"
>
<Icon className="h-3.5 w-3.5 text-[#ccff00]" />
{label}
{t(labelKey)}
</span>
))}
</div>
@ -149,18 +148,18 @@ export default function AppShopPage() {
<section className="mt-10">
<div className="mb-4 flex items-center gap-3">
<h3 className="text-sm font-black uppercase tracking-widest text-white">
In the works
{t('walletUi.appShop.inTheWorks')}
</h3>
<div className="h-px flex-1 bg-white/10" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
not for sale yet
{t('walletUi.appShop.notForSale')}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{PLANNED.slice(0, 2).map(({ icon: Icon, label }) => (
{PLANNED.slice(0, 2).map(({ icon: Icon, labelKey }) => (
<div
key={label}
key={labelKey}
className="group relative overflow-hidden rounded-2xl border-2 border-dashed border-white/12 bg-white/[0.02] p-4 transition-colors hover:border-[#ccff00]/30"
>
<div className="flex items-start gap-3">
@ -168,11 +167,11 @@ export default function AppShopPage() {
<Icon className="h-5 w-5" />
</span>
<p className="min-w-0 self-center font-black uppercase tracking-wide text-white">
{label}
{t(labelKey)}
</p>
</div>
<span className="absolute right-3 top-3 rounded-full border border-white/12 px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-gray-500">
soon
{t('walletUi.appShop.soon')}
</span>
</div>
))}
@ -186,39 +185,39 @@ export default function AppShopPage() {
<Sticker className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<p className="font-black uppercase tracking-wide text-white">Sticker packs</p>
<p className="font-black uppercase tracking-wide text-white">{t('walletUi.appShop.stickerPacks')}</p>
<p className="mt-0.5 text-xs leading-relaxed text-gray-400">
The packs are coming. The designs are not — print these two right now, free.
{t('walletUi.appShop.stickerPacksDesc')}
</p>
</div>
<span className="shrink-0 rounded-full bg-[#ccff00] px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-black">
Free
{t('common.free')}
</span>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{FREE_PRINT.map((item) => (
<div
key={item.title}
key={item.titleKey}
className="overflow-hidden rounded-xl border border-white/10 bg-black/40"
>
<div className="flex h-36 items-center justify-center p-3">
<img
src={item.file}
alt={`SkateSpots ${item.title} — printable`}
alt={t('walletUi.appShop.printableAlt', { title: t(item.titleKey) })}
className="max-h-full max-w-full drop-shadow-[0_4px_14px_rgba(0,0,0,0.6)]"
/>
</div>
<div className="border-t border-white/8 p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="font-bold uppercase tracking-wide text-white">{item.title}</p>
<p className="font-bold uppercase tracking-wide text-white">{t(item.titleKey)}</p>
<span className="shrink-0 text-[10px] font-bold uppercase tracking-widest text-gray-500">
{item.size}
</span>
</div>
<p className="mt-1 flex items-start gap-1.5 text-[11px] leading-relaxed text-gray-500">
<Scissors className="mt-0.5 h-3 w-3 shrink-0 text-gray-600" />
{item.tip}
{t(item.tipKey)}
</p>
<div className="mt-2.5 flex flex-wrap gap-2">
<Button
@ -227,12 +226,12 @@ export default function AppShopPage() {
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<a href={item.file} target="_blank" rel="noopener noreferrer">
<Printer className="h-3.5 w-3.5" /> Print
<Printer className="h-3.5 w-3.5" /> {t('walletUi.appShop.print')}
</a>
</Button>
<Button asChild size="sm" variant="outline" className="gap-1.5 border-white/15">
<a href={item.file} download>
<Download className="h-3.5 w-3.5" /> Save
<Download className="h-3.5 w-3.5" /> {t('common.save')}
</a>
</Button>
</div>
@ -242,15 +241,13 @@ export default function AppShopPage() {
</div>
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
Print at <strong className="text-gray-400">100% / “Actual size”</strong> so the size is
exact and the QR stays sharp — it points at skate-spots.org. Vector files, so they
scale without going fuzzy.
{t('walletUi.appShop.printNote1')} <strong className="text-gray-400">{t('walletUi.appShop.actualSize')}</strong> {t('walletUi.appShop.printNote2')}
</p>
</div>
{PLANNED.slice(2).map(({ icon: Icon, label }) => (
{PLANNED.slice(2).map(({ icon: Icon, labelKey }) => (
<div
key={label}
key={labelKey}
className="group relative overflow-hidden rounded-2xl border-2 border-dashed border-white/12 bg-white/[0.02] p-4 transition-colors hover:border-[#ccff00]/30"
>
<div className="flex items-start gap-3">
@ -258,11 +255,11 @@ export default function AppShopPage() {
<Icon className="h-5 w-5" />
</span>
<p className="min-w-0 self-center font-black uppercase tracking-wide text-white">
{label}
{t(labelKey)}
</p>
</div>
<span className="absolute right-3 top-3 rounded-full border border-white/12 px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-gray-500">
soon
{t('walletUi.appShop.soon')}
</span>
</div>
))}
@ -273,11 +270,11 @@ export default function AppShopPage() {
<section className="mt-10">
<div className="mb-4 flex items-center gap-3">
<h3 className="text-sm font-black uppercase tracking-widest text-white">
How buying works
{t('walletUi.appShop.howBuying')}
</h3>
<div className="h-px flex-1 bg-white/10" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
it's a DM, not a cart
{t('walletUi.appShop.dmNotCart')}
</span>
</div>
@ -285,18 +282,18 @@ export default function AppShopPage() {
{[
{
n: '1',
t: 'Message me',
d: 'Send a DM with what you want — item, size, colour, whatever you are after.',
tk: 'walletUi.appShop.step1Title',
dk: 'walletUi.appShop.step1Desc',
},
{
n: '2',
t: 'We sort it out',
d: 'Price in sats, and where and when to meet. It is a conversation, not a checkout.',
tk: 'walletUi.appShop.step2Title',
dk: 'walletUi.appShop.step2Desc',
},
{
n: '3',
t: 'Pay in sats',
d: 'Lightning or Cashu at handover, then it is yours. No shipping, no waiting rooms.',
tk: 'walletUi.appShop.step3Title',
dk: 'walletUi.appShop.step3Desc',
},
].map((s) => (
<li
@ -309,8 +306,8 @@ export default function AppShopPage() {
>
{s.n}
</span>
<p className="font-black uppercase tracking-wide text-white">{s.t}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">{s.d}</p>
<p className="font-black uppercase tracking-wide text-white">{t(s.tk)}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">{t(s.dk)}</p>
</li>
))}
</ol>

View file

@ -9,6 +9,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { useAuthor } from '@/hooks/useAuthor';
import { useSkateShops } from '@/hooks/useSkateShops';
import { useSkateSpots } from '@/hooks/useSkateSpots';
@ -31,6 +32,7 @@ function LeaderboardEntry({
type: 'shops' | 'spots' | 'total';
isCurrentUser: boolean;
}) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(pubkey);
@ -52,9 +54,9 @@ function LeaderboardEntry({
};
const getTypeLabel = () => {
if (type === 'shops') return 'shops';
if (type === 'spots') return 'spots';
return 'contributions';
if (type === 'shops') return t('walletUi.leaderboard.shopsLower');
if (type === 'spots') return t('walletUi.leaderboard.spotsLower');
return t('walletUi.leaderboard.contributions');
};
return (
@ -84,7 +86,7 @@ function LeaderboardEntry({
{displayName}
</p>
{isCurrentUser && (
<Badge className="bg-[#ff0040] text-white text-[10px] px-1.5 py-0">You</Badge>
<Badge className="bg-[#ff0040] text-white text-[10px] px-1.5 py-0">{t('walletUi.leaderboard.you')}</Badge>
)}
</div>
<p className="text-xs text-gray-500 capitalize">
@ -120,6 +122,7 @@ function LeaderboardSkeleton() {
}
export default function BountyLeaderboardPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Map & Earn - SkateSpots',
description: 'Map skate spots and shops, earn zaps from the community. See the top contributors.',
@ -204,13 +207,13 @@ export default function BountyLeaderboardPage() {
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/10 border border-[#ccff00]/30 mb-4">
<Zap className="h-5 w-5 text-[#ccff00]" />
<span className="text-[#ccff00] font-bold uppercase text-sm">Map & Earn</span>
<span className="text-[#ccff00] font-bold uppercase text-sm">{t('walletUi.leaderboard.mapEarn')}</span>
</div>
<h1 className="text-3xl sm:text-4xl font-black uppercase mb-3">
<span className="neon-lime">HELP MAP THE WORLD</span>
<span className="neon-lime">{t('walletUi.leaderboard.helpMap')}</span>
</h1>
<p className="text-gray-400 max-w-lg mx-auto">
Add spots and shops, get zapped by grateful skaters. Top contributors may receive bonus rewards!
{t('walletUi.leaderboard.intro')}
</p>
</div>
@ -219,53 +222,53 @@ export default function BountyLeaderboardPage() {
<CardContent className="p-6">
<h2 className="text-lg font-black uppercase mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-[#ccff00]" />
How It Works
{t('walletUi.leaderboard.howItWorks')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
<div className="w-10 h-10 rounded-lg bg-[#ff0040]/20 flex items-center justify-center mb-3">
<MapPin className="h-5 w-5 text-[#ff0040]" />
</div>
<h3 className="font-bold text-white mb-1">1. Map</h3>
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step1Title')}</h3>
<p className="text-xs text-gray-400">
Add spots & shops with photos and details
{t('walletUi.leaderboard.step1Desc')}
</p>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
<div className="w-10 h-10 rounded-lg bg-[#ccff00]/20 flex items-center justify-center mb-3">
<Zap className="h-5 w-5 text-[#ccff00]" />
</div>
<h3 className="font-bold text-white mb-1">2. Earn Zaps</h3>
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step2Title')}</h3>
<p className="text-xs text-gray-400">
Skaters who find your spots useful will tip you
{t('walletUi.leaderboard.step2Desc')}
</p>
</div>
<div className="p-4 rounded-xl bg-black/40 border border-gray-800">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center mb-3">
<Trophy className="h-5 w-5 text-orange-400" />
</div>
<h3 className="font-bold text-white mb-1">3. Bonus Rewards</h3>
<h3 className="font-bold text-white mb-1">{t('walletUi.leaderboard.step3Title')}</h3>
<p className="text-xs text-gray-400">
Top mappers may get bonus rewards from our fund
{t('walletUi.leaderboard.step3Desc')}
</p>
</div>
</div>
{/* Quality Tips */}
<div className="mt-4 p-4 rounded-xl bg-black/40 border border-[#ccff00]/20">
<p className="text-sm font-bold text-[#ccff00] mb-2">💡 Pro Tips for More Zaps:</p>
<p className="text-sm font-bold text-[#ccff00] mb-2">{t('walletUi.leaderboard.proTips')}</p>
<div className="flex flex-wrap gap-3 text-xs text-gray-400">
<span className="flex items-center gap-1">
<Camera className="h-3 w-3" />
Add 3+ photos
{t('walletUi.leaderboard.tipPhotos')}
</span>
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
Write detailed descriptions
{t('walletUi.leaderboard.tipDescriptions')}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
Accurate location
{t('walletUi.leaderboard.tipLocation')}
</span>
</div>
</div>
@ -278,8 +281,8 @@ export default function BountyLeaderboardPage() {
<Card className="h-full bg-[#ccff00]/10 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all hover:scale-[1.02] cursor-pointer">
<CardContent className="p-4 sm:p-6 text-center">
<Store className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
<h3 className="font-black uppercase text-sm sm:text-base mb-1">Map a Shop</h3>
<p className="text-xs text-gray-400 hidden sm:block">Skate shops, board stores</p>
<h3 className="font-black uppercase text-sm sm:text-base mb-1">{t('walletUi.leaderboard.mapShop')}</h3>
<p className="text-xs text-gray-400 hidden sm:block">{t('walletUi.leaderboard.mapShopDesc')}</p>
</CardContent>
</Card>
</Link>
@ -287,8 +290,8 @@ export default function BountyLeaderboardPage() {
<Card className="h-full bg-[#ff0040]/10 border-2 border-[#ff0040]/30 hover:border-[#ff0040]/60 transition-all hover:scale-[1.02] cursor-pointer">
<CardContent className="p-4 sm:p-6 text-center">
<MapPin className="h-8 w-8 mx-auto mb-2 text-[#ff0040]" />
<h3 className="font-black uppercase text-sm sm:text-base mb-1">Map a Spot</h3>
<p className="text-xs text-gray-400 hidden sm:block">Parks, street spots, DIY</p>
<h3 className="font-black uppercase text-sm sm:text-base mb-1">{t('walletUi.leaderboard.mapSpot')}</h3>
<p className="text-xs text-gray-400 hidden sm:block">{t('walletUi.leaderboard.mapSpotDesc')}</p>
</CardContent>
</Card>
</Link>
@ -300,21 +303,21 @@ export default function BountyLeaderboardPage() {
<CardContent className="p-3 sm:p-4 text-center">
<Store className="h-5 w-5 mx-auto mb-1 text-[#ccff00]" />
<p className="text-xl sm:text-2xl font-black text-white">{totalShops}</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">Shops</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.leaderboard.shops')}</p>
</CardContent>
</Card>
<Card className="bg-[#ff0040]/10 border-[#ff0040]/30">
<CardContent className="p-3 sm:p-4 text-center">
<MapPin className="h-5 w-5 mx-auto mb-1 text-[#ff0040]" />
<p className="text-xl sm:text-2xl font-black text-white">{totalSpots}</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">Spots</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.stats.spots')}</p>
</CardContent>
</Card>
<Card className="bg-orange-500/10 border-orange-500/30">
<CardContent className="p-3 sm:p-4 text-center">
<Users className="h-5 w-5 mx-auto mb-1 text-orange-400" />
<p className="text-xl sm:text-2xl font-black text-white">{totalMappers}</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">Mappers</p>
<p className="text-[10px] sm:text-xs text-gray-400 uppercase">{t('walletUi.stats.mappers')}</p>
</CardContent>
</Card>
</div>
@ -329,15 +332,15 @@ export default function BountyLeaderboardPage() {
<span className="text-lg font-black text-[#ff0040]">#{userCombinedRank}</span>
</div>
<div>
<p className="font-bold text-white">Your Ranking</p>
<p className="font-bold text-white">{t('walletUi.leaderboard.yourRanking')}</p>
<p className="text-sm text-gray-400">
{userContributions.spots} spots • {userContributions.shops} shops
{t('walletUi.leaderboard.spotsShops', { spots: userContributions.spots, shops: userContributions.shops })}
</p>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-black text-[#ccff00]">{userContributions.total}</p>
<p className="text-xs text-gray-500">total</p>
<p className="text-xs text-gray-500">{t('walletUi.leaderboard.totalLower')}</p>
</div>
</div>
</CardContent>
@ -348,7 +351,7 @@ export default function BountyLeaderboardPage() {
<div className="mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-400" />
Top Mappers
{t('walletUi.stats.topMappers')}
</h2>
</div>
@ -356,15 +359,15 @@ export default function BountyLeaderboardPage() {
<TabsList className="grid w-full grid-cols-3 bg-black/50 border border-gray-800">
<TabsTrigger value="combined" className="gap-1.5 text-xs sm:text-sm">
<Trophy className="h-4 w-4" />
<span className="hidden sm:inline">Overall</span>
<span className="hidden sm:inline">{t('walletUi.leaderboard.overall')}</span>
</TabsTrigger>
<TabsTrigger value="shops" className="gap-1.5 text-xs sm:text-sm">
<Store className="h-4 w-4" />
Shops
{t('walletUi.leaderboard.shops')}
</TabsTrigger>
<TabsTrigger value="spots" className="gap-1.5 text-xs sm:text-sm">
<MapPin className="h-4 w-4" />
Spots
{t('walletUi.stats.spots')}
</TabsTrigger>
</TabsList>
@ -388,10 +391,10 @@ export default function BountyLeaderboardPage() {
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<Trophy className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400 mb-4">No contributions yet. Be the first!</p>
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noContributions')}</p>
<Link to="/create-spot">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
Add First Spot
{t('walletUi.leaderboard.addFirstSpot')}
</Button>
</Link>
</CardContent>
@ -419,10 +422,10 @@ export default function BountyLeaderboardPage() {
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<Store className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400 mb-4">No shops mapped yet</p>
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noShops')}</p>
<Link to="/create-shop">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
Add First Shop
{t('walletUi.leaderboard.addFirstShop')}
</Button>
</Link>
</CardContent>
@ -450,10 +453,10 @@ export default function BountyLeaderboardPage() {
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<p className="text-gray-400 mb-4">No spots mapped yet</p>
<p className="text-gray-400 mb-4">{t('walletUi.leaderboard.noSpots')}</p>
<Link to="/create-spot">
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold">
Add First Spot
{t('walletUi.leaderboard.addFirstSpot')}
</Button>
</Link>
</CardContent>
@ -466,9 +469,9 @@ export default function BountyLeaderboardPage() {
<Card className="mt-8 bg-gray-900/50 border-gray-800">
<CardContent className="p-4 sm:p-6 text-center">
<Zap className="h-8 w-8 mx-auto mb-3 text-yellow-400" />
<h3 className="font-bold text-white mb-2">Powered by Community Zaps</h3>
<h3 className="font-bold text-white mb-2">{t('walletUi.leaderboard.poweredBy')}</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto">
When you find a spot helpful, zap the mapper! It's the best way to say thanks and encourage more contributions.
{t('walletUi.leaderboard.poweredByDesc')}
</p>
</CardContent>
</Card>

View file

@ -12,6 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useRelays } from '@/lib/relays';
import { useSpotCollection, useRemoveSpotFromCollection, useDeleteSpotCollection } from '@/hooks/useSpotCollections';
@ -41,6 +42,7 @@ function CollectionDetailSkeleton() {
}
export default function CollectionDetailPage() {
const { t } = useTranslation();
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: collection, isLoading, error, refetch } = useSpotCollection(identifier || '', pubkey);
const { user } = useCurrentUser();
@ -138,13 +140,13 @@ export default function CollectionDetailPage() {
spotIdentifier: spot.identifier,
});
toast({
title: 'Spot removed',
description: `"${spot.title}" has been removed from this collection`,
title: t('walletUi.collection.spotRemoved'),
description: t('walletUi.collection.spotRemovedDesc', { title: spot.title }),
});
} catch (error) {
toast({
title: 'Failed to remove spot',
description: error instanceof Error ? error.message : 'Something went wrong',
title: t('walletUi.collection.removeFailed'),
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
} finally {
@ -158,14 +160,14 @@ export default function CollectionDetailPage() {
try {
await deleteCollection(collection);
toast({
title: 'Collection deleted',
description: `"${collection.title}" has been removed`,
title: t('walletUi.collection.deleted'),
description: t('walletUi.collection.deletedDesc', { title: collection.title }),
});
navigate('/collections');
} catch (error) {
toast({
title: 'Failed to delete collection',
description: error instanceof Error ? error.message : 'Something went wrong',
title: t('walletUi.collection.deleteFailed'),
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
}
@ -177,7 +179,7 @@ export default function CollectionDetailPage() {
try {
await navigator.share({
title: collection?.title,
text: collection?.description || `Check out this spot collection: ${collection?.title}`,
text: collection?.description || t('walletUi.collection.shareText', { title: collection?.title ?? '' }),
url: window.location.href,
});
} catch {
@ -186,8 +188,8 @@ export default function CollectionDetailPage() {
} else {
navigator.clipboard.writeText(window.location.href);
toast({
title: 'Link copied!',
description: 'Collection link has been copied to clipboard',
title: t('walletUi.collection.linkCopied'),
description: t('walletUi.collection.linkCopiedDesc'),
});
}
};
@ -213,7 +215,7 @@ export default function CollectionDetailPage() {
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4 sm:mb-6 text-sm"
>
<ArrowLeft className="h-4 w-4 mr-1.5 sm:mr-2" />
Back to Collections
{t('walletUi.collection.back')}
</Link>
{/* Header */}
@ -258,11 +260,11 @@ export default function CollectionDetailPage() {
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-gray-500">
<Badge variant="outline" className="border-[#ccff00]/50 text-[#ccff00]">
<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' : ''}
{t('walletUi.addToCollection.spotCount', { count: collection.spotRefs.length })}
</Badge>
<span className="flex items-center gap-1">
<Calendar className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
Created {new Date(collection.createdAt * 1000).toLocaleDateString()}
{t('walletUi.collection.created', { date: new Date(collection.createdAt * 1000).toLocaleDateString() })}
</span>
</div>
</div>
@ -273,7 +275,7 @@ export default function CollectionDetailPage() {
<CardHeader className="p-3 sm:p-6 pb-2 sm:pb-4">
<CardTitle className="text-base sm:text-lg flex items-center gap-2">
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 text-[#ccff00]" />
Collection Map
{t('walletUi.collection.map')}
</CardTitle>
</CardHeader>
<CardContent className="p-3 sm:p-6 pt-0">
@ -326,16 +328,16 @@ export default function CollectionDetailPage() {
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center">
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">No spots in this collection</h2>
<h2 className="text-xl font-bold mb-2">{t('walletUi.collection.empty')}</h2>
<p className="text-gray-400 mb-6">
{isOwner
? 'Add spots to your collection by clicking "Save to Collection" on any spot'
: 'This collection is empty'}
? t('walletUi.collection.emptyOwner')
: t('walletUi.collection.emptyOther')}
</p>
{isOwner && (
<Link to="/map">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase">
Browse Spots
{t('walletUi.collection.browseSpots')}
</Button>
</Link>
)}
@ -350,14 +352,14 @@ export default function CollectionDetailPage() {
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Delete Collection?
{t('walletUi.collection.deleteTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400 text-sm">
This will permanently delete "{collection.title}". The spots themselves won't be affected.
{t('walletUi.collection.deleteDesc', { title: collection.title })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">Cancel</AlertDialogCancel>
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteCollection}
disabled={isDeleting}
@ -366,10 +368,10 @@ export default function CollectionDetailPage() {
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Deleting...
{t('walletUi.collection.deleting')}
</>
) : (
'Delete'
t('common.delete')
)}
</AlertDialogAction>
</AlertDialogFooter>

View file

@ -9,6 +9,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useGrowthStats } from '@/hooks/useGrowthStats';
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
import { useDeriveRegions } from '@/hooks/useDeriveRegions';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import { formatAge } from '@/lib/routeCache';
@ -76,6 +77,7 @@ function StatCard({
/** Abbreviate big numbers (e.g. sats) so they don't overflow the card. */
compact?: boolean;
}) {
const { t } = useTranslation();
const clickable = !!to && !loading && value > 0;
const card = (
<Card
@ -102,7 +104,7 @@ function StatCard({
style={{ background: `${color}22`, color }}
>
<TrendingUp className="h-2.5 w-2.5" />
+{thisMonthDelta} this month
{t('walletUi.stats.thisMonth', { count: thisMonthDelta })}
</span>
)}
</div>
@ -110,14 +112,14 @@ function StatCard({
{!loading && trustedValue !== undefined && (
<p className="text-xs mt-1.5 flex items-center gap-1" style={{ color: '#555' }}>
<span className="inline-flex items-center gap-0.5 text-green-500/80">
🛡 {trustedValue.toLocaleString()} from active accounts
🛡 {t('walletUi.stats.fromActive', { count: trustedValue.toLocaleString() })}
</span>
</p>
)}
{sub && <p className="text-xs mt-1.5" style={{ color: '#555' }}>{sub}</p>}
{clickable && (
<p className="text-[10px] mt-2 font-bold uppercase tracking-wide inline-flex items-center gap-1" style={{ color }}>
View all <ExternalLink className="h-2.5 w-2.5" />
{t('walletUi.stats.viewAll')} <ExternalLink className="h-2.5 w-2.5" />
</p>
)}
</div>
@ -135,6 +137,7 @@ function StatCard({
// ─── Top mapper row ──────────────────────────────────────────────────────────
function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number; spotCount: number }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
const name = meta?.name ?? genUserName(pubkey);
@ -153,7 +156,7 @@ function MapperRow({ pubkey, rank, spotCount }: { pubkey: string; rank: number;
</Avatar>
<span className="flex-1 text-sm font-semibold truncate">{name}</span>
<span className="text-xs font-bold flex-shrink-0" style={{ color: '#ccff00' }}>
{spotCount} {spotCount === 1 ? 'spot' : 'spots'}
{t('walletUi.stats.spotCount', { count: spotCount })}
</span>
</div>
);
@ -174,6 +177,7 @@ function ChartSkeleton() {
// ─── Page ────────────────────────────────────────────────────────────────────
export default function CommunityStatsPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Community Stats — SkateSpots',
description: 'Live community metrics for SkateSpots — spots, events, coaches, filmers, editors, tours and growth over time. Pulled directly from Nostr.',
@ -191,14 +195,14 @@ export default function CommunityStatsPage() {
const contentMix = useMemo(() => {
if (!stats) return [];
return [
{ name: 'Spots', value: stats.totalSpots, color: '#ff0040' },
{ name: 'Events', value: stats.totalEvents, color: '#ccff00' },
{ name: 'Lab', value: labCraftTotal(stats), color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' },
{ name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' },
{ name: t('walletUi.stats.spots'), value: stats.totalSpots, color: '#ff0040' },
{ name: t('walletUi.stats.events'), value: stats.totalEvents, color: '#ccff00' },
{ name: t('walletUi.stats.lab'), value: labCraftTotal(stats), color: '#ff6600' },
{ name: t('walletUi.stats.shops'), value: stats.totalShops, color: '#4ade80' },
{ name: t('walletUi.stats.tours'), value: stats.totalTours, color: '#c084fc' },
{ name: t('walletUi.stats.crews'), value: stats.totalCrews, color: '#2dd4bf' },
].filter(d => d.value > 0);
}, [stats]);
}, [stats, t]);
// Only show last 12 months on charts to keep them readable
const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
@ -235,15 +239,15 @@ export default function CommunityStatsPage() {
<span className="text-white">Stats</span>
</h1>
<p className="text-gray-400 max-w-xl mx-auto text-sm md:text-base mb-5">
Live metrics pulled directly from the Nostr network. Transparent, decentralized, verifiable by anyone.
{t('walletUi.stats.subtitle')}
</p>
{/* Data source badge + freshness + manual refresh */}
<div className="inline-flex flex-wrap items-center justify-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-gray-400">
<Radio className="h-3 w-3 text-[#ccff00]" />
Data source:&nbsp;
{t('walletUi.stats.dataSource')}&nbsp;
<span className="text-[#ccff00] font-mono">{SKATE_RELAY.replace('wss://', '')}</span>
{dataUpdatedAt > 0 && (
<><span aria-hidden>·</span><span>updated {formatAge(Date.now() - dataUpdatedAt)}</span></>
<><span aria-hidden>·</span><span>{t('walletUi.stats.updated', { age: formatAge(Date.now() - dataUpdatedAt) })}</span></>
)}
<button
type="button"
@ -251,7 +255,7 @@ export default function CommunityStatsPage() {
disabled={isFetching}
className="ml-1 inline-flex items-center gap-1 rounded-full border border-white/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white disabled:opacity-50"
>
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> Refresh
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} /> {t('walletUi.stats.refresh')}
</button>
</div>
@ -259,7 +263,7 @@ export default function CommunityStatsPage() {
{stats?.partial && (
<div className="mt-5 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
<p className="text-xs font-bold text-amber-300">
Couldn't reach every relay — these numbers may be <span className="uppercase">incomplete</span>.
{t('walletUi.stats.partial')}
</p>
</div>
)}
@ -268,20 +272,20 @@ export default function CommunityStatsPage() {
{/* ── Error state ── */}
{isError && (
<div className="mb-8 p-4 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/5 text-center text-sm text-gray-400">
Could not reach the relay right now. Stats will load once the connection is restored.
{t('walletUi.stats.error')}
</div>
)}
{/* ── Section 1: Hero numbers ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
<TrendingUp className="h-3.5 w-3.5" /> {t('walletUi.stats.atAGlance')}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} to="/bounties" />
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label={t('walletUi.stats.skateSpots')} color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} to="/map" />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label={t('walletUi.stats.listedShops')} color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} to="/map" />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label={t('walletUi.stats.events')} color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} to="/events" />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label={t('walletUi.stats.mappers')} color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub={t('walletUi.stats.uniqueContributors')} thisMonthDelta={thisMonth?.activeMappers} to="/bounties" />
</div>
</section>
@ -289,32 +293,32 @@ export default function CommunityStatsPage() {
<section className="mb-12">
<div className="flex items-center justify-between mb-4 gap-2">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center gap-2">
<FlaskConical className="h-3.5 w-3.5 text-[#ff6600]" /> The Lab · Creators &amp; Services
<FlaskConical className="h-3.5 w-3.5 text-[#ff6600]" /> {t('walletUi.stats.labHeader')}
</h2>
{!isLoading && labTotal > 0 && (
<span className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ff6600]/15 text-[#ff6600]">
{labTotal.toLocaleString()} total
{t('walletUi.stats.total', { count: labTotal.toLocaleString() })}
</span>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label="Photographers" color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label="Builders" color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label="Artists" color="#f472b6" loading={isLoading} to="/lab?craft=art" />
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label={t('walletUi.stats.coaches')} color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} to="/lab?craft=coaching" />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label={t('walletUi.stats.filmers')} color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} to="/lab?craft=filming" />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label={t('walletUi.stats.editors')} color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} to="/lab?craft=editing" />
<StatCard icon={Camera} value={stats?.totalPhotographers ?? 0} label={t('walletUi.stats.photographers')} color="#38bdf8" loading={isLoading} thisMonthDelta={thisMonth?.photographers} to="/lab?craft=photography" />
<StatCard icon={Hammer} value={stats?.totalBuilders ?? 0} label={t('walletUi.stats.builders')} color="#fb923c" loading={isLoading} thisMonthDelta={thisMonth?.builders} to="/lab?craft=building" />
<StatCard icon={Palette} value={stats?.totalArtists ?? 0} label={t('walletUi.stats.artists')} color="#f472b6" loading={isLoading} to="/lab?craft=art" />
{/* Extra crafts: collapsed on mobile (they were ~7 rows of scroll
before the first chart), always visible from `lg` up. The
`contents` wrapper keeps its children as real grid items. */}
<div className={cn(showAllLab ? 'contents' : 'hidden lg:contents')}>
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label="Graffiti" color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
<StatCard icon={SprayCan} value={stats?.totalGraffiti ?? 0} label={t('walletUi.stats.graffiti')} color="#a3e635" loading={isLoading} to="/lab?craft=graffiti" />
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label={t('walletUi.stats.tattoo')} color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label={t('walletUi.stats.trainers')} color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label={t('walletUi.stats.musicians')} color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label={t('walletUi.stats.judges')} color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label={t('walletUi.stats.organizers')} color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label={t('walletUi.stats.barbers')} color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
</div>
</div>
<button
@ -322,31 +326,31 @@ export default function CommunityStatsPage() {
onClick={() => setShowAllLab((v) => !v)}
className="mt-3 w-full rounded-full border border-white/10 py-2 text-[11px] font-bold uppercase tracking-wide text-gray-400 transition-colors hover:text-white lg:hidden"
>
{showAllLab ? 'Show fewer' : 'Show all 13 crafts'}
{showAllLab ? t('walletUi.stats.showFewer') : t('walletUi.stats.showAllCrafts')}
</button>
</section>
{/* ── Section 1c: Community — tours & crews ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> Community
<Users2 className="h-3.5 w-3.5 text-[#2dd4bf]" /> {t('walletUi.stats.community')}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} to="/crews" />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label={t('walletUi.stats.tours')} color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} to="/map" />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label={t('walletUi.stats.crews')} color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} to="/crews" />
</div>
</section>
{/* ── Section 1d: Marketplace ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> Marketplace
<ShoppingBag className="h-3.5 w-3.5 text-[#00cc66]" /> {t('walletUi.stats.marketplace')}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label="Live Listings" color="#00cc66" loading={isLoading} to="/marketplace" />
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label="Trades Recorded" color="#22c55e" loading={isLoading} to="/trades" />
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label="Sats Traded" color="#facc15" loading={isLoading} compact />
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label="Zaps to Mappers" color="#f472b6" loading={isLoading} />
<StatCard icon={Tag} value={stats?.totalListings ?? 0} label={t('walletUi.stats.liveListings')} color="#00cc66" loading={isLoading} to="/marketplace" />
<StatCard icon={Handshake} value={stats?.totalTrades ?? 0} label={t('walletUi.stats.tradesRecorded')} color="#22c55e" loading={isLoading} to="/trades" />
<StatCard icon={Zap} value={stats?.tradedSats ?? 0} label={t('walletUi.stats.satsTraded')} color="#facc15" loading={isLoading} compact />
<StatCard icon={Zap} value={stats?.zapCount ?? 0} label={t('walletUi.stats.zapsToMappers')} color="#f472b6" loading={isLoading} />
</div>
{tradesByMethod.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
@ -361,15 +365,12 @@ export default function CommunityStatsPage() {
</div>
)}
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> mutual trade
{(stats?.tradedCount ?? 0) === 1 ? '' : 's'} across{' '}
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> people. Sats are
self-reported by the traders — the app can't observe an off-app payment — so the headline
counts only <strong className="text-gray-400">mutual</strong> trades with a per-pair cap;
the raw claim is{' '}
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> {t('walletUi.stats.tradesNote1')}{' '}
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> {t('walletUi.stats.tradesNote2')}{' '}
<strong className="text-gray-400">{t('walletUi.stats.mutual')}</strong> {t('walletUi.stats.tradesNote3')}{' '}
<strong className="text-gray-500">{(stats?.tradedSatsClaimed ?? 0).toLocaleString()}</strong>{' '}
sats. "Zaps to mappers" counts only receipts addressed to our top contributors — it is
<strong className="text-gray-500"> not </strong> global zap volume.
{t('walletUi.stats.tradesNote4')}
<strong className="text-gray-500"> {t('walletUi.stats.not')} </strong> {t('walletUi.stats.tradesNote5')}
</p>
</section>
@ -377,20 +378,19 @@ export default function CommunityStatsPage() {
{(topCountries.length > 0 || topCities.length > 0) && (
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> Spots &amp; Shops by Region
<Globe className="h-3.5 w-3.5 text-[#4ade80]" /> {t('walletUi.stats.byRegion')}
</h2>
{derivingPending > 0 && (
<p className="-mt-2 mb-4 flex items-center gap-2 text-xs text-amber-300/90">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" />
Resolving {derivingPending} untagged location{derivingPending === 1 ? '' : 's'} from
their coordinates… one-time, then cached on this device.
{t('walletUi.stats.resolving', { count: derivingPending })}
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{topCountries.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">Top Countries</CardTitle>
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#4ade80]">{t('walletUi.stats.topCountries')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{topCountries.map((c) => (
@ -407,14 +407,14 @@ export default function CommunityStatsPage() {
))}
{otherCountries > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="Countries outside the top 6">Other</span>
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.otherCountriesTitle')}>{t('walletUi.stats.other')}</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCountries}</span>
</div>
)}
{uncategorised > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</span>
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.uncategorisedTitle')}>{t('walletUi.stats.uncategorised')}</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{uncategorised}</span>
</div>
@ -425,7 +425,7 @@ export default function CommunityStatsPage() {
{topCities.length > 0 && (
<Card className="border-white/8">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">Top Cities</CardTitle>
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#38bdf8]">{t('walletUi.stats.topCities')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{topCities.map((c) => (
@ -442,14 +442,14 @@ export default function CommunityStatsPage() {
))}
{otherCities > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="Cities outside the top 6">Other</span>
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.otherCitiesTitle')}>{t('walletUi.stats.other')}</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{otherCities}</span>
</div>
)}
{uncategorised > 0 && (
<div className="flex items-center gap-3">
<span className="w-24 truncate text-xs text-gray-500" title="No city/country tag">Uncategorised</span>
<span className="w-24 truncate text-xs text-gray-500" title={t('walletUi.stats.uncategorisedTitle')}>{t('walletUi.stats.uncategorised')}</span>
<div className="h-2 flex-1 rounded-full bg-white/5" />
<span className="w-8 shrink-0 text-right text-xs font-bold tabular-nums text-gray-400">{uncategorised}</span>
</div>
@ -459,12 +459,9 @@ export default function CommunityStatsPage() {
)}
</div>
<p className="mt-3 text-[11px] text-gray-600">
Counts <strong className="text-gray-500">spots &amp; shops</strong>, not skaters — from each
one's own city/country tag, or derived from its pin’s coordinates when it has
none (cached on your device; countries grouped by ISO code when present),
community relay only, with moderation-hidden content excluded.
{t('walletUi.stats.regionNote1')} <strong className="text-gray-500">{t('walletUi.stats.spotsAndShops')}</strong>{t('walletUi.stats.regionNote2')}
{locationsTotal > 0 && (
<> <strong className="text-gray-500">{uncategorised} of {locationsTotal}</strong> location{locationsTotal === 1 ? '' : 's'} have no location tag and show as “Uncategorised”.</>
<> <strong className="text-gray-500">{t('walletUi.stats.ofTotal', { count: uncategorised, total: locationsTotal })}</strong> {t('walletUi.stats.regionNote3')}</>
)}
</p>
{/* Admin-only (renders null otherwise): fix the gap right where it
@ -476,7 +473,7 @@ export default function CommunityStatsPage() {
{/* ── Section 2: Growth charts ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" /> Growth Over Time
<TrendingUp className="h-3.5 w-3.5" /> {t('walletUi.stats.growth')}
</h2>
<div className="grid md:grid-cols-2 gap-4">
@ -485,9 +482,9 @@ export default function CommunityStatsPage() {
<Card className="bg-black/80 border border-[#ff0040]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff0040]">
Spots Mapped (Cumulative)
{t('walletUi.stats.chartCumulativeTitle')}
</CardTitle>
<p className="text-xs text-gray-500">Total spots added to the map over time</p>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartCumulativeDesc')}</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
@ -514,9 +511,9 @@ export default function CommunityStatsPage() {
<Card className="bg-black/80 border border-[#ccff00]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
Monthly Activity
{t('walletUi.stats.chartMonthlyTitle')}
</CardTitle>
<p className="text-xs text-gray-500">New content published each month</p>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMonthlyDesc')}</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
@ -541,9 +538,9 @@ export default function CommunityStatsPage() {
<Card className="bg-black/80 border border-[#ff6600]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#ff6600]">
Active Mappers / Month
{t('walletUi.stats.chartMappersTitle')}
</CardTitle>
<p className="text-xs text-gray-500">Unique contributors publishing each month</p>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMappersDesc')}</p>
</CardHeader>
<CardContent className="px-2 pb-4">
{isLoading ? <ChartSkeleton /> : (
@ -568,9 +565,9 @@ export default function CommunityStatsPage() {
<Card className="bg-black/80 border border-[#c084fc]/20">
<CardHeader className="pb-2 pt-5 px-5">
<CardTitle className="text-sm font-black uppercase tracking-wide text-[#c084fc]">
Content Mix
{t('walletUi.stats.chartMixTitle')}
</CardTitle>
<p className="text-xs text-gray-500">Breakdown by content type (all time)</p>
<p className="text-xs text-gray-500">{t('walletUi.stats.chartMixDesc')}</p>
</CardHeader>
<CardContent className="px-2 pb-4 flex items-center justify-center">
{isLoading ? (
@ -590,7 +587,7 @@ export default function CommunityStatsPage() {
</PieChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-gray-600 py-8">No data yet</p>
<p className="text-sm text-gray-600 py-8">{t('walletUi.stats.noData')}</p>
)}
</CardContent>
</Card>
@ -601,8 +598,8 @@ export default function CommunityStatsPage() {
{/* ── Section 3: Top mappers ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Trophy className="h-3.5 w-3.5" /> Top Mappers
<span className="text-gray-600 font-normal normal-case tracking-normal">— most spots added</span>
<Trophy className="h-3.5 w-3.5" /> {t('walletUi.stats.topMappers')}
<span className="text-gray-600 font-normal normal-case tracking-normal">{t('walletUi.stats.mostSpots')}</span>
</h2>
<div className="grid md:grid-cols-2 gap-4">
@ -652,7 +649,7 @@ export default function CommunityStatsPage() {
</>
) : (
<div className="col-span-2 text-center py-10 text-gray-600 text-sm">
No mapper data yet
{t('walletUi.stats.noMapperData')}
</div>
)}
</div>
@ -661,17 +658,17 @@ export default function CommunityStatsPage() {
{/* ── Section 4: Supporters ── */}
<section className="mb-12">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<Zap className="h-3.5 w-3.5" /> Community Supporters
<Zap className="h-3.5 w-3.5" /> {t('walletUi.stats.supporters')}
</h2>
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ff0040]/5 to-[#ccff00]/5 p-6 md:p-8">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00]" />
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
<div>
<h3 className="text-xl font-black uppercase text-[#ccff00] mb-1">Funded by the Community</h3>
<h3 className="text-xl font-black uppercase text-[#ccff00] mb-1">{t('walletUi.stats.fundedTitle')}</h3>
<p className="text-sm text-gray-500 max-w-sm mb-4">
Decentralized Bitcoin crowdfunding via Angor.<br />
No ads, no investors, no data selling.
{t('walletUi.stats.fundedDesc1')}<br />
{t('walletUi.stats.fundedDesc2')}
</p>
<div className="flex flex-wrap gap-3">
<a
@ -680,14 +677,14 @@ export default function CommunityStatsPage() {
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ff0040]/15 border border-[#ff0040]/40 text-[#ff0040] text-xs font-bold hover:bg-[#ff0040]/25 transition-colors"
>
<Zap className="h-3.5 w-3.5" /> Back us on Angor
<Zap className="h-3.5 w-3.5" /> {t('walletUi.stats.backAngor')}
<ExternalLink className="h-3 w-3 opacity-60" />
</a>
<Link
to="/supporters"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00]/8 border border-[#ccff00]/30 text-[#ccff00] text-xs font-bold hover:bg-[#ccff00]/15 transition-colors"
>
<Users className="h-3.5 w-3.5" /> Community →
<Users className="h-3.5 w-3.5" /> {t('walletUi.stats.communityLink')}
</Link>
</div>
</div>
@ -698,11 +695,11 @@ export default function CommunityStatsPage() {
{/* ── Footer / verifiability note ── */}
<div className="text-center p-6 rounded-xl bg-white/[0.02] border border-white/5">
<p className="text-xs text-gray-600 leading-relaxed">
All data is pulled live from{' '}
{t('walletUi.stats.footer1')}{' '}
<span className="text-[#ccff00] font-mono">{SKATE_RELAY}</span>
{' '}— a public Nostr relay.{' '}
Every event is signed by its creator and cryptographically verifiable.{' '}
No numbers can be faked.
{' '}{t('walletUi.stats.footer2')}{' '}
{t('walletUi.stats.footer3')}{' '}
{t('walletUi.stats.footer4')}
</p>
</div>

Some files were not shown because too many files have changed in this diff Show more