i18n(es): translate shared component literals (camera, quality meter, entity picker, offline, comments)

This commit is contained in:
SkateSpots 2026-10-07 16:44:23 +07:00
parent c7a6421be1
commit 7e578fb7d9
7 changed files with 192 additions and 65 deletions

View file

@ -22,6 +22,7 @@ import {
} from '@/components/ui/dialog';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import {
Camera,
RotateCcw,
@ -106,6 +107,7 @@ export function CameraCapture({
onCapture,
isUploading,
}: CameraCaptureProps) {
const { t } = useTranslation();
const { toast } = useToast();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@ -134,14 +136,14 @@ export function CameraCapture({
return;
}
toast({
title: 'Camera error',
description: 'Could not open the camera. Please check app permissions in Settings.',
title: t('miscUi.cameraErrorTitle'),
description: t('miscUi.cameraErrorDesc'),
variant: 'destructive',
});
} finally {
setIsCapturingNative(false);
}
}, [onCapture, onOpenChange, toast]);
}, [onCapture, onOpenChange, toast, t]);
// ── Browser camera ────────────────────────────────────────────────────────
const startCamera = useCallback(async (facing: 'user' | 'environment' = facingMode) => {
@ -164,14 +166,14 @@ export function CameraCapture({
} catch {
setHasPermission(false);
toast({
title: 'Camera access denied',
description: 'Please allow camera access in your browser settings.',
title: t('miscUi.cameraDeniedTitle'),
description: t('miscUi.cameraDeniedDesc'),
variant: 'destructive',
});
} finally {
setIsStarting(false);
}
}, [facingMode, toast]);
}, [facingMode, toast, t]);
const stopCamera = useCallback(() => {
streamRef.current?.getTracks().forEach(track => track.stop());
@ -239,13 +241,13 @@ export function CameraCapture({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50">
<DialogTitle className="sr-only">Opening Camera</DialogTitle>
<DialogTitle className="sr-only">{t('miscUi.openingCamera')}</DialogTitle>
<div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
<Camera className="h-8 w-8 text-[#ff0040]" />
</div>
<p className="text-white font-bold text-center">Opening camera…</p>
<p className="text-gray-400 text-sm text-center">Use your phone camera, then return here</p>
<p className="text-white font-bold text-center">{t('miscUi.openingCamera')}</p>
<p className="text-gray-400 text-sm text-center">{t('miscUi.usePhoneCamera')}</p>
</div>
</DialogContent>
</Dialog>
@ -271,7 +273,7 @@ export function CameraCapture({
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-[#ccff00] mx-auto mb-2" />
<p className="text-gray-400 text-sm">Starting camera...</p>
<p className="text-gray-400 text-sm">{t('miscUi.startingCamera')}</p>
</div>
</div>
)}
@ -282,21 +284,21 @@ export function CameraCapture({
<div className="text-center p-6 space-y-4">
<Camera className="h-12 w-12 text-gray-600 mx-auto" />
<div>
<h3 className="text-white font-bold mb-2">Camera Access Required</h3>
<h3 className="text-white font-bold mb-2">{t('miscUi.cameraAccessRequired')}</h3>
<p className="text-gray-400 text-sm">
Allow camera access in your browser settings, or use the file picker below.
{t('miscUi.cameraAccessHint')}
</p>
</div>
<div className="flex flex-col gap-2">
<Button onClick={() => startCamera()} className="bg-[#ff0040] hover:bg-[#ff0040]/90 gap-2">
<Camera className="h-4 w-4" />
Try Again
{t('common.tryAgain')}
</Button>
<label className="cursor-pointer">
<Button variant="outline" className="gap-2 w-full border-gray-700" asChild>
<span>
<Upload className="h-4 w-4" />
Upload from Gallery
{t('miscUi.uploadFromGallery')}
</span>
</Button>
<input
@ -400,9 +402,9 @@ export function CameraCapture({
className="gap-2 bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
>
{isUploading ? (
<><Loader2 className="h-4 w-4 animate-spin" />Uploading...</>
<><Loader2 className="h-4 w-4 animate-spin" />{t('miscUi.uploading')}</>
) : (
<><Check className="h-4 w-4" />Use Photo</>
<><Check className="h-4 w-4" />{t('miscUi.usePhoto')}</>
)}
</Button>
</div>

View file

@ -9,6 +9,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import type { EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface EntityTypeFieldProps {
entityType: EntityType;
@ -22,6 +23,7 @@ interface EntityTypeFieldProps {
}
function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
const { t } = useTranslation();
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
@ -43,7 +45,7 @@ function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void
size="icon"
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
onClick={onRemove}
aria-label="Remove member"
aria-label={t('miscUi.removeMember')}
>
<X className="h-4 w-4" />
</Button>
@ -65,6 +67,7 @@ export function EntityTypeField({
accentBg = 'bg-indigo-600',
accentText = 'text-indigo-400',
}: EntityTypeFieldProps) {
const { t } = useTranslation();
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null);
@ -87,11 +90,11 @@ export function EntityTypeField({
}
if (!hex) {
setError('Enter a valid npub, nprofile, or 64-char hex pubkey.');
setError(t('miscUi.entityInvalidPubkey'));
return;
}
if (members.includes(hex)) {
setError('That member is already added.');
setError(t('miscUi.entityAlreadyAdded'));
return;
}
onMembersChange([...members, hex]);
@ -102,7 +105,7 @@ export function EntityTypeField({
return (
<div className="space-y-4 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div className="space-y-2">
<Label className="text-base font-bold">Who is this profile for?</Label>
<Label className="text-base font-bold">{t('miscUi.whoIsThisFor')}</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
@ -115,8 +118,8 @@ export function EntityTypeField({
)}
>
<User className="h-6 w-6" />
<span className="text-sm font-bold">Individual</span>
<span className="text-[11px] opacity-80">A single person</span>
<span className="text-sm font-bold">{t('miscUi.individual')}</span>
<span className="text-[11px] opacity-80">{t('miscUi.individualDesc')}</span>
</button>
<button
type="button"
@ -129,8 +132,8 @@ export function EntityTypeField({
)}
>
<Users className="h-6 w-6" />
<span className="text-sm font-bold">Collective</span>
<span className="text-[11px] opacity-80">Crew, studio or company</span>
<span className="text-sm font-bold">{t('miscUi.collective')}</span>
<span className="text-[11px] opacity-80">{t('miscUi.collectiveDesc')}</span>
</button>
</div>
</div>
@ -139,11 +142,10 @@ export function EntityTypeField({
<div className="space-y-3">
<div>
<Label className={cn('flex items-center gap-2', accentText)}>
<Users className="h-4 w-4" /> Members
<Users className="h-4 w-4" /> {t('miscUi.membersLabel')}
</Label>
<p className="mt-1 text-xs text-gray-500">
Add the Nostr accounts of your crew. Each member confirms their own
spot with their key — until then they show as “pending”.
{t('miscUi.entityMembersHint')}
</p>
</div>
@ -166,7 +168,7 @@ export function EntityTypeField({
className="bg-gray-800 border-gray-700 font-mono text-sm"
/>
<Button type="button" onClick={addMember} className={cn('shrink-0 gap-1', accentBg)}>
<Plus className="h-4 w-4" /> Add
<Plus className="h-4 w-4" /> {t('common.add')}
</Button>
</div>
@ -183,8 +185,7 @@ export function EntityTypeField({
<p className="flex items-start gap-1.5 rounded-lg border border-gray-700 bg-black/40 p-2 text-xs text-gray-400">
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
<span>
Membership is verified per member — nobody can be added to your crew
without confirming from their own account.
{t('miscUi.entityVerifiedHint')}
</span>
</p>
</div>

View file

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Wifi, WifiOff, Cloud, CloudOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface OfflineIndicatorProps {
className?: string;
@ -11,6 +12,7 @@ interface OfflineIndicatorProps {
}
export function OfflineIndicator({ className, showWhenOnline = false }: OfflineIndicatorProps) {
const { t } = useTranslation();
const isOnline = useOnlineStatus();
const { data: pendingCount } = usePendingPostsCount();
const { isSyncing } = useSyncPendingPosts();
@ -41,7 +43,7 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
) : (
<WifiOff className="h-4 w-4" />
)}
{!isOnline && <span className="text-xs">Offline</span>}
{!isOnline && <span className="text-xs">{t('miscUi.offline')}</span>}
{pendingCount !== undefined && pendingCount > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs bg-orange-500/20 text-orange-400">
{pendingCount}
@ -65,12 +67,12 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
</div>
<div>
<p className="font-bold text-white">
{isOnline ? 'Connected' : 'Offline Mode'}
{isOnline ? t('miscUi.connected') : t('miscUi.offlineMode')}
</p>
<p className="text-xs text-gray-400">
{isOnline
? 'All features available'
: 'Browsing cached spots'}
? t('miscUi.allFeaturesAvailable')
: t('miscUi.browsingCachedSpots')}
</p>
</div>
</div>
@ -80,19 +82,19 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
<div className="flex items-center justify-between">
<span className="text-sm text-orange-400 font-medium">
{pendingCount} queued action{pendingCount > 1 ? 's' : ''}
{t('miscUi.queuedActions', { count: pendingCount })}
</span>
{isSyncing && (
<Badge className="bg-orange-500 text-white text-xs">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
Syncing
{t('miscUi.syncing')}
</Badge>
)}
</div>
<p className="text-xs text-gray-400 mt-1">
{isOnline
? 'Publishing when connection stabilizes...'
: 'Will be published when back online'}
? t('miscUi.publishingWhenStable')
: t('miscUi.willPublishOnline')}
</p>
</div>
)}
@ -100,11 +102,11 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
{/* Offline Features Info */}
{!isOnline && (
<div className="text-xs text-gray-400 space-y-1">
<p className="font-medium text-gray-300">Available offline:</p>
<p className="font-medium text-gray-300">{t('miscUi.availableOffline')}</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Browse previously viewed spots</li>
<li>View cached spot details</li>
<li>Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect</li>
<li>{t('miscUi.offlineBrowseSpots')}</li>
<li>{t('miscUi.offlineViewDetails')}</li>
<li>{t('miscUi.offlineKeepActing')}</li>
</ul>
</div>
)}

View file

@ -11,6 +11,7 @@ import {
Circle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
export interface QualityCheckItem {
id: string;
@ -51,40 +52,41 @@ export function QualityMeter({
hasSpotType,
className,
}: QualityMeterProps) {
const { t } = useTranslation();
const checks = useMemo(() => {
const items: QualityCheckItem[] = [
{
id: 'location',
label: 'Location added',
label: t('miscUi.qualityLocationAdded'),
points: 20,
completed: hasLocation,
icon: MapPin,
},
{
id: 'photo1',
label: 'At least 1 photo',
label: t('miscUi.qualityOnePhoto'),
points: 20,
completed: hasPhotos,
icon: Camera,
},
{
id: 'photo3',
label: '3+ photos',
description: 'More angles = better',
label: t('miscUi.qualityThreePhotos'),
description: t('miscUi.qualityMoreAngles'),
points: 15,
completed: photoCount >= 3,
icon: Camera,
},
{
id: 'description',
label: 'Description',
label: t('miscUi.qualityDescription'),
points: 15,
completed: hasDescription && descriptionLength >= 20,
icon: FileText,
},
{
id: 'description-detailed',
label: 'Detailed description (100+ chars)',
label: t('miscUi.qualityDetailedDesc'),
points: 10,
completed: descriptionLength >= 100,
icon: FileText,
@ -95,15 +97,15 @@ export function QualityMeter({
items.push(
{
id: 'contact',
label: 'Contact info',
description: 'Phone or website',
label: t('miscUi.qualityContact'),
description: t('miscUi.qualityPhoneOrWebsite'),
points: 10,
completed: !!hasPhone || !!hasWebsite,
icon: Phone,
},
{
id: 'hours',
label: 'Opening hours',
label: t('miscUi.qualityHours'),
points: 10,
completed: !!hasHours,
icon: Clock,
@ -112,7 +114,7 @@ export function QualityMeter({
} else {
items.push({
id: 'spot-type',
label: 'Spot type selected',
label: t('miscUi.qualitySpotType'),
points: 10,
completed: !!hasSpotType,
icon: MapPin,
@ -120,7 +122,7 @@ export function QualityMeter({
}
return items;
}, [type, hasLocation, hasPhotos, photoCount, hasDescription, descriptionLength, hasPhone, hasWebsite, hasHours, hasSpotType]);
}, [type, hasLocation, hasPhotos, photoCount, hasDescription, descriptionLength, hasPhone, hasWebsite, hasHours, hasSpotType, t]);
const { score, maxScore } = useMemo(() => {
const completed = checks.filter(c => c.completed);
@ -151,10 +153,10 @@ export function QualityMeter({
};
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('miscUi.qualityExcellent');
if (percentage >= 50) return t('miscUi.qualityGood');
if (percentage >= 30) return t('miscUi.qualityBasic');
return t('miscUi.qualityGettingStarted');
};
return (
@ -163,7 +165,7 @@ export function QualityMeter({
<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('miscUi.earningPotential')}</span>
</div>
<div className="flex items-center gap-2">
<span className={cn('text-sm font-bold', getScoreColor())}>

View file

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { MessageSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import type { NostrEvent } from '@nostrify/nostrify';
import { CommentForm } from './CommentForm';
import { Comment } from './Comment';
@ -18,22 +19,27 @@ interface CommentsSectionProps {
export function CommentsSection({
root,
title = "Comments",
emptyStateMessage = "No comments yet",
emptyStateSubtitle = "Be the first to share your thoughts!",
title,
emptyStateMessage,
emptyStateSubtitle,
className,
limit = 500,
}: CommentsSectionProps) {
const { t } = useTranslation();
const { data: commentsData, isLoading, error } = useComments(root, limit);
const comments = commentsData?.topLevelComments || [];
const resolvedTitle = title ?? t('miscUi.commentsTitle');
const resolvedEmpty = emptyStateMessage ?? t('miscUi.noCommentsYet');
const resolvedEmptySub = emptyStateSubtitle ?? t('miscUi.beFirstComment');
if (error) {
return (
<Card className="rounded-none sm:rounded-lg mx-0 sm:mx-0">
<CardContent className="px-2 py-6 sm:p-6">
<div className="text-center text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Failed to load comments</p>
<p>{t('miscUi.failedToLoadComments')}</p>
</div>
</CardContent>
</Card>
@ -45,7 +51,7 @@ export function CommentsSection({
<CardHeader className="px-2 pt-6 pb-4 sm:p-6">
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>{title}</span>
<span>{resolvedTitle}</span>
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground">
({comments.length})
@ -80,8 +86,8 @@ export function CommentsSection({
) : comments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-2">{emptyStateMessage}</p>
<p className="text-sm">{emptyStateSubtitle}</p>
<p className="text-lg font-medium mb-2">{resolvedEmpty}</p>
<p className="text-sm">{resolvedEmptySub}</p>
</div>
) : (
<div className="space-y-4">

View file

@ -6821,6 +6821,63 @@ walletUi: {
itemsReadyToSell: '{{count}} item(s) ready to sell',
},
},
miscUi: {
openingCamera: 'Opening camera…',
usePhoneCamera: 'Use your phone camera, then return here',
startingCamera: 'Starting camera...',
cameraAccessRequired: 'Camera Access Required',
cameraAccessHint: 'Allow camera access in your browser settings, or use the file picker below.',
cameraErrorTitle: 'Camera error',
cameraErrorDesc: 'Could not open the camera. Please check app permissions in Settings.',
cameraDeniedTitle: 'Camera access denied',
cameraDeniedDesc: 'Please allow camera access in your browser settings.',
uploadFromGallery: 'Upload from Gallery',
uploading: 'Uploading...',
usePhoto: 'Use Photo',
earningPotential: 'Earning Potential',
qualityExcellent: 'Excellent!',
qualityGood: 'Good',
qualityBasic: 'Basic',
qualityGettingStarted: 'Getting started',
qualityLocationAdded: 'Location added',
qualityOnePhoto: 'At least 1 photo',
qualityThreePhotos: '3+ photos',
qualityMoreAngles: 'More angles = better',
qualityDescription: 'Description',
qualityDetailedDesc: 'Detailed description (100+ chars)',
qualityContact: 'Contact info',
qualityPhoneOrWebsite: 'Phone or website',
qualityHours: 'Opening hours',
qualitySpotType: 'Spot type selected',
failedToLoadComments: 'Failed to load comments',
commentsTitle: 'Comments',
noCommentsYet: 'No comments yet',
beFirstComment: 'Be the first to share your thoughts!',
removeMember: 'Remove member',
whoIsThisFor: 'Who is this profile for?',
individual: 'Individual',
individualDesc: 'A single person',
collective: 'Collective',
collectiveDesc: 'Crew, studio or company',
membersLabel: 'Members',
entityMembersHint: 'Add the Nostr accounts of your crew. Each member confirms their own spot with their key — until then they show as “pending”.',
entityVerifiedHint: 'Membership is verified per member — nobody can be added to your crew without confirming from their own account.',
entityInvalidPubkey: 'Enter a valid npub, nprofile, or 64-char hex pubkey.',
entityAlreadyAdded: 'That member is already added.',
offline: 'Offline',
connected: 'Connected',
offlineMode: 'Offline Mode',
allFeaturesAvailable: 'All features available',
browsingCachedSpots: 'Browsing cached spots',
queuedActions: '{{count}} queued action(s)',
syncing: 'Syncing',
publishingWhenStable: 'Publishing when connection stabilizes...',
willPublishOnline: 'Will be published when back online',
availableOffline: 'Available offline:',
offlineBrowseSpots: 'Browse previously viewed spots',
offlineViewDetails: 'View cached spot details',
offlineKeepActing: 'Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect',
},
cardUi: {
save: 'Save card to Nostr',
saved: 'Card saved',

View file

@ -6821,6 +6821,63 @@ walletUi: {
itemsReadyToSell: '{{count}} artículo(s) listos para vender',
},
},
miscUi: {
openingCamera: 'Abriendo cámara…',
usePhoneCamera: 'Usa la cámara del teléfono y vuelve aquí',
startingCamera: 'Iniciando cámara...',
cameraAccessRequired: 'Se requiere acceso a la cámara',
cameraAccessHint: 'Permite el acceso a la cámara en los ajustes del navegador, o usa el selector de archivos de abajo.',
cameraErrorTitle: 'Error de cámara',
cameraErrorDesc: 'No se pudo abrir la cámara. Revisa los permisos de la app en Ajustes.',
cameraDeniedTitle: 'Acceso a la cámara denegado',
cameraDeniedDesc: 'Permite el acceso a la cámara en los ajustes del navegador.',
uploadFromGallery: 'Subir desde la galería',
uploading: 'Subiendo...',
usePhoto: 'Usar foto',
earningPotential: 'Potencial de ganancias',
qualityExcellent: '¡Excelente!',
qualityGood: 'Bueno',
qualityBasic: 'Básico',
qualityGettingStarted: 'Empezando',
qualityLocationAdded: 'Ubicación añadida',
qualityOnePhoto: 'Al menos 1 foto',
qualityThreePhotos: '3+ fotos',
qualityMoreAngles: 'Más ángulos = mejor',
qualityDescription: 'Descripción',
qualityDetailedDesc: 'Descripción detallada (100+ caracteres)',
qualityContact: 'Datos de contacto',
qualityPhoneOrWebsite: 'Teléfono o web',
qualityHours: 'Horario de apertura',
qualitySpotType: 'Tipo de spot elegido',
failedToLoadComments: 'No se pudieron cargar los comentarios',
commentsTitle: 'Comentarios',
noCommentsYet: 'Aún no hay comentarios',
beFirstComment: '¡Sé el primero en opinar!',
removeMember: 'Quitar miembro',
whoIsThisFor: '¿Para quién es este perfil?',
individual: 'Individual',
individualDesc: 'Una sola persona',
collective: 'Colectivo',
collectiveDesc: 'Crew, estudio o empresa',
membersLabel: 'Miembros',
entityMembersHint: 'Añade las cuentas Nostr de tu crew. Cada miembro confirma su propio spot con su clave — hasta entonces aparecen como “pendientes”.',
entityVerifiedHint: 'La membresía se verifica por miembro — nadie puede añadirse a tu crew sin confirmarlo desde su propia cuenta.',
entityInvalidPubkey: 'Introduce un npub, nprofile o clave hex de 64 caracteres válidos.',
entityAlreadyAdded: 'Ese miembro ya está añadido.',
offline: 'Sin conexión',
connected: 'Conectado',
offlineMode: 'Modo sin conexión',
allFeaturesAvailable: 'Todas las funciones disponibles',
browsingCachedSpots: 'Navegando spots en caché',
queuedActions: '{{count}} acción(es) en cola',
syncing: 'Sincronizando',
publishingWhenStable: 'Publicando cuando se estabilice la conexión...',
willPublishOnline: 'Se publicará al volver a estar en línea',
availableOffline: 'Disponible sin conexión:',
offlineBrowseSpots: 'Explora spots vistos antes',
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',
},
cardUi: {
save: 'Guardar carné en Nostr',
saved: 'Carné guardado',