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
6 changes: 6 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ NEXT_PUBLIC_CRYPTO_NETWORK=stellar-testnet

# Human support destination shown on error fallback states.
NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com

# SDK base URL used by lib/config.ts. Defaults to MSW mock server in development.
NEXT_PUBLIC_SDK_URL=http://localhost:3000

# Public API key sent as x-api-key on SDK requests. Leave empty for local development.
NEXT_PUBLIC_API_KEY=
18 changes: 18 additions & 0 deletions frontend/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { StorybookConfig } from '@storybook/nextjs-vite';

const config: StorybookConfig = {
stories: [
'../components/**/*.stories.@(ts|tsx)',
'../stories/**/*.mdx',
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-docs',
'@storybook/addon-a11y',
'@chromatic-com/storybook',
],
framework: '@storybook/nextjs-vite',
staticDirs: ['../public'],
};

export default config;
14 changes: 14 additions & 0 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Preview } from '@storybook/nextjs-vite'

const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};

export default preview;
27 changes: 27 additions & 0 deletions frontend/components/claim-status-card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ClaimStatusCard } from './claim-status-card';

const meta: Meta<typeof ClaimStatusCard> = {
title: 'Components/ClaimStatusCard',
component: ClaimStatusCard,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof ClaimStatusCard>;

export const Loading: Story = {
args: { status: 'loading' },
};

export const Unclaimed: Story = {
args: { status: 'unclaimed', amount: '100', asset: 'USDC', expiresAt: '2026-07-15T12:00:00Z' },
};

export const Claimed: Story = {
args: { status: 'claimed', amount: '100', asset: 'USDC' },
};

export const Expired: Story = {
args: { status: 'expired', amount: '50', asset: 'XLM', expiresAt: '2026-06-01T00:00:00Z' },
};
27 changes: 27 additions & 0 deletions frontend/components/page-shell.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { PageShell } from './page-shell';

const meta: Meta<typeof PageShell> = {
title: 'Components/PageShell',
component: PageShell,
tags: ['autodocs'],
parameters: { layout: 'fullscreen' },
};
export default meta;

type Story = StoryObj<typeof PageShell>;

export const Default: Story = {
args: {
title: 'Page Title',
description: 'A short description of this page.',
children: <p>Page content goes here.</p>,
},
};

export const NoChildren: Story = {
args: {
title: 'Empty Page',
description: 'No content provided.',
},
};
59 changes: 59 additions & 0 deletions frontend/components/send-form-step.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { SendFormStep } from './send-form-step';

const meta: Meta<typeof SendFormStep> = {
title: 'Components/SendFormStep',
component: SendFormStep,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof SendFormStep>;

export const FirstStep: Story = {
args: {
step: 1,
totalSteps: 3,
title: 'Enter payment details',
description: 'Specify the amount and asset you want to send.',
children: <p className="text-sm text-slate-600">Form fields go here.</p>,
onNext: () => {},
},
};

export const MiddleStep: Story = {
args: {
step: 2,
totalSteps: 3,
title: 'Recipient information',
description: 'Add optional notes for the recipient.',
children: <p className="text-sm text-slate-600">Form fields go here.</p>,
onBack: () => {},
onNext: () => {},
},
};

export const LastStep: Story = {
args: {
step: 3,
totalSteps: 3,
title: 'Review and confirm',
children: <p className="text-sm text-slate-600">Summary goes here.</p>,
onBack: () => {},
onNext: () => {},
nextLabel: 'Send Payment',
},
};

export const Loading: Story = {
args: {
step: 3,
totalSteps: 3,
title: 'Sending…',
children: <p className="text-sm text-slate-600">Creating ephemeral account…</p>,
onBack: () => {},
onNext: () => {},
nextLabel: 'Send Payment',
isLoading: true,
},
};
81 changes: 81 additions & 0 deletions frontend/components/send-form-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
type SendFormStepProps = {
step: number;
totalSteps: number;
title: string;
description?: string;
children: React.ReactNode;
onBack?: () => void;
onNext?: () => void;
nextLabel?: string;
isLoading?: boolean;
};

export function SendFormStep({
step,
totalSteps,
title,
description,
children,
onBack,
onNext,
nextLabel = 'Continue',
isLoading = false,
}: SendFormStepProps) {
return (
<div className="flex flex-col gap-6">
{/* Progress */}
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
{Array.from({ length: totalSteps }).map((_, i) => (
<span
key={i}
className={`h-1.5 w-6 rounded-full transition-colors ${
i < step ? 'bg-slate-900' : 'bg-slate-200'
}`}
/>
))}
</div>
<span className="text-xs text-slate-500">
Step {step} of {totalSteps}
</span>
</div>

{/* Header */}
<div>
<h2 className="text-xl font-semibold text-slate-950">{title}</h2>
{description && <p className="mt-1 text-sm text-slate-600">{description}</p>}
</div>

{/* Content */}
<div className="flex flex-col gap-4">{children}</div>

{/* Actions */}
{(onBack || onNext) && (
<div className="flex justify-between gap-3 pt-2">
{onBack ? (
<button
type="button"
onClick={onBack}
disabled={isLoading}
className="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
>
Back
</button>
) : (
<span />
)}
{onNext && (
<button
type="button"
onClick={onNext}
disabled={isLoading}
className="rounded-lg bg-slate-900 px-5 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-50"
>
{isLoading ? 'Please wait…' : nextLabel}
</button>
)}
</div>
)}
</div>
);
}
21 changes: 21 additions & 0 deletions frontend/components/skeleton-loader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { SkeletonLoader } from './skeleton-loader';

const meta: Meta<typeof SkeletonLoader> = {
title: 'Components/SkeletonLoader',
component: SkeletonLoader,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof SkeletonLoader>;

export const Default: Story = {};

export const WithHeader: Story = {
args: { showHeader: true, rows: 3 },
};

export const ManyRows: Story = {
args: { rows: 6 },
};
20 changes: 20 additions & 0 deletions frontend/components/skeleton-loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
type SkeletonLoaderProps = {
/** Number of skeleton rows to render */
rows?: number;
/** Show a wider header block above the rows */
showHeader?: boolean;
};

export function SkeletonLoader({ rows = 3, showHeader = false }: SkeletonLoaderProps) {
return (
<div role="status" aria-label="Loading…" className="animate-pulse space-y-3">
{showHeader && <div className="h-5 w-2/5 rounded bg-slate-200" />}
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-3 rounded bg-slate-200" style={{ width: `${85 - i * 10}%` }} />
</div>
))}
<span className="sr-only">Loading content, please wait.</span>
</div>
);
}
24 changes: 24 additions & 0 deletions frontend/components/toast-notification.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ToastNotification } from './toast-notification';

