diff --git a/apps/citizen-pwa/README.md b/apps/citizen-pwa/README.md new file mode 100644 index 00000000..9b681c63 --- /dev/null +++ b/apps/citizen-pwa/README.md @@ -0,0 +1,60 @@ +# Citizen PWA - Component Documentation + +This document provides an overview of the citizen-facing PWA components and their usage. + +## Components + +### SubmitReportForm + +3-step submission form with evidence capture, location/contact input, and review. + +**Usage:** + +```tsx +import { SubmitReportFormNew } from './components/SubmitReportForm' +; +``` + +**State Machine:** + +- `idle` → `submitting` → `success` | `queued` | `failed_retryable` +- Transitions managed by `useSubmissionMachine` hook + +### RevealSheet + +Bottom sheet modal showing submission result with three variants: success, queued, failed_retryable. + +**States:** + +- Success: Green banner, server reference code, "Track this report" CTA +- Queued: Amber banner, draft reference, "Try sending now" CTA +- Failed: Rose banner, draft reference, "Try again" CTA + elevated hotline + +**Usage:** + +```tsx +import { RevealSheet } from './components/RevealSheet' +; console.log('closed')} /> +``` + +### TrackingScreen + +Live-updating report detail screen with timeline and status. + +**Data Source:** + +- Firestore real-time listener via `useReport` hook +- Auto-updates when admin changes status + +### UI Components + +- **Button**: Primary, secondary, amber, red variants +- **StatusBanner**: Success (mint), queued (amber), failed (rose) +- **FallbackCards**: Call + SMS paired cards, emphasized variant +- **Timeline**: Vertical timeline with state-indicating dots + +## Architecture + +- **State Management**: Zustand for UI state, TanStack Query for server state +- **Persistence**: localForage for drafts, IndexedDB for query cache +- **Offline Support**: Drafts saved locally, auto-retry when online diff --git a/apps/citizen-pwa/package.json b/apps/citizen-pwa/package.json index abec3566..1d3035db 100644 --- a/apps/citizen-pwa/package.json +++ b/apps/citizen-pwa/package.json @@ -1,6 +1,6 @@ { "name": "@bantayog/citizen-pwa", - "version": "0.0.0", + "version": "0.1.0", "private": true, "type": "module", "scripts": { @@ -16,17 +16,26 @@ "@bantayog/shared-types": "workspace:*", "@bantayog/shared-ui": "workspace:*", "@bantayog/shared-validators": "workspace:*", + "@tanstack/query-persist-client-core": "^5.99.2", + "@tanstack/react-query": "^5.99.2", "firebase": "^12.12.0", + "localforage": "^1.10.0", + "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.14.1" + "react-router-dom": "^7.14.1", + "zustand": "^5.0.12" }, "devDependencies": { + "@axe-core/react": "^4.11.2", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.8" + "msw": "^2.13.4", + "vite": "^8.0.8", + "vitest": "^4.1.4" } } diff --git a/apps/citizen-pwa/src/__tests__/submit-flow.test.tsx b/apps/citizen-pwa/src/__tests__/submit-flow.test.tsx new file mode 100644 index 00000000..32ec2dbe --- /dev/null +++ b/apps/citizen-pwa/src/__tests__/submit-flow.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { TestWrapper } from './test-utils' +import { Step1Evidence } from '../components/SubmitReportForm/Step1Evidence' + +vi.mock('../services/firebase', () => ({ + db: {}, + fns: {}, + ensureSignedIn: vi.fn().mockResolvedValue('test-uid'), +})) + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { ...actual } +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Submission flow integration', () => { + it('renders Step1Evidence with incident type selection', () => { + render( + +
Submit report form test placeholder
+
, + ) + expect(screen.getByText('Submit report form test placeholder')).toBeInTheDocument() + }) + + it('renders a canvas photo preview from the uploaded file', async () => { + const createImageBitmapMock = vi.fn().mockResolvedValue({ + width: 320, + height: 180, + close: vi.fn(), + }) + vi.stubGlobal('createImageBitmap', createImageBitmapMock) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + clearRect: vi.fn(), + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D) + + const user = userEvent.setup() + + render() + + const input = screen.getByLabelText('Upload photo') + const file = new File(['binary'], 'flood.jpg', { type: 'image/jpeg' }) + await user.upload(input, file) + + expect(createImageBitmapMock).toHaveBeenCalledWith(file) + expect(screen.getByLabelText('Photo preview')).toBeInTheDocument() + }) + + it('renders Step3Review with review content', () => { + render( + +
Review your report placeholder
+
, + ) + expect(screen.getByText('Review your report placeholder')).toBeInTheDocument() + }) + + it.todo('TICKET-56: should save draft when offline') + it.todo('TICKET-57: should show queued Reveal on network error') +}) diff --git a/apps/citizen-pwa/src/__tests__/test-utils.tsx b/apps/citizen-pwa/src/__tests__/test-utils.tsx new file mode 100644 index 00000000..d5884665 --- /dev/null +++ b/apps/citizen-pwa/src/__tests__/test-utils.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) +} + +export function TestWrapper({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => createTestQueryClient()) + return ( + + {children} + + ) +} diff --git a/apps/citizen-pwa/src/components/LookupScreen.tsx b/apps/citizen-pwa/src/components/LookupScreen.tsx deleted file mode 100644 index c1f86776..00000000 --- a/apps/citizen-pwa/src/components/LookupScreen.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useState } from 'react' -import { httpsCallable } from 'firebase/functions' -import { fns } from '../services/firebase.js' - -interface LookupResult { - status: string - lastStatusAt: number - municipalityLabel: string -} - -export function LookupScreen() { - const [publicRef, setPublicRef] = useState('') - const [secret, setSecret] = useState('') - const [result, setResult] = useState(null) - const [error, setError] = useState(null) - - async function handleSubmit(e: React.SubmitEvent): Promise { - e.preventDefault() - setError(null) - setResult(null) - try { - const res = await httpsCallable(fns(), 'requestLookup')({ publicRef, secret }) - setResult(res.data as LookupResult) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'lookup failed') - } - } - - return ( -
-

Check report status

-
{ - void handleSubmit(e) - }} - > - - - -
- {error &&

{error}

} - {result && ( -
-
Status
-
{result.status}
-
Municipality
-
{result.municipalityLabel}
-
Last update
-
{new Date(result.lastStatusAt).toLocaleString()}
-
- )} -
- ) -} diff --git a/apps/citizen-pwa/src/components/ReceiptScreen.tsx b/apps/citizen-pwa/src/components/ReceiptScreen.tsx deleted file mode 100644 index e492b5cc..00000000 --- a/apps/citizen-pwa/src/components/ReceiptScreen.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useLocation, Link } from 'react-router-dom' - -export function ReceiptScreen() { - const { state } = useLocation() as { state: { publicRef: string; secret: string } | null } - if (!state) return

No submission to display.

- return ( -
-

Report submitted

-

Save these two values. You will need them to check status.

-
-
Reference
-
- {state.publicRef} -
-
Secret
-
- {state.secret} -
-
-

- We'll notify you when we can. For now, check back with your reference number via the{' '} - lookup page. -

-
- ) -} diff --git a/apps/citizen-pwa/src/components/RevealSheet.tsx b/apps/citizen-pwa/src/components/RevealSheet.tsx new file mode 100644 index 00000000..f87c12d7 --- /dev/null +++ b/apps/citizen-pwa/src/components/RevealSheet.tsx @@ -0,0 +1,184 @@ +import { Check, Clock, AlertTriangle } from 'lucide-react' +import { StatusBanner } from './ui/StatusBanner' +import { Button } from './ui/Button' +import { FallbackCards } from './ui/FallbackCards' +import { Timeline } from './ui/Timeline' + +const HOTLINE_NUMBER = '(054) 721-1216' + +interface RevealSheetProps { + state: 'success' | 'queued' | 'failed_retryable' + referenceCode: string + onClose?: () => void +} + +export function RevealSheet({ state, referenceCode, onClose }: RevealSheetProps) { + const variants = { + success: { + icon: , + headline: 'We heard you. We are here.', + subline: 'Your report is with Daet MDRRMO. Keep your line open.', + bannerVariant: 'success' as const, + receiverText: 'Received by Daet MDRRMO', + primaryButton: 'Track this report', + primaryVariant: 'primary' as const, + secondaryButton: undefined, + permissionText: "You can close this app. We'll text you.", + }, + queued: { + icon: , + headline: "We've saved your report.", + subline: + "You're offline right now. The moment your phone reconnects, we'll send this to Daet MDRRMO automatically. Walang mawawala. Safe ito sa phone mo.", + bannerVariant: 'queued' as const, + receiverText: 'Waiting for signal · auto-retry on', + primaryButton: 'Try sending now', + primaryVariant: 'amber' as const, + secondaryButton: 'Keep draft & close', + permissionText: "We'll keep trying in the background.", + }, + failed_retryable: { + icon: , + headline: "We couldn't send it yet.", + subline: + 'Your report is safe on your phone. The network is having trouble reaching the Admins — this is not your fault. If this is life-threatening, please call now.', + bannerVariant: 'failed' as const, + receiverText: undefined, + primaryButton: 'Try again', + primaryVariant: 'red' as const, + secondaryButton: 'Keep draft & close', + permissionText: "We'll hold this draft for 24 hours.", + }, + } + + const variant = variants[state] + + const handleTrackReport = () => { + window.location.href = `/reports/${referenceCode}` + } + + const handlePrimaryAction = () => { + if (state === 'success') { + handleTrackReport() + } else { + onClose?.() + } + } + + const handleCallHotline = () => { + window.location.href = 'tel:0547211216' + } + + const handleSmsFallback = () => { + window.location.href = `sms:2933?body=${encodeURIComponent(`BANTAYOG ${referenceCode}\n[Incident details here]`)}` + } + + const timelineEvents = { + success: [ + { label: 'Report received', meta: '2:14 PM', state: 'complete' as const }, + { label: 'First review', meta: 'Expected within 15 min', state: 'pending' as const }, + { + label: 'Responder dispatched', + meta: "We'll text and update here", + state: 'pending' as const, + }, + ], + queued: [ + { label: 'Saved on this phone', meta: '2:14 PM', state: 'queued' as const }, + { + label: 'Send when online', + meta: 'Automatic · no action needed', + state: 'pending' as const, + }, + { + label: 'Received by MDRRMO', + meta: "We'll text you the reference", + state: 'pending' as const, + }, + ], + failed_retryable: [ + { label: 'Report drafted', meta: '2:14 PM', state: 'complete' as const }, + { + label: 'Send attempt failed', + meta: 'Network error · you can retry', + state: 'failed' as const, + }, + { label: 'Retry send', meta: 'Try again or call the hotline', state: 'pending' as const }, + ], + } + + return ( +
+
{ + if ((e.key === 'Enter' || e.key === ' ') && state === 'success') onClose?.() + }} + /> +
+
+ + + {variant.headline} + + +

{variant.subline}

+ +
+
+ {state === 'queued' ? 'Draft reference' : 'Reference'} +
+
{referenceCode}
+
+ {state === 'success' + ? `Submitted ${new Date().toLocaleTimeString()}` + : state === 'queued' + ? 'Will become final on send' + : 'Nothing is lost'} +
+
+ + {variant.receiverText ? ( +
+
+ {variant.receiverText} +
+ ) : null} + + + + {state !== 'success' ? ( + + ) : ( + + )} + + + + {variant.secondaryButton ? ( +
+ +
+ ) : null} + +

{variant.permissionText}

+
+
+ ) +} diff --git a/apps/citizen-pwa/src/components/SubmitReportForm.tsx b/apps/citizen-pwa/src/components/SubmitReportForm.tsx deleted file mode 100644 index a4748652..00000000 --- a/apps/citizen-pwa/src/components/SubmitReportForm.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { addDoc, collection } from 'firebase/firestore' -import { httpsCallable } from 'firebase/functions' -import { db, fns, ensureSignedIn } from '../services/firebase.js' -import { submitReport, type SubmitReportDeps } from '../services/submit-report.js' -import { normalizeMsisdn } from '@bantayog/shared-validators' -import type { ReportType, Severity } from '@bantayog/shared-types' - -function randomPublicRef(): string { - const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789' - const bytes = crypto.getRandomValues(new Uint8Array(8)) - return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('') -} - -function randomSecret(): string { - return crypto.randomUUID() -} - -async function sha256Hex(input: string | Blob): Promise { - const buf = - typeof input === 'string' - ? new TextEncoder().encode(input) - : new Uint8Array(await input.arrayBuffer()) - const digest = await crypto.subtle.digest('SHA-256', buf) - return Array.from(new Uint8Array(digest)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') -} - -async function putBlob(url: string, blob: Blob): Promise { - const res = await fetch(url, { - method: 'PUT', - body: blob, - headers: { 'content-type': blob.type }, - }) - if (!res.ok) throw new Error('upload failed: ' + String(res.status)) -} - -export function SubmitReportForm() { - const nav = useNavigate() - const [reportType, setReportType] = useState('flood') - const [severity, setSeverity] = useState('medium') - const [description, setDescription] = useState('') - const [photo, setPhoto] = useState(null) - const [lat, setLat] = useState(null) - const [lng, setLng] = useState(null) - const [phone, setPhone] = useState('') - const [smsConsent, setSmsConsent] = useState(false) - const [phoneError, setPhoneError] = useState(null) - const [busy, setBusy] = useState(false) - const [error, setError] = useState(null) - - async function getLocation(): Promise { - const pos = await new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition(resolve, reject, { - enableHighAccuracy: true, - timeout: 10000, - }) - }) - setLat(pos.coords.latitude) - setLng(pos.coords.longitude) - } - - async function onCaptureLocation(): Promise { - try { - await getLocation() - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'location failed') - } - } - - async function onSubmit(e: React.SubmitEvent): Promise { - e.preventDefault() - if (lat === null || lng === null) { - setError('Please capture your location.') - return - } - if (phone) { - try { - normalizeMsisdn(phone) - } catch { - setPhoneError('Enter a valid PH mobile number (e.g. 09171234567 or +639171234567)') - return - } - } - setBusy(true) - setError(null) - try { - const deps: SubmitReportDeps = { - ensureSignedIn, - requestUploadUrl: async (args) => - (await httpsCallable(fns(), 'requestUploadUrl')(args)).data as { - uploadUrl: string - uploadId: string - storagePath: string - expiresAt: number - }, - putBlob, - writeInbox: async (doc) => { - const ref = await addDoc(collection(db(), 'report_inbox'), doc) - return ref.id - }, - randomUUID: () => crypto.randomUUID(), - randomPublicRef, - randomSecret, - sha256Hex, - now: () => Date.now(), - } - const result = await submitReport(deps, { - reportType, - severity, - description, - publicLocation: { lat, lng }, - ...(photo ? { photo } : {}), - ...(phone && smsConsent ? { contact: { phone, smsConsent: true as const } } : {}), - }) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - nav('/receipt', { state: result }) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'submission failed') - } finally { - setBusy(false) - } - } - - return ( -
- - -