Skip to content

Commit 59fc3b8

Browse files
feat(citizen-portal-web): services catalog + shared gold-divider page headers (#34)
* feat(citizen-portal-web): remove application-status pill from catalog cards The "All services" cards on /services are purely navigational links to a service's detail page. They rendered a status pill for services the signed-in citizen already had an application for, conflating browse with application tracking (which lives in the "Your applications" band). Drop the pill and its now-dead byService/application plumbing; the applications query still feeds "Your applications". * feat(citizen-portal-web): shared clickable service card; drop /services applications band Extract a shared ServiceCard (components/services/service-card.tsx) used by both the home "Available services" panel and the /services "All services" grid. The whole card is now a click/tap target to the service detail via a stretched-link overlay (after:absolute inset-0), yet only the title is the accessible link text and only the title underlines on hover -- the description is a plain sibling and is never underlined. Also remove the "Your applications" band from /services (and its auth/ applications-query plumbing); application tracking stays on the home page. The catalog page is now purely browse-and-navigate. * feat(citizen-portal-web): /services heading + search above a full-width gold divider Restructure the catalog page to the citizen Account-pages pattern: a full-width header region carrying a bcgov-gold bottom divider (border-b-2 border-bcgov-gold) holds the page heading and the search box, with its content constrained to the standard max-w-280 column; the "All services" grid renders below the divider. SettingsPageHeader is not reused -- it has no slot for a search box -- only the divider layout is shared. Layout-only; search and catalog behaviour unchanged. * feat(citizen-portal-web): service detail header adopts the full-width gold divider Apply the /services (feature 143) / Account-pages header pattern to the service detail page: the breadcrumb, title/description, and "Start an application" button now sit in a full-width bcgov-gold-divided header region (border-b-2 border-bcgov-gold, content constrained to max-w-280), with the overview sections below in the standard column. Replaces the old thin per-column border-b. Layout only; service data, forms/apply flow, and version routing unchanged. The historical version page keeps its old header (out of scope). * refactor(citizen-portal-web): extract reusable PageHeaderBanner with breadcrumb slot Consolidate the full-width bcgov-gold divider header, previously hand-rolled in three places, into one PageHeaderBanner primitive: full-width gold rule + constrained max-w-280 column + an optional breadcrumb slot; content-agnostic children. SettingsPageHeader now composes it (account/notifications/agreements markup unchanged); /services and the service detail page render through it, the latter passing its breadcrumb via the new breadcrumb prop. Layout refactor only, no visual change. * feat(citizen-portal-web): title Your activity cards by application name Add a titleBy variant to ApplicationRow (default 'service', unchanged home behaviour). On the service detail "Your activity" section -- where every row is already this service -- pass titleBy='application' so the card leads with the application (form) name and the meta line is just "Ref #<reference> • <date>", dropping the now-redundant service/form-name prefix. Home "Track your applications" keeps the service name as the title. * feat(citizen-portal-web): add breadcrumb to the notification settings page Pass a breadcrumb to the notification settings SettingsPageHeader: Account settings -> Notification settings (current). Mirrors the Service Agreements detail page; the header already exposes a breadcrumb slot via PageHeaderBanner. * fix(citizen-portal-web): consent gate presents only agreements needing a decision The pre-application consent gate opened whenever any agreement was unsatisfied (consentPending over the full list) but then rendered EVERY agreement -- so a single new/changed one re-presented all the previously-approved (unchanged) ones, risking re-recording them into the append-only /account/service-agreements history. Filter the gate to pending = agreements where !satisfied(a, a.decision) (new/changed on the current version) for display, gating, and recording; already- satisfied agreements are hidden and never re-recorded. consentPending (whether the gate opens) and the server submit gate are unchanged -- the server stays authoritative; this is a client display/UX fix.
1 parent 81723a4 commit 59fc3b8

16 files changed

Lines changed: 380 additions & 193 deletions

apps/citizen-portal-web/src/components/application/consent-gate.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,26 @@ interface ConsentGateProps {
3333

3434
/**
3535
* The consent gate (feature 90): shown before the application form when a service's agreements
36-
* haven't all been decided. Renders each agreement read-only + an approve/reject radio (authored
37-
* labels, canonical values), gathers the decisions LOCALLY, and gates Continue until every required
38-
* agreement is approved. Decisions are recorded (POSTed) only when the citizen presses Continue —
39-
* not on each radio change. The server (feature 89) re-validates on submit — this is UX only.
36+
* haven't all been decided. Presents ONLY the agreements still needing a decision on their current
37+
* version — new/changed ones (feature 148); already-satisfied agreements are hidden so the gate
38+
* never re-presents or re-records an unchanged approval. Renders each pending agreement read-only +
39+
* an approve/reject radio (authored labels, canonical values), gathers the decisions LOCALLY, and
40+
* gates Continue until every required agreement is approved. Decisions are recorded (POSTed) only
41+
* when the citizen presses Continue — not on each radio change. The server (feature 89) re-validates
42+
* on submit — this is UX only.
4043
*/
4144
export function ConsentGate({ agreements, serviceId, onContinue }: ConsentGateProps) {
4245
const queryClient = useQueryClient();
46+
// Present ONLY agreements still needing a decision on their current version — i.e. new or changed
47+
// ones (consent is keyed to the published version, so a bumped version has no consent → unsatisfied).
48+
// Agreements the citizen already satisfied (approved-required / decided-optional) on the current
49+
// version are hidden, so the gate never re-presents — or re-records — an unchanged approval.
50+
const pending = agreements.filter((a) => !satisfied(a, a.decision));
4351
// Local decisions seeded from the server's current decisions (a rejected required agreement
4452
// arrives with decision='reject' and still blocks until re-decided to approve).
4553
const [decisions, setDecisions] = useState<Record<string, ConsentDecision>>(() =>
4654
Object.fromEntries(
47-
agreements.filter((a) => a.decision !== null).map((a) => [a.agreementVersionId, a.decision!]),
55+
pending.filter((a) => a.decision !== null).map((a) => [a.agreementVersionId, a.decision!]),
4856
),
4957
);
5058
const [failed, setFailed] = useState(false);
@@ -53,7 +61,7 @@ export function ConsentGate({ agreements, serviceId, onContinue }: ConsentGatePr
5361
const submit = useMutation({
5462
mutationFn: async () => {
5563
// Record only decisions that differ from what the server already has (append-only, latest-wins).
56-
const changed = agreements
64+
const changed = pending
5765
.map((a) => ({
5866
versionId: a.agreementVersionId,
5967
decision: decisions[a.agreementVersionId],
@@ -83,9 +91,7 @@ export function ConsentGate({ agreements, serviceId, onContinue }: ConsentGatePr
8391
setFailed(false);
8492
};
8593

86-
const allSatisfied = agreements.every((a) =>
87-
satisfied(a, decisions[a.agreementVersionId] ?? null),
88-
);
94+
const allSatisfied = pending.every((a) => satisfied(a, decisions[a.agreementVersionId] ?? null));
8995
const canContinue = allSatisfied && !submit.isPending;
9096

9197
return (
@@ -94,15 +100,15 @@ export function ConsentGate({ agreements, serviceId, onContinue }: ConsentGatePr
94100
<h1 className="font-heading text-2xl font-semibold text-foreground">Before you apply</h1>
95101
<p className="mt-1 text-sm text-muted-foreground">
96102
Please review and respond to the following{' '}
97-
{agreements.length === 1 ? 'agreement' : 'agreements'} to continue your application.
103+
{pending.length === 1 ? 'agreement' : 'agreements'} to continue your application.
98104
</p>
99105
</div>
100106

101107
<AccordionGroup
102-
values={agreements.map((a) => a.agreementVersionId)}
103-
defaultValue={agreements.map((a) => a.agreementVersionId)}
108+
values={pending.map((a) => a.agreementVersionId)}
109+
defaultValue={pending.map((a) => a.agreementVersionId)}
104110
>
105-
{agreements.map((a) => {
111+
{pending.map((a) => {
106112
const title = str(a.data.title, 'Service agreement');
107113
const description = str(a.data.description, '');
108114
const content = a.data.content as ComponentProps<typeof RichTextView>['value'];

apps/citizen-portal-web/src/components/landing/available-services.tsx

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Card, CardHeader, CardDescription, CardTitle } from '@repo/ui/card';
21
import { mdiChevronRight } from '@mdi/js';
32
import { Icon } from '@mdi/react';
43
import { Skeleton } from '@repo/ui/skeleton';
54
import { Link } from '@tanstack/react-router';
65
import { SectionHeading } from '@/components/landing/section-heading';
6+
import { ServiceCard } from '@/components/services/service-card';
77
import type { CatalogService } from '@/lib/catalog';
88

99
interface AvailableServicesProps {
@@ -12,18 +12,6 @@ interface AvailableServicesProps {
1212
loading?: boolean;
1313
}
1414

15-
/** The disclosure chevron shown at the top-right of a card, signalling "there's more here". */
16-
function DisclosureChevron() {
17-
return (
18-
<Icon
19-
path={mdiChevronRight}
20-
size="20px"
21-
className="mt-0.5 shrink-0 text-link"
22-
aria-hidden={true}
23-
/>
24-
);
25-
}
26-
2715
/**
2816
* The blue "Available services" panel with service cards. Shared by both landing pages and identical
2917
* whether or not the citizen is signed in — every card simply links to its service detail (a citizen's
@@ -48,23 +36,7 @@ export function AvailableServices({ services, loading = false }: AvailableServic
4836
<Skeleton className="mt-3 h-3 w-full" />
4937
</div>
5038
))
51-
: services.map((service) => (
52-
<Card key={service.id}>
53-
<CardHeader>
54-
<CardTitle className="text-base font-semibold">
55-
<Link
56-
to="/services/$serviceId"
57-
params={{ serviceId: service.id }}
58-
className="flex items-start justify-between gap-2 no-underline hover:underline"
59-
>
60-
<span>{service.title}</span>
61-
<DisclosureChevron />
62-
</Link>
63-
</CardTitle>
64-
<CardDescription className="line-clamp-2">{service.description}</CardDescription>
65-
</CardHeader>
66-
</Card>
67-
))}
39+
: services.map((service) => <ServiceCard key={service.id} service={service} />)}
6840
</div>
6941
{showBrowseAll ? (
7042
<div className="mt-6 text-right">
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ReactNode } from 'react';
2+
3+
/**
4+
* The shared citizen page-header banner: a **full-width `bcgov-gold` bottom divider** whose content
5+
* is constrained to the standard `max-w-280` column with standard page padding. Render it as a
6+
* full-width sibling above the constrained page body so the rule spans the whole main area.
7+
*
8+
* It owns only the divider, the constrained width, and an optional breadcrumb slot — each caller
9+
* supplies its own header body as `children`. Used directly by `/services` and the service detail
10+
* page, and composed by {@link SettingsPageHeader} for the Account settings surfaces.
11+
*/
12+
export function PageHeaderBanner({
13+
breadcrumb,
14+
children,
15+
}: {
16+
/** Optional breadcrumb, rendered above the header content. Omitted → nothing renders above. */
17+
breadcrumb?: ReactNode;
18+
children: ReactNode;
19+
}) {
20+
return (
21+
<div className="border-b-2 border-bcgov-gold">
22+
<div className="mx-auto flex w-full max-w-280 flex-col gap-3 px-4 py-6 md:px-8">
23+
{breadcrumb}
24+
{children}
25+
</div>
26+
</div>
27+
);
28+
}
Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { ReactNode } from 'react';
22
import { Icon } from '@mdi/react';
3+
import { PageHeaderBanner } from '@/components/layout/page-header-banner';
34

45
/**
56
* Shared page header for the citizen settings pages (account, notifications, service agreements):
67
* a full-width `bcgov-gold` divider with a light-blue icon badge to the left of the title/subtitle,
7-
* and an optional breadcrumb rendered above the title. Render it as a full-width sibling above the
8-
* constrained page body so the divider spans the page.
8+
* and an optional breadcrumb rendered above the title. Composes {@link PageHeaderBanner} (the shared
9+
* divider + constrained-width layout) with the settings-specific icon-badge title row.
910
*/
1011
export function SettingsPageHeader({
1112
icon,
@@ -24,20 +25,17 @@ export function SettingsPageHeader({
2425
breadcrumb?: ReactNode;
2526
}) {
2627
return (
27-
<div className="border-b-2 border-bcgov-gold">
28-
<div className="mx-auto flex w-full max-w-280 flex-col gap-3 px-4 py-6 md:px-8">
29-
{breadcrumb}
30-
<div className="flex items-center gap-4">
31-
<div className="flex items-center justify-center bg-blue-10 p-2">
32-
<Icon path={icon} size="32px" className="text-blue-80" aria-hidden={true} />
33-
</div>
34-
<div className="flex flex-col gap-1">
35-
<h1 className="font-heading text-2xl font-semibold text-foreground">{title}</h1>
36-
{subtitle ? <p className="text-sm text-muted-foreground">{subtitle}</p> : null}
37-
{meta}
38-
</div>
28+
<PageHeaderBanner breadcrumb={breadcrumb}>
29+
<div className="flex items-center gap-4">
30+
<div className="flex items-center justify-center bg-blue-10 p-2">
31+
<Icon path={icon} size="32px" className="text-blue-80" aria-hidden={true} />
32+
</div>
33+
<div className="flex flex-col gap-1">
34+
<h1 className="font-heading text-2xl font-semibold text-foreground">{title}</h1>
35+
{subtitle ? <p className="text-sm text-muted-foreground">{subtitle}</p> : null}
36+
{meta}
3937
</div>
4038
</div>
41-
</div>
39+
</PageHeaderBanner>
4240
);
4341
}

apps/citizen-portal-web/src/components/notification-preferences-page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useBlocker } from '@tanstack/react-router';
99
import { Icon } from '@mdi/react';
1010
import { mdiBellOutline, mdiLogin } from '@mdi/js';
1111

12+
import { Breadcrumb } from '@/components/breadcrumb';
1213
import { CitizenShell } from '@/components/layout/citizen-shell';
1314
import { SettingsPageHeader } from '@/components/layout/settings-page-header';
1415
import { useAuth, useLoginUrl } from '@/lib/auth';
@@ -216,6 +217,14 @@ export function NotificationPreferencesPage() {
216217
icon={mdiBellOutline}
217218
title="Notification settings"
218219
subtitle="Choose how you hear about updates to your applications."
220+
breadcrumb={
221+
<Breadcrumb
222+
trail={[
223+
{ label: 'Account settings', href: '/account' },
224+
{ label: 'Notification settings' },
225+
]}
226+
/>
227+
}
219228
/>
220229
<div className="mx-auto my-6 flex w-full max-w-280 flex-col gap-9 px-4 md:px-8">
221230
{prefs.isSuccess ? (

apps/citizen-portal-web/src/components/service-detail-page.tsx

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Skeleton } from '@repo/ui/skeleton';
33
import { useQuery } from '@tanstack/react-query';
44
import { Link, useParams } from '@tanstack/react-router';
55
import { CitizenShell } from '@/components/layout/citizen-shell';
6+
import { PageHeaderBanner } from '@/components/layout/page-header-banner';
67
import { ServiceSections } from '@/components/services/detail-sections';
78
import { Breadcrumb } from '@/components/services/service-content';
89
import { serviceQueryOptions } from '@/lib/catalog';
@@ -48,32 +49,42 @@ export function ServiceDetailPage() {
4849

4950
return (
5051
<CitizenShell activeNav="services">
51-
<div className="mx-auto px-4 md:px-8 my-6 w-full max-w-280 flex flex-col gap-9">
52-
<Breadcrumb
53-
trail={[
54-
{ label: 'Home', href: '/' },
55-
{ label: 'Services', href: '/services' },
56-
{ label: service.title },
57-
]}
58-
/>
52+
<div className="flex flex-col">
53+
{/* Header region — breadcrumb + title/description + action above the shared full-width
54+
bcgov-gold divider. */}
55+
<PageHeaderBanner
56+
breadcrumb={
57+
<Breadcrumb
58+
trail={[
59+
{ label: 'Home', href: '/' },
60+
{ label: 'Services', href: '/services' },
61+
{ label: service.title },
62+
]}
63+
/>
64+
}
65+
>
66+
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
67+
<div className="flex flex-col gap-1">
68+
<h1 className="font-heading text-2xl font-semibold text-foreground">
69+
{service.title}
70+
</h1>
71+
{service.description ? (
72+
<p className="max-w-2xl text-sm text-muted-foreground">{service.description}</p>
73+
) : null}
74+
</div>
75+
<Button className="shrink-0">Start an application</Button>
76+
</header>
77+
</PageHeaderBanner>
5978

60-
<header className="flex flex-col gap-3 border-b pb-6 sm:flex-row sm:items-start sm:justify-between">
61-
<div className="flex flex-col gap-1">
62-
<h1 className="font-heading text-2xl font-semibold text-foreground">{service.title}</h1>
63-
{service.description ? (
64-
<p className="max-w-2xl text-sm text-muted-foreground">{service.description}</p>
65-
) : null}
66-
</div>
67-
<Button className="shrink-0">Start an application</Button>
68-
</header>
69-
70-
<ServiceSections
71-
serviceId={service.id}
72-
schema={service.schema}
73-
uischema={service.uischema}
74-
data={service.data}
75-
applications={service.applications}
76-
/>
79+
<div className="mx-auto my-6 flex w-full max-w-280 flex-col gap-9 px-4 md:px-8">
80+
<ServiceSections
81+
serviceId={service.id}
82+
schema={service.schema}
83+
uischema={service.uischema}
84+
data={service.data}
85+
applications={service.applications}
86+
/>
87+
</div>
7788
</div>
7889
</CitizenShell>
7990
);

0 commit comments

Comments
 (0)