Code updated added styles to public pages as well as adminpanel pages.

This commit is contained in:
Amey Bhagwat 2026-10-08 13:16:09 +05:30
parent a95d7710c3
commit 5246560f1f
21 changed files with 1137 additions and 319 deletions

View File

@ -3,6 +3,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>Adherence Pack Study</title>
</head>
<body>

View File

@ -5,7 +5,16 @@ export default function SiteFooter() {
return (
<footer className="site-footer">
<div className="site-footer-inner">
<p>Chest Research and Training Pvt. Ltd.</p>
<p className="site-footer-credit">
<span>Chest Research and Training Pvt. Ltd.</span>
<span className="site-footer-sep" aria-hidden="true" />
<span>
Powered by Virtual Engage by{' '}
<a href="https://stratawiz.com/" target="_blank" rel="noopener noreferrer">
Stratawiz
</a>
</span>
</p>
{supportEmail ? (
<p>
<a href={`mailto:${supportEmail}`}>{supportEmail}</a>

View File

@ -2,6 +2,377 @@
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
@theme {
--font-sans: Poppins, system-ui, sans-serif;
}
.admin-shell,
.admin-login {
min-height: 100vh;
font-family: var(--font-sans);
color: #1c1917;
}
.admin-login {
position: relative;
isolation: isolate;
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
background-color: #f7fbfb;
background-image:
radial-gradient(ellipse 55% 45% at 8% 12%, rgb(232 245 233 / 0.95), transparent 62%),
radial-gradient(ellipse 50% 50% at 92% 8%, rgb(219 234 254 / 0.8), transparent 58%),
radial-gradient(ellipse 60% 50% at 88% 92%, rgb(204 251 241 / 0.8), transparent 55%);
}
.admin-login::before {
content: '';
position: absolute;
inset: -12% -8%;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 18% 22%, #c8e6c9 0%, transparent 42%),
radial-gradient(circle at 88% 16%, #bbdefb 0%, transparent 38%),
radial-gradient(circle at 78% 86%, #b2dfdb 0%, transparent 42%);
filter: blur(80px);
opacity: 0.45;
}
.admin-login .site-header {
position: relative;
z-index: 1;
}
@media (min-width: 40.0625rem) {
.admin-login .site-brand {
justify-content: center;
}
}
.admin-login-body {
flex: 1;
display: grid;
place-items: center;
padding: 1.5rem;
}
.admin-shell {
display: flex;
background: #f7fbfb;
}
.admin-main {
min-width: 0;
flex: 1;
padding: 2rem;
}
.admin-title {
margin: 0 0 1rem;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #134e4a;
}
.admin-login-card .admin-title {text-align: center;}
.admin-title-inline {
margin-bottom: 0;
}
.admin-card {
border-radius: 1rem;
background: #fff;
padding: 1.5rem;
box-shadow: 0 1px 2px rgb(15 118 110 / 0.06), 0 18px 40px rgb(19 78 74 / 0.08);
}
.admin-stat-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.admin-stat-label {
margin: 0;
min-height: 2.5rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.25rem;
color: #78716c;
}
.admin-stat-value {
margin: 0.35rem 0 0;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.2;
color: #134e4a;
}
.admin-login-card {
position: relative;
z-index: 1;
width: 100%;
max-width: 24rem;
}
.admin-login-card .admin-btn {
width: 100%;
}
.admin-login-logo {
display: block;
width: 100%;
max-width: 250px;
height: auto;
margin: auto;
margin-bottom: 0.75rem;
padding: 0.0rem;
border-radius: 0.6rem;
background: #f7faf9;
box-sizing: border-box;
}
.admin-kicker {
margin: 0 0 0.25rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #0f766e;
text-align: center;
}
.admin-input {
width: 100%;
box-sizing: border-box;
border-radius: 0.5rem;
border: 1px solid #d6d3d1;
background: #fff;
padding: 0.7rem 0.85rem;
font: inherit;
font-size: 16px;
color: #1c1917;
}
.admin-input:focus {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.admin-file {
display: block;
width: 100%;
max-width: 28rem;
box-sizing: border-box;
padding: 0.35rem;
border: 1px dashed #99f6e4;
border-radius: 0.6rem;
background: #f0fdfa;
font: inherit;
font-size: 0.875rem;
color: #57534e;
cursor: pointer;
}
.admin-file::file-selector-button {
margin-right: 0.75rem;
padding: 0.5rem 1rem;
border: 0;
border-radius: 0.45rem;
background: linear-gradient(135deg, #0f766e 0%, #1578c8 100%);
color: #f0fdfa;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.admin-file::file-selector-button:hover {
filter: brightness(1.08);
}
.admin-file:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.admin-input-inline {
width: auto;
display: inline-block;
max-width: 12rem;
padding: 0.4rem 0.65rem;
}
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 0.65rem 1.1rem;
border: 0;
border-radius: 0.55rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.admin-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-btn-primary {
background: linear-gradient(135deg, #0f766e 0%, #1578c8 100%);
color: #f0fdfa;
}
.admin-btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, #0d6a63 0%, #1d4ed8 100%);
}
.admin-btn-secondary {
background: #eef2f1;
color: #134e4a;
}
.admin-btn-secondary:hover:not(:disabled) {
background: #e4eceb;
}
.admin-btn-danger {
background: #991b1b;
color: #fff;
}
.admin-btn-danger:hover:not(:disabled) {
background: #7f1d1d;
}
.admin-btn-ghost {
background: transparent;
color: #ccfbf1;
font-weight: 500;
text-align: left;
}
.admin-btn-ghost:hover:not(:disabled) {
background: rgb(15 118 110 / 0.35);
}
.admin-table-wrap {
overflow-x: auto;
border-radius: 1rem;
background: #fff;
box-shadow: 0 1px 2px rgb(15 118 110 / 0.06), 0 18px 40px rgb(19 78 74 / 0.08);
}
.admin-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.875rem;
}
.admin-table thead {
position: sticky;
top: 0;
background: #f7faf9;
color: #57534e;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f5f5f4;
}
.admin-table tbody tr:hover {
background: rgb(240 253 250 / 0.7);
}
.admin-table a {
color: #0f766e;
font-weight: 600;
}
.admin-sidebar {
position: sticky;
top: 0;
flex: none;
width: 14rem;
height: 100vh;
overflow: hidden;
background: #134e4a;
transition:
width 0.3s ease,
visibility 0s linear 0s;
}
.admin-sidebar.is-collapsed {
width: 0;
visibility: hidden;
transition:
width 0.3s ease,
visibility 0s linear 0.3s;
}
.admin-nav {
width: 14rem;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
transition: transform 0.3s ease;
}
.admin-sidebar.is-collapsed .admin-nav {
transform: translateX(-100%);
}
@media (prefers-reduced-motion: reduce) {
.admin-sidebar,
.admin-sidebar.is-collapsed,
.admin-nav {
transition: none;
}
}
.admin-nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin-bottom: 1rem;
padding: 0;
border: 1px solid #e4e4e0;
border-radius: 0.6rem;
background: #fff;
color: #134e4a;
cursor: pointer;
box-shadow: 0 1px 2px rgb(15 118 110 / 0.06);
}
.admin-nav-toggle:hover {
background: #f0fdfa;
}
.admin-nav-toggle:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.admin-logout {
width: 100%;
justify-content: flex-start;
gap: 0.6rem;
}
.admin-nav-logo {
display: block;
width: 100%;

View File

@ -12,9 +12,16 @@ const LINKS = [
['/admin/audit', 'Audit log'],
];
const NAV_KEY = 'crest-admin-nav';
export default function AdminLayout() {
const navigate = useNavigate();
const [admin, setAdmin] = useState(null);
const [navOpen, setNavOpen] = useState(() => localStorage.getItem(NAV_KEY) !== 'closed');
useEffect(() => {
localStorage.setItem(NAV_KEY, navOpen ? 'open' : 'closed');
}, [navOpen]);
useEffect(() => {
let cancelled = false;
@ -46,18 +53,18 @@ export default function AdminLayout() {
}
return (
<div className="min-h-screen bg-stone-100 text-stone-900">
<div className="flex min-h-screen">
<nav className="admin-nav flex w-56 flex-col gap-2 bg-stone-900 p-4">
<div className="admin-shell font-sans">
<aside className={`admin-sidebar ${navOpen ? '' : 'is-collapsed'}`}>
<nav id="admin-nav" className="admin-nav flex w-56 flex-col gap-2 bg-[#134e4a] p-4">
<img src={logo} alt="CREST" className="admin-nav-logo" />
<p className="admin-nav-heading mb-2 text-sm font-semibold">CREST admin</p>
<p className="mb-2 truncate text-xs text-teal-100/70">{admin.email}</p>
{LINKS.map(([to, label]) => (
<NavLink
key={to}
to={to}
end={to === '/admin'}
className={({ isActive }) =>
`admin-nav-link rounded px-3 py-2 text-sm ${isActive ? 'bg-stone-700' : 'hover:bg-stone-800'}`
`admin-nav-link rounded-lg px-3 py-2 text-sm ${isActive ? 'bg-[#0f766e]' : 'hover:bg-[#0d5c56]'}`
}
>
{label}
@ -65,16 +72,52 @@ export default function AdminLayout() {
))}
<button
type="button"
className="mt-auto rounded px-3 py-2 text-left text-sm"
className="admin-btn admin-btn-ghost admin-logout mt-auto text-sm"
onClick={logout}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
Log out
</button>
<p className="truncate text-xs text-stone-400">{admin.email}</p>
</nav>
<div className="min-w-0 flex-1 p-6">
<Outlet />
</div>
</aside>
<div className="admin-main">
<button
type="button"
className="admin-nav-toggle"
aria-controls="admin-nav"
aria-expanded={navOpen}
aria-label={navOpen ? 'Hide menu' : 'Show menu'}
title={navOpen ? 'Hide menu' : 'Show menu'}
onClick={() => setNavOpen((open) => !open)}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<Outlet />
</div>
</div>
);

View File

@ -99,21 +99,21 @@ export default function ApplicationDetailPage() {
return (
<div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold">{application.full_name || 'Application'}</h1>
<h1 className="admin-title admin-title-inline">{application.full_name || 'Application'}</h1>
<StatusBadge status={application.status} />
</div>
{error ? <p className="mb-3 text-red-700">{error}</p> : null}
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg bg-white p-4 shadow-sm">
<div className="admin-card">
<dl className="grid gap-2 text-sm">
{FIELDS.map(([key, label]) => (
<div key={key}>
<dt className="text-stone-500">{label}</dt>
<dd>{application[key] || '—'}</dd>
<dd className="m-0">{application[key] || '—'}</dd>
</div>
))}
</dl>
<h2 className="mt-6 font-semibold">Signature</h2>
<h2 className="mt-6 font-semibold text-[#134e4a]">Signature</h2>
{application.signature ? (
<p className="text-sm">
{application.signature.type} at {application.signature.signedAt}
@ -129,21 +129,21 @@ export default function ApplicationDetailPage() {
<div className="mt-4 flex gap-2">
<button
type="button"
className="rounded bg-green-800 px-3 py-2 text-white"
className="admin-btn admin-btn-primary"
onClick={() => setConfirming(true)}
>
Approve
</button>
<button
type="button"
className="rounded bg-red-800 px-3 py-2 text-white"
className="admin-btn admin-btn-danger"
onClick={() => setRejecting(true)}
>
Reject
</button>
</div>
) : null}
<h2 className="mt-6 font-semibold">Events</h2>
<h2 className="mt-6 font-semibold text-[#134e4a]">Events</h2>
<ol className="mt-2 list-disc pl-5 text-sm">
{application.audit.map((event) => (
<li key={event.id}>
@ -151,7 +151,7 @@ export default function ApplicationDetailPage() {
</li>
))}
</ol>
<h2 className="mt-6 font-semibold">Emails</h2>
<h2 className="mt-6 font-semibold text-[#134e4a]">Emails</h2>
{application.emails.some(
(email) => email.status === 'bounced' || email.status === 'dropped',
) ? (
@ -169,7 +169,7 @@ export default function ApplicationDetailPage() {
{application.status === 'APPROVED' ? (
<button
type="button"
className="mt-3 rounded bg-stone-900 px-3 py-2 text-sm text-white"
className="admin-btn admin-btn-primary mt-3 text-sm"
disabled={busy}
onClick={resendSurveyLink}
>
@ -177,7 +177,7 @@ export default function ApplicationDetailPage() {
</button>
) : null}
</div>
<div className="min-h-[70vh] rounded-lg bg-white p-2 shadow-sm">
<div className="admin-card min-h-[70vh] p-2">
{application.pdfUrl ? (
<iframe title="Agreement PDF" className="h-[70vh] w-full" src={application.pdfUrl} />
) : (
@ -190,7 +190,7 @@ export default function ApplicationDetailPage() {
<p className="mb-3 text-sm">This adds the CREST signature and emails the survey link.</p>
<button
type="button"
className="rounded bg-green-800 px-3 py-2 text-white"
className="admin-btn admin-btn-primary"
disabled={busy}
onClick={approve}
>
@ -207,14 +207,14 @@ export default function ApplicationDetailPage() {
id="reject-reason"
required
minLength={10}
className="min-h-24 rounded border border-stone-300 px-3 py-2"
className="admin-input min-h-24"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
<button
type="submit"
className="rounded bg-red-800 px-3 py-2 text-white"
className="admin-btn admin-btn-danger"
disabled={busy}
>
{busy ? 'Rejecting…' : 'Reject'}

View File

@ -43,20 +43,20 @@ export default function ApplicationsPage() {
return (
<div>
<h1 className="mb-4 text-2xl font-semibold">Applications</h1>
<form className="mb-4 grid gap-3 md:grid-cols-6">
<h1 className="admin-title">Applications</h1>
<form className="admin-card mb-4 grid gap-3 md:grid-cols-6">
<input
name="q"
aria-label="Search"
placeholder="Name, email, registration, PAN"
className="rounded border border-stone-300 px-3 py-2 md:col-span-2"
className="admin-input md:col-span-2"
value={filters.q}
onChange={update}
/>
<select
name="status"
aria-label="Status"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.status}
onChange={update}
>
@ -70,7 +70,7 @@ export default function ApplicationsPage() {
name="from"
aria-label="From"
type="date"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.from}
onChange={update}
/>
@ -78,14 +78,14 @@ export default function ApplicationsPage() {
name="to"
aria-label="To"
type="date"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.to}
onChange={update}
/>
<select
name="sort"
aria-label="Sort"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.sort}
onChange={update}
>
@ -96,31 +96,29 @@ export default function ApplicationsPage() {
</select>
</form>
{error ? <p className="text-red-700">{error}</p> : null}
<div className="overflow-x-auto rounded-lg bg-white shadow-sm">
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 text-stone-500">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Registration</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Submitted</th>
<th>Name</th>
<th>Email</th>
<th>Registration</th>
<th>Status</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
{result?.items.map((item) => (
<tr key={item.id} className="border-b border-stone-100">
<td className="px-3 py-2">
<Link className="font-medium underline" to={`/admin/applications/${item.id}`}>
{item.full_name || 'Untitled'}
</Link>
<tr key={item.id}>
<td>
<Link to={`/admin/applications/${item.id}`}>{item.full_name || 'Untitled'}</Link>
</td>
<td className="px-3 py-2">{item.email}</td>
<td className="px-3 py-2">{item.registration_no}</td>
<td className="px-3 py-2">
<td>{item.email}</td>
<td>{item.registration_no}</td>
<td>
<StatusBadge status={item.status} />
</td>
<td className="px-3 py-2">{item.submitted_at || '—'}</td>
<td>{item.submitted_at || '—'}</td>
</tr>
))}
</tbody>
@ -130,18 +128,18 @@ export default function ApplicationsPage() {
<div className="mt-4 flex items-center gap-3">
<button
type="button"
className="rounded border border-stone-300 px-3 py-1"
className="admin-btn admin-btn-secondary"
disabled={result.page <= 1}
onClick={() => setFilters((current) => ({ ...current, page: current.page - 1 }))}
>
Previous
</button>
<span className="text-sm">
<span className="text-sm text-stone-600">
Page {result.page} of {Math.max(1, Math.ceil(result.total / result.pageSize))}
</span>
<button
type="button"
className="rounded border border-stone-300 px-3 py-1"
className="admin-btn admin-btn-secondary"
disabled={result.page * result.pageSize >= result.total}
onClick={() => setFilters((current) => ({ ...current, page: current.page + 1 }))}
>

View File

@ -29,20 +29,20 @@ export default function AuditPage() {
return (
<div>
<h1 className="mb-4 text-2xl font-semibold">Audit log</h1>
<form className="mb-4 grid gap-3 md:grid-cols-5">
<h1 className="admin-title">Audit log</h1>
<form className="admin-card mb-4 grid gap-3 md:grid-cols-5">
<input
name="entity"
aria-label="Entity"
placeholder="Entity"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.entity}
onChange={update}
/>
<select
name="actor"
aria-label="Actor"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.actor}
onChange={update}
>
@ -55,7 +55,7 @@ export default function AuditPage() {
name="action"
aria-label="Action"
placeholder="Action"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.action}
onChange={update}
/>
@ -63,7 +63,7 @@ export default function AuditPage() {
name="from"
aria-label="From"
type="date"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.from}
onChange={update}
/>
@ -71,31 +71,31 @@ export default function AuditPage() {
name="to"
aria-label="To"
type="date"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={filters.to}
onChange={update}
/>
</form>
{error ? <p className="text-red-700">{error}</p> : null}
<div className="overflow-x-auto rounded-lg bg-white shadow-sm">
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 text-stone-500">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th className="px-3 py-2">When</th>
<th className="px-3 py-2">Actor</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Entity</th>
<th>When</th>
<th>Actor</th>
<th>Action</th>
<th>Entity</th>
</tr>
</thead>
<tbody>
{result?.items.map((item) => (
<tr key={item.id} className="border-b border-stone-100">
<td className="px-3 py-2">{item.createdAt}</td>
<td className="px-3 py-2">
<tr key={item.id}>
<td>{item.createdAt}</td>
<td>
{item.actorType} {item.actorId ?? ''}
</td>
<td className="px-3 py-2">{item.action}</td>
<td className="px-3 py-2">
<td>{item.action}</td>
<td>
{item.entityType} {item.entityId ?? ''}
</td>
</tr>
@ -107,16 +107,16 @@ export default function AuditPage() {
<div className="mt-4 flex items-center gap-3">
<button
type="button"
className="rounded border border-stone-300 px-3 py-1"
className="admin-btn admin-btn-secondary"
disabled={result.page <= 1}
onClick={() => setFilters((current) => ({ ...current, page: current.page - 1 }))}
>
Previous
</button>
<span className="text-sm">Page {result.page}</span>
<span className="text-sm text-stone-600">Page {result.page}</span>
<button
type="button"
className="rounded border border-stone-300 px-3 py-1"
className="admin-btn admin-btn-secondary"
disabled={result.page * result.pageSize >= result.total}
onClick={() => setFilters((current) => ({ ...current, page: current.page + 1 }))}
>

View File

@ -31,22 +31,22 @@ export default function DashboardPage() {
return (
<div>
<h1 className="mb-4 text-2xl font-semibold">Dashboard</h1>
<h1 className="admin-title">Dashboard</h1>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{cards.map(([label, value]) => (
<div key={label} className="rounded-lg bg-white p-4 shadow-sm">
<p className="text-sm text-stone-500">{label}</p>
<p className="text-2xl font-semibold">{value}</p>
<div key={label} className="admin-card admin-stat-card">
<p className="admin-stat-label">{label}</p>
<p className="admin-stat-value">{value}</p>
</div>
))}
</div>
<div className="h-72 rounded-lg bg-white p-4 shadow-sm">
<div className="admin-card h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chart}>
<XAxis dataKey="status" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#1c1917" />
<Bar dataKey="count" fill="#0f766e" />
</BarChart>
</ResponsiveContainer>
</div>

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import SiteHeader from '../SiteHeader.jsx';
import { adminApi } from './adminApi.js';
export default function LoginPage() {
@ -28,46 +29,51 @@ export default function LoginPage() {
}
return (
<div className="mx-auto mt-24 max-w-sm rounded-lg bg-white p-6 shadow">
<h1 className="mb-4 text-2xl font-semibold">Admin login</h1>
<form className="grid gap-3" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="admin-email">
Email
<input
id="admin-email"
type="email"
autoComplete="username"
required
className="rounded border border-stone-300 px-3 py-2"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="admin-password">
Password
<input
id="admin-password"
type="password"
autoComplete="current-password"
required
className="rounded border border-stone-300 px-3 py-2"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? (
<p className="text-sm text-red-700" role="alert">
{error}
</p>
) : null}
<button
type="submit"
className="rounded bg-stone-900 px-3 py-2 text-white disabled:opacity-60"
disabled={submitting}
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
<div className="admin-login">
<SiteHeader />
<div className="admin-login-body">
<div className="admin-card admin-login-card">
{/* <p className="admin-kicker">CREST admin</p> */}
<h1 className="admin-title">Admin login</h1>
<form className="grid gap-3" onSubmit={submit}>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="admin-email">
Email
<input
id="admin-email"
type="email"
autoComplete="username"
required
className="admin-input"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label
className="grid gap-1 text-sm font-medium text-stone-600"
htmlFor="admin-password"
>
Password
<input
id="admin-password"
type="password"
autoComplete="current-password"
required
className="admin-input"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? (
<p className="text-sm text-red-700" role="alert">
{error}
</p>
) : null}
<button type="submit" className="admin-btn admin-btn-primary" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -1,14 +1,14 @@
export default function Modal({ title, children, onClose }) {
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-stone-900/40 p-4">
<div className="fixed inset-0 z-20 flex items-center justify-center bg-[#134e4a]/35 p-4">
<div
className="w-full max-w-md rounded-lg bg-white p-5 shadow-lg"
className="admin-card w-full max-w-md"
role="dialog"
aria-modal="true"
>
<h2 className="mb-3 text-lg font-semibold">{title}</h2>
<h2 className="mb-3 text-lg font-semibold text-[#134e4a]">{title}</h2>
{children}
<button type="button" className="mt-3 text-sm text-stone-600" onClick={onClose}>
<button type="button" className="admin-btn admin-btn-secondary mt-3" onClick={onClose}>
Cancel
</button>
</div>

View File

@ -51,36 +51,32 @@ export default function ReportsPage() {
return (
<div className="max-w-xl">
<h1 className="mb-4 text-2xl font-semibold">Reports</h1>
<p className="mb-4 rounded bg-amber-100 px-3 py-2 text-sm" role="status">
<h1 className="admin-title">Reports</h1>
<p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900" role="status">
This file contains personal data.
</p>
<form className="grid gap-3 rounded-lg bg-white p-4 shadow-sm" onSubmit={download}>
<label className="grid gap-1 text-sm" htmlFor="export-from">
<form className="admin-card grid gap-3" onSubmit={download}>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="export-from">
From
<input
id="export-from"
type="date"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={from}
onChange={(event) => setFrom(event.target.value)}
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="export-to">
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="export-to">
To
<input
id="export-to"
type="date"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={to}
onChange={(event) => setTo(event.target.value)}
/>
</label>
<button
type="submit"
className="w-fit rounded bg-stone-900 px-3 py-2 text-white"
disabled={busy}
>
<button type="submit" className="admin-btn admin-btn-primary" disabled={busy}>
{busy ? 'Exporting…' : 'Export to Excel'}
</button>
</form>

View File

@ -89,44 +89,57 @@ export default function SettingsPage() {
return (
<div className="max-w-xl">
<h1 className="mb-4 text-2xl font-semibold">Settings</h1>
<form className="grid gap-3 rounded-lg bg-white p-4 shadow-sm" onSubmit={save}>
<label className="grid gap-1 text-sm" htmlFor="signatory-name">
<h1 className="admin-title">Settings</h1>
<form className="admin-card grid gap-3" onSubmit={save}>
<label className="grid gap-1 text-sm font-medium text-stone-600" htmlFor="signatory-name">
Signatory name
<input
id="signatory-name"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={settings.name}
onChange={(event) => setSettings({ ...settings, name: event.target.value })}
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="signatory-designation">
<label
className="grid gap-1 text-sm font-medium text-stone-600"
htmlFor="signatory-designation"
>
Designation
<input
id="signatory-designation"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={settings.designation}
onChange={(event) => setSettings({ ...settings, designation: event.target.value })}
/>
</label>
<button type="submit" className="w-fit rounded bg-stone-900 px-3 py-2 text-white">
<button type="submit" className="admin-btn admin-btn-primary">
Save
</button>
</form>
<div className="mt-4 rounded-lg bg-white p-4 shadow-sm">
<h2 className="mb-2 font-semibold">Signature image</h2>
<div className="admin-card mt-4">
<h2 className="mb-2 font-semibold text-[#134e4a]">Signature image</h2>
<p className="mb-3 text-sm text-stone-600">
PNG only, up to 500KB. A transparent background is recommended.
</p>
{settings.signatureUrl ? (
<img src={settings.signatureUrl} alt="CREST signature" className="mb-3 h-16" />
<img
src={settings.signatureUrl}
alt="CREST signature"
className="mb-3 block h-16 rounded-lg border border-stone-200 bg-stone-50 p-2"
/>
) : (
<p className="mb-3 text-sm">No signature uploaded</p>
)}
<input aria-label="Signature image" type="file" accept="image/png" onChange={upload} />
<input
aria-label="Signature image"
type="file"
accept="image/png"
className="admin-file"
onChange={upload}
/>
</div>
<form className="mt-4 grid gap-3 rounded-lg bg-white p-4 shadow-sm" onSubmit={saveReminders}>
<h2 className="font-semibold">Reminders</h2>
<form className="admin-card mt-4 grid gap-3" onSubmit={saveReminders}>
<h2 className="font-semibold text-[#134e4a]">Reminders</h2>
<label className="flex items-center gap-2 text-sm" htmlFor="reminders-enabled">
<input
id="reminders-enabled"
@ -144,7 +157,7 @@ export default function SettingsPage() {
type="number"
min={1}
max={30}
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={reminders.draftReminderDays}
onChange={(event) =>
setReminders({ ...reminders, draftReminderDays: event.target.value })
@ -158,14 +171,14 @@ export default function SettingsPage() {
type="number"
min={1}
max={30}
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={reminders.surveyReminderDays}
onChange={(event) =>
setReminders({ ...reminders, surveyReminderDays: event.target.value })
}
/>
</label>
<button type="submit" className="w-fit rounded bg-stone-900 px-3 py-2 text-white">
<button type="submit" className="admin-btn admin-btn-primary">
Save reminders
</button>
</form>

View File

@ -63,16 +63,13 @@ export default function UsersPage() {
return (
<div>
<h1 className="mb-4 text-2xl font-semibold">Admin users</h1>
<h1 className="admin-title">Admin users</h1>
{error ? <p className="mb-3 text-red-700">{error}</p> : null}
<form
className="mb-6 grid gap-3 rounded-lg bg-white p-4 shadow-sm md:grid-cols-4"
onSubmit={create}
>
<form className="admin-card mb-6 grid gap-3 md:grid-cols-4" onSubmit={create}>
<input
aria-label="Name"
placeholder="Name"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
@ -80,7 +77,7 @@ export default function UsersPage() {
aria-label="Email"
type="email"
placeholder="Email"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={form.email}
onChange={(event) => setForm({ ...form, email: event.target.value })}
/>
@ -88,46 +85,46 @@ export default function UsersPage() {
aria-label="Password"
type="password"
placeholder="Password"
className="rounded border border-stone-300 px-3 py-2"
className="admin-input"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
/>
<button type="submit" className="rounded bg-stone-900 px-3 py-2 text-white">
<button type="submit" className="admin-btn admin-btn-primary">
Create admin
</button>
</form>
<div className="overflow-x-auto rounded-lg bg-white shadow-sm">
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 text-stone-500">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Active</th>
<th className="px-3 py-2">Password</th>
<th>Name</th>
<th>Email</th>
<th>Active</th>
<th>Password</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id} className="border-b border-stone-100">
<td className="px-3 py-2">{user.name}</td>
<td className="px-3 py-2">{user.email}</td>
<td className="px-3 py-2">
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
{user.isActive ? 'Yes' : 'No'}
{user.isActive && user.id !== me?.id ? (
<button
type="button"
className="ml-2 underline"
className="admin-btn admin-btn-danger ml-2"
onClick={() => deactivate(user.id)}
>
Deactivate
</button>
) : null}
</td>
<td className="px-3 py-2">
<td>
<input
aria-label={`New password for ${user.email}`}
type="password"
className="mr-2 rounded border border-stone-300 px-2 py-1"
className="admin-input admin-input-inline mr-2"
value={passwords[user.id] || ''}
onChange={(event) =>
setPasswords({ ...passwords, [user.id]: event.target.value })
@ -135,7 +132,7 @@ export default function UsersPage() {
/>
<button
type="button"
className="underline"
className="admin-btn admin-btn-secondary"
onClick={() => resetPassword(user.id)}
>
Reset

View File

@ -1,80 +1,167 @@
:root {
--brand: #0f766e;
--brand-dark: #134e4a;
--brand-blue: #1d4ed8;
--page: #f7fbfb;
--surface: #ffffff;
--surface-muted: #f7faf9;
--text: #1c1917;
--text-muted: #57534e;
--border: #e4e4e0;
--green-wash: #e8f5e9;
--teal-wash: #ccfbf1;
--blue-wash: #dbeafe;
--radius: 1rem;
--radius-sm: 0.6rem;
--shadow: 0 1px 2px rgb(15 118 110 / 0.06), 0 18px 40px rgb(19 78 74 / 0.08);
}
html {
font-family: Poppins, system-ui, sans-serif;
}
body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
color: #1c1917;
background: #fafaf9;
font-family: inherit;
color: var(--text);
background: var(--page);
}
main {
.site main {
max-width: 40rem;
margin: 4rem auto;
padding: 0 1.5rem;
margin: 2.5rem auto;
padding: 1.75rem 2rem;
box-sizing: border-box;
}
h1 {
font-size: 2rem;
.site h1 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
margin: 0 0 0.35rem;
color: var(--brand-dark);
}
p {
.site h2 {
font-size: 1rem;
font-weight: 600;
color: var(--brand-dark);
}
.site h3 {
font-size: 1rem;
font-weight: 600;
}
.site p {
margin: 0 0 1rem;
color: #44403c;
color: var(--text-muted);
}
form {
display: grid;
gap: 0.75rem;
}
fieldset {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
border: 0;
}
legend {
font-weight: 600;
margin-bottom: 0.25rem;
}
label {
.page-lead {
margin-bottom: 1.25rem;
font-size: 0.95rem;
}
input,
textarea,
button {
.site form {
display: grid;
gap: 1rem;
}
.site fieldset {
display: grid;
gap: 0.55rem;
margin: 0;
padding: 1.15rem 1.2rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-muted);
}
.site legend {
padding: 0 0.35rem;
font-weight: 600;
font-size: 0.92rem;
color: var(--brand-dark);
}
.site label {
font-size: 0.8rem;
font-weight: 500;
color: #44403c;
}
.site input,
.site textarea,
.site button,
.site select {
font: inherit;
}
input,
textarea {
.site input,
.site textarea,
.site select {
font-size: 16px;
}
.site input,
.site textarea,
.site select {
width: 100%;
box-sizing: border-box;
padding: 0.55rem 0.7rem;
padding: 0.7rem 0.85rem;
border: 1px solid #d6d3d1;
border-radius: 0.4rem;
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
}
a:focus-visible {
outline: 2px solid #1d4ed8;
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.site {
position: relative;
isolation: isolate;
overflow: hidden;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f4f7f8;
color: #1c1917;
color: var(--text);
background-color: var(--page);
background-image:
radial-gradient(ellipse 55% 45% at 8% 12%, rgb(232 245 233 / 0.95), transparent 62%),
radial-gradient(ellipse 50% 50% at 92% 8%, rgb(219 234 254 / 0.8), transparent 58%),
radial-gradient(ellipse 60% 50% at 88% 92%, rgb(204 251 241 / 0.8), transparent 55%),
radial-gradient(ellipse 40% 35% at 16% 88%, rgb(241 248 233 / 0.85), transparent 50%);
}
.site::before {
content: '';
position: absolute;
inset: -12% -8%;
z-index: 0;
pointer-events: none;
background:
radial-gradient(circle at 18% 22%, #c8e6c9 0%, transparent 42%),
radial-gradient(circle at 88% 16%, #bbdefb 0%, transparent 38%),
radial-gradient(circle at 78% 86%, #b2dfdb 0%, transparent 42%);
filter: blur(80px);
opacity: 0.45;
}
.site-header,
.site-footer,
.site main {
position: relative;
z-index: 1;
}
.site-header {
background: #fff;
border-bottom: 3px solid #0f766e;
background: rgb(255 255 255 / 0.92);
backdrop-filter: blur(16px);
border-bottom: 3px solid var(--brand);
}
.site-brand {
@ -84,7 +171,7 @@ a:focus-visible {
max-width: 64rem;
margin: 0 auto;
padding: 0.75rem 1.5rem;
color: #134e4a;
color: var(--brand-dark);
font-weight: 600;
text-decoration: none;
}
@ -92,63 +179,153 @@ a:focus-visible {
.site-brand img {
height: 56px;
width: auto;
padding-right: 0.5rem;
border-right: 1px dotted var(--brand);
}
@media (min-width: 40.0625rem) {
.site-brand span {
font-size: 1.4rem;
}
}
.site main {
flex: 1;
width: 100%;
width: calc(100% - 2rem);
isolation: isolate;
background: rgb(255 255 255 / 0.94);
border: 1px solid rgb(255 255 255 / 0.8);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.site main::before,
.site main::after {
content: '';
position: absolute;
pointer-events: none;
z-index: -1;
}
.site main::before {
top: -40%;
right: -18%;
width: 17rem;
height: 17rem;
background: radial-gradient(circle, rgb(187 222 251 / 0.5), transparent 70%);
}
.site main::after {
bottom: -36%;
left: -16%;
width: 15rem;
height: 15rem;
background: radial-gradient(circle, rgb(200 230 201 / 0.42), transparent 70%);
}
.site-footer {
margin-top: auto;
background: #134e4a;
background: var(--brand-dark);
color: #f0fdfa;
font-size: 0.95rem;
font-size: 0.8rem;
}
.site-footer-inner {
max-width: 64rem;
margin: 0 auto;
padding: 1.25rem 1.5rem;
padding: 1rem 1.5rem;
text-align: center;
}
.site-footer p {
margin: 0 0 0.35rem;
margin: 0;
color: #f0fdfa;
}
.site-footer a {
color: #ccfbf1;
.site-footer-credit {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.65rem;
}
.site-footer-sep {
display: inline-block;
width: 1px;
height: 0.85rem;
background: #99f6e4;
}
.site .site-footer a {
color: #99f6e4;
font-weight: 600;
text-underline-offset: 2px;
}
.site .site-footer a:hover {
color: #fff;
}
.site .site-footer a:focus-visible {
outline-color: #99f6e4;
}
.site a {
color: var(--brand);
}
.site button {
background: #0f766e;
padding: 0.7rem 1.15rem;
border: 0;
border-radius: 0.55rem;
background: linear-gradient(135deg, var(--brand) 0%, #1578c8 100%);
color: #f0fdfa;
font-weight: 600;
cursor: pointer;
}
.site button:hover:not(:disabled) {
background: linear-gradient(135deg, #0d6a63 0%, #1d4ed8 100%);
}
.site button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.site button.secondary,
.site .tabs button[aria-selected='false'],
.site .survey-actions button.secondary {
background: #e7e5e4;
color: #1c1917;
background: #eef2f1;
color: var(--brand-dark);
font-weight: 500;
}
.site button.secondary:hover:not(:disabled),
.site .tabs button[aria-selected='false']:hover,
.site .survey-actions button.secondary:hover:not(:disabled) {
background: #e4eceb;
}
.site input:focus,
.site textarea:focus,
.site select:focus,
.site button:focus,
.site a:focus-visible {
outline: 2px solid #0f766e;
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.site .progress span {
background: #0f766e;
background: linear-gradient(90deg, var(--brand), #1578c8);
}
.flow-steps {
display: flex;
list-style: none;
margin: 0 0 1.5rem;
padding: 0;
padding: 0.35rem 0 0.5rem;
}
.flow-steps li {
@ -159,7 +336,8 @@ a:focus-visible {
align-items: center;
gap: 0.4rem;
text-align: center;
font-size: 0.8rem;
font-size: 0.78rem;
font-weight: 500;
color: #64748b;
}
@ -179,12 +357,12 @@ a:focus-visible {
.flow-steps li.done,
.flow-steps li.current {
color: #134e4a;
color: var(--brand-dark);
}
.flow-steps li.done::before,
.flow-steps li.current::before {
background: #0f766e;
background: var(--brand);
}
.flow-mark {
@ -202,104 +380,113 @@ a:focus-visible {
.flow-steps li.current .flow-mark,
.flow-steps li.done .flow-mark {
border-color: #0f766e;
background: #0f766e;
border-color: var(--brand);
background: linear-gradient(135deg, var(--brand), #1578c8);
color: #f0fdfa;
}
.flow-steps li.current .flow-label {
font-weight: 700;
font-weight: 600;
}
input:focus,
textarea:focus,
button:focus {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
textarea {
.site textarea {
min-height: 6rem;
}
button {
padding: 0.6rem 1rem;
border: 0;
border-radius: 0.4rem;
background: #1c1917;
color: #fafaf9;
resize: vertical;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
}
.accept {
display: flex;
gap: 0.6rem;
align-items: flex-start;
margin-bottom: 0.75rem;
font-size: 0.92rem;
font-weight: 500;
color: var(--text);
}
.accept input {
width: auto;
margin-top: 0.2rem;
accent-color: var(--brand);
}
.agreement-text {
max-height: 16rem;
max-height: max(16rem, calc(100vh - 31rem));
max-height: max(16rem, calc(100svh - 31rem));
overflow: auto;
white-space: pre-wrap;
padding: 1rem;
padding: 1.1rem 1.2rem;
margin-bottom: 1.5rem;
border: 1px solid #e7e5e4;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-muted);
font-size: 0.92rem;
line-height: 1.55;
}
.error {
color: #b91c1c;
}
main.wide {
.site main.wide {
max-width: 64rem;
}
.banner {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: #fef3c7;
border: 1px solid #fcd34d;
padding: 0.85rem 1rem;
background: #fff8e8;
border: 1px solid #f5e0a8;
border-radius: var(--radius-sm);
}
.banner p {
margin-bottom: 0.75rem;
color: #92400e;
}
.preview-frame {
width: 100%;
height: 70vh;
margin-bottom: 1rem;
border: 1px solid #e7e5e4;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
}
.tabs {
display: flex;
gap: 0.5rem;
width: fit-content;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 999px;
background: #eef2f1;
}
.tabs button {
border-radius: 999px;
}
.tabs button[aria-selected='false'] {
background: #e7e5e4;
color: #1c1917;
background: transparent;
color: var(--brand-dark);
}
.signature-pad {
width: 100%;
max-width: 100%;
height: auto;
border: 1px solid #d6d3d1;
border-radius: 0.4rem;
margin: 0.35rem 0 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff;
touch-action: none;
}
@ -309,14 +496,90 @@ main.wide {
font-size: 1.8rem;
}
.timeline .done {
.timeline {
list-style: none;
margin: 1.25rem 0;
padding: 0;
}
.timeline li {
position: relative;
padding: 0.45rem 0 0.45rem 1.85rem;
color: var(--text-muted);
}
.timeline li::before {
content: '';
position: absolute;
left: 0.35rem;
top: 0.75rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: #cbd5e1;
}
.timeline .current {
color: var(--brand-dark);
font-weight: 600;
}
fieldset:disabled {
.timeline .current::before {
background: var(--brand);
}
.timeline .done {
font-weight: 600;
color: var(--brand-dark);
}
.timeline .done::before {
background: linear-gradient(135deg, var(--brand), #1578c8);
}
.site fieldset:disabled {
opacity: 0.75;
}
.survey-fields {
display: grid;
gap: 1.35rem;
}
.survey-fields > label {
display: grid;
gap: 0.45rem;
}
.survey-fields > fieldset {
min-width: 0;
gap: 0.45rem;
padding: 0;
border: 0;
border-radius: 0;
background: none;
}
.survey-fields > fieldset > legend {
margin-bottom: 0.45rem;
padding: 0;
font-size: 0.8rem;
font-weight: 500;
color: #44403c;
}
.survey-review p {
margin-bottom: 0.85rem;
font-size: 0.85rem;
line-height: 1.5;
}
.survey-review strong {
font-size: 0.8rem;
font-weight: 600;
color: #44403c;
}
.survey-help {
font-size: 0.95rem;
}
@ -330,6 +593,7 @@ fieldset:disabled {
.survey-choice input {
width: auto;
margin-top: 0.2rem;
accent-color: var(--brand);
}
.survey-grid {
@ -340,10 +604,46 @@ fieldset:disabled {
.survey-grid th,
.survey-grid td {
border: 1px solid #e7e5e4;
padding: 0.45rem;
border: 1px solid var(--border);
padding: 0.55rem;
text-align: left;
vertical-align: top;
background: #fff;
}
.survey-grid th {
font-weight: 600;
}
.survey-grid-input {
display: flex;
align-items: center;
gap: 0.45rem;
}
.survey-grid-input input {
flex: 1;
min-width: 0;
}
.survey-grid-suffix {
flex: none;
white-space: nowrap;
color: var(--text-muted);
}
.survey-grid td.survey-grid-choice {
text-align: center;
vertical-align: middle;
}
.survey-grid-choice .survey-choice {
justify-content: center;
align-items: center;
}
.survey-grid-choice .survey-choice input {
margin: 0;
}
.survey-total.warn {
@ -364,6 +664,7 @@ fieldset:disabled {
margin: 1rem 0;
background: #e7e5e4;
border-radius: 999px;
overflow: hidden;
}
.progress span {
@ -392,20 +693,38 @@ fieldset:disabled {
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(28, 25, 23, 0.4);
background: rgba(19 78 74 / 0.35);
z-index: 20;
}
.survey-dialog div {
.survey-dialog > div {
width: 100%;
max-width: 28rem;
padding: 1.25rem;
background: #fff;
border-radius: 0.5rem;
padding: 1.35rem;
background: rgb(255 255 255 / 0.96);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
@media (max-width: 40rem) {
main {
margin: 2rem auto;
.site main {
margin: 1.25rem auto;
padding: 1.25rem;
width: calc(100% - 1.5rem);
border-radius: 0.85rem;
}
.site main::before,
.site main::after {
opacity: 0.65;
}
.site h1 {
font-size: 1.25rem;
}
.flow-steps li {
font-size: 0.68rem;
}
.cell-label {
@ -435,12 +754,40 @@ fieldset:disabled {
.survey-grid tr {
margin-bottom: 0.75rem;
border: 1px solid #e7e5e4;
border: 1px solid var(--border);
background: #fff;
border-radius: var(--radius-sm);
overflow: hidden;
}
.survey-grid th,
.survey-grid td {
border: 0;
}
.survey-grid td.survey-grid-choice {
text-align: left;
}
.survey-grid-choice .survey-choice {
justify-content: flex-start;
gap: 0.6rem;
cursor: pointer;
}
.survey-grid-choice .cell-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.survey-grid-choice .survey-choice .visually-hidden {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
}
}

View File

@ -216,6 +216,7 @@ export default function AgreementPage() {
return (
<main>
<h1>Agreement</h1>
<p className="page-lead">Read the terms, then enter your details to continue.</p>
<FlowSteps current={readOnly ? 'done' : accepted ? 'details' : 'read'} />
{application?.rejection_reason ? (
<div className="banner" role="status">

View File

@ -4,6 +4,9 @@ export default function PrivacyPage() {
return (
<main>
<h1>Privacy</h1>
<p className="page-lead">
How Chest Research and Training Pvt. Ltd. uses information for this study.
</p>
<p>
Chest Research and Training Pvt. Ltd. uses the personal information you provide for the Adherence Pack
Study, for payments, for statutory compliance, and for audit.

View File

@ -38,7 +38,9 @@ export default function ResumePage() {
{error}
</p>
) : (
<p role="status">Checking your private link…</p>
<p className="page-lead" role="status">
Checking your private link…
</p>
)}
</main>
);

View File

@ -63,6 +63,7 @@ export default function ReviewPage() {
return (
<main className="wide">
<h1>Review agreement</h1>
<p className="page-lead">Check the preview before you sign.</p>
<FlowSteps current={locked ? 'done' : 'review'} />
{application?.rejection_reason ? (
<div className="banner" role="status">
@ -86,7 +87,7 @@ export default function ReviewPage() {
) : null}
{locked ? null : (
<div className="actions">
<button type="button" onClick={editDetails}>
<button type="button" className="secondary" onClick={editDetails}>
Edit details
</button>
<button type="button" onClick={() => navigate('/sign')}>

View File

@ -84,6 +84,7 @@ export default function SignPage() {
return (
<main>
<h1>Sign the agreement</h1>
<p className="page-lead">Draw your signature, or type it instead.</p>
<FlowSteps current="sign" />
<form onSubmit={submitSignature}>
<div className="tabs" role="tablist">
@ -115,7 +116,7 @@ export default function SignPage() {
'aria-label': 'Draw your signature',
}}
/>
<button type="button" onClick={() => padRef.current?.clear()}>
<button type="button" className="secondary" onClick={() => padRef.current?.clear()}>
Clear
</button>
</div>

View File

@ -36,13 +36,13 @@ export default function SubmittedPage() {
return (
<main>
<h1>Agreement submitted</h1>
<p className="page-lead">Your signed agreement has been sent for review.</p>
<FlowSteps current="done" />
{error ? (
<p className="error" role="alert">
{error}
</p>
) : null}
<p>Your signed agreement has been sent for review.</p>
<ol className="timeline">
<li className="done">Submitted</li>
<li className={decided ? 'done' : 'current'}>Under review</li>

View File

@ -70,9 +70,11 @@ function formatAnswer(question, value) {
}
/**
* @param {{ question: Record<string, unknown>, value: unknown, invalid?: boolean, onChange: (value: unknown) => void }} props
* @param {{ question: Record<string, unknown>, value: unknown, invalid?: boolean, hideLabel?: boolean, onChange: (value: unknown) => void }} props
*/
function QuestionField({ question, value, invalid = false, onChange }) {
function QuestionField({ question, value, invalid = false, hideLabel = false, onChange }) {
const legendClass = hideLabel ? 'visually-hidden' : undefined;
if (question.type === 'help') {
return <p className="survey-help">{question.text}</p>;
}
@ -118,7 +120,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
const current = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend>{question.label}</legend>
<legend className={legendClass}>{question.label}</legend>
{question.options.map((option) => (
<label key={option.id} className="survey-choice">
<input
@ -165,7 +167,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
const grid = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend>{question.label}</legend>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
@ -184,26 +186,30 @@ function QuestionField({ question, value, invalid = false, onChange }) {
{question.columns.map((column) => (
<td key={column.id}>
<span className="cell-label">{column.label}</span>
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
aria-label={`${row.label}, ${column.label}`}
value={grid[row.id]?.[column.id] ?? ''}
onChange={(event) => {
const raw = event.target.value;
onChange({
...grid,
[row.id]: {
...(grid[row.id] ?? {}),
[column.id]: raw === '' ? '' : Number(raw),
},
});
}}
/>
{question.suffix ? <span> {question.suffix}</span> : null}
<div className="survey-grid-input">
<input
type="number"
inputMode={question.integer ? 'numeric' : 'decimal'}
min={question.min}
max={question.max}
aria-invalid={invalid || undefined}
aria-label={`${row.label}, ${column.label}`}
value={grid[row.id]?.[column.id] ?? ''}
onChange={(event) => {
const raw = event.target.value;
onChange({
...grid,
[row.id]: {
...(grid[row.id] ?? {}),
[column.id]: raw === '' ? '' : Number(raw),
},
});
}}
/>
{question.suffix ? (
<span className="survey-grid-suffix">{question.suffix}</span>
) : null}
</div>
</td>
))}
</tr>
@ -243,7 +249,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
const selected = value && typeof value === 'object' ? value : {};
return (
<fieldset>
<legend>{question.label}</legend>
<legend className={legendClass}>{question.label}</legend>
<table className="survey-grid">
<thead>
<tr>
@ -260,7 +266,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
<tr key={row.id}>
<th scope="row">{row.label}</th>
{question.scale.map((point) => (
<td key={point.id}>
<td key={point.id} className="survey-grid-choice">
<span className="cell-label">{point.label}</span>
<label className="survey-choice">
<input
@ -356,6 +362,18 @@ export default function SurveyPage() {
return () => clearInterval(timer);
}, [screen, token]);
const shownSection = useRef(sectionIndex);
useEffect(() => {
if (shownSection.current === sectionIndex) {
return;
}
shownSection.current = sectionIndex;
const page = document.scrollingElement || document.documentElement;
page.scrollTo?.({ top: 0, behavior: 'smooth' });
}, [sectionIndex]);
useEffect(() => {
if (stepErrors.length > 0) {
stepAlertRef.current?.scrollIntoView?.({ block: 'nearest' });
@ -497,7 +515,7 @@ export default function SurveyPage() {
<span style={{ width: `${progress}%` }} />
</div>
{review ? (
<section>
<section className="survey-review">
<h2>Review</h2>
{sections.map((item) => (
<div key={item.id}>
@ -528,15 +546,21 @@ export default function SurveyPage() {
))}
</div>
) : null}
{section.questions.map((question) => (
<QuestionField
key={question.id}
question={question}
invalid={stepErrors.some((item) => item.id === question.id)}
value={answers[question.id]}
onChange={(value) => updateAnswer(question.id, value)}
/>
))}
<div className="survey-fields">
{section.questions.map((question, index) => (
<QuestionField
key={question.id}
question={question}
hideLabel={
section.questions[index - 1]?.type === 'section_header' &&
section.questions[index - 1].label === question.label
}
invalid={stepErrors.some((item) => item.id === question.id)}
value={answers[question.id]}
onChange={(value) => updateAnswer(question.id, value)}
/>
))}
</div>
</section>
)}
{error ? (