const meta: Meta<typeof ToastNotification> = {
title: 'Components/ToastNotification',
component: ToastNotification,
tags: ['autodocs'],
args: { duration: 0 }, // disable auto-dismiss in stories
};
export default meta;

type Story = StoryObj<typeof ToastNotification>;

export const Success: Story = {
args: { message: 'Payment claim submitted successfully.', variant: 'success' },
};

export const Error: Story = {
args: { message: 'Something went wrong. Please try again.', variant: 'error' },
};

export const Info: Story = {
args: { message: 'Your claim link will expire in 24 hours.', variant: 'info' },
};
68 changes: 68 additions & 0 deletions frontend/components/toast-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import { useEffect, useState } from 'react';

export type ToastVariant = 'success' | 'error' | 'info';

type ToastNotificationProps = {
message: string;
variant?: ToastVariant;
/** Auto-dismiss after this many ms. Set to 0 to disable. */
duration?: number;
onDismiss?: () => void;
};

const VARIANT_STYLES: Record<ToastVariant, string> = {
success: 'bg-emerald-50 border-emerald-200 text-emerald-900',
error: 'bg-red-50 border-red-200 text-red-900',
info: 'bg-blue-50 border-blue-200 text-blue-900',
};

const ICONS: Record<ToastVariant, string> = {
success: '✓',
error: '✕',
info: 'i',
};

export function ToastNotification({
message,
variant = 'info',
duration = 4000,
onDismiss,
}: ToastNotificationProps) {
const [visible, setVisible] = useState(true);

useEffect(() => {
if (!duration) return;
const t = setTimeout(() => {
setVisible(false);
onDismiss?.();
}, duration);
return () => clearTimeout(t);
}, [duration, onDismiss]);

if (!visible) return null;

return (
<div
role="alert"
className={`flex items-start gap-3 rounded-lg border px-4 py-3 text-sm shadow-sm ${VARIANT_STYLES[variant]}`}
>
<span className="mt-0.5 font-bold" aria-hidden="true">
{ICONS[variant]}
</span>
<p className="flex-1">{message}</p>
<button
type="button"
onClick={() => {
setVisible(false);
onDismiss?.();
}}
aria-label="Dismiss notification"
className="text-current opacity-50 hover:opacity-100"
>
</button>
</div>
);
}
Loading
Loading