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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Adherence Pack Study</title>
</head> </head>
<body> <body>

View File

@ -5,7 +5,16 @@ export default function SiteFooter() {
return ( return (
<footer className="site-footer"> <footer className="site-footer">
<div className="site-footer-inner"> <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 ? ( {supportEmail ? (
<p> <p>
<a href={`mailto:${supportEmail}`}>{supportEmail}</a> <a href={`mailto:${supportEmail}`}>{supportEmail}</a>

View File

@ -2,6 +2,377 @@
@import 'tailwindcss/theme.css' layer(theme); @import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities); @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 { .admin-nav-logo {
display: block; display: block;
width: 100%; width: 100%;

View File

@ -12,9 +12,16 @@ const LINKS = [
['/admin/audit', 'Audit log'], ['/admin/audit', 'Audit log'],
]; ];
const NAV_KEY = 'crest-admin-nav';
export default function AdminLayout() { export default function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const [admin, setAdmin] = useState(null); const [admin, setAdmin] = useState(null);
const [navOpen, setNavOpen] = useState(() => localStorage.getItem(NAV_KEY) !== 'closed');
useEffect(() => {
localStorage.setItem(NAV_KEY, navOpen ? 'open' : 'closed');
}, [navOpen]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -46,18 +53,18 @@ export default function AdminLayout() {
} }
return ( return (
<div className="min-h-screen bg-stone-100 text-stone-900"> <div className="admin-shell font-sans">
<div className="flex min-h-screen"> <aside className={`admin-sidebar ${navOpen ? '' : 'is-collapsed'}`}>
<nav className="admin-nav flex w-56 flex-col gap-2 bg-stone-900 p-4"> <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" /> <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]) => ( {LINKS.map(([to, label]) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
end={to === '/admin'} end={to === '/admin'}
className={({ isActive }) => 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} {label}
@ -65,16 +72,52 @@ export default function AdminLayout() {
))} ))}
<button <button
type="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} 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 Log out
</button> </button>
<p className="truncate text-xs text-stone-400">{admin.email}</p>
</nav> </nav>
<div className="min-w-0 flex-1 p-6"> </aside>
<Outlet /> <div className="admin-main">
</div> <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>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
export default function Modal({ title, children, onClose }) { export default function Modal({ title, children, onClose }) {
return ( 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 <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" role="dialog"
aria-modal="true" 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} {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 Cancel
</button> </button>
</div> </div>

View File

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

View File

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

View File

@ -63,16 +63,13 @@ export default function UsersPage() {
return ( return (
<div> <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} {error ? <p className="mb-3 text-red-700">{error}</p> : null}
<form <form className="admin-card mb-6 grid gap-3 md:grid-cols-4" onSubmit={create}>
className="mb-6 grid gap-3 rounded-lg bg-white p-4 shadow-sm md:grid-cols-4"
onSubmit={create}
>
<input <input
aria-label="Name" aria-label="Name"
placeholder="Name" placeholder="Name"
className="rounded border border-stone-300 px-3 py-2" className="admin-input"
value={form.name} value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })} onChange={(event) => setForm({ ...form, name: event.target.value })}
/> />
@ -80,7 +77,7 @@ export default function UsersPage() {
aria-label="Email" aria-label="Email"
type="email" type="email"
placeholder="Email" placeholder="Email"
className="rounded border border-stone-300 px-3 py-2" className="admin-input"
value={form.email} value={form.email}
onChange={(event) => setForm({ ...form, email: event.target.value })} onChange={(event) => setForm({ ...form, email: event.target.value })}
/> />
@ -88,46 +85,46 @@ export default function UsersPage() {
aria-label="Password" aria-label="Password"
type="password" type="password"
placeholder="Password" placeholder="Password"
className="rounded border border-stone-300 px-3 py-2" className="admin-input"
value={form.password} value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })} 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 Create admin
</button> </button>
</form> </form>
<div className="overflow-x-auto rounded-lg bg-white shadow-sm"> <div className="admin-table-wrap">
<table className="w-full text-left text-sm"> <table className="admin-table">
<thead className="border-b border-stone-200 text-stone-500"> <thead>
<tr> <tr>
<th className="px-3 py-2">Name</th> <th>Name</th>
<th className="px-3 py-2">Email</th> <th>Email</th>
<th className="px-3 py-2">Active</th> <th>Active</th>
<th className="px-3 py-2">Password</th> <th>Password</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users?.map((user) => ( {users?.map((user) => (
<tr key={user.id} className="border-b border-stone-100"> <tr key={user.id}>
<td className="px-3 py-2">{user.name}</td> <td>{user.name}</td>
<td className="px-3 py-2">{user.email}</td> <td>{user.email}</td>
<td className="px-3 py-2"> <td>
{user.isActive ? 'Yes' : 'No'} {user.isActive ? 'Yes' : 'No'}
{user.isActive && user.id !== me?.id ? ( {user.isActive && user.id !== me?.id ? (
<button <button
type="button" type="button"
className="ml-2 underline" className="admin-btn admin-btn-danger ml-2"
onClick={() => deactivate(user.id)} onClick={() => deactivate(user.id)}
> >
Deactivate Deactivate
</button> </button>
) : null} ) : null}
</td> </td>
<td className="px-3 py-2"> <td>
<input <input
aria-label={`New password for ${user.email}`} aria-label={`New password for ${user.email}`}
type="password" 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] || ''} value={passwords[user.id] || ''}
onChange={(event) => onChange={(event) =>
setPasswords({ ...passwords, [user.id]: event.target.value }) setPasswords({ ...passwords, [user.id]: event.target.value })
@ -135,7 +132,7 @@ export default function UsersPage() {
/> />
<button <button
type="button" type="button"
className="underline" className="admin-btn admin-btn-secondary"
onClick={() => resetPassword(user.id)} onClick={() => resetPassword(user.id)}
> >
Reset 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 { body {
margin: 0; margin: 0;
font-family: Georgia, 'Times New Roman', serif; font-family: inherit;
color: #1c1917; color: var(--text);
background: #fafaf9; background: var(--page);
} }
main { .site main {
max-width: 40rem; max-width: 40rem;
margin: 4rem auto; margin: 2.5rem auto;
padding: 0 1.5rem; padding: 1.75rem 2rem;
box-sizing: border-box;
} }
h1 { .site h1 {
font-size: 2rem; font-size: 1.5rem;
font-weight: 600; 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; margin: 0 0 1rem;
color: #44403c; color: var(--text-muted);
} }
form { .page-lead {
display: grid; margin-bottom: 1.25rem;
gap: 0.75rem;
}
fieldset {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
border: 0;
}
legend {
font-weight: 600;
margin-bottom: 0.25rem;
}
label {
font-size: 0.95rem; font-size: 0.95rem;
} }
input, .site form {
textarea, display: grid;
button { 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; font: inherit;
} }
input, .site input,
textarea { .site textarea,
.site select {
font-size: 16px;
}
.site input,
.site textarea,
.site select {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.55rem 0.7rem; padding: 0.7rem 0.85rem;
border: 1px solid #d6d3d1; border: 1px solid #d6d3d1;
border-radius: 0.4rem; border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
} }
a:focus-visible { a:focus-visible {
outline: 2px solid #1d4ed8; outline: 2px solid var(--brand);
outline-offset: 2px; outline-offset: 2px;
} }
.site { .site {
position: relative;
isolation: isolate;
overflow: hidden;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #f4f7f8; color: var(--text);
color: #1c1917; 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 { .site-header {
background: #fff; background: rgb(255 255 255 / 0.92);
border-bottom: 3px solid #0f766e; backdrop-filter: blur(16px);
border-bottom: 3px solid var(--brand);
} }
.site-brand { .site-brand {
@ -84,7 +171,7 @@ a:focus-visible {
max-width: 64rem; max-width: 64rem;
margin: 0 auto; margin: 0 auto;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
color: #134e4a; color: var(--brand-dark);
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
@ -92,63 +179,153 @@ a:focus-visible {
.site-brand img { .site-brand img {
height: 56px; height: 56px;
width: auto; 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 { .site main {
flex: 1; 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 { .site-footer {
margin-top: auto; margin-top: auto;
background: #134e4a; background: var(--brand-dark);
color: #f0fdfa; color: #f0fdfa;
font-size: 0.95rem; font-size: 0.8rem;
} }
.site-footer-inner { .site-footer-inner {
max-width: 64rem; max-width: 64rem;
margin: 0 auto; margin: 0 auto;
padding: 1.25rem 1.5rem; padding: 1rem 1.5rem;
text-align: center;
} }
.site-footer p { .site-footer p {
margin: 0 0 0.35rem; margin: 0;
color: #f0fdfa; color: #f0fdfa;
} }
.site-footer a { .site-footer-credit {
color: #ccfbf1; 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 { .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; 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 .tabs button[aria-selected='false'],
.site .survey-actions button.secondary { .site .survey-actions button.secondary {
background: #e7e5e4; background: #eef2f1;
color: #1c1917; 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 input:focus,
.site textarea:focus, .site textarea:focus,
.site select:focus,
.site button:focus, .site button:focus,
.site a:focus-visible { .site a:focus-visible {
outline: 2px solid #0f766e; outline: 2px solid var(--brand);
outline-offset: 2px; outline-offset: 2px;
} }
.site .progress span { .site .progress span {
background: #0f766e; background: linear-gradient(90deg, var(--brand), #1578c8);
} }
.flow-steps { .flow-steps {
display: flex; display: flex;
list-style: none; list-style: none;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
padding: 0; padding: 0.35rem 0 0.5rem;
} }
.flow-steps li { .flow-steps li {
@ -159,7 +336,8 @@ a:focus-visible {
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
text-align: center; text-align: center;
font-size: 0.8rem; font-size: 0.78rem;
font-weight: 500;
color: #64748b; color: #64748b;
} }
@ -179,12 +357,12 @@ a:focus-visible {
.flow-steps li.done, .flow-steps li.done,
.flow-steps li.current { .flow-steps li.current {
color: #134e4a; color: var(--brand-dark);
} }
.flow-steps li.done::before, .flow-steps li.done::before,
.flow-steps li.current::before { .flow-steps li.current::before {
background: #0f766e; background: var(--brand);
} }
.flow-mark { .flow-mark {
@ -202,104 +380,113 @@ a:focus-visible {
.flow-steps li.current .flow-mark, .flow-steps li.current .flow-mark,
.flow-steps li.done .flow-mark { .flow-steps li.done .flow-mark {
border-color: #0f766e; border-color: var(--brand);
background: #0f766e; background: linear-gradient(135deg, var(--brand), #1578c8);
color: #f0fdfa; color: #f0fdfa;
} }
.flow-steps li.current .flow-label { .flow-steps li.current .flow-label {
font-weight: 700; font-weight: 600;
} }
input:focus, .site textarea {
textarea:focus,
button:focus {
outline: 2px solid #1d4ed8;
outline-offset: 2px;
}
textarea {
min-height: 6rem; min-height: 6rem;
} resize: vertical;
button {
padding: 0.6rem 1rem;
border: 0;
border-radius: 0.4rem;
background: #1c1917;
color: #fafaf9;
} }
.actions { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
margin-top: 0.25rem;
} }
.accept { .accept {
display: flex; display: flex;
gap: 0.6rem; gap: 0.6rem;
align-items: flex-start; align-items: flex-start;
margin-bottom: 0.75rem;
font-size: 0.92rem;
font-weight: 500;
color: var(--text);
} }
.accept input { .accept input {
width: auto; width: auto;
margin-top: 0.2rem; margin-top: 0.2rem;
accent-color: var(--brand);
} }
.agreement-text { .agreement-text {
max-height: 16rem; max-height: max(16rem, calc(100vh - 31rem));
max-height: max(16rem, calc(100svh - 31rem));
overflow: auto; overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
padding: 1rem; padding: 1.1rem 1.2rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border: 1px solid #e7e5e4; border: 1px solid var(--border);
background: #fff; border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-muted);
font-size: 0.92rem;
line-height: 1.55;
} }
.error { .error {
color: #b91c1c; color: #b91c1c;
} }
main.wide { .site main.wide {
max-width: 64rem; max-width: 64rem;
} }
.banner { .banner {
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem 1rem; padding: 0.85rem 1rem;
background: #fef3c7; background: #fff8e8;
border: 1px solid #fcd34d; border: 1px solid #f5e0a8;
border-radius: var(--radius-sm);
} }
.banner p { .banner p {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
color: #92400e;
} }
.preview-frame { .preview-frame {
width: 100%; width: 100%;
height: 70vh; height: 70vh;
margin-bottom: 1rem; margin-bottom: 1rem;
border: 1px solid #e7e5e4; border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff; background: #fff;
} }
.tabs { .tabs {
display: flex; 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'] { .tabs button[aria-selected='false'] {
background: #e7e5e4; background: transparent;
color: #1c1917; color: var(--brand-dark);
} }
.signature-pad { .signature-pad {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
border: 1px solid #d6d3d1; margin: 0.35rem 0 0.65rem;
border-radius: 0.4rem; border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #fff; background: #fff;
touch-action: none; touch-action: none;
} }
@ -309,14 +496,90 @@ main.wide {
font-size: 1.8rem; 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; 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; 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 { .survey-help {
font-size: 0.95rem; font-size: 0.95rem;
} }
@ -330,6 +593,7 @@ fieldset:disabled {
.survey-choice input { .survey-choice input {
width: auto; width: auto;
margin-top: 0.2rem; margin-top: 0.2rem;
accent-color: var(--brand);
} }
.survey-grid { .survey-grid {
@ -340,10 +604,46 @@ fieldset:disabled {
.survey-grid th, .survey-grid th,
.survey-grid td { .survey-grid td {
border: 1px solid #e7e5e4; border: 1px solid var(--border);
padding: 0.45rem; padding: 0.55rem;
text-align: left; text-align: left;
vertical-align: top; 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 { .survey-total.warn {
@ -364,6 +664,7 @@ fieldset:disabled {
margin: 1rem 0; margin: 1rem 0;
background: #e7e5e4; background: #e7e5e4;
border-radius: 999px; border-radius: 999px;
overflow: hidden;
} }
.progress span { .progress span {
@ -392,20 +693,38 @@ fieldset:disabled {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 1rem; 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%; width: 100%;
max-width: 28rem; max-width: 28rem;
padding: 1.25rem; padding: 1.35rem;
background: #fff; background: rgb(255 255 255 / 0.96);
border-radius: 0.5rem; border-radius: var(--radius);
box-shadow: var(--shadow);
} }
@media (max-width: 40rem) { @media (max-width: 40rem) {
main { .site main {
margin: 2rem auto; 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 { .cell-label {
@ -435,12 +754,40 @@ fieldset:disabled {
.survey-grid tr { .survey-grid tr {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
border: 1px solid #e7e5e4; border: 1px solid var(--border);
background: #fff; background: #fff;
border-radius: var(--radius-sm);
overflow: hidden;
} }
.survey-grid th, .survey-grid th,
.survey-grid td { .survey-grid td {
border: 0; 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 ( return (
<main> <main>
<h1>Agreement</h1> <h1>Agreement</h1>
<p className="page-lead">Read the terms, then enter your details to continue.</p>
<FlowSteps current={readOnly ? 'done' : accepted ? 'details' : 'read'} /> <FlowSteps current={readOnly ? 'done' : accepted ? 'details' : 'read'} />
{application?.rejection_reason ? ( {application?.rejection_reason ? (
<div className="banner" role="status"> <div className="banner" role="status">

View File

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

View File

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

View File

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

View File

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

View File

@ -36,13 +36,13 @@ export default function SubmittedPage() {
return ( return (
<main> <main>
<h1>Agreement submitted</h1> <h1>Agreement submitted</h1>
<p className="page-lead">Your signed agreement has been sent for review.</p>
<FlowSteps current="done" /> <FlowSteps current="done" />
{error ? ( {error ? (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}
</p> </p>
) : null} ) : null}
<p>Your signed agreement has been sent for review.</p>
<ol className="timeline"> <ol className="timeline">
<li className="done">Submitted</li> <li className="done">Submitted</li>
<li className={decided ? 'done' : 'current'}>Under review</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') { if (question.type === 'help') {
return <p className="survey-help">{question.text}</p>; 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 : {}; const current = value && typeof value === 'object' ? value : {};
return ( return (
<fieldset> <fieldset>
<legend>{question.label}</legend> <legend className={legendClass}>{question.label}</legend>
{question.options.map((option) => ( {question.options.map((option) => (
<label key={option.id} className="survey-choice"> <label key={option.id} className="survey-choice">
<input <input
@ -165,7 +167,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
const grid = value && typeof value === 'object' ? value : {}; const grid = value && typeof value === 'object' ? value : {};
return ( return (
<fieldset> <fieldset>
<legend>{question.label}</legend> <legend className={legendClass}>{question.label}</legend>
<table className="survey-grid"> <table className="survey-grid">
<thead> <thead>
<tr> <tr>
@ -184,26 +186,30 @@ function QuestionField({ question, value, invalid = false, onChange }) {
{question.columns.map((column) => ( {question.columns.map((column) => (
<td key={column.id}> <td key={column.id}>
<span className="cell-label">{column.label}</span> <span className="cell-label">{column.label}</span>
<input <div className="survey-grid-input">
type="number" <input
inputMode={question.integer ? 'numeric' : 'decimal'} type="number"
min={question.min} inputMode={question.integer ? 'numeric' : 'decimal'}
max={question.max} min={question.min}
aria-invalid={invalid || undefined} max={question.max}
aria-label={`${row.label}, ${column.label}`} aria-invalid={invalid || undefined}
value={grid[row.id]?.[column.id] ?? ''} aria-label={`${row.label}, ${column.label}`}
onChange={(event) => { value={grid[row.id]?.[column.id] ?? ''}
const raw = event.target.value; onChange={(event) => {
onChange({ const raw = event.target.value;
...grid, onChange({
[row.id]: { ...grid,
...(grid[row.id] ?? {}), [row.id]: {
[column.id]: raw === '' ? '' : Number(raw), ...(grid[row.id] ?? {}),
}, [column.id]: raw === '' ? '' : Number(raw),
}); },
}} });
/> }}
{question.suffix ? <span> {question.suffix}</span> : null} />
{question.suffix ? (
<span className="survey-grid-suffix">{question.suffix}</span>
) : null}
</div>
</td> </td>
))} ))}
</tr> </tr>
@ -243,7 +249,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
const selected = value && typeof value === 'object' ? value : {}; const selected = value && typeof value === 'object' ? value : {};
return ( return (
<fieldset> <fieldset>
<legend>{question.label}</legend> <legend className={legendClass}>{question.label}</legend>
<table className="survey-grid"> <table className="survey-grid">
<thead> <thead>
<tr> <tr>
@ -260,7 +266,7 @@ function QuestionField({ question, value, invalid = false, onChange }) {
<tr key={row.id}> <tr key={row.id}>
<th scope="row">{row.label}</th> <th scope="row">{row.label}</th>
{question.scale.map((point) => ( {question.scale.map((point) => (
<td key={point.id}> <td key={point.id} className="survey-grid-choice">
<span className="cell-label">{point.label}</span> <span className="cell-label">{point.label}</span>
<label className="survey-choice"> <label className="survey-choice">
<input <input
@ -356,6 +362,18 @@ export default function SurveyPage() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [screen, token]); }, [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(() => { useEffect(() => {
if (stepErrors.length > 0) { if (stepErrors.length > 0) {
stepAlertRef.current?.scrollIntoView?.({ block: 'nearest' }); stepAlertRef.current?.scrollIntoView?.({ block: 'nearest' });
@ -497,7 +515,7 @@ export default function SurveyPage() {
<span style={{ width: `${progress}%` }} /> <span style={{ width: `${progress}%` }} />
</div> </div>
{review ? ( {review ? (
<section> <section className="survey-review">
<h2>Review</h2> <h2>Review</h2>
{sections.map((item) => ( {sections.map((item) => (
<div key={item.id}> <div key={item.id}>
@ -528,15 +546,21 @@ export default function SurveyPage() {
))} ))}
</div> </div>
) : null} ) : null}
{section.questions.map((question) => ( <div className="survey-fields">
<QuestionField {section.questions.map((question, index) => (
key={question.id} <QuestionField
question={question} key={question.id}
invalid={stepErrors.some((item) => item.id === question.id)} question={question}
value={answers[question.id]} hideLabel={
onChange={(value) => updateAnswer(question.id, value)} 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> </section>
)} )}
{error ? ( {error ? (