feat: implement AI styling foundation and billing scope rework
This commit is contained in:
229
resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx
Normal file
229
resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const fetchGuestAiStylesMock = vi.fn();
|
||||
const createGuestAiEditMock = vi.fn();
|
||||
const fetchGuestAiEditStatusMock = vi.fn();
|
||||
|
||||
const translate = (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof options === 'string') {
|
||||
return options;
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
vi.mock('../services/aiEditsApi', () => ({
|
||||
fetchGuestAiStyles: (...args: unknown[]) => fetchGuestAiStylesMock(...args),
|
||||
createGuestAiEdit: (...args: unknown[]) => createGuestAiEditMock(...args),
|
||||
fetchGuestAiEditStatus: (...args: unknown[]) => fetchGuestAiEditStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: translate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <span>copy</span>,
|
||||
Download: () => <span>download</span>,
|
||||
Loader2: () => <span>loader</span>,
|
||||
MessageSquare: () => <span>message</span>,
|
||||
RefreshCcw: () => <span>refresh</span>,
|
||||
Share2: () => <span>share</span>,
|
||||
Sparkles: () => <span>sparkles</span>,
|
||||
Wand2: () => <span>wand</span>,
|
||||
X: () => <span>x</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/sheet', () => {
|
||||
const Sheet = ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null);
|
||||
|
||||
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
|
||||
return { Sheet };
|
||||
});
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/button', () => ({
|
||||
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
|
||||
describe('AiMagicEditSheet', () => {
|
||||
const originalOnLine = navigator.onLine;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchGuestAiStylesMock.mockReset();
|
||||
createGuestAiEditMock.mockReset();
|
||||
fetchGuestAiEditStatusMock.mockReset();
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: originalOnLine,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads styles and creates an ai edit request', async () => {
|
||||
fetchGuestAiStylesMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
key: 'ghibli-soft',
|
||||
name: 'Ghibli Soft',
|
||||
description: 'Soft shading style',
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
createGuestAiEditMock.mockResolvedValue({
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 15,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'succeeded',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [{ id: 99, provider_url: 'https://example.com/ai.jpg', is_primary: true }],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Generate AI edit'));
|
||||
|
||||
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByText('AI result')).toBeInTheDocument());
|
||||
expect(screen.getByText('Copy link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error when style loading fails', async () => {
|
||||
fetchGuestAiStylesMock.mockRejectedValue(new Error('Styles not reachable'));
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Styles not reachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pauses polling while offline and resumes after reconnect', async () => {
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
|
||||
fetchGuestAiStylesMock.mockResolvedValue({
|
||||
data: [{ id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' }],
|
||||
meta: {},
|
||||
});
|
||||
createGuestAiEditMock.mockResolvedValue({
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 22,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'processing',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [],
|
||||
},
|
||||
});
|
||||
fetchGuestAiEditStatusMock.mockResolvedValue({
|
||||
data: {
|
||||
id: 22,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'succeeded',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [{ id: 7, provider_url: 'https://example.com/generated.jpg', is_primary: true }],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Generate AI edit'));
|
||||
|
||||
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('You are offline. Status updates resume automatically when connection is back.')).toBeInTheDocument();
|
||||
|
||||
vi.useFakeTimers();
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const setSearchParamsMock = vi.fn();
|
||||
const pushGuestToastMock = vi.fn();
|
||||
const mockEventData = {
|
||||
token: 'demo',
|
||||
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
|
||||
};
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
@@ -11,7 +15,7 @@ vi.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'demo', event: { name: 'Demo Event' } }),
|
||||
useEventData: () => mockEventData,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollGalleryDelta', () => ({
|
||||
@@ -73,6 +77,10 @@ vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AiMagicEditSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args),
|
||||
}));
|
||||
@@ -115,6 +123,8 @@ describe('GalleryScreen', () => {
|
||||
pushGuestToastMock.mockClear();
|
||||
fetchGalleryMock.mockReset();
|
||||
fetchPhotoMock.mockReset();
|
||||
mockEventData.token = 'demo';
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -160,4 +170,33 @@ describe('GalleryScreen', () => {
|
||||
expect(setSearchParamsMock).not.toHaveBeenCalled();
|
||||
expect(pushGuestToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show ai magic edit action when ai styling is not entitled', async () => {
|
||||
fetchGalleryMock.mockResolvedValue({
|
||||
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
|
||||
});
|
||||
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
|
||||
fetchGalleryMock.mockResolvedValue({
|
||||
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
|
||||
});
|
||||
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const mockEventData = {
|
||||
token: 'token',
|
||||
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
|
||||
};
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ photoId: '123' }),
|
||||
useNavigate: () => vi.fn(),
|
||||
@@ -36,8 +41,12 @@ vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => <div>ShareSheet</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AiMagicEditSheet', () => ({
|
||||
default: () => <div>AiMagicEditSheet</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'token', event: { name: 'Demo Event' } }),
|
||||
useEventData: () => mockEventData,
|
||||
}));
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
@@ -66,9 +75,18 @@ import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
|
||||
|
||||
describe('PhotoLightboxScreen', () => {
|
||||
it('renders lightbox layout', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
|
||||
render(<PhotoLightboxScreen />);
|
||||
|
||||
expect(await screen.findByText('Gallery')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Like')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
|
||||
render(<PhotoLightboxScreen />);
|
||||
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
642
resources/js/guest-v2/components/AiMagicEditSheet.tsx
Normal file
642
resources/js/guest-v2/components/AiMagicEditSheet.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
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.provider_url === 'string' && output.provider_url)
|
||||
|| (typeof output.storage_path === 'string' && output.storage_path)
|
||||
)
|
||||
);
|
||||
if (primary?.provider_url) {
|
||||
return primary.provider_url;
|
||||
}
|
||||
if (primary?.storage_path) {
|
||||
return normalizeStorageUrl(primary.storage_path);
|
||||
}
|
||||
|
||||
const first = request.outputs.find(
|
||||
(output) =>
|
||||
(typeof output.provider_url === 'string' && output.provider_url)
|
||||
|| (typeof output.storage_path === 'string' && output.storage_path)
|
||||
);
|
||||
|
||||
if (first?.provider_url) {
|
||||
return first.provider_url;
|
||||
}
|
||||
|
||||
return normalizeStorageUrl(first?.storage_path);
|
||||
}
|
||||
|
||||
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<GuestAiStyle[]>([]);
|
||||
const [stylesLoading, setStylesLoading] = React.useState(false);
|
||||
const [stylesError, setStylesError] = React.useState<string | null>(null);
|
||||
const [selectedStyleKey, setSelectedStyleKey] = React.useState<string | null>(null);
|
||||
const [request, setRequest] = React.useState<GuestAiEditRequest | null>(null);
|
||||
const [requestError, setRequestError] = React.useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [isOnline, setIsOnline] = React.useState<boolean>(() => {
|
||||
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 = (
|
||||
<YStack gap="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Wand2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<YStack>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditSubtitle', 'Choose a style and generate an AI version.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={() => onOpenChange(false)}
|
||||
aria-label={t('common.actions.close', 'Close')}
|
||||
>
|
||||
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
{stylesLoading ? (
|
||||
<XStack alignItems="center" gap="$2" paddingVertical="$2">
|
||||
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{t('galleryPage.lightbox.aiMagicEditLoadingStyles', 'Loading styles...')}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{stylesError ? (
|
||||
<YStack gap="$2" padding="$3" borderRadius="$card" backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
|
||||
<Text fontSize="$2" color="#FCA5A5">{stylesError}</Text>
|
||||
<XStack gap="$2">
|
||||
<Button onPress={() => void loadStyles()} backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">{t('common.actions.retry', 'Retry')}</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{!stylesLoading && !stylesError && !request ? (
|
||||
<YStack gap="$3">
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{t('galleryPage.lightbox.aiMagicEditSelectStyle', 'Select style')}
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{styles.map((style) => {
|
||||
const selected = style.key === selectedStyleKey;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={style.key}
|
||||
size="$3"
|
||||
backgroundColor={selected ? '$primary' : mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={selected ? '$primary' : mutedBorder}
|
||||
onPress={() => setSelectedStyleKey(style.key)}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$6" color={selected ? '#FFFFFF' : undefined}>
|
||||
{style.name}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
{selectedStyle?.description ? (
|
||||
<Text fontSize="$1" color="$color" opacity={0.75}>
|
||||
{selectedStyle.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{originalImageUrl ? (
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditOriginalPreview', 'Original photo')}
|
||||
</Text>
|
||||
<img
|
||||
src={originalImageUrl}
|
||||
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 180,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${mutedBorder}`,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
backgroundColor="$primary"
|
||||
onPress={() => void startAiEdit()}
|
||||
disabled={!selectedStyleKey || submitting}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
{submitting ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : <Sparkles size={14} color="#FFFFFF" />}
|
||||
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||
{submitting
|
||||
? t('galleryPage.lightbox.aiMagicEditStarting', 'Starting...')
|
||||
: t('galleryPage.lightbox.aiMagicEditGenerate', 'Generate AI edit')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{request ? (
|
||||
<YStack gap="$3">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{request.style?.name ?? t('galleryPage.lightbox.aiMagicEditResultTitle', 'Result')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{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.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{isProcessing ? (
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{t('galleryPage.lightbox.aiMagicEditProcessingHint', 'This can take a few seconds.')}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
{isProcessing && !isOnline ? (
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{t(
|
||||
'galleryPage.lightbox.aiMagicEditOfflineHint',
|
||||
'You are offline. Status updates resume automatically when connection is back.'
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{isDone && originalImageUrl ? (
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<YStack flex={1} minWidth={150} gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditOriginalLabel', 'Original')}
|
||||
</Text>
|
||||
<img
|
||||
src={originalImageUrl}
|
||||
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 220,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${mutedBorder}`,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
<YStack flex={1} minWidth={150} gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditGeneratedLabel', 'AI result')}
|
||||
</Text>
|
||||
<img
|
||||
src={outputUrl}
|
||||
alt={t('galleryPage.lightbox.aiMagicEditGeneratedAlt', 'AI generated photo')}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 220,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${mutedBorder}`,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{requestError ? (
|
||||
<Text fontSize="$2" color="#FCA5A5">{requestError}</Text>
|
||||
) : null}
|
||||
|
||||
{(request.status === 'failed' || request.status === 'blocked' || request.status === 'canceled') && request.failure_message ? (
|
||||
<Text fontSize="$2" color="#FCA5A5">{request.failure_message}</Text>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={resetRequestState}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('galleryPage.lightbox.aiMagicEditTryAnother', 'Try another style')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
{isDone ? (
|
||||
<Button backgroundColor="$primary" onPress={downloadGenerated}>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Download size={14} color="#FFFFFF" />
|
||||
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||
{t('common.actions.download', 'Download')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
{isDone ? (
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={shareGeneratedNative}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.button', 'Share')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={shareGeneratedWhatsApp}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.whatsapp', 'WhatsApp')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={shareGeneratedMessages}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.imessage', 'Messages')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={() => void copyGeneratedLink()}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Copy size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.copyLink', 'Copy link')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange} snapPoints={[88]} position={open ? 0 : -1} modal>
|
||||
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.35)' } as any)} />
|
||||
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
|
||||
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
|
||||
<YStack style={{ maxHeight: '82vh', overflowY: 'auto' }}>
|
||||
{content}
|
||||
</YStack>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
1
resources/js/guest-v2/lib/featureFlags.ts
Normal file
1
resources/js/guest-v2/lib/featureFlags.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const GUEST_AI_MAGIC_EDITS_ENABLED = false;
|
||||
@@ -6,6 +6,7 @@ import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sp
|
||||
import AppShell from '../components/AppShell';
|
||||
import PhotoFrameTile from '../components/PhotoFrameTile';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
|
||||
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
||||
@@ -17,6 +18,7 @@ import { buildEventPath } from '../lib/routes';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
import { usePollStats } from '../hooks/usePollStats';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
|
||||
|
||||
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
@@ -111,6 +113,7 @@ export default function GalleryScreen() {
|
||||
url: null,
|
||||
loading: false,
|
||||
});
|
||||
const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||
const [deleteConfirmMounted, setDeleteConfirmMounted] = React.useState(false);
|
||||
@@ -294,6 +297,7 @@ export default function GalleryScreen() {
|
||||
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
|
||||
const lightboxOpen = Boolean(selectedPhotoId);
|
||||
const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id)));
|
||||
const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
||||
@@ -340,6 +344,7 @@ export default function GalleryScreen() {
|
||||
[searchParams, setSearchParams, token]
|
||||
);
|
||||
const closeLightbox = React.useCallback(() => {
|
||||
setAiMagicEditOpen(false);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('photo');
|
||||
setSearchParams(next, { replace: true });
|
||||
@@ -769,6 +774,14 @@ export default function GalleryScreen() {
|
||||
document.body.removeChild(link);
|
||||
}, []);
|
||||
|
||||
const openAiMagicEdit = React.useCallback(() => {
|
||||
if (!lightboxPhoto || !hasAiStylingAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiMagicEditOpen(true);
|
||||
}, [hasAiStylingAccess, lightboxPhoto]);
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent) => {
|
||||
touchStartX.current = event.touches[0]?.clientX ?? null;
|
||||
};
|
||||
@@ -1313,6 +1326,17 @@ export default function GalleryScreen() {
|
||||
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
) : null}
|
||||
{lightboxPhoto && hasAiStylingAccess ? (
|
||||
<Button
|
||||
unstyled
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$1.5"
|
||||
onPress={openAiMagicEdit}
|
||||
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
|
||||
>
|
||||
<Sparkles size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
) : null}
|
||||
{lightboxPhoto && canDelete ? (
|
||||
<Button
|
||||
unstyled
|
||||
@@ -1454,6 +1478,15 @@ export default function GalleryScreen() {
|
||||
onCopyLink={() => copyLink(shareSheet.url)}
|
||||
variant="inline"
|
||||
/>
|
||||
{hasAiStylingAccess ? (
|
||||
<AiMagicEditSheet
|
||||
open={aiMagicEditOpen}
|
||||
onOpenChange={setAiMagicEditOpen}
|
||||
eventToken={token ?? null}
|
||||
photoId={lightboxPhoto?.id ?? null}
|
||||
originalImageUrl={lightboxPhoto?.imageUrl ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2 } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2, Sparkles } from 'lucide-react';
|
||||
import { useGesture } from '@use-gesture/react';
|
||||
import { animated, to, useSpring } from '@react-spring/web';
|
||||
import AppShell from '../components/AppShell';
|
||||
import SurfaceCard from '../components/SurfaceCard';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
|
||||
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
||||
@@ -16,6 +17,7 @@ import { useLocale } from '@/shared/guest/i18n/LocaleContext';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
|
||||
|
||||
type LightboxPhoto = {
|
||||
id: number;
|
||||
@@ -85,6 +87,7 @@ export default function PhotoLightboxScreen() {
|
||||
url: null,
|
||||
loading: false,
|
||||
});
|
||||
const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false);
|
||||
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
const baseSizeRef = React.useRef({ width: 0, height: 0 });
|
||||
@@ -100,6 +103,7 @@ export default function PhotoLightboxScreen() {
|
||||
}));
|
||||
|
||||
const selected = selectedIndex !== null ? photos[selectedIndex] : null;
|
||||
const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling);
|
||||
|
||||
const loadPage = React.useCallback(
|
||||
async (nextCursor?: string | null, replace = false) => {
|
||||
@@ -381,6 +385,14 @@ export default function PhotoLightboxScreen() {
|
||||
document.body.removeChild(link);
|
||||
}, []);
|
||||
|
||||
const openAiMagicEdit = React.useCallback(() => {
|
||||
if (!selected || !hasAiStylingAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiMagicEditOpen(true);
|
||||
}, [hasAiStylingAccess, selected]);
|
||||
|
||||
const bind = useGesture(
|
||||
{
|
||||
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
|
||||
@@ -642,6 +654,22 @@ export default function PhotoLightboxScreen() {
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
{hasAiStylingAccess ? (
|
||||
<Button
|
||||
unstyled
|
||||
onPress={openAiMagicEdit}
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
@@ -667,6 +695,15 @@ export default function PhotoLightboxScreen() {
|
||||
onCopyLink={() => copyLink(shareSheet.url)}
|
||||
variant="inline"
|
||||
/>
|
||||
{hasAiStylingAccess ? (
|
||||
<AiMagicEditSheet
|
||||
open={aiMagicEditOpen}
|
||||
onOpenChange={setAiMagicEditOpen}
|
||||
eventToken={token ?? null}
|
||||
photoId={selected?.id ?? null}
|
||||
originalImageUrl={selected?.imageUrl ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</SurfaceCard>
|
||||
</YStack>
|
||||
</AppShell>
|
||||
|
||||
81
resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts
Normal file
81
resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchJsonMock = vi.fn();
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
fetchJson: (...args: unknown[]) => fetchJsonMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/device', () => ({
|
||||
getDeviceId: () => 'device-123',
|
||||
}));
|
||||
|
||||
import { createGuestAiEdit, fetchGuestAiEditStatus, fetchGuestAiStyles } from '../aiEditsApi';
|
||||
|
||||
describe('aiEditsApi', () => {
|
||||
beforeEach(() => {
|
||||
fetchJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads guest ai styles with device header', async () => {
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 10, key: 'style-a', name: 'Style A' }],
|
||||
meta: { allow_custom_prompt: false },
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await fetchGuestAiStyles('token-abc');
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/ai-styles', {
|
||||
headers: {
|
||||
'X-Device-Id': 'device-123',
|
||||
},
|
||||
noStore: true,
|
||||
});
|
||||
expect(payload.data).toHaveLength(1);
|
||||
expect(payload.data[0]?.key).toBe('style-a');
|
||||
expect(payload.meta.allow_custom_prompt).toBe(false);
|
||||
});
|
||||
|
||||
it('creates guest ai edit with json payload', async () => {
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
data: {
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 55,
|
||||
event_id: 1,
|
||||
photo_id: 9,
|
||||
status: 'queued',
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await createGuestAiEdit('token-abc', 9, {
|
||||
style_key: 'style-a',
|
||||
idempotency_key: 'demo-key',
|
||||
});
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/photos/9/ai-edits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Device-Id': 'device-123',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
style_key: 'style-a',
|
||||
idempotency_key: 'demo-key',
|
||||
}),
|
||||
noStore: true,
|
||||
});
|
||||
expect(payload.data.id).toBe(55);
|
||||
expect(payload.data.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('throws when status payload is malformed', async () => {
|
||||
fetchJsonMock.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(fetchGuestAiEditStatus('token-abc', 55)).rejects.toThrow('AI edit status response is invalid.');
|
||||
});
|
||||
});
|
||||
142
resources/js/guest-v2/services/aiEditsApi.ts
Normal file
142
resources/js/guest-v2/services/aiEditsApi.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { fetchJson } from './apiClient';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export type GuestAiStyle = {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
description?: string | null;
|
||||
provider?: string | null;
|
||||
provider_model?: string | null;
|
||||
requires_source_image?: boolean;
|
||||
is_premium?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GuestAiStylesMeta = {
|
||||
required_feature?: string | null;
|
||||
addon_keys?: string[] | null;
|
||||
allow_custom_prompt?: boolean;
|
||||
allowed_style_keys?: string[] | null;
|
||||
policy_message?: string | null;
|
||||
};
|
||||
|
||||
export type GuestAiEditOutput = {
|
||||
id: number;
|
||||
storage_disk?: string | null;
|
||||
storage_path?: string | null;
|
||||
provider_url?: string | null;
|
||||
mime_type?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
is_primary?: boolean;
|
||||
safety_state?: string | null;
|
||||
safety_reasons?: string[];
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
export type GuestAiEditRequest = {
|
||||
id: number;
|
||||
event_id: number;
|
||||
photo_id: number;
|
||||
style?: {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
} | null;
|
||||
provider?: string | null;
|
||||
provider_model?: string | null;
|
||||
status: 'queued' | 'processing' | 'succeeded' | 'failed' | 'blocked' | 'canceled' | string;
|
||||
safety_state?: string | null;
|
||||
safety_reasons?: string[];
|
||||
failure_code?: string | null;
|
||||
failure_message?: string | null;
|
||||
queued_at?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
outputs: GuestAiEditOutput[];
|
||||
};
|
||||
|
||||
export type GuestAiStylesResponse = {
|
||||
data: GuestAiStyle[];
|
||||
meta: GuestAiStylesMeta;
|
||||
};
|
||||
|
||||
export type GuestAiEditEnvelope = {
|
||||
message?: string;
|
||||
duplicate?: boolean;
|
||||
data: GuestAiEditRequest;
|
||||
};
|
||||
|
||||
function deviceHeaders(): Record<string, string> {
|
||||
return {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGuestAiStyles(eventToken: string): Promise<GuestAiStylesResponse> {
|
||||
const response = await fetchJson<GuestAiStylesResponse>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-styles`,
|
||||
{
|
||||
headers: deviceHeaders(),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
const payload = response.data;
|
||||
|
||||
return {
|
||||
data: Array.isArray(payload?.data) ? payload.data : [],
|
||||
meta: payload?.meta && typeof payload.meta === 'object' ? payload.meta : {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGuestAiEdit(
|
||||
eventToken: string,
|
||||
photoId: number,
|
||||
payload: {
|
||||
style_key?: string;
|
||||
prompt?: string;
|
||||
negative_prompt?: string;
|
||||
provider_model?: string;
|
||||
idempotency_key?: string;
|
||||
session_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<GuestAiEditEnvelope> {
|
||||
const response = await fetchJson<GuestAiEditEnvelope>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/ai-edits`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...deviceHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
|
||||
throw new Error('AI edit request response is invalid.');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchGuestAiEditStatus(eventToken: string, requestId: number): Promise<{ data: GuestAiEditRequest }> {
|
||||
const response = await fetchJson<{ data: GuestAiEditRequest }>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-edits/${requestId}`,
|
||||
{
|
||||
headers: deviceHeaders(),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
|
||||
throw new Error('AI edit status response is invalid.');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
Reference in New Issue
Block a user