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
12 changes: 12 additions & 0 deletions frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PolicyDashboard } from '@/features/policies/components/PolicyDashboard';

export const metadata = { title: 'My Policies' };

export default function PolicyDashboardPage() {
return (
<main className="container mx-auto px-4 py-8 max-w-6xl">
<h1 className="text-2xl font-bold text-gray-900 mb-6">My Policies</h1>
<PolicyDashboard />
</main>
);
}
83 changes: 83 additions & 0 deletions frontend/src/app/policy/[holder]/[policyId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getPolicy } from '@/lib/api/chain';
import { formatXlm } from '@/features/policies/components/PolicyItem';
import { SECS_PER_LEDGER } from '@/lib/schemas/vote';

interface Props {
params: Promise<{ holder: string; policyId: string }>;
}

export default async function PolicyDetailPage({ params }: Props) {
const { holder, policyId } = await params;
const id = parseInt(policyId, 10);
if (isNaN(id)) notFound();

const policy = await getPolicy(decodeURIComponent(holder), id).catch(() => null);
if (!policy) notFound();

// policy is Record<string, unknown> from chain read — cast to known shape
const p = policy as {
policy_id: number;
policy_type: string;
region: string;
is_active: boolean;
coverage: string;
premium: string;
start_ledger: number;
end_ledger: number;
strike_count: number;
};

const statusLabel = p.is_active ? 'Active' : 'Inactive';

return (
<main className="container mx-auto px-4 py-8 max-w-2xl space-y-6">
<nav aria-label="Breadcrumb" className="text-sm text-gray-500">
<Link href="/dashboard" className="hover:underline text-blue-600">My Policies</Link>
{' / '}
<span>Policy #{p.policy_id}</span>
</nav>

<div className="rounded-xl border border-gray-200 bg-white p-6 space-y-4 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900">Policy #{p.policy_id}</h1>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${p.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
{statusLabel}
</span>
</div>

<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<Detail label="Type" value={p.policy_type} />
<Detail label="Region" value={p.region} />
<Detail label="Coverage" value={`${formatXlm(p.coverage)} XLM`} />
<Detail label="Premium / yr" value={`${formatXlm(p.premium)} XLM`} />
<Detail label="Start ledger" value={String(p.start_ledger)} />
<Detail label="End ledger" value={String(p.end_ledger)} />
<Detail label="Strike count" value={String(p.strike_count)} />
</dl>

<p className="text-xs text-gray-400">
ⓘ This data is read directly from the Soroban ledger (no indexer lag).
Amounts are in XLM (7 decimal places). Ledger timing: ~{SECS_PER_LEDGER}s per ledger.
</p>
</div>

<Link
href={`/policy/${encodeURIComponent(holder)}/${policyId}/claim`}
className="inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
>
File a claim
</Link>
</main>
);
}

function Detail({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="font-medium text-gray-900 tabular-nums">{value}</dd>
</div>
);
}
88 changes: 88 additions & 0 deletions frontend/src/features/policies/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { z } from 'zod';
import { getConfig } from '@/config/env';

// ── DTO schemas (mirrors backend/src/dto/policy.dto.ts) ──────────────────────

export const ClaimSummaryDtoSchema = z.object({
claim_id: z.number(),
amount: z.string(),
status: z.enum(['Processing', 'Approved', 'Rejected']),
approve_votes: z.number(),
reject_votes: z.number(),
voting_deadline_ledger: z.number().optional(),
_link: z.string(),
});

export const PolicyDtoSchema = z.object({
holder: z.string(),
policy_id: z.number(),
policy_type: z.enum(['Auto', 'Health', 'Property']),
region: z.enum(['Low', 'Medium', 'High']),
is_active: z.boolean(),
coverage_summary: z.object({
coverage_amount: z.string(),
premium_amount: z.string(),
currency: z.literal('XLM'),
decimals: z.literal(7),
}),
expiry_countdown: z.object({
start_ledger: z.number(),
end_ledger: z.number(),
ledgers_remaining: z.number(),
avg_ledger_close_seconds: z.literal(5),
}),
claims: z.array(ClaimSummaryDtoSchema),
_link: z.string(),
});

