i18n(es): translate SupportSkateSpots + hook toasts (follow, report, offline, publish)

This commit is contained in:
SkateSpots 2026-10-07 16:50:00 +07:00
parent 7e578fb7d9
commit b01bd22e96
8 changed files with 167 additions and 58 deletions

View file

@ -32,6 +32,7 @@ import {
} from '@/lib/lightningAddress'; } from '@/lib/lightningAddress';
import { Zap, Copy, Check, Loader2, CheckCircle2 } from 'lucide-react'; import { Zap, Copy, Check, Loader2, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
const PRESETS = [1000, 5000, 21000]; const PRESETS = [1000, 5000, 21000];
@ -39,6 +40,7 @@ const PRESETS = [1000, 5000, 21000];
type PayState = 'idle' | 'preparing' | 'awaiting' | 'paid' | 'error'; type PayState = 'idle' | 'preparing' | 'awaiting' | 'paid' | 'error';
export function SupportSkateSpots({ className }: { className?: string }) { export function SupportSkateSpots({ className }: { className?: string }) {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { getActiveConnection, sendPayment } = useNWC(); const { getActiveConnection, sendPayment } = useNWC();
const { webln } = useWallet(); const { webln } = useWallet();
@ -105,7 +107,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
const pay = async (mode: 'zap' | 'quiet') => { const pay = async (mode: 'zap' | 'quiet') => {
if (!Number.isFinite(amount) || amount <= 0) { if (!Number.isFinite(amount) || amount <= 0) {
toast({ title: 'Pick an amount', description: 'Enter how many sats you want to send.', variant: 'destructive' }); toast({ title: t('supportUi.pickAmount'), description: t('supportUi.enterAmount'), variant: 'destructive' });
return; return;
} }
setState('preparing'); setState('preparing');
@ -119,7 +121,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
try { try {
await sendPayment(nwc, pr); await sendPayment(nwc, pr);
setState('paid'); setState('paid');
toast({ title: 'Thank you! ⚡', description: 'Your sats keep the map free.' }); toast({ title: t('supportUi.thankYou'), description: t('supportUi.satsKeepMapFree') });
return; return;
} catch { /* fall through */ } } catch { /* fall through */ }
} }
@ -128,7 +130,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
if (typeof webln.enable === 'function') await webln.enable(); if (typeof webln.enable === 'function') await webln.enable();
await webln.sendPayment(pr); await webln.sendPayment(pr);
setState('paid'); setState('paid');
toast({ title: 'Thank you! ⚡', description: 'Your sats keep the map free.' }); toast({ title: t('supportUi.thankYou'), description: t('supportUi.satsKeepMapFree') });
return; return;
} catch { /* fall through */ } } catch { /* fall through */ }
} }
@ -136,7 +138,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
setState('awaiting'); setState('awaiting');
} catch (err) { } catch (err) {
setState('error'); setState('error');
setError(err instanceof Error ? err.message : 'Could not start the payment.'); setError(err instanceof Error ? err.message : t('supportUi.couldNotStartPayment'));
} }
}; };
@ -144,7 +146,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
toast({ title: `${label} copied` }); toast({ title: t('supportUi.copiedSuffix', { label }) });
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { /* clipboard blocked */ } } catch { /* clipboard blocked */ }
}; };
@ -159,34 +161,34 @@ export function SupportSkateSpots({ className }: { className?: string }) {
<Zap className="h-5 w-5" /> <Zap className="h-5 w-5" />
</span> </span>
<div> <div>
<h3 className="text-lg font-black uppercase text-white leading-tight">Keep the map free</h3> <h3 className="text-lg font-black uppercase text-white leading-tight">{t('supportUi.keepMapFree')}</h3>
<p className="text-xs text-gray-400">No ads, no tracking, no VC. Your sats pay for hosting, the AI node and the relay.</p> <p className="text-xs text-gray-400">{t('supportUi.keepMapFreeDesc')}</p>
</div> </div>
</div> </div>
{state === 'paid' ? ( {state === 'paid' ? (
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center space-y-2"> <div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center space-y-2">
<CheckCircle2 className="mx-auto h-7 w-7 text-[#ccff00]" /> <CheckCircle2 className="mx-auto h-7 w-7 text-[#ccff00]" />
<p className="font-black text-[#ccff00]">Thank you! ⚡</p> <p className="font-black text-[#ccff00]">{t('supportUi.thankYou')}</p>
<p className="text-xs text-gray-300">You just kept SkateSpots free for the next skater.</p> <p className="text-xs text-gray-300">{t('supportUi.keptFreeNext')}</p>
<Button size="sm" variant="outline" className="border-white/15" onClick={reset}>Done</Button> <Button size="sm" variant="outline" className="border-white/15" onClick={reset}>{t('common.done')}</Button>
</div> </div>
) : state === 'awaiting' && qr ? ( ) : state === 'awaiting' && qr ? (
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
Scan to pay <span className="font-black text-white">{amount.toLocaleString()} sats</span> {t('supportUi.scanToPay')} <span className="font-black text-white">{amount.toLocaleString()} sats</span>
</p> </p>
<img src={qr} alt="Lightning invoice QR" className="mx-auto rounded-xl bg-white p-2" width={240} height={240} /> <img src={qr} alt={t('feesUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={() => copy(invoice ?? '', 'Invoice')}> <Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={() => copy(invoice ?? '', t('supportUi.invoiceLabel'))}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} Copy invoice {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} {t('walletUi.nwc.copyInvoice')}
</Button> </Button>
<Button className="flex-1 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" onClick={reset}> <Button className="flex-1 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" onClick={reset}>
Done {t('common.done')}
</Button> </Button>
</div> </div>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Pays to <span className="font-mono text-gray-300">⚡ {PLATFORM_LIGHTNING_ADDRESS}</span> {t('sasquatchUi.paysTo')} <span className="font-mono text-gray-300">⚡ {PLATFORM_LIGHTNING_ADDRESS}</span>
</p> </p>
</div> </div>
) : ( ) : (
@ -213,7 +215,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
value={amount} value={amount}
onChange={(e) => setAmount(Number(e.target.value))} onChange={(e) => setAmount(Number(e.target.value))}
className="h-9 w-28 border-white/15 bg-black/40 text-sm text-white" className="h-9 w-28 border-white/15 bg-black/40 text-sm text-white"
aria-label="Custom amount in sats" aria-label={t('supportUi.customAmountAria')}
/> />
<span className="text-xs text-gray-500">sats</span> <span className="text-xs text-gray-500">sats</span>
</div> </div>
@ -222,7 +224,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
<Input <Input
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
placeholder="Say something (optional — public zaps only)" placeholder={t('supportUi.commentPlaceholder')}
maxLength={120} maxLength={120}
className="h-9 border-white/15 bg-black/40 text-sm text-white placeholder:text-gray-600" className="h-9 border-white/15 bg-black/40 text-sm text-white placeholder:text-gray-600"
/> />
@ -233,28 +235,28 @@ export function SupportSkateSpots({ className }: { className?: string }) {
<Button <Button
onClick={() => pay('zap')} onClick={() => pay('zap')}
disabled={state === 'preparing' || !user} disabled={state === 'preparing' || !user}
title={user ? 'Public zap with your comment' : 'Log in to send a public zap'} title={user ? t('supportUi.publicZapTitle') : t('supportUi.loginToZapTitle')}
className="flex-1 gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90" className="flex-1 gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
> >
{state === 'preparing' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />} {state === 'preparing' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
Zap SkateSpots {t('supportUi.zapSkateSpots')}
</Button> </Button>
<Button <Button
onClick={() => pay('quiet')} onClick={() => pay('quiet')}
disabled={state === 'preparing'} disabled={state === 'preparing'}
title="Anonymous — no login, no record" title={t('supportUi.anonymousTitle')}
variant="outline" variant="outline"
className="flex-1 border-white/20 text-gray-200 hover:bg-white/5" className="flex-1 border-white/20 text-gray-200 hover:bg-white/5"
> >
Pay quietly {t('supportUi.payQuietly')}
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<button <button
onClick={() => copy(PLATFORM_LIGHTNING_ADDRESS, 'Address')} onClick={() => copy(PLATFORM_LIGHTNING_ADDRESS, t('supportUi.addressLabel'))}
className="flex items-center gap-1.5 font-mono text-[11px] text-gray-400 hover:text-[#ccff00] transition-colors" className="flex items-center gap-1.5 font-mono text-[11px] text-gray-400 hover:text-[#ccff00] transition-colors"
title="Copy the lightning address" title={t('supportUi.copyLightningTitle')}
> >
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />} {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
⚡ {PLATFORM_LIGHTNING_ADDRESS} ⚡ {PLATFORM_LIGHTNING_ADDRESS}
@ -262,9 +264,7 @@ export function SupportSkateSpots({ className }: { className?: string }) {
</div> </div>
<p className="text-[11px] leading-relaxed text-gray-600"> <p className="text-[11px] leading-relaxed text-gray-600">
Support, <strong className="text-gray-500">not a purchase</strong> — it buys nothing and {t('supportUi.notAPurchase')}
there are no refunds. Merchandise and Sasquatch Pro are separate. Your location and
identity are never involved in a quiet payment.
</p> </p>
</> </>
)} )}

View file

@ -2,6 +2,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser'; import { useCurrentUser } from './useCurrentUser';
import { useToast } from './useToast'; import { useToast } from './useToast';
import { useTranslation } from '@/contexts/language';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
/** /**
@ -74,6 +75,7 @@ export function useFollowers(pubkey?: string) {
* Hook to follow/unfollow users * Hook to follow/unfollow users
*/ */
export function useFollowActions() { export function useFollowActions() {
const { t } = useTranslation();
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
@ -120,13 +122,13 @@ export function useFollowActions() {
queryClient.invalidateQueries({ queryKey: ['trust-score'] }); queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({ toast({
title: 'Followed successfully', title: t('toastsUi.followingSuccess'),
description: 'This user is now in your network', description: t('toastsUi.followingSuccessDesc'),
}); });
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
title: 'Failed to follow', title: t('toastsUi.followFailed'),
description: error instanceof Error ? error.message : 'Unknown error', description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive', variant: 'destructive',
}); });
@ -169,13 +171,13 @@ export function useFollowActions() {
queryClient.invalidateQueries({ queryKey: ['trust-score'] }); queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({ toast({
title: 'Unfollowed successfully', title: t('toastsUi.unfollowSuccess'),
description: 'Removed from your network', description: t('toastsUi.unfollowSuccessDesc'),
}); });
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
title: 'Failed to unfollow', title: t('toastsUi.unfollowFailed'),
description: error instanceof Error ? error.message : 'Unknown error', description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive', variant: 'destructive',
}); });
@ -217,10 +219,10 @@ export function useFollowActions() {
return; return;
} }
throw new Error('Invalid identifier. Use npub1..., user@domain.com, or hex pubkey'); throw new Error(t('toastsUi.invalidIdentifier'));
} catch (error) { } catch (error) {
toast({ toast({
title: 'Failed to follow', title: t('toastsUi.followFailed'),
description: error instanceof Error ? error.message : 'Invalid identifier', description: error instanceof Error ? error.message : 'Invalid identifier',
variant: 'destructive', variant: 'destructive',
}); });

View file

@ -5,6 +5,7 @@ import { useCurrentUser } from "./useCurrentUser";
import { isOffline, queueSignedEvent } from "@/lib/offlineCache"; import { isOffline, queueSignedEvent } from "@/lib/offlineCache";
import { minePow } from "@/lib/pow"; import { minePow } from "@/lib/pow";
import { toast } from "@/hooks/useToast"; import { toast } from "@/hooks/useToast";
import { useTranslation } from "@/contexts/language";
import type { NostrEvent, NPool } from "@nostrify/nostrify"; import type { NostrEvent, NPool } from "@nostrify/nostrify";
@ -13,12 +14,12 @@ import type { NostrEvent, NPool } from "@nostrify/nostrify";
* happened (above), so an interactive signer is NOT prompted again later — we * happened (above), so an interactive signer is NOT prompted again later — we
* just hand the signature over. * just hand the signature over.
*/ */
async function queueForLater(event: NostrEvent): Promise<boolean> { async function queueForLater(event: NostrEvent, tr: (key: string) => string): Promise<boolean> {
try { try {
await queueSignedEvent(event); await queueSignedEvent(event);
toast({ toast({
title: 'Saved offline', title: tr('toastsUi.savedOffline'),
description: "We'll publish it automatically when you're back online.", description: tr('toastsUi.savedOfflineDesc'),
}); });
return true; return true;
} catch { } catch {
@ -36,6 +37,7 @@ async function queueForLater(event: NostrEvent): Promise<boolean> {
export function useNostrPublish(): UseMutationResult<NostrEvent> { export function useNostrPublish(): UseMutationResult<NostrEvent> {
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { t: tr } = useTranslation();
return useMutation({ return useMutation({
mutationFn: async (t: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { pow?: number }) => { mutationFn: async (t: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { pow?: number }) => {
@ -73,7 +75,7 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
// Offline: don't sit through a 15s publish timeout before giving up. // Offline: don't sit through a 15s publish timeout before giving up.
if (isOffline()) { if (isOffline()) {
await queueForLater(event); await queueForLater(event, tr);
return event; return event;
} }
@ -82,7 +84,7 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
} catch (err) { } catch (err) {
// Relays unreachable — keep it and deliver later, unless queuing failed // Relays unreachable — keep it and deliver later, unless queuing failed
// (then the real error is the honest thing to surface). // (then the real error is the honest thing to surface).
const queued = await queueForLater(event); const queued = await queueForLater(event, tr);
if (!queued) throw err; if (!queued) throw err;
} }

View file

@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types'; import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
/** /**
@ -18,6 +19,7 @@ import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
* not for disagreeing about whether a spot is good. * not for disagreeing about whether a spot is good.
*/ */
export function useNotASpotReport() { export function useNotASpotReport() {
const { t } = useTranslation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { nostr } = useNostr(); const { nostr } = useNostr();
const { getWriteRelays } = useSkateRelays(); const { getWriteRelays } = useSkateRelays();
@ -35,7 +37,7 @@ export function useNotASpotReport() {
pubkey: string; pubkey: string;
title?: string; title?: string;
}) => { }) => {
if (!user) throw new Error('Log in to report a spot.'); if (!user) throw new Error(t('toastsUi.loginToReport'));
const event = await user.signer.signEvent({ const event = await user.signer.signEvent({
kind: 1984, // NIP-56 report kind: 1984, // NIP-56 report
@ -60,20 +62,19 @@ export function useNotASpotReport() {
), ),
); );
if (results.every((r) => r.status === 'rejected')) { 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('toastsUi.relayUnreachable'));
} }
}, },
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: 'Thanks — sent for review', title: t('toastsUi.notASpotThanks'),
description: description: t('toastsUi.notASpotThanksDesc'),
'No hard feelings: this flags the pin as off-topic so people can decide for themselves.',
}); });
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
title: 'Could not send the report', title: t('toastsUi.notASpotFailed'),
description: error instanceof Error ? error.message : 'Please try again.', description: error instanceof Error ? error.message : t('toastsUi.pleaseTryAgain'),
variant: 'destructive', variant: 'destructive',
}); });
}, },

