diff --git a/src/components/SupportSkateSpots.tsx b/src/components/SupportSkateSpots.tsx index c00e6cb..378074d 100644 --- a/src/components/SupportSkateSpots.tsx +++ b/src/components/SupportSkateSpots.tsx @@ -32,6 +32,7 @@ import { } from '@/lib/lightningAddress'; import { Zap, Copy, Check, Loader2, CheckCircle2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useTranslation } from '@/contexts/language'; const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; const PRESETS = [1000, 5000, 21000]; @@ -39,6 +40,7 @@ const PRESETS = [1000, 5000, 21000]; type PayState = 'idle' | 'preparing' | 'awaiting' | 'paid' | 'error'; export function SupportSkateSpots({ className }: { className?: string }) { + const { t } = useTranslation(); const { user } = useCurrentUser(); const { getActiveConnection, sendPayment } = useNWC(); const { webln } = useWallet(); @@ -105,7 +107,7 @@ export function SupportSkateSpots({ className }: { className?: string }) { const pay = async (mode: 'zap' | 'quiet') => { 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; } setState('preparing'); @@ -119,7 +121,7 @@ export function SupportSkateSpots({ className }: { className?: string }) { try { await sendPayment(nwc, pr); setState('paid'); - toast({ title: 'Thank you! ⚡', description: 'Your sats keep the map free.' }); + toast({ title: t('supportUi.thankYou'), description: t('supportUi.satsKeepMapFree') }); return; } catch { /* fall through */ } } @@ -128,7 +130,7 @@ export function SupportSkateSpots({ className }: { className?: string }) { if (typeof webln.enable === 'function') await webln.enable(); await webln.sendPayment(pr); setState('paid'); - toast({ title: 'Thank you! ⚡', description: 'Your sats keep the map free.' }); + toast({ title: t('supportUi.thankYou'), description: t('supportUi.satsKeepMapFree') }); return; } catch { /* fall through */ } } @@ -136,7 +138,7 @@ export function SupportSkateSpots({ className }: { className?: string }) { setState('awaiting'); } catch (err) { 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 { await navigator.clipboard.writeText(text); setCopied(true); - toast({ title: `${label} copied` }); + toast({ title: t('supportUi.copiedSuffix', { label }) }); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard blocked */ } }; @@ -159,34 +161,34 @@ export function SupportSkateSpots({ className }: { className?: string }) {
-

Keep the map free

-

No ads, no tracking, no VC. Your sats pay for hosting, the AI node and the relay.

+

{t('supportUi.keepMapFree')}

+

{t('supportUi.keepMapFreeDesc')}

{state === 'paid' ? (
-

Thank you! ⚡

-

You just kept SkateSpots free for the next skater.

- +

{t('supportUi.thankYou')}

+

{t('supportUi.keptFreeNext')}

+
) : state === 'awaiting' && qr ? (

- Scan to pay {amount.toLocaleString()} sats + {t('supportUi.scanToPay')} {amount.toLocaleString()} sats

- Lightning invoice QR + {t('feesUi.invoiceQrAlt')}
-

- Pays to ⚡ {PLATFORM_LIGHTNING_ADDRESS} + {t('sasquatchUi.paysTo')} ⚡ {PLATFORM_LIGHTNING_ADDRESS}

) : ( @@ -213,7 +215,7 @@ export function SupportSkateSpots({ className }: { className?: string }) { value={amount} onChange={(e) => setAmount(Number(e.target.value))} 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')} /> sats @@ -222,7 +224,7 @@ export function SupportSkateSpots({ className }: { className?: string }) { setComment(e.target.value)} - placeholder="Say something (optional — public zaps only)" + placeholder={t('supportUi.commentPlaceholder')} maxLength={120} 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 }) {

- 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. + {t('supportUi.notAPurchase')}

)} diff --git a/src/hooks/useFollowing.ts b/src/hooks/useFollowing.ts index a1a954e..794ec22 100644 --- a/src/hooks/useFollowing.ts +++ b/src/hooks/useFollowing.ts @@ -2,6 +2,7 @@ import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useToast } from './useToast'; +import { useTranslation } from '@/contexts/language'; import { nip19 } from 'nostr-tools'; /** @@ -74,6 +75,7 @@ export function useFollowers(pubkey?: string) { * Hook to follow/unfollow users */ export function useFollowActions() { + const { t } = useTranslation(); const { nostr } = useNostr(); const { user } = useCurrentUser(); const { toast } = useToast(); @@ -120,13 +122,13 @@ export function useFollowActions() { queryClient.invalidateQueries({ queryKey: ['trust-score'] }); toast({ - title: 'Followed successfully', - description: 'This user is now in your network', + title: t('toastsUi.followingSuccess'), + description: t('toastsUi.followingSuccessDesc'), }); }, onError: (error) => { toast({ - title: 'Failed to follow', + title: t('toastsUi.followFailed'), description: error instanceof Error ? error.message : 'Unknown error', variant: 'destructive', }); @@ -169,13 +171,13 @@ export function useFollowActions() { queryClient.invalidateQueries({ queryKey: ['trust-score'] }); toast({ - title: 'Unfollowed successfully', - description: 'Removed from your network', + title: t('toastsUi.unfollowSuccess'), + description: t('toastsUi.unfollowSuccessDesc'), }); }, onError: (error) => { toast({ - title: 'Failed to unfollow', + title: t('toastsUi.unfollowFailed'), description: error instanceof Error ? error.message : 'Unknown error', variant: 'destructive', }); @@ -217,10 +219,10 @@ export function useFollowActions() { return; } - throw new Error('Invalid identifier. Use npub1..., user@domain.com, or hex pubkey'); + throw new Error(t('toastsUi.invalidIdentifier')); } catch (error) { toast({ - title: 'Failed to follow', + title: t('toastsUi.followFailed'), description: error instanceof Error ? error.message : 'Invalid identifier', variant: 'destructive', }); diff --git a/src/hooks/useNostrPublish.ts b/src/hooks/useNostrPublish.ts index 26356f1..cc662a6 100644 --- a/src/hooks/useNostrPublish.ts +++ b/src/hooks/useNostrPublish.ts @@ -5,6 +5,7 @@ import { useCurrentUser } from "./useCurrentUser"; import { isOffline, queueSignedEvent } from "@/lib/offlineCache"; import { minePow } from "@/lib/pow"; import { toast } from "@/hooks/useToast"; +import { useTranslation } from "@/contexts/language"; 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 * just hand the signature over. */ -async function queueForLater(event: NostrEvent): Promise { +async function queueForLater(event: NostrEvent, tr: (key: string) => string): Promise { try { await queueSignedEvent(event); toast({ - title: 'Saved offline', - description: "We'll publish it automatically when you're back online.", + title: tr('toastsUi.savedOffline'), + description: tr('toastsUi.savedOfflineDesc'), }); return true; } catch { @@ -36,6 +37,7 @@ async function queueForLater(event: NostrEvent): Promise { export function useNostrPublish(): UseMutationResult { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { t: tr } = useTranslation(); return useMutation({ mutationFn: async (t: Omit & { pow?: number }) => { @@ -73,7 +75,7 @@ export function useNostrPublish(): UseMutationResult { // Offline: don't sit through a 15s publish timeout before giving up. if (isOffline()) { - await queueForLater(event); + await queueForLater(event, tr); return event; } @@ -82,7 +84,7 @@ export function useNostrPublish(): UseMutationResult { } catch (err) { // Relays unreachable — keep it and deliver later, unless queuing failed // (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; } diff --git a/src/hooks/useNotASpotReport.ts b/src/hooks/useNotASpotReport.ts index 5479af7..c6aa2bc 100644 --- a/src/hooks/useNotASpotReport.ts +++ b/src/hooks/useNotASpotReport.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; +import { useTranslation } from '@/contexts/language'; 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. */ export function useNotASpotReport() { + const { t } = useTranslation(); const { user } = useCurrentUser(); const { nostr } = useNostr(); const { getWriteRelays } = useSkateRelays(); @@ -35,7 +37,7 @@ export function useNotASpotReport() { pubkey: 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({ kind: 1984, // NIP-56 report @@ -60,20 +62,19 @@ export function useNotASpotReport() { ), ); 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: () => { toast({ - title: 'Thanks — sent for review', - description: - 'No hard feelings: this flags the pin as off-topic so people can decide for themselves.', + title: t('toastsUi.notASpotThanks'), + description: t('toastsUi.notASpotThanksDesc'), }); }, onError: (error) => { toast({ - title: 'Could not send the report', - description: error instanceof Error ? error.message : 'Please try again.', + title: t('toastsUi.notASpotFailed'), + description: error instanceof Error ? error.message : t('toastsUi.pleaseTryAgain'), variant: 'destructive', }); }, diff --git a/src/hooks/useOfflineCache.ts b/src/hooks/useOfflineCache.ts index d5e1fd4..83520d1 100644 --- a/src/hooks/useOfflineCache.ts +++ b/src/hooks/useOfflineCache.ts @@ -22,6 +22,7 @@ import { } from '@/lib/offlineCache'; import { useCurrentUser } from './useCurrentUser'; import { useToast } from './useToast'; +import { useTranslation } from '@/contexts/language'; import type { NostrEvent } from '@nostrify/nostrify'; import type { SkateSpot } from '@/lib/types'; @@ -121,6 +122,7 @@ export function useCacheStats() { * Hook to manage cache (clear old/all) */ export function useCacheManagement() { + const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); @@ -133,8 +135,8 @@ export function useCacheManagement() { queryClient.invalidateQueries({ queryKey: ['cached-spots'] }); queryClient.invalidateQueries({ queryKey: ['cache-stats'] }); toast({ - title: 'Cache cleaned', - description: `Removed ${count} old spots from cache`, + title: t('toastsUi.cacheCleaned'), + description: t('toastsUi.cacheCleanedDesc', { count }), }); }, }); @@ -145,8 +147,8 @@ export function useCacheManagement() { queryClient.invalidateQueries({ queryKey: ['cached-spots'] }); queryClient.invalidateQueries({ queryKey: ['cache-stats'] }); toast({ - title: 'Cache cleared', - description: 'All cached spots have been removed', + title: t('toastsUi.cacheCleared'), + description: t('toastsUi.cacheClearedDesc'), }); }, }); @@ -184,6 +186,7 @@ export function usePendingPostsCount() { * through `useNostrPublish` — that would re-queue on failure and duplicate. */ export function useSyncPendingPosts() { + const { t } = useTranslation(); const isOnline = useOnlineStatus(); const queryClient = useQueryClient(); const { nostr } = useNostr(); @@ -240,8 +243,8 @@ export function useSyncPendingPosts() { if (successCount > 0) { toast({ - title: 'Back online', - description: `Sent ${successCount} saved action${successCount > 1 ? 's' : ''}.`, + title: t('toastsUi.backOnline'), + description: t('toastsUi.backOnlineDesc', { count: successCount }), }); } }; @@ -254,7 +257,7 @@ export function useSyncPendingPosts() { clearTimeout(timeout); clearInterval(interval); }; - }, [isOnline, user, nostr, queryClient, toast]); + }, [isOnline, user, nostr, queryClient, toast, t]); return { isSyncing }; } @@ -263,6 +266,7 @@ export function useSyncPendingPosts() { * Hook to queue a post for offline publishing */ export function useQueuePost() { + const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); @@ -273,8 +277,8 @@ export function useQueuePost() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['pending-posts-count'] }); toast({ - title: 'Post queued', - description: 'Your post will be published when you\'re back online', + title: t('toastsUi.postQueued'), + description: t('toastsUi.postQueuedDesc'), }); }, }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f0cbad8..74c6ca3 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6878,6 +6878,56 @@ walletUi: { offlineViewDetails: 'View cached spot details', 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: { save: 'Save card to Nostr', saved: 'Card saved', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index de81350..4ecdce4 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6878,6 +6878,56 @@ walletUi: { 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', }, + 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: { save: 'Guardar carné en Nostr', saved: 'Carné guardado', diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index cfeff66..9c7d1c1 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -669,9 +669,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) { {g.forSale && ( {g.willingToTrade && !g.priceSats ? ( - <>Trade + <>{t('marketUi.seller.trade')} ) : ( - <>For Sale + <>{t('marketUi.gearSwap.forSale')} )} )}