export const PolicyListDtoSchema = z.object({
data: z.array(PolicyDtoSchema),
next_cursor: z.string().nullable(),
total: z.number(),
});

export type PolicyDto = z.infer<typeof PolicyDtoSchema>;
export type PolicyListDto = z.infer<typeof PolicyListDtoSchema>;
export type PolicyStatusFilter = 'active' | 'expired' | 'all';
export type PolicySortField = 'expiry' | 'coverage' | 'premium';

export interface PolicyListParams {
holder: string;
status?: PolicyStatusFilter;
sort?: PolicySortField;
after?: string;
limit?: number;
}

export class PolicyListError extends Error {
constructor(
public code: string,
message: string,
) {
super(message);
this.name = 'PolicyListError';
}
}

export async function fetchPolicies(
params: PolicyListParams,
signal?: AbortSignal,
): Promise<PolicyListDto> {
const { apiUrl } = getConfig();
const qs = new URLSearchParams({ holder: params.holder });
if (params.status && params.status !== 'all') qs.set('status', params.status);
if (params.after) qs.set('after', params.after);
if (params.limit) qs.set('limit', String(params.limit));

const res = await fetch(`${apiUrl}/api/policies?${qs}`, { signal });
if (!res.ok) {
const err = await res.json().catch(() => ({ code: 'FETCH_FAILED', message: 'Failed to load policies' }));
throw new PolicyListError(err.code ?? 'FETCH_FAILED', err.message ?? 'Failed to load policies');
}
const json: unknown = await res.json();
const parsed = PolicyListDtoSchema.safeParse(json);
if (!parsed.success) {
throw new PolicyListError('PARSE_ERROR', `Invalid response: ${parsed.error.message}`);
}
return parsed.data;
}
203 changes: 203 additions & 0 deletions frontend/src/features/policies/components/PolicyDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
'use client';

import { useCallback, useState } from 'react';
import { useWallet } from '@/hooks/use-wallet';
import { useLatestLedger } from '@/hooks/use-latest-ledger';
import { getConfig } from '@/config/env';
import { usePolicies } from '../hooks/usePolicies';
import { PolicyCard, PolicyRow } from './PolicyItem';
import { PolicyListSkeleton, PolicyEmptyState, PolicyErrorState } from './PolicyStates';
import { RenewModal } from './RenewModal';
import { TerminateModal } from './TerminateModal';
import type { PolicyDto, PolicyStatusFilter, PolicySortField } from '../api';

const SORT_OPTIONS: { value: PolicySortField; label: string }[] = [
{ value: 'expiry', label: 'Expiry (soonest)' },
{ value: 'coverage', label: 'Coverage (highest)' },
{ value: 'premium', label: 'Premium (highest)' },
];

