i18n(es): translate shared component literals (camera, quality meter, entity picker, offline, comments)
This commit is contained in:
parent
c7a6421be1
commit
7e578fb7d9
7 changed files with 192 additions and 65 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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())}>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue