diff --git a/apps/frontend/src/app/app/customize/page.tsx b/apps/frontend/src/app/app/customize/page.tsx new file mode 100644 index 0000000..923d5bb --- /dev/null +++ b/apps/frontend/src/app/app/customize/page.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useCallback } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { AppShell } from '@/components/app'; +import { LoadingSkeleton } from '@/components/app/LoadingSkeleton'; +import { ErrorState } from '@/components/app/ErrorState'; +import { CustomizationStudio } from '@/components/app/CustomizationStudio'; +import { useCustomizationStudio } from '@/hooks/useCustomizationStudio'; +import type { CustomizationConfig } from '@craft/types'; +import type { User, NavItem } from '@/types/navigation'; + +const mockUser: User = { + id: '1', + name: 'John Doe', + email: 'john@example.com', + role: 'user', +}; + +const navItems: NavItem[] = [ + { + id: 'home', + label: 'Home', + icon: ( + + + + ), + path: '/app', + }, + { + id: 'templates', + label: 'Templates', + icon: ( + + + + ), + path: '/app/templates', + badge: 3, + }, + { + id: 'deployments', + label: 'Deployments', + icon: ( + + + + ), + path: '/app/deployments', + }, + { + id: 'customize', + label: 'Customize', + icon: ( + + + + ), + path: '/app/customize', + }, +]; + +export default function CustomizePage() { + const searchParams = useSearchParams(); + const router = useRouter(); + + // templateId is required — passed from the template detail page + const templateId = searchParams.get('templateId') ?? ''; + + const { config, isDirty, saveState, loadError, loading, setConfig, save } = + useCustomizationStudio(templateId); + + const handleDeploy = useCallback(() => { + router.push(`/app/deployments?templateId=${templateId}`); + }, [router, templateId]); + + // Guard: no templateId in URL + if (!templateId) { + return ( + +
+ router.push('/app/templates')} + /> +
+
+ ); + } + + return ( + window.open('https://status.craft.com', '_blank')} + > + {/* Full-height studio — no extra padding so the studio fills the shell */} +
+ {loading && ( +
+ +
+ )} + + {!loading && loadError && ( +
+ window.location.reload()} + /> +
+ )} + + {!loading && !loadError && ( + + )} +
+
+ ); +} diff --git a/apps/frontend/src/components/app/CustomizationStudio.test.tsx b/apps/frontend/src/components/app/CustomizationStudio.test.tsx new file mode 100644 index 0000000..8a8a3e8 --- /dev/null +++ b/apps/frontend/src/components/app/CustomizationStudio.test.tsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CustomizationStudio } from './CustomizationStudio'; +import type { CustomizationConfig } from '@craft/types'; + +// ─── Fixture ────────────────────────────────────────────────────────────────── + +const BASE_CONFIG: CustomizationConfig = { + branding: { + appName: 'My DEX', + primaryColor: '#6366f1', + secondaryColor: '#a5b4fc', + fontFamily: 'Inter', + }, + features: { + enableCharts: true, + enableTransactionHistory: true, + enableAnalytics: false, + enableNotifications: false, + }, + stellar: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + }, +}; + +function renderStudio(overrides: Partial[0]> = {}) { + const props = { + config: BASE_CONFIG, + isDirty: false, + saveState: 'idle' as const, + onChange: vi.fn(), + onSave: vi.fn(), + onDeploy: vi.fn(), + ...overrides, + }; + return { ...render(), props }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('CustomizationStudio', () => { + describe('tab navigation', () => { + it('renders both tabs', () => { + renderStudio(); + expect(screen.getByRole('tab', { name: /Branding/i })).toBeDefined(); + expect(screen.getByRole('tab', { name: /Stellar/i })).toBeDefined(); + }); + + it('shows Branding panel by default', () => { + renderStudio(); + const brandingPanel = screen.getByRole('tabpanel', { name: /Branding/i }); + expect(brandingPanel.getAttribute('hidden')).toBeNull(); + }); + + it('switches to Stellar panel on tab click', async () => { + renderStudio(); + await userEvent.click(screen.getByRole('tab', { name: /Stellar/i })); + const stellarPanel = screen.getByRole('tabpanel', { name: /Stellar/i }); + expect(stellarPanel.getAttribute('hidden')).toBeNull(); + }); + }); + + describe('save state bar', () => { + it('shows "Saving…" when saveState is saving', () => { + renderStudio({ saveState: 'saving' }); + expect(screen.getAllByText('Saving…').length).toBeGreaterThan(0); + }); + + it('shows "✓ Saved" when saveState is saved', () => { + renderStudio({ saveState: 'saved' }); + expect(screen.getByText('✓ Saved')).toBeDefined(); + }); + + it('shows "⚠ Save failed" when saveState is error', () => { + renderStudio({ saveState: 'error' }); + expect(screen.getByText('⚠ Save failed')).toBeDefined(); + }); + + it('shows "Unsaved changes" when isDirty', () => { + renderStudio({ isDirty: true }); + expect(screen.getByText('Unsaved changes')).toBeDefined(); + }); + + it('calls onSave when Save button is clicked', async () => { + const onSave = vi.fn(); + renderStudio({ isDirty: true, onSave }); + await userEvent.click(screen.getByRole('button', { name: 'Save customization' })); + expect(onSave).toHaveBeenCalled(); + }); + + it('Save button is disabled when not dirty', () => { + renderStudio({ isDirty: false }); + const btn = screen.getByRole('button', { name: 'Save customization' }); + expect(btn.hasAttribute('disabled')).toBe(true); + }); + }); + + describe('mainnet warning', () => { + it('shows mainnet warning when network is mainnet', () => { + renderStudio({ + config: { + ...BASE_CONFIG, + stellar: { ...BASE_CONFIG.stellar, network: 'mainnet' }, + }, + }); + expect(screen.getByRole('alert')).toBeDefined(); + expect(screen.getByText(/Mainnet selected/i)).toBeDefined(); + }); + + it('does not show mainnet warning on testnet', () => { + renderStudio(); + expect(screen.queryByText(/Mainnet selected/i)).toBeNull(); + }); + }); + + describe('progression cues', () => { + it('shows all three progression steps', () => { + renderStudio(); + expect(screen.getByText('App name set')).toBeDefined(); + expect(screen.getByText('Colors configured')).toBeDefined(); + expect(screen.getByText('Horizon URL set')).toBeDefined(); + }); + + it('shows correct done count', () => { + renderStudio(); + // appName "My DEX" ✓, colors valid ✓, horizonUrl set ✓ → 3/3 + expect(screen.getByText('Setup progress (3/3)')).toBeDefined(); + }); + + it('shows 0/3 when config is empty', () => { + renderStudio({ + config: { + ...BASE_CONFIG, + branding: { ...BASE_CONFIG.branding, appName: '', primaryColor: 'bad', secondaryColor: 'bad' }, + stellar: { ...BASE_CONFIG.stellar, horizonUrl: '' }, + }, + }); + expect(screen.getByText('Setup progress (0/3)')).toBeDefined(); + }); + }); + + describe('deploy CTA', () => { + it('Deploy button is enabled when required fields are complete', () => { + renderStudio(); + const btns = screen.getAllByRole('button', { name: 'Deploy this customization' }); + // At least one should not be disabled + expect(btns.some((b) => !b.hasAttribute('disabled'))).toBe(true); + }); + + it('Deploy button is disabled when appName is empty', () => { + renderStudio({ + config: { ...BASE_CONFIG, branding: { ...BASE_CONFIG.branding, appName: '' } }, + }); + const btns = screen.getAllByRole('button', { name: 'Deploy this customization' }); + expect(btns.every((b) => b.hasAttribute('disabled'))).toBe(true); + }); + + it('calls onDeploy when Deploy is clicked', async () => { + const onDeploy = vi.fn(); + renderStudio({ onDeploy }); + const btns = screen.getAllByRole('button', { name: 'Deploy this customization' }); + const enabled = btns.find((b) => !b.hasAttribute('disabled'))!; + await userEvent.click(enabled); + expect(onDeploy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/components/app/CustomizationStudio.tsx b/apps/frontend/src/components/app/CustomizationStudio.tsx new file mode 100644 index 0000000..a941d4c --- /dev/null +++ b/apps/frontend/src/components/app/CustomizationStudio.tsx @@ -0,0 +1,294 @@ +'use client'; + +import React, { useState } from 'react'; +import type { CustomizationConfig } from '@craft/types'; +import { BrandingForm, useBrandingForm, StellarConfigForm } from '@/components/app/branding'; +import type { SaveState } from '@/hooks/useCustomizationStudio'; + +// ─── Tab definitions ────────────────────────────────────────────────────────── + +type TabId = 'branding' | 'stellar'; + +const TABS: { id: TabId; label: string; icon: string }[] = [ + { id: 'branding', label: 'Branding & Features', icon: '🎨' }, + { id: 'stellar', label: 'Stellar Setup', icon: '🌐' }, +]; + +// ─── Save state bar ─────────────────────────────────────────────────────────── + +const SAVE_STATE_COPY: Record = { + idle: { text: '', className: '' }, + saving: { text: 'Saving…', className: 'text-on-surface-variant' }, + saved: { text: '✓ Saved', className: 'text-green-600' }, + error: { text: '⚠ Save failed', className: 'text-error' }, +}; + +interface SaveStateBarProps { + isDirty: boolean; + saveState: SaveState; + onSave: () => void; +} + +function SaveStateBar({ isDirty, saveState, onSave }: SaveStateBarProps) { + const { text, className } = SAVE_STATE_COPY[saveState]; + const isBusy = saveState === 'saving'; + + return ( +
+ {text} + +
+ {isDirty && !isBusy && ( + Unsaved changes + )} + +
+
+ ); +} + +// ─── Mainnet warning banner ─────────────────────────────────────────────────── + +function MainnetWarning() { + return ( +
+ +