export function PolicyDashboard() {
const { address } = useWallet();
const { network } = getConfig();
const currentLedger = useLatestLedger();

const [status, setStatus] = useState<PolicyStatusFilter>('all');
const [sort, setSort] = useState<PolicySortField>('expiry');
const [layout, setLayout] = useState<'row' | 'card'>('row');

const [renewTarget, setRenewTarget] = useState<PolicyDto | null>(null);
const [terminateTarget, setTerminateTarget] = useState<PolicyDto | null>(null);

const { policies, total, pageIndex, hasNextPage, hasPrevPage, loading, error, goToPage, retry } =
usePolicies(address, network, status, sort);

const handleRenew = useCallback((policy: PolicyDto) => setRenewTarget(policy), []);
const handleTerminate = useCallback((policy: PolicyDto) => setTerminateTarget(policy), []);

const totalPages = Math.max(1, Math.ceil(total / 20));

return (
<section aria-label="My policies" className="space-y-4">
{/* ── Toolbar ─────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-end gap-3">
{/* Status filter */}
<label className="flex flex-col gap-1 text-xs font-medium text-gray-600">
Status
<select
value={status}
onChange={(e) => { setStatus(e.target.value as PolicyStatusFilter); goToPage(0); }}
className="rounded border border-gray-300 px-2 py-1.5 text-sm min-h-[44px] focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Filter by policy status"
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="expired">Expired</option>
</select>
</label>

{/* Sort */}
<label className="flex flex-col gap-1 text-xs font-medium text-gray-600">
Sort by
<select
value={sort}
onChange={(e) => setSort(e.target.value as PolicySortField)}
className="rounded border border-gray-300 px-2 py-1.5 text-sm min-h-[44px] focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Sort policies"
>
{SORT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>

{/* Layout toggle */}
<div
role="group"
aria-label="Layout"
className="ml-auto flex rounded border border-gray-300 overflow-hidden"
>
<LayoutButton active={layout === 'row'} onClick={() => setLayout('row')} label="Table view" icon="☰" />
<LayoutButton active={layout === 'card'} onClick={() => setLayout('card')} label="Card view" icon="⊞" />
</div>
</div>

{/* ── Count ───────────────────────────────────────────────────── */}
{!loading && !error && (
<p className="text-xs text-gray-500" aria-live="polite">
{total} {total === 1 ? 'policy' : 'policies'}
{status !== 'all' ? ` · ${status}` : ''}
</p>
)}

{/* ── Content ─────────────────────────────────────────────────── */}
{loading ? (
<PolicyListSkeleton layout={layout} />
) : error ? (
<PolicyErrorState message={error} onRetry={retry} />
) : policies.length === 0 ? (
<PolicyEmptyState filter={status === 'all' ? 'all' : status} />
) : layout === 'card' ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{policies.map((p) => (
<PolicyCard
key={`${p.holder}:${p.policy_id}`}
policy={p}
onRenew={handleRenew}
onTerminate={handleTerminate}
currentLedger={currentLedger}
/>
))}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
<tr>
<th className="px-4 py-3 text-left">Policy</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-right">Coverage</th>
<th className="px-4 py-3 text-right">Premium / yr</th>
<th className="px-4 py-3 text-left">Expiry</th>
<th className="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody>
{policies.map((p) => (
<PolicyRow
key={`${p.holder}:${p.policy_id}`}
policy={p}
onRenew={handleRenew}
onTerminate={handleTerminate}
currentLedger={currentLedger}
/>
))}
</tbody>
</table>
</div>
)}

{/* ── Pagination ──────────────────────────────────────────────── */}
{!loading && !error && totalPages > 1 && (
<nav aria-label="Policy pages" className="flex items-center justify-center gap-4">
<button
type="button"
onClick={() => goToPage(pageIndex - 1)}
disabled={!hasPrevPage}
aria-label="Previous page"
className="min-h-[44px] min-w-[44px] rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
Previous
</button>
<span aria-live="polite" className="text-sm text-gray-700">
Page {pageIndex + 1} of {totalPages}
</span>
<button
type="button"
onClick={() => goToPage(pageIndex + 1)}
disabled={!hasNextPage}
aria-label="Next page"
className="min-h-[44px] min-w-[44px] rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
)}

{/* ── Action modals ───────────────────────────────────────────── */}
{renewTarget && (
<RenewModal policy={renewTarget} onClose={() => setRenewTarget(null)} />
)}
{terminateTarget && (
<TerminateModal policy={terminateTarget} onClose={() => setTerminateTarget(null)} />
)}
</section>
);
}

function LayoutButton({
active,
onClick,
label,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
icon: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
className={[
'px-3 py-2 text-sm min-h-[44px] focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500',
active ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50',
].join(' ')}
>
<span aria-hidden="true">{icon}</span>
</button>
);
}
Loading