diff --git a/frontend/.env.example b/frontend/.env.example index 527c421..6ca4ac0 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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= \ No newline at end of file diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 0000000..c6535af --- /dev/null +++ b/frontend/.storybook/main.ts @@ -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; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 0000000..7889553 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -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; \ No newline at end of file diff --git a/frontend/components/claim-status-card.stories.tsx b/frontend/components/claim-status-card.stories.tsx new file mode 100644 index 0000000..d6fe077 --- /dev/null +++ b/frontend/components/claim-status-card.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { ClaimStatusCard } from './claim-status-card'; + +const meta: Meta = { + title: 'Components/ClaimStatusCard', + component: ClaimStatusCard, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +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' }, +}; diff --git a/frontend/components/page-shell.stories.tsx b/frontend/components/page-shell.stories.tsx new file mode 100644 index 0000000..2abcf68 --- /dev/null +++ b/frontend/components/page-shell.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { PageShell } from './page-shell'; + +const meta: Meta = { + title: 'Components/PageShell', + component: PageShell, + tags: ['autodocs'], + parameters: { layout: 'fullscreen' }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Page Title', + description: 'A short description of this page.', + children:

Page content goes here.

, + }, +}; + +export const NoChildren: Story = { + args: { + title: 'Empty Page', + description: 'No content provided.', + }, +}; diff --git a/frontend/components/send-form-step.stories.tsx b/frontend/components/send-form-step.stories.tsx new file mode 100644 index 0000000..4b554a8 --- /dev/null +++ b/frontend/components/send-form-step.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { SendFormStep } from './send-form-step'; + +const meta: Meta = { + title: 'Components/SendFormStep', + component: SendFormStep, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const FirstStep: Story = { + args: { + step: 1, + totalSteps: 3, + title: 'Enter payment details', + description: 'Specify the amount and asset you want to send.', + children:

Form fields go here.

, + onNext: () => {}, + }, +}; + +export const MiddleStep: Story = { + args: { + step: 2, + totalSteps: 3, + title: 'Recipient information', + description: 'Add optional notes for the recipient.', + children:

Form fields go here.

, + onBack: () => {}, + onNext: () => {}, + }, +}; + +export const LastStep: Story = { + args: { + step: 3, + totalSteps: 3, + title: 'Review and confirm', + children:

Summary goes here.

, + onBack: () => {}, + onNext: () => {}, + nextLabel: 'Send Payment', + }, +}; + +export const Loading: Story = { + args: { + step: 3, + totalSteps: 3, + title: 'Sending…', + children:

Creating ephemeral account…

, + onBack: () => {}, + onNext: () => {}, + nextLabel: 'Send Payment', + isLoading: true, + }, +}; diff --git a/frontend/components/send-form-step.tsx b/frontend/components/send-form-step.tsx new file mode 100644 index 0000000..02b76b1 --- /dev/null +++ b/frontend/components/send-form-step.tsx @@ -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 ( +
+ {/* Progress */} +
+
+ {Array.from({ length: totalSteps }).map((_, i) => ( + + ))} +
+ + Step {step} of {totalSteps} + +
+ + {/* Header */} +
+

{title}

+ {description &&

{description}

} +
+ + {/* Content */} +
{children}
+ + {/* Actions */} + {(onBack || onNext) && ( +
+ {onBack ? ( + + ) : ( + + )} + {onNext && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/skeleton-loader.stories.tsx b/frontend/components/skeleton-loader.stories.tsx new file mode 100644 index 0000000..a3a6472 --- /dev/null +++ b/frontend/components/skeleton-loader.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { SkeletonLoader } from './skeleton-loader'; + +const meta: Meta = { + title: 'Components/SkeletonLoader', + component: SkeletonLoader, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithHeader: Story = { + args: { showHeader: true, rows: 3 }, +}; + +export const ManyRows: Story = { + args: { rows: 6 }, +}; diff --git a/frontend/components/skeleton-loader.tsx b/frontend/components/skeleton-loader.tsx new file mode 100644 index 0000000..6e1e1af --- /dev/null +++ b/frontend/components/skeleton-loader.tsx @@ -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 ( +
+ {showHeader &&
} + {Array.from({ length: rows }).map((_, i) => ( +
+
+
+ ))} + Loading content, please wait. +
+ ); +} diff --git a/frontend/components/toast-notification.stories.tsx b/frontend/components/toast-notification.stories.tsx new file mode 100644 index 0000000..0e9ce36 --- /dev/null +++ b/frontend/components/toast-notification.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { ToastNotification } from './toast-notification'; + +const meta: Meta = { + title: 'Components/ToastNotification', + component: ToastNotification, + tags: ['autodocs'], + args: { duration: 0 }, // disable auto-dismiss in stories +}; +export default meta; + +type Story = StoryObj; + +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' }, +}; diff --git a/frontend/components/toast-notification.tsx b/frontend/components/toast-notification.tsx new file mode 100644 index 0000000..7184d04 --- /dev/null +++ b/frontend/components/toast-notification.tsx @@ -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 = { + 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 = { + 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 ( +
+ +

{message}

+ +
+ ); +} diff --git a/frontend/components/wallet-address-input.stories.tsx b/frontend/components/wallet-address-input.stories.tsx new file mode 100644 index 0000000..ed87b92 --- /dev/null +++ b/frontend/components/wallet-address-input.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { WalletAddressInput } from './wallet-address-input'; + +const meta: Meta = { + title: 'Components/WalletAddressInput', + component: WalletAddressInput, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { value: '', onChange: () => {} }, +}; + +export const ValidAddress: Story = { + args: { + value: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + onChange: () => {}, + }, +}; + +export const WithError: Story = { + args: { + value: 'not-a-valid-key', + onChange: () => {}, + error: 'Enter a valid Stellar public key (starts with G, 56 characters).', + }, +}; + +export const Disabled: Story = { + args: { + value: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + onChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/components/wallet-address-input.tsx b/frontend/components/wallet-address-input.tsx index cc345ea..2efde9a 100644 --- a/frontend/components/wallet-address-input.tsx +++ b/frontend/components/wallet-address-input.tsx @@ -1,48 +1,107 @@ -// #108 – Stellar wallet address input with G... format validation 'use client'; + import { useState } from 'react'; const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/; -type Props = { onValid: (address: string) => void }; +type WalletAddressInputProps = { + value: string; + onChange: (value: string) => void; + onValid?: (address: string) => void; + label?: string; + placeholder?: string; + error?: string; + disabled?: boolean; +}; + +export function WalletAddressInput({ + value, + onChange, + onValid, + label = 'Stellar Wallet Address', + placeholder = 'G...', + error, + disabled = false, +}: WalletAddressInputProps) { + const [touched, setTouched] = useState(false); -export function WalletAddressInput({ onValid }: Props) { - const [value, setValue] = useState(''); - const [error, setError] = useState(null); + const validationError = + touched && + value.length > 0 && + !STELLAR_ADDRESS.test(value) + ? 'Enter a valid Stellar public key (starts with G, 56 characters).' + : null; + + const displayError = error ?? validationError; + const inputId = 'wallet-address-input'; function handleChange(e: React.ChangeEvent) { - const v = e.target.value.trim(); - setValue(v); - setError(null); - if (v && STELLAR_ADDRESS.test(v)) onValid(v); - } + const nextValue = e.target.value.trim(); - function handleBlur() { - if (value && !STELLAR_ADDRESS.test(value)) { - setError('Enter a valid Stellar address (starts with G, 56 characters).'); + onChange(nextValue); + + if (onValid && STELLAR_ADDRESS.test(nextValue)) { + onValid(nextValue); } } return ( -
-