View file

@ -22,6 +22,7 @@ import {
} from '@/lib/offlineCache'; } from '@/lib/offlineCache';
import { useCurrentUser } from './useCurrentUser'; import { useCurrentUser } from './useCurrentUser';
import { useToast } from './useToast'; import { useToast } from './useToast';
import { useTranslation } from '@/contexts/language';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import type { SkateSpot } from '@/lib/types'; import type { SkateSpot } from '@/lib/types';
@ -121,6 +122,7 @@ export function useCacheStats() {
* Hook to manage cache (clear old/all) * Hook to manage cache (clear old/all)
*/ */
export function useCacheManagement() { export function useCacheManagement() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
@ -133,8 +135,8 @@ export function useCacheManagement() {
queryClient.invalidateQueries({ queryKey: ['cached-spots'] }); queryClient.invalidateQueries({ queryKey: ['cached-spots'] });
queryClient.invalidateQueries({ queryKey: ['cache-stats'] }); queryClient.invalidateQueries({ queryKey: ['cache-stats'] });
toast({ toast({
title: 'Cache cleaned', title: t('toastsUi.cacheCleaned'),
description: `Removed ${count} old spots from cache`, description: t('toastsUi.cacheCleanedDesc', { count }),
}); });
}, },
}); });
@ -145,8 +147,8 @@ export function useCacheManagement() {
queryClient.invalidateQueries({ queryKey: ['cached-spots'] }); queryClient.invalidateQueries({ queryKey: ['cached-spots'] });
queryClient.invalidateQueries({ queryKey: ['cache-stats'] }); queryClient.invalidateQueries({ queryKey: ['cache-stats'] });
toast({ toast({
title: 'Cache cleared', title: t('toastsUi.cacheCleared'),
description: 'All cached spots have been removed', description: t('toastsUi.cacheClearedDesc'),
}); });
}, },
}); });
@ -184,6 +186,7 @@ export function usePendingPostsCount() {
* through `useNostrPublish` — that would re-queue on failure and duplicate. * through `useNostrPublish` — that would re-queue on failure and duplicate.
*/ */
export function useSyncPendingPosts() { export function useSyncPendingPosts() {
const { t } = useTranslation();
const isOnline = useOnlineStatus(); const isOnline = useOnlineStatus();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { nostr } = useNostr(); const { nostr } = useNostr();
@ -240,8 +243,8 @@ export function useSyncPendingPosts() {
if (successCount > 0) { if (successCount > 0) {
toast({ toast({
title: 'Back online', title: t('toastsUi.backOnline'),
description: `Sent ${successCount} saved action${successCount > 1 ? 's' : ''}.`, description: t('toastsUi.backOnlineDesc', { count: successCount }),
}); });
} }
}; };
@ -254,7 +257,7 @@ export function useSyncPendingPosts() {
clearTimeout(timeout); clearTimeout(timeout);
clearInterval(interval); clearInterval(interval);
}; };
}, [isOnline, user, nostr, queryClient, toast]); }, [isOnline, user, nostr, queryClient, toast, t]);
return { isSyncing }; return { isSyncing };
} }
@ -263,6 +266,7 @@ export function useSyncPendingPosts() {
* Hook to queue a post for offline publishing * Hook to queue a post for offline publishing
*/ */
export function useQueuePost() { export function useQueuePost() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
@ -273,8 +277,8 @@ export function useQueuePost() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] }); queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] });
toast({ toast({
title: 'Post queued', title: t('toastsUi.postQueued'),
description: 'Your post will be published when you\'re back online', description: t('toastsUi.postQueuedDesc'),
}); });
}, },
}); });

