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
43 changes: 39 additions & 4 deletions apps/citizen-pwa/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE_NAME = 'bantayog_shell_v1'
const CACHE_NAME = 'bantayog_shell_v2'
// WARNING: This must match the localforage instance name in
// apps/citizen-pwa/src/services/draft-store.ts ('bantayog-drafts').
// The SW uses raw IndexedDB; localforage wraps IndexedDB with its own
Expand All @@ -8,8 +8,35 @@ const CACHE_NAME = 'bantayog_shell_v1'
const DB_NAME = 'bantayog-drafts'
const DB_STORE = 'drafts'

// Precache the app shell so a cold offline navigation falls back to
// index.html instead of the browser's default offline page. Vite's hashed
// JS/CSS chunks are still cached opportunistically by the fetch handler.
//
// REQUIRED: core shell files — install fails if any of these can't be cached
// so the old worker stays active rather than serving a broken shell.
const REQUIRED_URLS = ['/', '/index.html']
// OPTIONAL: manifest + icons — best-effort; a transient 404 here should not
// block the entire install.
const OPTIONAL_URLS = ['/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png']

self.addEventListener('install', (event) => {
self.skipWaiting()
event.waitUntil(
caches
.open(CACHE_NAME)
.then(async (cache) => {
// Required URLs must all succeed; let any failure reject install.
await cache.addAll(REQUIRED_URLS)
// Optional URLs are best-effort; log and continue on individual failure.
await Promise.allSettled(
OPTIONAL_URLS.map((url) =>
cache.add(url).catch((err) => {
console.warn('[SW] optional precache failed for', url, err)
}),
),
)
})
.then(() => self.skipWaiting()),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

self.addEventListener('activate', (event) => {
Expand Down Expand Up @@ -41,9 +68,17 @@ self.addEventListener('fetch', (event) => {
}
return response
})
.catch(() => {
.catch(async () => {
// Navigation requests (SPA routes) fall back to the cached shell so
// React Router can render the right view instead of the browser
// showing its default offline page.
if (event.request.mode === 'navigate') {
const shell = await caches.match('/index.html')
if (shell) return shell
}
if (event.request.method === 'GET') {
return caches.match(event.request)
const cached = await caches.match(event.request)
if (cached) return cached
}
throw new Error('not found')
}),
Expand Down
25 changes: 23 additions & 2 deletions apps/citizen-pwa/src/components/SubmitReportForm/Step1Evidence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface Step1EvidenceProps {
onNext: (data: { reportType: string; photoFile: File | null }) => void
onBack: () => void
isSubmitting?: boolean
initialReportType?: string
}

const INCIDENT_TYPES = [
Expand Down Expand Up @@ -98,8 +99,17 @@ const INCIDENT_TYPES = [
},
] as const

export function Step1Evidence({ onNext, onBack, isSubmitting = false }: Step1EvidenceProps) {
const [reportType, setReportType] = useState('flood')
export function Step1Evidence({
onNext,
onBack,
isSubmitting = false,
initialReportType = '',
}: Step1EvidenceProps) {
// Default to empty so the user must actively pick a type. This is what
// gates the "Skip photo for now" path — without it, Skip silently
// submitted whatever the seeded default was.
const [reportType, setReportType] = useState(initialReportType)
const [reportTypeError, setReportTypeError] = useState<string | null>(null)
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [photoError, setPhotoError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
Expand Down Expand Up @@ -170,6 +180,11 @@ export function Step1Evidence({ onNext, onBack, isSubmitting = false }: Step1Evi
}

const handleNext = () => {
if (!reportType) {
setReportTypeError('Please select an incident type to continue.')
return
}
setReportTypeError(null)
onNext({ reportType, photoFile })
}

Expand Down Expand Up @@ -293,6 +308,7 @@ export function Step1Evidence({ onNext, onBack, isSubmitting = false }: Step1Evi
type="button"
onClick={() => {
setReportType(value)
setReportTypeError(null)
}}
className={`flex flex-col items-center justify-center gap-2 min-h-[80px] rounded-xl border-2 transition-all active:scale-95 ${
isSelected
Expand All @@ -310,6 +326,11 @@ export function Step1Evidence({ onNext, onBack, isSubmitting = false }: Step1Evi
)
})}
</div>
{reportTypeError && (
<p role="alert" className="mt-2 text-xs text-danger-500 font-medium">
{reportTypeError}
</p>
)}
</div>
</div>

Expand Down
55 changes: 49 additions & 6 deletions apps/citizen-pwa/src/components/SubmitReportForm/Step2WhoWhere.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,24 @@ interface Step2WhoWhereProps {
}) => void
onBack: () => void
isSubmitting?: boolean
initialValues?: {
location?: { lat: number; lng: number }
reporterName?: string
reporterMsisdn?: string
patientCount?: number
locationMethod?: 'gps' | 'manual'
municipalityId?: string
barangayId?: string
nearestLandmark?: string
}
}

export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2WhoWhereProps) {
export function Step2WhoWhere({
onNext,
onBack,
isSubmitting = false,
initialValues,
}: Step2WhoWhereProps) {
const {
location,
locationMethod,
Expand Down Expand Up @@ -54,6 +69,28 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who
const [hasMemory, setHasMemory] = useState(false)
const [municipalityError, setMunicipalityError] = useState<string | null>(null)

// Hydrate from snapshot when resuming or navigating back to Step 2.
useEffect(() => {
if (!initialValues) return

if (initialValues.locationMethod) setLocationMethod(initialValues.locationMethod)
// eslint-disable-next-line react-hooks/set-state-in-effect
if (initialValues.reporterName) setReporterName(initialValues.reporterName)

if (initialValues.reporterMsisdn) setReporterMsisdn(initialValues.reporterMsisdn)
if (initialValues.patientCount) {
setPatientCount(initialValues.patientCount)

setAnyoneHurt(initialValues.patientCount > 0)
}

if (initialValues.nearestLandmark) setNearestLandmark(initialValues.nearestLandmark)
// Municipality / barangay are handled via useMunicipalityBarangays; those
// hooks don't expose setters, so we rely on localStorage/sessionStorage
// pre-fill below for reporter fields, and the user re-selects location.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

useEffect(() => {
try {
const savedName = localStorage.getItem('bantayog.reporter.name')
Expand Down Expand Up @@ -307,9 +344,15 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who
)}
</div>

{/* Bottom action */}
{locationMethod !== null && (
<div className="sticky bottom-0 z-float bg-surface-100/90 backdrop-blur-md border-t border-surface-200 px-5 py-4">
{/* Bottom action — always rendered so users always see what to do next.
Before a location method is picked, show a polite hint instead of
hiding the entire region (which previously felt like silent failure). */}
<div className="sticky bottom-0 z-float bg-surface-100/90 backdrop-blur-md border-t border-surface-200 px-5 py-4">
{locationMethod === null ? (
<p role="status" className="text-center text-sm font-medium text-surface-700">
Pick a location method above (GPS or Manual) to continue.
</p>
) : (
<Button
variant="primary"
fullWidth
Expand All @@ -318,8 +361,8 @@ export function Step2WhoWhere({ onNext, onBack, isSubmitting = false }: Step2Who
>
{isSubmitting ? 'Please wait...' : 'Review Report'}
</Button>
</div>
)}
)}
</div>
</div>
)
}
59 changes: 59 additions & 0 deletions apps/citizen-pwa/src/components/SubmitReportForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { normalizeMsisdn } from '@bantayog/shared-validators'
import type { ReportType } from '@bantayog/shared-types'
import { createDraft } from '../../services/submit-report.js'
import type { Draft } from '../../services/draft-store.js'
import { wizardSnapshot } from '../../services/wizard-snapshot.js'
import { useSubmissionMachine } from '../../hooks/useSubmissionMachine.js'
import { Step1Evidence } from './Step1Evidence.js'
import { Step2WhoWhere } from './Step2WhoWhere.js'
Expand Down Expand Up @@ -41,13 +42,51 @@ export function SubmitReportForm() {

function WizardContainer() {
const nav = useNavigate()
const [hasLoadedSnapshot, setHasLoadedSnapshot] = useState(false)
const [step, setStep] = useState<1 | 2 | 3>(1)
const [formData, setFormData] = useState<FormData>({ step1: null, step2: null })
const [draft, setDraft] = useState<Draft | null>(null)
const [secret, setSecret] = useState<string | null>(null)
const [isCreatingDraft, setIsCreatingDraft] = useState(false)
const [draftError, setDraftError] = useState<string | null>(null)

// Resume an in-progress wizard from a prior session (refresh, accidental close).
// photoFile is intentionally not persisted — File can't be reliably serialized;
// the user re-attaches if needed by going back to Step 1.
useEffect(() => {
let cancelled = false
void wizardSnapshot
.load()
.then((snap) => {
if (cancelled) return
if (snap) {
setStep(snap.step)
setFormData({
step1: snap.step1 ? { reportType: snap.step1.reportType, photoFile: null } : null,
step2: snap.step2 ?? null,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
setHasLoadedSnapshot(true)
})
.catch(() => {
if (!cancelled) setHasLoadedSnapshot(true)
})
return () => {
cancelled = true
}
}, [])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Persist after load + on every step/formData change. The hasLoadedSnapshot
// gate prevents the initial empty state from clobbering a fresh resume.
useEffect(() => {
if (!hasLoadedSnapshot) return
void wizardSnapshot.save({
step,
step1: formData.step1 ? { reportType: formData.step1.reportType } : null,
step2: formData.step2,
})
}, [hasLoadedSnapshot, step, formData])

const handleStep1Next = (data: Step1Data) => {
setFormData((prev) => ({ ...prev, step1: data }))
setStep(2)
Expand Down Expand Up @@ -108,6 +147,8 @@ function WizardContainer() {

setDraft(created)
setSecret(draftSecret)
// Prevent stale snapshot from causing a second draft on refresh.
await wizardSnapshot.clear()
} catch (err: unknown) {
setDraftError(err instanceof Error ? err.message : 'Failed to create draft')
} finally {
Expand All @@ -116,15 +157,31 @@ function WizardContainer() {
}

const handleStep1Back = () => {
// User abandoned the wizard from Step 1 — drop the snapshot so a fresh
// /report visit starts clean rather than resuming the old draft.
void wizardSnapshot.clear()
void nav('/')
}

if (!hasLoadedSnapshot) {
return (
<div
role="status"
aria-label="Loading report wizard"
className="min-h-[100dvh] bg-surface-100 flex items-center justify-center"
>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-surface-300 border-t-brand-600" />
</div>
)
}

if (draft) {
return (
<SubmissionPanel
draft={draft}
secret={secret}
onSuccess={(publicRef) => {
void wizardSnapshot.clear()
void nav(`/reports/${publicRef}`)
}}
/>
Expand All @@ -137,6 +194,7 @@ function WizardContainer() {
onNext={handleStep1Next}
onBack={handleStep1Back}
isSubmitting={isCreatingDraft}
initialReportType={formData.step1?.reportType ?? ''}
/>
)
}
Expand All @@ -147,6 +205,7 @@ function WizardContainer() {
onNext={handleStep2Next}
onBack={handleStep2Back}
isSubmitting={isCreatingDraft}
{...(formData.step2 ? { initialValues: formData.step2 } : {})}
/>
)
}
Expand Down
9 changes: 7 additions & 2 deletions apps/citizen-pwa/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import {
import { useToast } from '../hooks/useToast.js'
import { Toast } from '../components/Toast.js'
import { auth, hasFirebaseConfig } from '../services/firebase.js'
import { getStoredPhone, setStoredPhone } from '../services/phone-session-storage.js'

export function LoginPage() {
const navigate = useNavigate()
const { show, message, type, toast } = useToast()
const [step, setStep] = useState<'phone' | 'otp'>('phone')
const [phone, setPhone] = useState('+63')
// Seed from sessionStorage so users who bounce between /login and /register
// (e.g. wrong-account → register flow) keep the phone they already typed.
const [phone, setPhone] = useState(() => getStoredPhone())
const [otp, setOtp] = useState('')
const [loading, setLoading] = useState(false)
const [verificationId, setVerificationId] = useState<string | null>(null)
Expand Down Expand Up @@ -151,7 +154,9 @@ export function LoginPage() {
type="tel"
value={phone}
onChange={(e) => {
setPhone(e.target.value)
const next = e.target.value
setPhone(next)
setStoredPhone(next)
}}
placeholder="+63 XXX XXX XXXX"
className="w-full pl-10 pr-4 py-3 border border-surface-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none"
Expand Down
Loading