Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/frontend/src/app/app/customize/page.tsx
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
role: 'user',
};

const navItems: NavItem[] = [
{
id: 'home',
label: 'Home',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
path: '/app',
},
{
id: 'templates',
label: 'Templates',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
path: '/app/templates',
badge: 3,
},
{
id: 'deployments',
label: 'Deployments',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
path: '/app/deployments',
},
{
id: 'customize',
label: 'Customize',
icon: (
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
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 (
<AppShell
user={mockUser}
navItems={navItems}
breadcrumbs={[{ label: 'Home', path: '/app' }, { label: 'Customize' }]}
status="operational"
>
<div className="p-6 lg:p-8">
<ErrorState
title="No template selected"
message="Please choose a template from the catalog before customizing."
onRetry={() => router.push('/app/templates')}
/>
</div>
</AppShell>
);
}

return (
<AppShell
user={mockUser}
navItems={navItems}
breadcrumbs={[
{ label: 'Home', path: '/app' },
{ label: 'Templates', path: '/app/templates' },
{ label: 'Customize' },
]}
status="operational"
onStatusClick={() => window.open('https://status.craft.com', '_blank')}
>
{/* Full-height studio — no extra padding so the studio fills the shell */}
<div className="h-[calc(100vh-4rem)] flex flex-col">
{loading && (
<div className="p-6">
<LoadingSkeleton variant="rect" height={400} />
</div>
)}

{!loading && loadError && (
<div className="p-6">
<ErrorState
title="Failed to load draft"
message={loadError}
onRetry={() => window.location.reload()}
/>
</div>
)}

{!loading && !loadError && (
<CustomizationStudio
config={config}
isDirty={isDirty}
saveState={saveState}
onChange={setConfig}
onSave={save}
onDeploy={handleDeploy}
/>
)}
</div>
</AppShell>
);
}
169 changes: 169 additions & 0 deletions apps/frontend/src/components/app/CustomizationStudio.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof CustomizationStudio>[0]> = {}) {
const props = {
config: BASE_CONFIG,
isDirty: false,
saveState: 'idle' as const,
onChange: vi.fn(),
onSave: vi.fn(),
onDeploy: vi.fn(),
...overrides,
};
return { ...render(<CustomizationStudio {...props} />), 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();
});
});
});
Loading