View file

@ -6878,6 +6878,56 @@ walletUi: {
offlineViewDetails: 'View cached spot details', offlineViewDetails: 'View cached spot details',
offlineKeepActing: 'Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect', offlineKeepActing: 'Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect',
}, },
supportUi: {
keepMapFree: 'Keep the map free',
keepMapFreeDesc: 'No ads, no tracking, no VC. Your sats pay for hosting, the AI node and the relay.',
thankYou: 'Thank you! ⚡',
satsKeepMapFree: 'Your sats keep the map free.',
keptFreeNext: 'You just kept SkateSpots free for the next skater.',
scanToPay: 'Scan to pay',
zapSkateSpots: 'Zap SkateSpots',
payQuietly: 'Pay quietly',
pickAmount: 'Pick an amount',
enterAmount: 'Enter how many sats you want to send.',
couldNotStartPayment: 'Could not start the payment.',
customAmountAria: 'Custom amount in sats',
commentPlaceholder: 'Say something (optional — public zaps only)',
publicZapTitle: 'Public zap with your comment',
loginToZapTitle: 'Log in to send a public zap',
anonymousTitle: 'Anonymous — no login, no record',
copyLightningTitle: 'Copy the lightning address',
copiedSuffix: '{{label}} copied',
invoiceLabel: 'Invoice',
addressLabel: 'Address',
notAPurchase: 'Support, not a purchase — it buys nothing and there are no refunds. Merchandise and Sasquatch Pro are separate. Your location and identity are never involved in a quiet payment.',
},
toastsUi: {
followingSuccess: 'Followed successfully',
followingSuccessDesc: 'This user is now in your network',
followFailed: 'Failed to follow',
unfollowSuccess: 'Unfollowed successfully',
unfollowSuccessDesc: 'Removed from your network',
unfollowFailed: 'Failed to unfollow',
reportThanks: 'Thanks — sent for review',
reportFailed: 'Could not send the report',
savedOffline: 'Saved offline',
savedOfflineDesc: "We'll publish it automatically when you're back online.",
cacheCleaned: 'Cache cleaned',
cacheCleanedDesc: 'Removed {{count}} old spots from cache',
cacheCleared: 'Cache cleared',
cacheClearedDesc: 'All cached spots have been removed',
backOnline: 'Back online',
backOnlineDesc: 'Sent {{count}} saved action(s).',
postQueued: 'Post queued',
postQueuedDesc: "Your post will be published when you're back online",
invalidIdentifier: 'Invalid identifier. Use npub1..., user@domain.com, or hex pubkey',
notASpotThanks: 'Thanks — sent for review',
notASpotThanksDesc: 'No hard feelings: this flags the pin as off-topic so people can decide for themselves.',
notASpotFailed: 'Could not send the report',
pleaseTryAgain: 'Please try again.',
relayUnreachable: 'Could not reach any relay — check your connection and try again.',
loginToReport: 'Log in to report a spot.',
},
cardUi: { cardUi: {
save: 'Save card to Nostr', save: 'Save card to Nostr',
saved: 'Card saved', saved: 'Card saved',

View file

@ -6878,6 +6878,56 @@ walletUi: {
offlineViewDetails: 'Ver detalles de spots en caché', offlineViewDetails: 'Ver detalles de spots en caché',
offlineKeepActing: 'Sigue actuando — publicaciones, RSVPs, anuncios y mensajes se ponen en cola y se envían al reconectar', offlineKeepActing: 'Sigue actuando — publicaciones, RSVPs, anuncios y mensajes se ponen en cola y se envían al reconectar',
}, },
supportUi: {
keepMapFree: 'Mantén el mapa gratis',
keepMapFreeDesc: 'Sin anuncios, sin rastreo, sin capital riesgo. Tus sats pagan el hosting, el nodo de IA y el relay.',
thankYou: '¡Gracias! ⚡',
satsKeepMapFree: 'Tus sats mantienen el mapa gratis.',
keptFreeNext: 'Acabas de mantener SkateSpots gratis para el próximo skater.',
scanToPay: 'Escanea para pagar',
zapSkateSpots: 'Zapear a SkateSpots',
payQuietly: 'Pagar en privado',
pickAmount: 'Elige un importe',
enterAmount: 'Introduce cuántos sats quieres enviar.',
couldNotStartPayment: 'No se pudo iniciar el pago.',
customAmountAria: 'Importe personalizado en sats',
commentPlaceholder: 'Di algo (opcional — solo zaps públicos)',
publicZapTitle: 'Zap público con tu comentario',
loginToZapTitle: 'Inicia sesión para enviar un zap público',
anonymousTitle: 'Anónimo — sin login, sin registro',
copyLightningTitle: 'Copiar la dirección Lightning',
copiedSuffix: '{{label}} copiado',
invoiceLabel: 'Factura',
addressLabel: 'Dirección',
notAPurchase: 'Es apoyo, no una compra — no compra nada y no hay reembolsos. La merch y Sasquatch Pro son aparte. Tu ubicación e identidad nunca intervienen en un pago privado.',
},
toastsUi: {
followingSuccess: 'Siguiendo',
followingSuccessDesc: 'Este usuario ya está en tu red',
followFailed: 'No se pudo seguir',
unfollowSuccess: 'Dejaste de seguir',
unfollowSuccessDesc: 'Eliminado de tu red',
unfollowFailed: 'No se pudo dejar de seguir',
reportThanks: 'Gracias — enviado a revisión',
reportFailed: 'No se pudo enviar el informe',
savedOffline: 'Guardado sin conexión',
savedOfflineDesc: 'Lo publicaremos automáticamente al volver a estar en línea.',
cacheCleaned: 'Caché limpiada',
cacheCleanedDesc: 'Se eliminaron {{count}} spots antiguos de la caché',
cacheCleared: 'Caché borrada',
cacheClearedDesc: 'Se han eliminado todos los spots en caché',
backOnline: 'De nuevo en línea',
backOnlineDesc: 'Se enviaron {{count}} acción(es) guardadas.',
postQueued: 'Publicación en cola',
postQueuedDesc: 'Tu publicación se enviará cuando vuelvas a estar en línea',
invalidIdentifier: 'Identificador no válido. Usa npub1..., usuario@dominio.com o clave hex',
notASpotThanks: 'Gracias — enviado a revisión',
notASpotThanksDesc: 'Sin rencores: esto marca el pin como fuera de tema para que cada cual decida.',
notASpotFailed: 'No se pudo enviar el informe',
pleaseTryAgain: 'Inténtalo de nuevo.',
relayUnreachable: 'No se pudo alcanzar ningún relay — revisa tu conexión e inténtalo de nuevo.',
loginToReport: 'Inicia sesión para informar de un spot.',
},
cardUi: { cardUi: {
save: 'Guardar carné en Nostr', save: 'Guardar carné en Nostr',
saved: 'Carné guardado', saved: 'Carné guardado',

View file

@ -669,9 +669,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{g.forSale && ( {g.forSale && (
<span className="absolute top-2 left-2 flex items-center gap-1 text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ccff00] text-black"> <span className="absolute top-2 left-2 flex items-center gap-1 text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ccff00] text-black">
{g.willingToTrade && !g.priceSats ? ( {g.willingToTrade && !g.priceSats ? (
<><ArrowRightLeft className="h-2.5 w-2.5" />Trade</> <><ArrowRightLeft className="h-2.5 w-2.5" />{t('marketUi.seller.trade')}</>
) : ( ) : (
<><Tag className="h-2.5 w-2.5" />For Sale</> <><Tag className="h-2.5 w-2.5" />{t('marketUi.gearSwap.forSale')}</>
)} )}
</span> </span>
)} )}