import React from 'react'; import { Sheet } from '@tamagui/sheet'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; import { Copy, Download, Loader2, MessageSquare, RefreshCcw, Share2, Sparkles, Wand2, X } from 'lucide-react'; import { useTranslation } from '@/shared/guest/i18n/useTranslation'; import { useGuestThemeVariant } from '../lib/guestTheme'; import { createGuestAiEdit, fetchGuestAiEditStatus, fetchGuestAiStyles, type GuestAiEditRequest, type GuestAiStyle, } from '../services/aiEditsApi'; import type { ApiError } from '../services/apiClient'; import { pushGuestToast } from '../lib/toast'; type AiMagicEditSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; eventToken: string | null; photoId: number | null; originalImageUrl: string | null; }; const POLLABLE_STATUSES = new Set(['queued', 'processing']); const MAX_POLL_ATTEMPTS = 72; const POLL_INTERVAL_MS = 2500; function resolveErrorMessage(error: unknown, fallback: string): string { const apiError = error as ApiError; if (typeof apiError?.message === 'string' && apiError.message.trim() !== '') { return apiError.message; } return fallback; } function buildIdempotencyKey(photoId: number): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return `guest-ai-${photoId}-${crypto.randomUUID()}`; } return `guest-ai-${photoId}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function resolveOutputUrl(request: GuestAiEditRequest | null): string | null { if (!request || !Array.isArray(request.outputs)) { return null; } const normalizeStorageUrl = (storagePath?: string | null): string | null => { if (!storagePath || typeof storagePath !== 'string') { return null; } if (/^https?:/i.test(storagePath)) { return storagePath; } const cleanPath = storagePath.replace(/^\/+/g, ''); if (cleanPath.startsWith('storage/')) { return `/${cleanPath}`; } return `/storage/${cleanPath}`; }; const primary = request.outputs.find( (output) => output.is_primary && ( (typeof output.url === 'string' && output.url) || (typeof output.storage_path === 'string' && output.storage_path) || (typeof output.provider_url === 'string' && output.provider_url) ) ); if (primary?.url) { return primary.url; } if (primary?.storage_path) { return normalizeStorageUrl(primary.storage_path); } if (primary?.provider_url) { return primary.provider_url; } const first = request.outputs.find( (output) => (typeof output.url === 'string' && output.url) || (typeof output.storage_path === 'string' && output.storage_path) || (typeof output.provider_url === 'string' && output.provider_url) ); if (first?.url) { return first.url; } if (first?.storage_path) { return normalizeStorageUrl(first.storage_path); } if (first?.provider_url) { return first.provider_url; } return null; } export default function AiMagicEditSheet({ open, onOpenChange, eventToken, photoId, originalImageUrl, }: AiMagicEditSheetProps) { const { t } = useTranslation(); const { isDark } = useGuestThemeVariant(); const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; const [styles, setStyles] = React.useState([]); const [stylesLoading, setStylesLoading] = React.useState(false); const [stylesError, setStylesError] = React.useState(null); const [selectedStyleKey, setSelectedStyleKey] = React.useState(null); const [request, setRequest] = React.useState(null); const [requestError, setRequestError] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); const [isOnline, setIsOnline] = React.useState(() => { if (typeof navigator === 'undefined') { return true; } return navigator.onLine; }); const pollAttemptsRef = React.useRef(0); const selectedStyle = React.useMemo(() => { if (!selectedStyleKey) { return null; } return styles.find((style) => style.key === selectedStyleKey) ?? null; }, [selectedStyleKey, styles]); const outputUrl = React.useMemo(() => resolveOutputUrl(request), [request]); const resetRequestState = React.useCallback(() => { setRequest(null); setRequestError(null); setSubmitting(false); pollAttemptsRef.current = 0; }, []); const loadStyles = React.useCallback(async () => { if (!eventToken || !photoId) { return; } setStylesLoading(true); setStylesError(null); try { const payload = await fetchGuestAiStyles(eventToken); const nextStyles = Array.isArray(payload.data) ? payload.data : []; setStyles(nextStyles); if (nextStyles.length > 0) { setSelectedStyleKey(nextStyles[0]?.key ?? null); } else { setSelectedStyleKey(null); setStylesError(t('galleryPage.lightbox.aiMagicEditNoStyles', 'No AI styles are currently available.')); } } catch (error) { setStyles([]); setSelectedStyleKey(null); setStylesError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStylesFailed', 'AI styles could not be loaded.'))); } finally { setStylesLoading(false); } }, [eventToken, photoId, t]); React.useEffect(() => { if (!open) { resetRequestState(); return; } if (!eventToken || !photoId) { setStyles([]); setSelectedStyleKey(null); setStylesError(t('galleryPage.lightbox.aiMagicEditUnavailable', 'AI Magic Edit is currently unavailable.')); return; } resetRequestState(); void loadStyles(); }, [eventToken, loadStyles, open, photoId, resetRequestState, t]); React.useEffect(() => { const handleOnline = () => { setIsOnline(true); }; const handleOffline = () => { setIsOnline(false); }; window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); React.useEffect(() => { if (!open || !eventToken || !request || !POLLABLE_STATUSES.has(request.status) || !isOnline) { return; } let cancelled = false; const timer = window.setTimeout(async () => { if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) { setRequestError(t('galleryPage.lightbox.aiMagicEditPollingTimeout', 'AI generation took too long. Please try again.')); return; } pollAttemptsRef.current += 1; try { const payload = await fetchGuestAiEditStatus(eventToken, request.id); if (cancelled) { return; } setRequest(payload.data); if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) { setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.')); } } catch (error) { if (cancelled) { return; } if (typeof navigator !== 'undefined' && !navigator.onLine) { setIsOnline(false); return; } setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStatusFailed', 'AI status could not be refreshed.'))); } }, POLL_INTERVAL_MS); return () => { cancelled = true; window.clearTimeout(timer); }; }, [eventToken, isOnline, open, request, t]); const startAiEdit = React.useCallback(async () => { if (!eventToken || !photoId || !selectedStyleKey) { return; } setSubmitting(true); setRequestError(null); try { const payload = await createGuestAiEdit(eventToken, photoId, { style_key: selectedStyleKey, idempotency_key: buildIdempotencyKey(photoId), metadata: { client: 'guest-v2', entrypoint: 'lightbox', }, }); pollAttemptsRef.current = 0; setRequest(payload.data); if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) { setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.')); } } catch (error) { setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStartFailed', 'AI edit could not be started.'))); } finally { setSubmitting(false); } }, [eventToken, photoId, selectedStyleKey, t]); const downloadGenerated = React.useCallback(() => { if (!outputUrl || !request?.id) { return; } const link = document.createElement('a'); link.href = outputUrl; link.download = `ai-edit-${request.id}.jpg`; link.rel = 'noreferrer'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }, [outputUrl, request?.id]); const copyGeneratedLink = React.useCallback(async () => { if (!outputUrl) { return; } try { await navigator.clipboard?.writeText(outputUrl); pushGuestToast({ text: t('share.copySuccess', 'Link copied!') }); } catch (error) { console.error('Copy generated link failed', error); pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' }); } }, [outputUrl, t]); const shareGeneratedNative = React.useCallback(() => { if (!outputUrl) { return; } const shareData: ShareData = { title: t('galleryPage.lightbox.aiMagicEditShareTitle', 'AI Magic Edit'), text: t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!'), url: outputUrl, }; if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) { navigator.share(shareData).catch(() => undefined); return; } void copyGeneratedLink(); }, [copyGeneratedLink, outputUrl, t]); const shareGeneratedWhatsApp = React.useCallback(() => { if (!outputUrl) { return; } const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!'); const waUrl = `https://wa.me/?text=${encodeURIComponent(`${text} ${outputUrl}`)}`; window.open(waUrl, '_blank', 'noopener'); }, [outputUrl, t]); const shareGeneratedMessages = React.useCallback(() => { if (!outputUrl) { return; } const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!'); const smsUrl = `sms:?&body=${encodeURIComponent(`${text} ${outputUrl}`)}`; window.open(smsUrl, '_blank', 'noopener'); }, [outputUrl, t]); const isProcessing = Boolean(request && POLLABLE_STATUSES.has(request.status)); const isDone = request?.status === 'succeeded' && Boolean(outputUrl); const content = ( {t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')} {t('galleryPage.lightbox.aiMagicEditSubtitle', 'Choose a style and generate an AI version.')} {stylesLoading ? ( {t('galleryPage.lightbox.aiMagicEditLoadingStyles', 'Loading styles...')} ) : null} {stylesError ? ( {stylesError} ) : null} {!stylesLoading && !stylesError && !request ? ( {t('galleryPage.lightbox.aiMagicEditSelectStyle', 'Select style')} {styles.map((style) => { const selected = style.key === selectedStyleKey; return ( ); })} {selectedStyle?.description ? ( {selectedStyle.description} ) : null} {originalImageUrl ? ( {t('galleryPage.lightbox.aiMagicEditOriginalPreview', 'Original photo')} {t('galleryPage.lightbox.aiMagicEditOriginalAlt', ) : null} ) : null} {request ? ( {request.style?.name ?? t('galleryPage.lightbox.aiMagicEditResultTitle', 'Result')} {isProcessing ? t('galleryPage.lightbox.aiMagicEditProcessing', 'Generating your AI edit...') : request.status === 'succeeded' ? t('galleryPage.lightbox.aiMagicEditReady', 'Your AI edit is ready.') : t('galleryPage.lightbox.aiMagicEditFailed', 'AI edit could not be completed.')} {isProcessing ? ( {t('galleryPage.lightbox.aiMagicEditProcessingHint', 'This can take a few seconds.')} ) : null} {isProcessing && !isOnline ? ( {t( 'galleryPage.lightbox.aiMagicEditOfflineHint', 'You are offline. Status updates resume automatically when connection is back.' )} ) : null} {isDone && originalImageUrl ? ( {t('galleryPage.lightbox.aiMagicEditOriginalLabel', 'Original')} {t('galleryPage.lightbox.aiMagicEditOriginalAlt', {t('galleryPage.lightbox.aiMagicEditGeneratedLabel', 'AI result')} {t('galleryPage.lightbox.aiMagicEditGeneratedAlt', ) : null} {requestError ? ( {requestError} ) : null} {(request.status === 'failed' || request.status === 'blocked' || request.status === 'canceled') && request.failure_message ? ( {request.failure_message} ) : null} {isDone ? ( ) : null} {isDone ? ( ) : null} ) : null} ); return ( {content} ); }