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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import {
Circle, Circle,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
export interface QualityCheckItem { export interface QualityCheckItem {
id: string; id: string;
@ -51,40 +52,41 @@ export function QualityMeter({
hasSpotType, hasSpotType,
className, className,
}: QualityMeterProps) { }: QualityMeterProps) {
const { t } = useTranslation();
const checks = useMemo(() => { const checks = useMemo(() => {
const items: QualityCheckItem[] = [ const items: QualityCheckItem[] = [
{ {
id: 'location', id: 'location',
label: 'Location added', label: t('miscUi.qualityLocationAdded'),
points: 20, points: 20,
completed: hasLocation, completed: hasLocation,
icon: MapPin, icon: MapPin,
}, },
{ {
id: 'photo1', id: 'photo1',
label: 'At least 1 photo', label: t('miscUi.qualityOnePhoto'),
points: 20, points: 20,
completed: hasPhotos, completed: hasPhotos,
icon: Camera, icon: Camera,
}, },
{ {
id: 'photo3', id: 'photo3',
label: '3+ photos', label: t('miscUi.qualityThreePhotos'),
description: 'More angles = better', description: t('miscUi.qualityMoreAngles'),
points: 15, points: 15,
completed: photoCount >= 3, completed: photoCount >= 3,
icon: Camera, icon: Camera,
}, },
{ {
id: 'description', id: 'description',
label: 'Description', label: t('miscUi.qualityDescription'),
points: 15, points: 15,
completed: hasDescription && descriptionLength >= 20, completed: hasDescription && descriptionLength >= 20,
icon: FileText, icon: FileText,
}, },
{ {
id: 'description-detailed', id: 'description-detailed',
label: 'Detailed description (100+ chars)', label: t('miscUi.qualityDetailedDesc'),
points: 10, points: 10,
completed: descriptionLength >= 100, completed: descriptionLength >= 100,
icon: FileText, icon: FileText,
@ -95,15 +97,15 @@ export function QualityMeter({
items.push( items.push(
{ {
id: 'contact', id: 'contact',
label: 'Contact info', label: t('miscUi.qualityContact'),
description: 'Phone or website', description: t('miscUi.qualityPhoneOrWebsite'),
points: 10, points: 10,
completed: !!hasPhone || !!hasWebsite, completed: !!hasPhone || !!hasWebsite,
icon: Phone, icon: Phone,
}, },
{ {
id: 'hours', id: 'hours',
label: 'Opening hours', label: t('miscUi.qualityHours'),
points: 10, points: 10,
completed: !!hasHours, completed: !!hasHours,
icon: Clock, icon: Clock,
@ -112,7 +114,7 @@ export function QualityMeter({
} else { } else {
items.push({ items.push({
id: 'spot-type', id: 'spot-type',
label: 'Spot type selected', label: t('miscUi.qualitySpotType'),
points: 10, points: 10,
completed: !!hasSpotType, completed: !!hasSpotType,
icon: MapPin, icon: MapPin,
@ -120,7 +122,7 @@ export function QualityMeter({
} }
return items; 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 { score, maxScore } = useMemo(() => {
const completed = checks.filter(c => c.completed); const completed = checks.filter(c => c.completed);
@ -151,10 +153,10 @@ export function QualityMeter({
}; };
const getScoreLabel = () => { const getScoreLabel = () => {
if (percentage >= 80) return 'Excellent!'; if (percentage >= 80) return t('miscUi.qualityExcellent');
if (percentage >= 50) return 'Good'; if (percentage >= 50) return t('miscUi.qualityGood');
if (percentage >= 30) return 'Basic'; if (percentage >= 30) return t('miscUi.qualityBasic');
return 'Getting started'; return t('miscUi.qualityGettingStarted');
}; };
return ( return (
@ -163,7 +165,7 @@ export function QualityMeter({
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ccff00]" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={cn('text-sm font-bold', getScoreColor())}> <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 { Skeleton } from '@/components/ui/skeleton';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { CommentForm } from './CommentForm'; import { CommentForm } from './CommentForm';
import { Comment } from './Comment'; import { Comment } from './Comment';
@ -18,22 +19,27 @@ interface CommentsSectionProps {
export function CommentsSection({ export function CommentsSection({
root, root,
title = "Comments", title,
emptyStateMessage = "No comments yet", emptyStateMessage,
emptyStateSubtitle = "Be the first to share your thoughts!", emptyStateSubtitle,
className, className,
limit = 500, limit = 500,
}: CommentsSectionProps) { }: CommentsSectionProps) {
const { t } = useTranslation();
const { data: commentsData, isLoading, error } = useComments(root, limit); const { data: commentsData, isLoading, error } = useComments(root, limit);
const comments = commentsData?.topLevelComments || []; const comments = commentsData?.topLevelComments || [];
const resolvedTitle = title ?? t('miscUi.commentsTitle');
const resolvedEmpty = emptyStateMessage ?? t('miscUi.noCommentsYet');
const resolvedEmptySub = emptyStateSubtitle ?? t('miscUi.beFirstComment');
if (error) { if (error) {
return ( return (
<Card className="rounded-none sm:rounded-lg mx-0 sm:mx-0"> <Card className="rounded-none sm:rounded-lg mx-0 sm:mx-0">
<CardContent className="px-2 py-6 sm:p-6"> <CardContent className="px-2 py-6 sm:p-6">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" /> <MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Failed to load comments</p> <p>{t('miscUi.failedToLoadComments')}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -45,7 +51,7 @@ export function CommentsSection({
<CardHeader className="px-2 pt-6 pb-4 sm:p-6"> <CardHeader className="px-2 pt-6 pb-4 sm:p-6">
<CardTitle className="flex items-center space-x-2"> <CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" /> <MessageSquare className="h-5 w-5" />
<span>{title}</span> <span>{resolvedTitle}</span>
{!isLoading && ( {!isLoading && (
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground">
({comments.length}) ({comments.length})
@ -80,8 +86,8 @@ export function CommentsSection({
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-30" /> <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-lg font-medium mb-2">{resolvedEmpty}</p>
<p className="text-sm">{emptyStateSubtitle}</p> <p className="text-sm">{resolvedEmptySub}</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">

View file

@ -6821,6 +6821,63 @@ walletUi: {
itemsReadyToSell: '{{count}} item(s) ready to sell', 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: { cardUi: {
save: 'Save card to Nostr', save: 'Save card to Nostr',
saved: 'Card saved', saved: 'Card saved',

View file

@ -6821,6 +6821,63 @@ walletUi: {
itemsReadyToSell: '{{count}} artículo(s) listos para vender', 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: { cardUi: {
save: 'Guardar carné en Nostr', save: 'Guardar carné en Nostr',
saved: 'Carné guardado', saved: 'Carné guardado',