Code updated added styles to public pages as well as adminpanel pages.
This commit is contained in:
parent
a95d7710c3
commit
5246560f1f
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue