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
8 changes: 4 additions & 4 deletions apps/citizen-pwa/src/App.routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('App routes', () => {

it('shows the report form at /report', async () => {
await renderAppAt('/report')
expect(screen.getByText('Report form')).toBeInTheDocument()
expect(await screen.findByText('Report form')).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: /main navigation/i })).not.toBeInTheDocument()
})

Expand All @@ -95,17 +95,17 @@ describe('App routes', () => {

it('shows incident detail without shell chrome at /incidents/:id', async () => {
await renderAppAt('/incidents/test-id')
expect(screen.getByText('Incident detail')).toBeInTheDocument()
expect(await screen.findByText('Incident detail')).toBeInTheDocument()
expect(screen.queryByRole('navigation', { name: /main navigation/i })).not.toBeInTheDocument()
})

it('shows register page at /register', async () => {
await renderAppAt('/register')
expect(screen.getByText('Register page')).toBeInTheDocument()
expect(await screen.findByText('Register page')).toBeInTheDocument()
})

it('shows settings page at /settings', async () => {
await renderAppAt('/settings')
expect(screen.getByText('Settings page')).toBeInTheDocument()
expect(await screen.findByText('Settings page')).toBeInTheDocument()
})
})
1 change: 1 addition & 0 deletions apps/citizen-pwa/src/components/MapTab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface SelectedPin {
}

function severityLabel(severity: Filters['severity'] | MyReport['severity']): string {
if (severity === 'all') return 'All'
return severity.charAt(0).toUpperCase() + severity.slice(1)
}

