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
160 changes: 160 additions & 0 deletions apps/frontend/src/app/app/templates/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use client';

import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AppShell } from '@/components/app';
import { TemplateDetailView } from '@/components/app/templates';
import { LoadingSkeleton } from '@/components/app/LoadingSkeleton';
import { ErrorState } from '@/components/app/ErrorState';
import type { Template, TemplateMetadata } 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: (
<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',
},
];

interface TemplateDetailPageProps {
params: { id: string };
}

export default function TemplateDetailPage({ params }: TemplateDetailPageProps) {
const router = useRouter();
const { id } = params;

const [template, setTemplate] = useState<Template | null>(null);
const [metadata, setMetadata] = useState<TemplateMetadata | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;

async function load() {
setLoading(true);
setError(null);

try {
const [tplRes, metaRes] = await Promise.all([
fetch(`/api/templates/${id}`),
fetch(`/api/templates/${id}/metadata`),
]);

if (!tplRes.ok) {
const status = tplRes.status;
throw new Error(
status === 404
? 'Template not found.'
: `Failed to load template (${status})`,
);
}

const tpl: Template = await tplRes.json();
const meta: TemplateMetadata | undefined = metaRes.ok
? await metaRes.json()
: undefined;

if (!cancelled) {
setTemplate(tpl);
setMetadata(meta);
}
} catch (err: any) {
if (!cancelled) setError(err?.message ?? 'An unexpected error occurred.');
} finally {
if (!cancelled) setLoading(false);
}
}

load();
return () => { cancelled = true; };
}, [id]);

const handleCustomize = useCallback(
(tpl: Template) => {
router.push(`/app/customize?templateId=${tpl.id}`);
},
[router],
);

const handleRetry = useCallback(() => {
setError(null);
setLoading(true);
// Re-trigger the effect by toggling a key would require state; instead
// we reload via a simple page-level mechanism.
window.location.reload();
}, []);

const templateName = template?.name ?? 'Template';

return (
<AppShell
user={mockUser}
navItems={navItems}
breadcrumbs={[
{ label: 'Home', path: '/app' },
{ label: 'Templates', path: '/app/templates' },
{ label: templateName },
]}
status="operational"
onStatusClick={() => window.open('https://status.craft.com', '_blank')}
>
<div className="p-6 lg:p-8">
<div className="max-w-3xl mx-auto">
{loading && <LoadingSkeleton />}

{!loading && error && (
<ErrorState
title="Failed to load template"
message={error}
onRetry={handleRetry}
/>
)}

{!loading && !error && template && (
<TemplateDetailView
template={template}
metadata={metadata}
onCustomize={handleCustomize}
/>
)}
</div>
</div>
</AppShell>
);
}
215 changes: 215 additions & 0 deletions apps/frontend/src/components/app/templates/TemplateDetailView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TemplateDetailView } from './TemplateDetailView';
import type { Template, TemplateMetadata } from '@craft/types';

// ─── Fixtures ────────────────────────────────────────────────────────────────

const makeTemplate = (overrides: Partial<Template> = {}): Template => ({
id: 'tpl-1',
name: 'Stellar DEX',
description: 'A decentralized exchange for trading Stellar assets.',
category: 'dex',
blockchainType: 'stellar',
baseRepositoryUrl: 'https://github.com/org/stellar-dex',
previewImageUrl: '',
features: [
{
id: 'enableCharts',
name: 'Charts',
description: 'Enable charts',
enabled: true,
configurable: true,
},
{
id: 'enableAnalytics',
name: 'Analytics',
description: 'Enable analytics',
enabled: false,
configurable: true,
},
],
customizationSchema: {
branding: {
appName: { type: 'string', required: true },
primaryColor: { type: 'color', required: true },
secondaryColor: { type: 'color', required: true },
fontFamily: { type: 'string', required: true },
},
features: {
enableCharts: { type: 'boolean', default: true },
enableTransactionHistory: { type: 'boolean', default: true },
enableAnalytics: { type: 'boolean', default: false },
enableNotifications: { type: 'boolean', default: false },
},
stellar: {
network: { type: 'enum', values: ['mainnet', 'testnet'], required: true },
horizonUrl: { type: 'string', required: true },
sorobanRpcUrl: { type: 'string', required: false },
assetPairs: { type: 'array', required: false },
},
},
isActive: true,
createdAt: new Date('2024-01-01'),
...overrides,
});