+ Mainnet selected. Deployments on mainnet use real funds. + Double-check your Horizon URL and Soroban RPC settings before deploying. +

+
+ ); +} + +// ─── Progression cues ───────────────────────────────────────────────────────── + +interface ProgressionCuesProps { + config: CustomizationConfig; +} + +function ProgressionCues({ config }: ProgressionCuesProps) { + const steps = [ + { + label: 'App name set', + done: config.branding.appName.trim().length > 0, + }, + { + label: 'Colors configured', + done: + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(config.branding.primaryColor) && + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(config.branding.secondaryColor), + }, + { + label: 'Horizon URL set', + done: config.stellar.horizonUrl.trim().length > 0, + }, + ]; + + const doneCount = steps.filter((s) => s.done).length; + + return ( + + ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export interface CustomizationStudioProps { + config: CustomizationConfig; + isDirty: boolean; + saveState: SaveState; + onChange: (config: CustomizationConfig) => void; + onSave: () => void; + onDeploy: () => void; +} + +/** + * Customization studio layout. + * + * Desktop (lg+): two-column — editor panels left, progression sidebar right. + * Mobile/tablet: single column with tab navigation between panels. + * + * Panels: + * - Branding & Features (BrandingForm) + * - Stellar Setup (StellarConfigForm) + * + * Persistent elements: + * - SaveStateBar (top of editor area) + * - MainnetWarning (when mainnet is selected) + * - ProgressionCues sidebar + * - Deploy CTA (enabled only when all required fields are complete) + */ +export function CustomizationStudio({ + config, + isDirty, + saveState, + onChange, + onSave, + onDeploy, +}: CustomizationStudioProps) { + const [activeTab, setActiveTab] = useState('branding'); + + const brandingFormState = { + branding: config.branding, + features: config.features, + }; + + const brandingForm = useBrandingForm(brandingFormState); + + // Sync branding form changes back to the studio config + function handleBrandingSubmit() { + onChange({ ...config, branding: brandingForm.state.branding, features: brandingForm.state.features }); + onSave(); + } + + function handleStellarChange(stellar: CustomizationConfig['stellar']) { + onChange({ ...config, stellar }); + } + + const isMainnet = config.stellar.network === 'mainnet'; + const canDeploy = + config.branding.appName.trim().length > 0 && + config.stellar.horizonUrl.trim().length > 0; + + return ( +
+ {/* Save state bar */} + + + {/* Mainnet warning */} + {isMainnet && ( +
+ +
+ )} + + {/* Tab navigation (visible on all sizes; on lg the sidebar is always shown) */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Content area */} +
+ {/* Editor panels */} +
+ + + +
+ + {/* Sidebar: progression + deploy CTA */} + +
+ + {/* Mobile deploy bar */} +
+ +
+
+ ); +} diff --git a/apps/frontend/src/components/app/branding/StellarConfigForm.tsx b/apps/frontend/src/components/app/branding/StellarConfigForm.tsx new file mode 100644 index 0000000..3c3dcc8 --- /dev/null +++ b/apps/frontend/src/components/app/branding/StellarConfigForm.tsx @@ -0,0 +1,135 @@ +'use client'; + +import React from 'react'; +import type { StellarConfig } from '@craft/types'; + +interface StellarConfigFormProps { + value: StellarConfig; + onChange: (value: StellarConfig) => void; + errors?: Map; +} + +const HORIZON_DEFAULTS: Record<'mainnet' | 'testnet', string> = { + mainnet: 'https://horizon.stellar.org', + testnet: 'https://horizon-testnet.stellar.org', +}; + +/** + * Form panel for Stellar network configuration. + * Handles network selection, Horizon URL, and optional Soroban RPC URL. + */ +export function StellarConfigForm({ value, onChange, errors = new Map() }: StellarConfigFormProps) { + function set(key: K, val: StellarConfig[K]) { + onChange({ ...value, [key]: val }); + } + + function handleNetworkChange(network: 'mainnet' | 'testnet') { + onChange({ + ...value, + network, + // Auto-fill Horizon URL when switching networks if it still matches the + // previous default (i.e. the user hasn't customised it). + horizonUrl: + value.horizonUrl === HORIZON_DEFAULTS[value.network] + ? HORIZON_DEFAULTS[network] + : value.horizonUrl, + }); + } + + return ( +
+

+ Stellar Configuration +

+ + {/* Network selector */} +
+ Network +
+ {(['testnet', 'mainnet'] as const).map((net) => ( + + ))} +
+ {value.network === 'mainnet' && ( +

+ + Mainnet uses real funds. Verify all settings before deploying. +

+ )} +
+ + {/* Horizon URL */} +
+ + set('horizonUrl', e.target.value)} + placeholder={HORIZON_DEFAULTS[value.network]} + aria-describedby={errors.get('stellar.horizonUrl') ? 'horizon-url-error' : undefined} + aria-invalid={!!errors.get('stellar.horizonUrl')} + className={`w-full px-3 py-2.5 rounded-lg border bg-surface-container-lowest text-sm text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/40 transition-colors ${ + errors.get('stellar.horizonUrl') + ? 'border-error focus:ring-error/40' + : 'border-outline-variant/20 focus:border-primary/40' + }`} + /> + {errors.get('stellar.horizonUrl') && ( + + )} +
+ + {/* Soroban RPC URL (optional) */} +
+ + set('sorobanRpcUrl', e.target.value || undefined)} + placeholder="https://soroban-testnet.stellar.org" + aria-describedby={errors.get('stellar.sorobanRpcUrl') ? 'soroban-rpc-error' : undefined} + aria-invalid={!!errors.get('stellar.sorobanRpcUrl')} + className={`w-full px-3 py-2.5 rounded-lg border bg-surface-container-lowest text-sm text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/40 transition-colors ${ + errors.get('stellar.sorobanRpcUrl') + ? 'border-error focus:ring-error/40' + : 'border-outline-variant/20 focus:border-primary/40' + }`} + /> + {errors.get('stellar.sorobanRpcUrl') && ( + + )} +

+ Required only if your template uses Soroban smart contracts. +

+
+
+ ); +} diff --git a/apps/frontend/src/components/app/branding/index.ts b/apps/frontend/src/components/app/branding/index.ts index 06975bf..2571cd8 100644 --- a/apps/frontend/src/components/app/branding/index.ts +++ b/apps/frontend/src/components/app/branding/index.ts @@ -3,5 +3,6 @@ export { ColorInput } from './ColorInput'; export { FontSelector, FONT_OPTIONS } from './FontSelector'; export { FeatureToggles } from './FeatureToggles'; export { BrandingForm } from './BrandingForm'; +export { StellarConfigForm } from './StellarConfigForm'; export { useBrandingForm } from './useBrandingForm'; export type { BrandingFormState, BrandingFormReturn } from './useBrandingForm'; diff --git a/apps/frontend/src/hooks/useCustomizationStudio.test.ts b/apps/frontend/src/hooks/useCustomizationStudio.test.ts new file mode 100644 index 0000000..2fb759b --- /dev/null +++ b/apps/frontend/src/hooks/useCustomizationStudio.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCustomizationStudio } from './useCustomizationStudio'; +import type { CustomizationConfig } from '@craft/types'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const TEMPLATE_ID = 'tpl-abc'; + +const DRAFT_CONFIG: CustomizationConfig = { + branding: { + appName: 'My DEX', + primaryColor: '#ff0000', + secondaryColor: '#00ff00', + fontFamily: 'Roboto', + }, + features: { + enableCharts: true, + enableTransactionHistory: false, + enableAnalytics: false, + enableNotifications: false, + }, + stellar: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + }, +}; + +function mockFetch(handler: (url: string, init?: RequestInit) => Response) { + return vi.spyOn(globalThis, 'fetch').mockImplementation( + (url, init) => Promise.resolve(handler(String(url), init as RequestInit)), + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('useCustomizationStudio', () => { + it('starts in loading state', () => { + mockFetch(() => new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 })); + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + expect(result.current.loading).toBe(true); + }); + + it('loads draft config from API', async () => { + mockFetch(() => + new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 }), + ); + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.config.branding.appName).toBe('My DEX'); + }); + + it('uses default config when draft returns 404', async () => { + mockFetch(() => new Response(null, { status: 404 })); + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.config.stellar.network).toBe('testnet'); + expect(result.current.loadError).toBeNull(); + }); + + it('sets loadError on fetch failure', async () => { + mockFetch(() => new Response(null, { status: 500 })); + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.loadError).toMatch(/500/); + }); + + it('isDirty is false after load', async () => { + mockFetch(() => + new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 }), + ); + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isDirty).toBe(false); + }); + + it('isDirty becomes true after setConfig', async () => { + mockFetch(() => + new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 }), + ); + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setConfig({ + ...DRAFT_CONFIG, + branding: { ...DRAFT_CONFIG.branding, appName: 'Changed' }, + }); + }); + + expect(result.current.isDirty).toBe(true); + }); + + it('save() sets saveState to saved on success', async () => { + const fetchSpy = mockFetch((url, init) => { + if ((init as RequestInit)?.method === 'POST') { + return new Response('{}', { status: 200 }); + } + return new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 }); + }); + + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.save(); + }); + + expect(result.current.saveState).toBe('saved'); + expect(fetchSpy).toHaveBeenCalledWith( + `/api/drafts/${TEMPLATE_ID}`, + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('save() sets saveState to error on failure', async () => { + mockFetch((url, init) => { + if ((init as RequestInit)?.method === 'POST') { + return new Response(null, { status: 500 }); + } + return new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 }); + }); + + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.save(); + }); + + expect(result.current.saveState).toBe('error'); + }); + + it('auto-saves after debounce delay', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const postCalls: string[] = []; + mockFetch((url, init) => { + if ((init as RequestInit)?.method === 'POST') postCalls.push(url); + return new Response(JSON.stringify({ customizationConfig: DRAFT_CONFIG }), { status: 200 }); + }); + + const { result } = renderHook(() => useCustomizationStudio(TEMPLATE_ID)); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setConfig({ + ...DRAFT_CONFIG, + branding: { ...DRAFT_CONFIG.branding, appName: 'Auto' }, + }); + }); + + expect(postCalls).toHaveLength(0); + + await act(async () => { + vi.advanceTimersByTime(2100); + }); + + expect(postCalls.length).toBeGreaterThan(0); + + vi.useRealTimers(); + }); +}); diff --git a/apps/frontend/src/hooks/useCustomizationStudio.ts b/apps/frontend/src/hooks/useCustomizationStudio.ts new file mode 100644 index 0000000..998ca98 --- /dev/null +++ b/apps/frontend/src/hooks/useCustomizationStudio.ts @@ -0,0 +1,153 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { CustomizationConfig } from '@craft/types'; + +export type SaveState = 'idle' | 'saving' | 'saved' | 'error'; + +export interface UseCustomizationStudioReturn { + config: CustomizationConfig; + isDirty: boolean; + saveState: SaveState; + loadError: string | null; + loading: boolean; + setConfig: (config: CustomizationConfig) => void; + save: () => Promise; +} + +const DEFAULT_CONFIG: CustomizationConfig = { + branding: { + appName: '', + primaryColor: '#6366f1', + secondaryColor: '#a5b4fc', + fontFamily: 'Inter', + }, + features: { + enableCharts: true, + enableTransactionHistory: true, + enableAnalytics: false, + enableNotifications: false, + }, + stellar: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + }, +}; + +const AUTO_SAVE_DELAY_MS = 2000; + +/** + * Manages the full lifecycle of a customization draft: + * - Loads the existing draft from the API on mount + * - Tracks dirty state against the last-saved snapshot + * - Exposes an explicit save() and auto-saves after a debounce period + */ +export function useCustomizationStudio(templateId: string): UseCustomizationStudioReturn { + const [config, setConfigState] = useState(DEFAULT_CONFIG); + const [savedSnapshot, setSavedSnapshot] = useState(DEFAULT_CONFIG); + const [saveState, setSaveState] = useState('idle'); + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(true); + + const autoSaveTimer = useRef | null>(null); + const isMounted = useRef(true); + + // Load draft on mount + useEffect(() => { + isMounted.current = true; + let cancelled = false; + + async function loadDraft() { + setLoading(true); + setLoadError(null); + try { + const res = await fetch(`/api/drafts/${templateId}`); + if (res.status === 404) { + // No draft yet — use defaults + if (!cancelled) setLoading(false); + return; + } + if (!res.ok) throw new Error(`Failed to load draft (${res.status})`); + const draft = await res.json(); + if (!cancelled) { + const cfg: CustomizationConfig = draft.customizationConfig ?? DEFAULT_CONFIG; + setConfigState(cfg); + setSavedSnapshot(cfg); + } + } catch (err: any) { + if (!cancelled) setLoadError(err?.message ?? 'Failed to load draft'); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadDraft(); + return () => { + cancelled = true; + isMounted.current = false; + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + }; + }, [templateId]); + + const isDirty = JSON.stringify(config) !== JSON.stringify(savedSnapshot); + + const save = useCallback(async () => { + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + setSaveState('saving'); + try { + const res = await fetch(`/api/drafts/${templateId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error(`Save failed (${res.status})`); + if (isMounted.current) { + setSavedSnapshot(config); + setSaveState('saved'); + // Reset to idle after 2 s so the "Saved" indicator fades + setTimeout(() => { + if (isMounted.current) setSaveState('idle'); + }, 2000); + } + } catch { + if (isMounted.current) setSaveState('error'); + } + }, [config, templateId]); + + const setConfig = useCallback( + (next: CustomizationConfig) => { + setConfigState(next); + setSaveState('idle'); + + // Debounced auto-save + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + autoSaveTimer.current = setTimeout(() => { + // Use the latest config via a ref-free approach: call save() which + // closes over the current `config` — but since setConfig is called + // before the timer fires, we need to trigger save with `next` directly. + setSaveState('saving'); + fetch(`/api/drafts/${templateId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(next), + }) + .then((res) => { + if (!res.ok) throw new Error(); + if (isMounted.current) { + setSavedSnapshot(next); + setSaveState('saved'); + setTimeout(() => { + if (isMounted.current) setSaveState('idle'); + }, 2000); + } + }) + .catch(() => { + if (isMounted.current) setSaveState('error'); + }); + }, AUTO_SAVE_DELAY_MS); + }, + [templateId], + ); + + return { config, isDirty, saveState, loadError, loading, setConfig, save }; +}