refactor(frontend): consolidate shared styles into centralized style system

- Create comprehensive shared.ts with design tokens and categorized styles:
  - layoutStyles: main, loader, content variants
  - cardStyles: card, tableCard, cardHeader
  - tableStyles: complete table styling
  - paginationStyles: pagination controls
  - formStyles: inputs, labels, errors
  - buttonStyles: primary, secondary, accent, danger variants
  - badgeStyles: status badges with color variants
  - bannerStyles: error/success banners
  - modalStyles: modal overlay and content
  - toastStyles: toast notifications
  - utilityStyles: divider, emptyState, etc.

- Refactor all page components to use shared styles:
  - page.tsx (counter)
  - audit/page.tsx
  - booking/page.tsx
  - appointments/page.tsx
  - profile/page.tsx
  - invites/page.tsx
  - admin/invites/page.tsx
  - admin/availability/page.tsx

- Reduce code duplication significantly (each page now has only
  truly page-specific styles)
- Maintain backwards compatibility with sharedStyles export
This commit is contained in:
counterweight 2025-12-21 23:45:47 +01:00
parent 4d9edd7fd4
commit 81cd34b0e7
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 1148 additions and 1173 deletions

View file

@ -3,7 +3,6 @@
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
@ -15,6 +14,13 @@ import {
formatTimeString,
isWeekend,
} from "../../utils/date";
import {
layoutStyles,
typographyStyles,
bannerStyles,
buttonStyles,
modalStyles,
} from "../../styles/shared";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking;
@ -217,8 +223,8 @@ export default function AdminAvailabilityPage() {
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -228,15 +234,15 @@ export default function AdminAvailabilityPage() {
}
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="admin-availability" />
<div style={styles.content}>
<div style={layoutStyles.contentScrollable}>
<div style={styles.pageContainer}>
<div style={styles.headerRow}>
<div>
<h1 style={styles.pageTitle}>Availability</h1>
<p style={styles.pageSubtitle}>
<h1 style={typographyStyles.pageTitle}>Availability</h1>
<p style={typographyStyles.pageSubtitle}>
Configure your available time slots for the next {maxAdvanceDays} days
</p>
</div>
@ -246,18 +252,18 @@ export default function AdminAvailabilityPage() {
<button
onClick={executeCopy}
disabled={copyTargets.size === 0 || isCopying}
style={styles.copyButton}
style={buttonStyles.accentButton}
>
{isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`}
</button>
<button onClick={cancelCopyMode} style={styles.cancelButton}>
<button onClick={cancelCopyMode} style={buttonStyles.secondaryButton}>
Cancel
</button>
</div>
)}
</div>
{error && !selectedDate && <div style={styles.errorBanner}>{error}</div>}
{error && !selectedDate && <div style={bannerStyles.errorBanner}>{error}</div>}
<div style={styles.calendar}>
{dates.map((date) => {
@ -322,11 +328,13 @@ export default function AdminAvailabilityPage() {
{/* Edit Modal */}
{selectedDate && (
<div style={styles.modalOverlay} onClick={closeModal}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.modalTitle}>Edit Availability - {formatDisplayDate(selectedDate)}</h2>
<div style={modalStyles.modalOverlay} onClick={closeModal}>
<div style={modalStyles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={modalStyles.modalTitle}>
Edit Availability - {formatDisplayDate(selectedDate)}
</h2>
{error && <div style={styles.modalError}>{error}</div>}
{error && <div style={modalStyles.modalError}>{error}</div>}
<div style={styles.slotsEditor}>
{editSlots.map((slot, index) => (
@ -368,15 +376,19 @@ export default function AdminAvailabilityPage() {
</button>
</div>
<div style={styles.modalActions}>
<div style={modalStyles.modalActions}>
<button onClick={clearAvailability} disabled={isSaving} style={styles.clearButton}>
Clear All
</button>
<div style={styles.modalActionsRight}>
<button onClick={closeModal} style={styles.cancelButton}>
<div style={modalStyles.modalActionsRight}>
<button onClick={closeModal} style={buttonStyles.secondaryButton}>
Cancel
</button>
<button onClick={saveAvailability} disabled={isSaving} style={styles.saveButton}>
<button
onClick={saveAvailability}
disabled={isSaving}
style={buttonStyles.accentButton}
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
@ -388,12 +400,8 @@ export default function AdminAvailabilityPage() {
);
}
const pageStyles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
pageContainer: {
maxWidth: "1200px",
margin: "0 auto",
@ -406,19 +414,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
flexWrap: "wrap",
gap: "1rem",
},
pageTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "2rem",
fontWeight: 400,
color: "#fff",
margin: 0,
},
pageSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
marginTop: "0.5rem",
fontSize: "0.95rem",
},
copyActions: {
display: "flex",
alignItems: "center",
@ -429,16 +424,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
fontSize: "0.85rem",
color: "rgba(255, 255, 255, 0.6)",
},
copyButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
padding: "0.5rem 1rem",
background: "rgba(99, 102, 241, 0.3)",
color: "#fff",
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: "8px",
cursor: "pointer",
},
calendar: {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
@ -506,44 +491,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
borderRadius: "4px",
color: "rgba(129, 140, 248, 0.9)",
},
modalOverlay: {
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
},
modal: {
background: "#1a1a3e",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "16px",
padding: "2rem",
width: "90%",
maxWidth: "400px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
modalTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "1.5rem",
fontWeight: 400,
color: "#fff",
margin: "0 0 1.5rem 0",
},
modalError: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
padding: "0.75rem",
background: "rgba(239, 68, 68, 0.15)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "8px",
color: "#f87171",
marginBottom: "1rem",
},
slotsEditor: {
display: "flex",
flexDirection: "column",
@ -593,16 +540,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
borderRadius: "6px",
cursor: "pointer",
},
modalActions: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
},
modalActionsRight: {
display: "flex",
gap: "0.75rem",
},
clearButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
@ -613,27 +550,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
borderRadius: "8px",
cursor: "pointer",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
padding: "0.6rem 1rem",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
cursor: "pointer",
},
saveButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
fontWeight: 500,
padding: "0.6rem 1.5rem",
background: "rgba(99, 102, 241, 0.3)",
color: "#fff",
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: "8px",
cursor: "pointer",
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -3,11 +3,20 @@
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import constants from "../../../../shared/constants.json";
import {
layoutStyles,
cardStyles,
tableStyles,
paginationStyles,
formStyles,
buttonStyles,
badgeStyles,
utilityStyles,
} from "../../styles/shared";
const { READY, SPENT, REVOKED } = constants.inviteStatuses;
@ -101,11 +110,11 @@ export default function AdminInvitesPage() {
const getStatusBadgeStyle = (status: string) => {
switch (status) {
case READY:
return styles.statusReady;
return badgeStyles.badgeReady;
case SPENT:
return styles.statusSpent;
return badgeStyles.badgeSuccess;
case REVOKED:
return styles.statusRevoked;
return badgeStyles.badgeError;
default:
return {};
}
@ -113,8 +122,8 @@ export default function AdminInvitesPage() {
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -124,21 +133,21 @@ export default function AdminInvitesPage() {
}
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="admin-invites" />
<div style={styles.content}>
<div style={layoutStyles.contentScrollable}>
<div style={styles.pageContainer}>
{/* Create Invite Section */}
<div style={styles.createCard}>
<h2 style={styles.createTitle}>Create Invite</h2>
<div style={styles.createForm}>
<div style={styles.inputGroup}>
<div style={formStyles.field}>
<label style={styles.inputLabel}>Godfather (user who can share this invite)</label>
<select
value={newGodfatherId}
onChange={(e) => setNewGodfatherId(e.target.value)}
style={styles.select}
style={{ ...formStyles.select, maxWidth: "400px" }}
>
<option value="">Select a user...</option>
{users.map((u) => (
@ -148,7 +157,7 @@ export default function AdminInvitesPage() {
))}
</select>
{users.length === 0 && (
<span style={styles.inputHint}>
<span style={formStyles.hint}>
No users loaded yet. Create at least one invite to populate the list.
</span>
)}
@ -158,8 +167,9 @@ export default function AdminInvitesPage() {
onClick={handleCreateInvite}
disabled={isCreating || !newGodfatherId}
style={{
...styles.createButton,
...(!newGodfatherId ? styles.createButtonDisabled : {}),
...buttonStyles.accentButton,
alignSelf: "flex-start",
...(!newGodfatherId ? buttonStyles.buttonDisabled : {}),
}}
>
{isCreating ? "Creating..." : "Create Invite"}
@ -168,10 +178,10 @@ export default function AdminInvitesPage() {
</div>
{/* Invites Table */}
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>All Invites</h2>
<div style={styles.filterGroup}>
<div style={cardStyles.tableCard}>
<div style={tableStyles.tableHeader}>
<h2 style={tableStyles.tableTitle}>All Invites</h2>
<div style={utilityStyles.filterGroup}>
<select
value={statusFilter}
onChange={(e) => {
@ -185,49 +195,49 @@ export default function AdminInvitesPage() {
<option value={SPENT}>Spent</option>
<option value={REVOKED}>Revoked</option>
</select>
<span style={styles.totalCount}>{data?.total ?? 0} invites</span>
<span style={tableStyles.totalCount}>{data?.total ?? 0} invites</span>
</div>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<div style={tableStyles.tableWrapper}>
<table style={tableStyles.table}>
<thead>
<tr>
<th style={styles.th}>Code</th>
<th style={styles.th}>Godfather</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Used By</th>
<th style={styles.th}>Created</th>
<th style={styles.th}>Actions</th>
<th style={tableStyles.th}>Code</th>
<th style={tableStyles.th}>Godfather</th>
<th style={tableStyles.th}>Status</th>
<th style={tableStyles.th}>Used By</th>
<th style={tableStyles.th}>Created</th>
<th style={tableStyles.th}>Actions</th>
</tr>
</thead>
<tbody>
{error && (
<tr>
<td colSpan={6} style={styles.errorRow}>
<td colSpan={6} style={tableStyles.errorRow}>
{error}
</td>
</tr>
)}
{!error &&
data?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.tdCode}>{record.identifier}</td>
<td style={styles.td}>{record.godfather_email}</td>
<td style={styles.td}>
<tr key={record.id} style={tableStyles.tr}>
<td style={tableStyles.tdMono}>{record.identifier}</td>
<td style={tableStyles.td}>{record.godfather_email}</td>
<td style={tableStyles.td}>
<span
style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}
style={{ ...badgeStyles.badge, ...getStatusBadgeStyle(record.status) }}
>
{record.status}
</span>
</td>
<td style={styles.td}>{record.used_by_email || "-"}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<td style={styles.td}>
<td style={tableStyles.td}>{record.used_by_email || "-"}</td>
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
<td style={tableStyles.td}>
{record.status === READY && (
<button
onClick={() => handleRevoke(record.id)}
style={styles.revokeButton}
style={buttonStyles.dangerButton}
>
Revoke
</button>
@ -237,7 +247,7 @@ export default function AdminInvitesPage() {
))}
{!error && (!data || data.records.length === 0) && (
<tr>
<td colSpan={6} style={styles.emptyRow}>
<td colSpan={6} style={tableStyles.emptyRow}>
No invites yet
</td>
</tr>
@ -247,21 +257,21 @@ export default function AdminInvitesPage() {
</div>
{data && data.total_pages > 1 && (
<div style={styles.pagination}>
<div style={paginationStyles.pagination}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={styles.pageBtn}
style={paginationStyles.pageBtn}
>
</button>
<span style={styles.pageInfo}>
<span style={paginationStyles.pageInfo}>
{page} / {data.total_pages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.total_pages, p + 1))}
disabled={page === data.total_pages}
style={styles.pageBtn}
style={paginationStyles.pageBtn}
>
</button>
@ -274,12 +284,8 @@ export default function AdminInvitesPage() {
);
}
const pageStyles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
pageContainer: {
display: "flex",
flexDirection: "column",
@ -305,82 +311,16 @@ const pageStyles: Record<string, React.CSSProperties> = {
flexDirection: "column",
gap: "1rem",
},
inputGroup: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
inputLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.5)",
},
select: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
padding: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
maxWidth: "400px",
cursor: "pointer",
},
createError: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
color: "#f87171",
},
createButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.9rem",
fontWeight: 500,
padding: "0.75rem 1.5rem",
background: "rgba(99, 102, 241, 0.3)",
color: "#fff",
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: "8px",
cursor: "pointer",
alignSelf: "flex-start",
},
createButtonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
inputHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
fontStyle: "italic",
},
tableCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "20px",
padding: "1.5rem",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
tableHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
flexWrap: "wrap",
gap: "1rem",
},
tableTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "1.5rem",
fontWeight: 400,
color: "#fff",
margin: 0,
},
filterGroup: {
display: "flex",
alignItems: "center",
gap: "1rem",
},
filterSelect: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem",
@ -391,115 +331,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
color: "#fff",
cursor: "pointer",
},
totalCount: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.4)",
},
tableWrapper: {
overflowX: "auto",
},
table: {
width: "100%",
borderCollapse: "collapse",
fontFamily: "'DM Sans', system-ui, sans-serif",
},
th: {
textAlign: "left",
padding: "0.75rem 1rem",
fontSize: "0.75rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.4)",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
},
tr: {
borderBottom: "1px solid rgba(255, 255, 255, 0.04)",
},
td: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.7)",
},
tdCode: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "#fff",
fontFamily: "'DM Mono', monospace",
},
tdDate: {
padding: "0.875rem 1rem",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
},
statusBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
borderRadius: "4px",
textTransform: "uppercase",
},
statusReady: {
background: "rgba(99, 102, 241, 0.2)",
color: "rgba(129, 140, 248, 0.9)",
},
statusSpent: {
background: "rgba(34, 197, 94, 0.2)",
color: "rgba(34, 197, 94, 0.9)",
},
statusRevoked: {
background: "rgba(239, 68, 68, 0.2)",
color: "rgba(239, 68, 68, 0.9)",
},
revokeButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
padding: "0.4rem 0.75rem",
background: "rgba(239, 68, 68, 0.15)",
color: "rgba(239, 68, 68, 0.9)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
cursor: "pointer",
},
emptyRow: {
padding: "2rem 1rem",
textAlign: "center",
color: "rgba(255, 255, 255, 0.3)",
fontSize: "0.875rem",
},
errorRow: {
padding: "2rem 1rem",
textAlign: "center",
color: "#f87171",
fontSize: "0.875rem",
},
pagination: {
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "1rem",
marginTop: "1rem",
paddingTop: "1rem",
borderTop: "1px solid rgba(255, 255, 255, 0.06)",
},
pageBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.5rem 1rem",
fontSize: "1rem",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
},
pageInfo: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
},
};
const styles = { ...sharedStyles, ...pageStyles };