diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 2bbd51b..13a9538 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -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(null); const canvasRef = useRef(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 ( - Opening Camera + {t('miscUi.openingCamera')}
-

Opening camera…

-

Use your phone camera, then return here

+

{t('miscUi.openingCamera')}

+

{t('miscUi.usePhoneCamera')}

@@ -271,7 +273,7 @@ export function CameraCapture({
-

Starting camera...

+

{t('miscUi.startingCamera')}

)} @@ -282,21 +284,21 @@ export function CameraCapture({
-

Camera Access Required

+

{t('miscUi.cameraAccessRequired')}

- Allow camera access in your browser settings, or use the file picker below. + {t('miscUi.cameraAccessHint')}

diff --git a/src/components/EntityTypeField.tsx b/src/components/EntityTypeField.tsx index 981bf46..f3459cf 100644 --- a/src/components/EntityTypeField.tsx +++ b/src/components/EntityTypeField.tsx @@ -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')} > @@ -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(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 (
- +
@@ -139,11 +142,10 @@ export function EntityTypeField({

- 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')}

@@ -166,7 +168,7 @@ export function EntityTypeField({ className="bg-gray-800 border-gray-700 font-mono text-sm" />
@@ -183,8 +185,7 @@ export function EntityTypeField({

- Membership is verified per member — nobody can be added to your crew - without confirming from their own account. + {t('miscUi.entityVerifiedHint')}

diff --git a/src/components/OfflineIndicator.tsx b/src/components/OfflineIndicator.tsx index 938e745..4781239 100644 --- a/src/components/OfflineIndicator.tsx +++ b/src/components/OfflineIndicator.tsx @@ -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 ) : ( )} - {!isOnline && Offline} + {!isOnline && {t('miscUi.offline')}} {pendingCount !== undefined && pendingCount > 0 && ( {pendingCount} @@ -65,12 +67,12 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI

- {isOnline ? 'Connected' : 'Offline Mode'} + {isOnline ? t('miscUi.connected') : t('miscUi.offlineMode')}

{isOnline - ? 'All features available' - : 'Browsing cached spots'} + ? t('miscUi.allFeaturesAvailable') + : t('miscUi.browsingCachedSpots')}

@@ -80,19 +82,19 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI
- {pendingCount} queued action{pendingCount > 1 ? 's' : ''} + {t('miscUi.queuedActions', { count: pendingCount })} {isSyncing && ( - Syncing + {t('miscUi.syncing')} )}

{isOnline - ? 'Publishing when connection stabilizes...' - : 'Will be published when back online'} + ? t('miscUi.publishingWhenStable') + : t('miscUi.willPublishOnline')}

)} @@ -100,11 +102,11 @@ export function OfflineIndicator({ className, showWhenOnline = false }: OfflineI {/* Offline Features Info */} {!isOnline && (
-

Available offline:

+

{t('miscUi.availableOffline')}

    -
  • Browse previously viewed spots
  • -
  • View cached spot details
  • -
  • Keep acting — posts, RSVPs, listings and messages are queued and send when you reconnect
  • +
  • {t('miscUi.offlineBrowseSpots')}
  • +
  • {t('miscUi.offlineViewDetails')}
  • +
  • {t('miscUi.offlineKeepActing')}
)} diff --git a/src/components/QualityMeter.tsx b/src/components/QualityMeter.tsx index 8553768..b7d895b 100644 --- a/src/components/QualityMeter.tsx +++ b/src/components/QualityMeter.tsx @@ -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({
- Earning Potential + {t('miscUi.earningPotential')}
diff --git a/src/components/comments/CommentsSection.tsx b/src/components/comments/CommentsSection.tsx index a269934..bc849fd 100644 --- a/src/components/comments/CommentsSection.tsx +++ b/src/components/comments/CommentsSection.tsx @@ -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 (
-

Failed to load comments

+

{t('miscUi.failedToLoadComments')}

@@ -45,7 +51,7 @@ export function CommentsSection({ - {title} + {resolvedTitle} {!isLoading && ( ({comments.length}) @@ -80,8 +86,8 @@ export function CommentsSection({ ) : comments.length === 0 ? (
-

{emptyStateMessage}

-

{emptyStateSubtitle}

+

{resolvedEmpty}

+

{resolvedEmptySub}

) : (
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c64dd2c..f0cbad8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index e31f9c1..de81350 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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',