Skip to content

Commit 4260d93

Browse files
authored
Merge pull request #301 from dev-fatima-24/feature/issue-234-claim-review-step
Frontend — Claim filing confirmation: summary review step before wallet signing
2 parents 28ad1e5 + bf7f40e commit 4260d93

5 files changed

Lines changed: 358 additions & 20 deletions

File tree

frontend/src/components/claims/ClaimWizard.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
useToast,
1717
} from '@/components/ui';
1818
import { useWallet } from '@/hooks/use-wallet';
19+
import { useDraftPersistence } from '@/hooks/use-draft-persistence';
1920
import { ClaimAPI } from '@/lib/api/claim';
2021

2122
import { AmountStep } from './steps/AmountStep';
2223
import { EvidenceStep } from './steps/EvidenceStep';
2324
import { NarrativeStep } from './steps/NarrativeStep';
2425
import { ReviewStep } from './steps/ReviewStep';
26+
import { DraftResumeBanner } from './DraftResumeBanner';
2527

2628
interface ClaimWizardProps {
2729
policyId: string;
@@ -35,6 +37,8 @@ const STEPS = [
3537
{ id: '4', title: 'Review', description: 'Confirm & Sign' },
3638
];
3739

40+
const CLAIM_DRAFT_SCHEMA_VERSION = 1;
41+
3842
export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
3943
const router = useRouter();
4044
const { toast } = useToast();
@@ -43,6 +47,7 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
4347
const [isSubmitting, setIsSubmitting] = useState(false);
4448
const [isSuccess, setIsSuccess] = useState(false);
4549
const [txStatus, setTxStatus] = useState<string>('');
50+
const [showBanner, setShowBanner] = useState(true);
4651
const stepHeadingRef = useRef<HTMLHeadingElement>(null);
4752

4853
const [formData, setFormData] = useState({
@@ -51,6 +56,44 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
5156
evidence: [] as { url: string; contentSha256Hex: string }[],
5257
});
5358

59+
const { hasDraft, saveDraft, loadDraft, clearDraft } = useDraftPersistence(
60+
`claim-${policyId}`,
61+
CLAIM_DRAFT_SCHEMA_VERSION
62+
);
63+
64+
// Sync draft on form changes (excluding files/IPFS handled by hook sanitize)
65+
useEffect(() => {
66+
if (activeStep > 0 || formData.amount || formData.details) {
67+
saveDraft({ ...formData, _step: activeStep });
68+
}
69+
}, [formData, activeStep, saveDraft]);
70+
71+
const handleResumeDraft = () => {
72+
const draft = loadDraft();
73+
if (draft) {
74+
const { _step, ...data } = draft as any;
75+
setFormData(prev => ({ ...prev, ...data }));
76+
if (typeof _step === 'number') {
77+
setActiveStep(_step);
78+
}
79+
toast({
80+
title: 'Draft Restored',
81+
description: 'You are continuing where you left off.',
82+
});
83+
}
84+
setShowBanner(false);
85+
};
86+
87+
const handleDismissBanner = () => {
88+
clearDraft(); // Clearing explicitly if they choose to start over?
89+
// Actually, maybe not clearing immediately, but just hiding banner?
90+
// Requirements say "Resume draft banner when a valid draft is detected".
91+
// If they dismiss, we should probably stop showing it but maybe keep draft until new data overwrites?
92+
// Let's clear it to be safe and avoid confusion.
93+
clearDraft();
94+
setShowBanner(false);
95+
};
96+
5497
// Move focus to step heading when step changes
5598
useEffect(() => {
5699
if (stepHeadingRef.current) {
@@ -103,6 +146,10 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
103146

104147
setTxStatus('Claim submitted successfully.');
105148
setIsSuccess(true);
149+
150+
// EXTREMELY IMPORTANT: Clear draft on success (Issue #229)
151+
clearDraft();
152+
106153
toast({
107154
title: 'Claim Submitted!',
108155
description: 'Your claim has been successfully filed on-chain.',
@@ -173,6 +220,11 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
173220
</div>
174221
</CardHeader>
175222
<CardContent className="space-y-6">
223+
{/* Banner for resuming draft (Requirement: Resume draft banner detected on wizard mount) */}
224+
{hasDraft && showBanner && (
225+
<DraftResumeBanner onConfirm={handleResumeDraft} onDismiss={handleDismissBanner} />
226+
)}
227+
176228
{/* Visually hidden heading receives focus on step change */}
177229
<h2
178230
ref={stepHeadingRef}
@@ -205,9 +257,14 @@ export function ClaimWizard({ policyId, maxCoverage }: ClaimWizardProps) {
205257
</StepContent>
206258

207259
<StepContent title={STEPS[3].title} isActive={activeStep === 3} isCompleted={activeStep > 3}>
208-
<ReviewStep data={formData} policyId={policyId} />
260+
<ReviewStep
261+
data={formData}
262+
policyId={policyId}
263+
onEdit={(step) => setActiveStep(step)}
264+
/>
209265
</StepContent>
210266

267+
211268
<div className="flex justify-between pt-4 border-t">
212269
<Button
213270
variant="ghost"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { History, X } from 'lucide-react';
5+
import { Button } from '@/components/ui';
6+
7+
interface DraftResumeBannerProps {
8+
onConfirm: () => void;
9+
onDismiss: () => void;
10+
}
11+
12+
export function DraftResumeBanner({ onConfirm, onDismiss }: DraftResumeBannerProps) {
13+
return (
14+
<div
15+
className="flex items-center justify-between gap-4 rounded-lg bg-primary/5 border border-primary/20 p-4 mb-6 animate-in fade-in slide-in-from-top-4 duration-500"
16+
role="alert"
17+
>
18+
<div className="flex items-center gap-3">
19+
<div className="rounded-full bg-primary/20 p-2 text-primary">
20+
<History className="h-5 w-5" />
21+
</div>
22+
<div>
23+
<p className="text-sm font-semibold">Incomplete Claim Draft Found</p>
24+
<p className="text-xs text-muted-foreground">
25+
You have a partially completed claim for this policy. Would you like to resume?
26+
</p>
27+
</div>
28+
</div>
29+
<div className="flex shrink-0 items-center gap-2">
30+
<Button size="sm" variant="ghost" className="h-8 px-2" onClick={onDismiss} aria-label="Dismiss draft">
31+
<X className="h-4 w-4" />
32+
</Button>
33+
<Button size="sm" className="h-8" onClick={onConfirm}>
34+
Resume Draft
35+
</Button>
36+
</div>
37+
</div>
38+
);
39+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import '@testing-library/jest-dom';
2+
import React from 'react';
3+
import { render, screen, fireEvent } from '@testing-library/react';
4+
import { ReviewStep } from '../steps/ReviewStep';
5+
6+
describe('ReviewStep', () => {
7+
const mockOnEdit = jest.fn();
8+
9+
const mockData = {
10+
amount: '1000',
11+
details: 'This is a test claim narrative documenting the incident.',
12+
evidence: [
13+
{ url: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2SmxWeN4A7h...', contentSha256Hex: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' },
14+
{ url: 'https://example.com/long-file-name-that-should-be-truncated-in-ui.jpg', contentSha256Hex: '01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b' }
15+
]
16+
};
17+
18+
const policyId = '12345';
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('renders all claim data correctly', () => {
25+
render(<ReviewStep data={mockData} policyId={policyId} />);
26+
27+
// Check amount
28+
expect(screen.getByText('1000 stroops')).toBeInTheDocument();
29+
30+
// Check policy ID
31+
expect(screen.getByText('Policy ID: #12345')).toBeInTheDocument();
32+
33+
// Check narrative
34+
expect(screen.getByText('This is a test claim narrative documenting the incident.')).toBeInTheDocument();
35+
36+
// Check evidence count
37+
expect(screen.getByText('Evidence (2 files)')).toBeInTheDocument();
38+
39+
// Check evidence items
40+
expect(screen.getByText('QmYwAPJzv5CZsnA625s3Xf2SmxWeN4A7h...')).toBeInTheDocument();
41+
expect(screen.getByText('long-file-name-that-should-be-truncated-in-ui.jpg')).toBeInTheDocument();
42+
43+
// Check hashes (first and last parts)
44+
expect(screen.getByText('e3b0c44298fc1c14...7852b855')).toBeInTheDocument();
45+
expect(screen.getByText('01ba4719c80b6fe9...daca546b')).toBeInTheDocument();
46+
});
47+
48+
it('handles empty evidence correctly', () => {
49+
render(<ReviewStep data={{ ...mockData, evidence: [] }} policyId={policyId} />);
50+
51+
expect(screen.getByText('Evidence (0 files)')).toBeInTheDocument();
52+
expect(screen.getByText('No evidence uploaded.')).toBeInTheDocument();
53+
});
54+
55+
it('triggers onEdit with correct step index when Edit is clicked', () => {
56+
render(<ReviewStep data={mockData} policyId={policyId} onEdit={mockOnEdit} />);
57+
58+
const editButtons = screen.getAllByText('Edit');
59+
expect(editButtons).toHaveLength(3);
60+
61+
// Click Edit Amount
62+
fireEvent.click(editButtons[0]);
63+
expect(mockOnEdit).toHaveBeenCalledWith(0);
64+
65+
// Click Edit Narrative
66+
fireEvent.click(editButtons[1]);
67+
expect(mockOnEdit).toHaveBeenCalledWith(1);
68+
69+
// Click Edit Evidence
70+
fireEvent.click(editButtons[2]);
71+
expect(mockOnEdit).toHaveBeenCalledWith(2);
72+
});
73+
});

frontend/src/components/claims/steps/ReviewStep.tsx

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ interface ReviewStepProps {
99
evidence: { url: string; contentSha256Hex: string }[];
1010
};
1111
policyId: string;
12+
onEdit?: (step: number) => void;
1213
}
1314

14-
export function ReviewStep({ data, policyId }: ReviewStepProps) {
15+
export function ReviewStep({ data, policyId, onEdit }: ReviewStepProps) {
1516
return (
1617
<div className="space-y-6 py-4">
1718
<div className="space-y-2">
@@ -24,17 +25,24 @@ export function ReviewStep({ data, policyId }: ReviewStepProps) {
2425
<div className="grid gap-4">
2526
<Card>
2627
<CardContent className="pt-6">
27-
<div className="flex items-center gap-4">
28+
<div className="flex items-start gap-4">
2829
<div className="rounded-full bg-primary/10 p-2 text-primary">
2930
<Wallet className="h-5 w-5" />
3031
</div>
3132
<div className="flex-1">
32-
<p className="text-xs font-medium text-muted-foreground">Claim Amount</p>
33+
<div className="flex items-center justify-between">
34+
<p className="text-xs font-medium text-muted-foreground">Claim Amount</p>
35+
{onEdit && (
36+
<button
37+
onClick={() => onEdit(0)}
38+
className="text-xs font-medium text-primary hover:underline"
39+
>
40+
Edit
41+
</button>
42+
)}
43+
</div>
3344
<p className="text-lg font-bold">{data.amount} stroops</p>
34-
</div>
35-
<div className="text-right">
36-
<p className="text-xs font-medium text-muted-foreground">Policy ID</p>
37-
<p className="font-medium text-primary">#{policyId}</p>
45+
<p className="text-xs text-muted-foreground">Policy ID: #{policyId}</p>
3846
</div>
3947
</div>
4048
</CardContent>
@@ -47,7 +55,17 @@ export function ReviewStep({ data, policyId }: ReviewStepProps) {
4755
<FileText className="h-5 w-5" />
4856
</div>
4957
<div className="flex-1 space-y-1">
50-
<p className="text-xs font-medium text-muted-foreground">Narrative</p>
58+
<div className="flex items-center justify-between">
59+
<p className="text-xs font-medium text-muted-foreground">Narrative</p>
60+
{onEdit && (
61+
<button
62+
onClick={() => onEdit(1)}
63+
className="text-xs font-medium text-primary hover:underline"
64+
>
65+
Edit
66+
</button>
67+
)}
68+
</div>
5169
<p className="text-sm leading-relaxed">{data.details || 'No details provided.'}</p>
5270
</div>
5371
</div>
@@ -61,20 +79,40 @@ export function ReviewStep({ data, policyId }: ReviewStepProps) {
6179
<ImageIcon className="h-5 w-5" />
6280
</div>
6381
<div className="flex-1 space-y-3">
64-
<p className="text-xs font-medium text-muted-foreground">Evidence ({data.evidence.length} files)</p>
82+
<div className="flex items-center justify-between">
83+
<p className="text-xs font-medium text-muted-foreground">Evidence ({data.evidence.length} files)</p>
84+
{onEdit && (
85+
<button
86+
onClick={() => onEdit(2)}
87+
className="text-xs font-medium text-primary hover:underline"
88+
>
89+
Edit
90+
</button>
91+
)}
92+
</div>
6593
<div className="grid grid-cols-1 gap-2">
6694
{data.evidence.length > 0 ? (
6795
data.evidence.map((item, i) => (
68-
<div key={i} className="flex items-center gap-2 rounded-md border bg-muted/30 p-2 text-xs">
69-
<span className="truncate flex-1">{item.url}</span>
70-
<a
71-
href={item.url}
72-
target="_blank"
73-
rel="noopener noreferrer"
74-
className="text-primary hover:underline"
75-
>
76-
View
77-
</a>
96+
<div key={i} className="flex flex-col gap-1 rounded-md border bg-muted/30 p-2 text-xs">
97+
<div className="flex items-center justify-between">
98+
<span className="font-medium truncate max-w-[200px]" title={item.url}>
99+
{item.url.split('/').pop() || 'file'}
100+
</span>
101+
<a
102+
href={item.url}
103+
target="_blank"
104+
rel="noopener noreferrer"
105+
className="text-primary hover:underline"
106+
>
107+
View
108+
</a>
109+
</div>
110+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
111+
<span className="shrink-0 font-mono">Hash:</span>
112+
<span className="truncate font-mono" title={item.contentSha256Hex}>
113+
{item.contentSha256Hex.substring(0, 16)}...{item.contentSha256Hex.substring(item.contentSha256Hex.length - 8)}
114+
</span>
115+
</div>
78116
</div>
79117
))
80118
) : (

0 commit comments

Comments
 (0)