Expand Down
88 changes: 32 additions & 56 deletions apps/citizen-pwa/src/components/ProfileTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ClipboardList,
Expand All @@ -18,51 +18,15 @@ import {
import { onAuthStateChanged, signOut } from 'firebase/auth'
import type { User } from 'firebase/auth'
import { useMyActiveReports } from '../hooks/useMyActiveReports.js'
import { incidentIcon, incidentLabel } from '../utils/incident-meta.js'
import {
incidentIcon,
incidentLabel,
statusMeta,
severityDotColor,
} from '../utils/incident-meta.js'
import { auth, hasFirebaseConfig } from '../services/firebase.js'
import type { MyReport } from './MapTab/types.js'

function statusMeta(status: string): { label: string; bg: string; color: string } {
switch (status) {
case 'queued':
case 'draft_inbox':
return { label: 'Sending…', bg: 'bg-surface-200', color: 'text-surface-600' }
case 'new':
return { label: 'Received', bg: 'bg-brand-100', color: 'text-brand-600' }
case 'awaiting_verify':
return { label: 'Under review', bg: 'bg-warning-400/20', color: 'text-warning-500' }
case 'verified':
return { label: 'Verified', bg: 'bg-brand-100', color: 'text-brand-600' }
case 'assigned':
case 'acknowledged':
return { label: 'Help on the way', bg: 'bg-success-400/20', color: 'text-success-500' }
case 'en_route':
return { label: 'Responder en route', bg: 'bg-success-400/20', color: 'text-success-500' }
case 'on_scene':
return { label: 'On scene', bg: 'bg-success-400/20', color: 'text-success-500' }
case 'resolved':
case 'closed':
return { label: 'Resolved', bg: 'bg-success-400/20', color: 'text-success-500' }
case 'reopened':
return { label: 'Re-opened', bg: 'bg-brand-100', color: 'text-brand-600' }
case 'rejected':
return { label: 'Not accepted', bg: 'bg-danger-400/20', color: 'text-danger-500' }
case 'cancelled':
case 'cancelled_false_report':
return { label: 'Cancelled', bg: 'bg-surface-200', color: 'text-surface-600' }
case 'merged_as_duplicate':
return { label: 'Merged', bg: 'bg-surface-200', color: 'text-surface-600' }
default:
return { label: status.replace(/_/g, ' '), bg: 'bg-surface-200', color: 'text-surface-600' }
}
}

function severityDot(severity: string): string {
if (severity === 'high') return '#dc2626'
if (severity === 'medium') return '#d97706'
return '#334155'
}

function timeAgo(ts: number): string {
const minutes = Math.floor((Date.now() - ts) / 60000)
if (minutes < 1) return 'just now'
Expand Down Expand Up @@ -106,17 +70,21 @@ const BADGE_DEFS: Omit<BadgeDef, 'earned'>[] = [
function useBadges(reports: MyReport[]): BadgeDef[] {
const count = reports.length
const verifiedCount = reports.filter((r) => r.status === 'verified').length
return BADGE_DEFS.map((def) => ({
...def,
earned:
def.id === 'first-report'
? count >= 1
: def.id === 'verified-reporter'
? verifiedCount >= 1
: def.id === 'community-helper'
? count >= 3
: count >= 5,
}))
return useMemo(
() =>
BADGE_DEFS.map((def) => ({
...def,
earned:
def.id === 'first-report'
? count >= 1
: def.id === 'verified-reporter'
? verifiedCount >= 1
: def.id === 'community-helper'
? count >= 3
: count >= 5,
})),
[count, verifiedCount],
)
}

/* ── Guardian pitch card ── */
Expand Down Expand Up @@ -216,7 +184,7 @@ function ReportCard({ report, onTap }: { report: MyReport; onTap: () => void })
const icon = incidentIcon(report.reportType)
const label = incidentLabel(report.reportType)
const { label: statusLabel, bg, color } = statusMeta(report.status)
const dot = severityDot(report.severity)
const dot = severityDotColor(report.severity)

return (
<button
Expand Down Expand Up @@ -318,12 +286,15 @@ export function ProfileTab() {
/* eslint-enable react-hooks/set-state-in-effect */
}, [reports])

const [signOutError, setSignOutError] = useState(false)

const handleSignOut = async () => {
setSignOutError(false)
try {
await signOut(auth())
void navigate('/', { replace: true })
} catch {
// sign out failure is non-critical; page will still function
setSignOutError(true)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -627,6 +598,11 @@ export function ProfileTab() {

{/* Sign out */}
<div className="mx-4 mt-5 pt-5 border-t border-surface-200 pb-2">
{signOutError && (
<p role="alert" className="m-0 mb-2 text-xs text-danger-500">
Sign out failed. Please try again.
</p>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button
type="button"
onClick={() => {
Expand Down
128 changes: 76 additions & 52 deletions apps/citizen-pwa/src/components/RevealSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, ShieldCheck, Copy, CheckCircle, LogIn, Save } from 'lucide-react'
import { onAuthStateChanged } from 'firebase/auth'
Expand All @@ -13,6 +13,50 @@ import { Timeline } from './ui/Timeline'

const HOTLINE_NUMBER = '(054) 721-1216'

const REVEAL_VARIANTS = {
success: {
headline: 'We heard you. We are here.',
subline: 'Your report is with Daet MDRRMO. Keep your line open.',
sublineTl: 'Narinig namin kayo. Hawak na ng MDRRMO ang inyong ulat.',
bannerVariant: 'success' as const,
receiverText: 'Received by Daet MDRRMO',
primaryButton: 'Track this report',
primaryVariant: 'primary' as const,
secondaryButton: undefined as string | undefined,
permissionText: "You can close this app. We'll text you.",
},
queued: {
headline: "Saved. We'll send it for you.",
subline:
"Your report is safe on this phone. The moment signal returns, we'll automatically forward it to Daet MDRRMO — no action needed from you. Walang mawawala.",
sublineTl: undefined as string | undefined,
bannerVariant: 'queued' as const,
receiverText: 'Saved to device · auto-send when online',
primaryButton: 'Try sending now',
primaryVariant: 'amber' as const,
secondaryButton: 'Keep draft & close',
permissionText: "We'll keep trying quietly in the background.",
},
failed_retryable: {
headline: 'Your report is safe. Still trying.',
subline:
"We saved it securely on your phone and are retrying automatically. The network is having trouble — this is not your fault and nothing is lost. If it's a life-threatening emergency, call now.",
sublineTl: 'Ligtas ang inyong ulat. Nagre-retry kami. Kung emergency, tawagan kami ngayon.',
bannerVariant: 'queued' as const,
receiverText: undefined as string | undefined,
primaryButton: 'Retry now',
primaryVariant: 'amber' as const,
secondaryButton: 'Keep draft & close',
permissionText: "We'll hold this draft for 24 hours and keep retrying.",
},
}

const GUARDIAN_BENEFITS = [
'Track reports across devices',
'Earn Guardian badges',
'Get status updates via app',
]

function RadarRings({ rgb = '5,150,105' }: { rgb?: string }) {
const ringsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
Expand Down Expand Up @@ -59,51 +103,18 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
const displayedCode = reducedMotion ? referenceCode : slotDisplay
const typewriterComplete = reducedMotion ? true : slotDone
const [copied, setCopied] = useState(false)
const [hasCopyError, setHasCopyError] = useState(false)
const [secretVisible, setSecretVisible] = useState(false)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// null = loading (firebase present), true = guest (no account), false = registered
const [isGuest, setIsGuest] = useState<boolean | null>(() => (hasFirebaseConfig() ? null : true))
const variants = {
success: {
icon: <Check size={16} />,
headline: 'We heard you. We are here.',
subline: 'Your report is with Daet MDRRMO. Keep your line open.',
sublineTl: 'Narinig namin kayo. Hawak na ng MDRRMO ang inyong ulat.',
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: <Save size={16} />,
headline: "Saved. We'll send it for you.",
subline:
"Your report is safe on this phone. The moment signal returns, we'll automatically forward it to Daet MDRRMO — no action needed from you. Walang mawawala.",
sublineTl: undefined,
bannerVariant: 'queued' as const,
receiverText: 'Saved to device · auto-send when online',
primaryButton: 'Try sending now',
primaryVariant: 'amber' as const,
secondaryButton: 'Keep draft & close',
permissionText: "We'll keep trying quietly in the background.",
},
failed_retryable: {
icon: <ShieldCheck size={16} />,
headline: 'Your report is safe. Still trying.',
subline:
"We saved it securely on your phone and are retrying automatically. The network is having trouble — this is not your fault and nothing is lost. If it's a life-threatening emergency, call now.",
sublineTl: 'Ligtas ang inyong ulat. Nagre-retry kami. Kung emergency, tawagan kami ngayon.',
bannerVariant: 'queued' as const,
receiverText: undefined,
primaryButton: 'Retry now',
primaryVariant: 'amber' as const,
secondaryButton: 'Keep draft & close',
permissionText: "We'll hold this draft for 24 hours and keep retrying.",
},
}
const variant = REVEAL_VARIANTS[state]

const variant = variants[state]
const guardianIcon = useMemo(() => {
if (state === 'success') return <Check size={16} />
if (state === 'queued') return <Save size={16} />
return <ShieldCheck size={16} />
}, [state])

useEffect(() => {
if (!('vibrate' in navigator)) return
Expand Down Expand Up @@ -141,15 +152,27 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
try {
await navigator.clipboard.writeText(secretCode)
setCopied(true)
const t = setTimeout(() => {
setHasCopyError(false)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => {
setCopied(false)
}, 2000)
void t
} catch (e) {
console.error('Failed to copy secret code:', e)
} catch {
setCopied(false)
setHasCopyError(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => {
setHasCopyError(false)
}, 3000)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}, [secretCode])

useEffect(() => {
return () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
}
}, [])

const afterglowTime = new Date().toLocaleTimeString('en-PH', {
hour: '2-digit',
minute: '2-digit',
Expand Down Expand Up @@ -305,7 +328,7 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
</div>
)}

<StatusBanner variant={variant.bannerVariant} icon={variant.icon}>
<StatusBanner variant={variant.bannerVariant} icon={guardianIcon}>
{variant.headline}
</StatusBanner>

Expand Down Expand Up @@ -405,6 +428,11 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
{copied && (
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#16a34a' }}>Copied!</p>
)}
{hasCopyError && (
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#dc2626' }}>
Copy failed — please write it down
</p>
)}
<p style={{ margin: '8px 0 0', fontSize: '0.6875rem', color: '#7b8794' }}>
Save this to check your report without an account.
<span style={{ display: 'block', fontStyle: 'italic' }}>
Expand Down Expand Up @@ -447,11 +475,7 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
community.
</p>
<ul className="space-y-1 mb-3">
{[
'Track reports across devices',
'Earn Guardian badges',
'Get status updates via app',
].map((benefit) => (
{GUARDIAN_BENEFITS.map((benefit) => (
<li key={benefit} className="flex items-center gap-2 text-white text-xs">
<CheckCircle size={12} className="text-brand-200 shrink-0" />
{benefit}
Expand Down
2 changes: 1 addition & 1 deletion apps/citizen-pwa/src/components/TrackingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function TrackingScreen() {

const timelineEvents = report.timeline.map((e) => ({
label: e.event,
meta: `${e.actor} · ${new Date(e.timestamp).toLocaleString()}`,
meta: `${e.actor ?? 'system'} · ${new Date(e.timestamp).toLocaleString()}`,
state: 'complete' as const,
}))

Expand Down
2 changes: 1 addition & 1 deletion apps/citizen-pwa/src/hooks/useReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ReportStatus } from '@bantayog/shared-types'
export interface ReportTimelineEvent {
event: string
timestamp: number
actor: string
actor?: string
Comment thread
coderabbitai[bot] marked this conversation as resolved.
note?: string
}

Expand Down
16 changes: 15 additions & 1 deletion apps/citizen-pwa/src/lib/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,21 @@ export function mapReportFromFirestore(data: Record<string, unknown>): ReportDat
const result: ReportData = {
id: data.id as string,
status: data.status as ReportStatus,
timeline: data.timeline as ReportData['timeline'],
timeline: (data.timeline as unknown[]).map((rawEvt, index) => {
if (!rawEvt || typeof rawEvt !== 'object' || Array.isArray(rawEvt)) {
throw new Error(`Invalid timeline event at index ${index}`)
}
const evt = rawEvt as Record<string, unknown>
if (typeof evt.event !== 'string' || typeof evt.timestamp !== 'number') {
throw new Error(`Invalid timeline event fields at index ${index}`)
}
return {
event: evt.event,
timestamp: evt.timestamp,
...(typeof evt.actor === 'string' && { actor: evt.actor }),
...(typeof evt.note === 'string' && { note: evt.note }),
}
}),
};

if (data.type !== undefined) {
Expand Down
Loading