Skip to content
Merged
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
85 changes: 29 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,14 @@ export function ProfileTab() {
/* eslint-enable react-hooks/set-state-in-effect */
}, [reports])

const [signOutError, setSignOutError] = useState(false)

const handleSignOut = async () => {
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 +597,9 @@ export function ProfileTab() {

{/* Sign out */}
<div className="mx-4 mt-5 pt-5 border-t border-surface-200 pb-2">
{signOutError && (
<p 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
127 changes: 75 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 [copyError, setCopyError] = useState(false)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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,26 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
try {
await navigator.clipboard.writeText(secretCode)
setCopied(true)
const t = setTimeout(() => {
setCopyError(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 {
setCopyError(true)
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => {
setCopyError(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 +327,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 +427,11 @@ export function RevealSheet({ state, referenceCode, secretCode, onClose }: Revea
{copied && (
<p style={{ margin: '4px 0 0', fontSize: '0.75rem', color: '#16a34a' }}>Copied!</p>
)}
{copyError && (
<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 +474,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/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
7 changes: 6 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,12 @@ 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 Array<Record<string, unknown>>).map((evt) => ({
event: evt.event as string,
timestamp: evt.timestamp as number,
...(evt.actor !== undefined && { actor: evt.actor as string }),
...(evt.note !== undefined && { note: evt.note as string }),
})),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};

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