const makeMetadata = (overrides: Partial<TemplateMetadata> = {}): TemplateMetadata => ({
id: 'tpl-1',
name: 'Stellar DEX',
version: '1.2.0',
lastUpdated: new Date('2024-06-15'),
totalDeployments: 42,
...overrides,
});

// ─── Tests ───────────────────────────────────────────────────────────────────

describe('TemplateDetailView', () => {
// Header
describe('header', () => {
it('renders the template name as h1', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(screen.getByRole('heading', { level: 1, name: 'Stellar DEX' })).toBeDefined();
});

it('shows the category badge', () => {
render(<TemplateDetailView template={makeTemplate({ category: 'payment' })} onCustomize={vi.fn()} />);
expect(screen.getByText('Payment')).toBeDefined();
});

it('shows "Stellar" blockchain label', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(screen.getByText('Stellar')).toBeDefined();
});
});

// Overview
describe('OverviewSection', () => {
it('renders the description', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(
screen.getByText('A decentralized exchange for trading Stellar assets.'),
).toBeDefined();
});

it('renders preview image when URL is provided', () => {
render(
<TemplateDetailView
template={makeTemplate({ previewImageUrl: '/thumb.png' })}
onCustomize={vi.fn()}
/>,
);
const img = screen.getByAltText('Stellar DEX preview');
expect(img).toBeDefined();
expect(img.getAttribute('src')).toBe('/thumb.png');
});

it('renders emoji fallback when previewImageUrl is empty', () => {
render(
<TemplateDetailView
template={makeTemplate({ previewImageUrl: '' })}
onCustomize={vi.fn()}
/>,
);
// The placeholder div should be present (no img element)
expect(screen.queryByRole('img', { name: 'Stellar DEX preview' })).toBeNull();
expect(screen.getByText('📊')).toBeDefined();
});
});

// Features
describe('FeatureListSection', () => {
it('renders all feature names', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(screen.getByText('Charts')).toBeDefined();
expect(screen.getByText('Analytics')).toBeDefined();
});

it('marks disabled features with "disabled" label', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(screen.getByText('disabled')).toBeDefined();
});

it('renders nothing when features array is empty', () => {
render(
<TemplateDetailView
template={makeTemplate({ features: [] })}
onCustomize={vi.fn()}
/>,
);
expect(screen.queryByRole('heading', { name: 'Features' })).toBeNull();
});
});

// Stellar config
describe('StellarConfigSection', () => {
it('renders Mainnet and Testnet network badges', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(screen.getByText('Mainnet')).toBeDefined();
expect(screen.getByText('Testnet')).toBeDefined();
});

it('shows Horizon URL as Required', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
// There may be multiple "Required" labels; at least one should exist
const required = screen.getAllByText('Required');
expect(required.length).toBeGreaterThan(0);
});

it('shows Soroban RPC as Optional', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
const optional = screen.getAllByText('Optional');
expect(optional.length).toBeGreaterThan(0);
});

it('renders nothing when customizationSchema.stellar is absent', () => {
const tpl = makeTemplate();
(tpl.customizationSchema as any).stellar = undefined;
render(<TemplateDetailView template={tpl} onCustomize={vi.fn()} />);
expect(screen.queryByRole('heading', { name: 'Stellar Configuration' })).toBeNull();
});
});

// Metadata
describe('MetadataSection', () => {
it('renders version, deployments, and last updated when metadata is provided', () => {
render(
<TemplateDetailView
template={makeTemplate()}
metadata={makeMetadata()}
onCustomize={vi.fn()}
/>,
);
expect(screen.getByText('1.2.0')).toBeDefined();
expect(screen.getByText('42')).toBeDefined();
});

it('does not render metadata section when metadata is absent', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(screen.queryByRole('heading', { name: 'Template Info' })).toBeNull();
});
});

// CTA
describe('CTASection', () => {
it('renders the Customize & Deploy button', () => {
render(<TemplateDetailView template={makeTemplate()} onCustomize={vi.fn()} />);
expect(
screen.getByRole('button', { name: 'Customize and deploy Stellar DEX' }),
).toBeDefined();
});

it('calls onCustomize with the template when button is clicked', async () => {
const onCustomize = vi.fn();
const tpl = makeTemplate();
render(<TemplateDetailView template={tpl} onCustomize={onCustomize} />);

await userEvent.click(
screen.getByRole('button', { name: 'Customize and deploy Stellar DEX' }),
);
expect(onCustomize).toHaveBeenCalledWith(tpl);
});
});
});
Loading