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 };

View file

@ -9,145 +9,16 @@ import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
import { formatDateTime } from "../utils/date";
import { getStatusDisplay } from "../utils/appointment";
import { sharedStyles } from "../styles/shared";
import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
} from "../styles/shared";
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
const pageStyles: Record<string, React.CSSProperties> = {
main: {
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
display: "flex",
flexDirection: "column",
},
loader: {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
},
content: {
flex: 1,
padding: "2rem",
maxWidth: "800px",
margin: "0 auto",
width: "100%",
},
pageTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.75rem",
fontWeight: 600,
color: "#fff",
marginBottom: "0.5rem",
},
pageSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.9rem",
marginBottom: "1.5rem",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
sectionTitleMuted: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "rgba(255, 255, 255, 0.5)",
marginBottom: "1rem",
},
appointmentList: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
appointmentCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.25rem",
transition: "all 0.2s",
},
appointmentCardPast: {
opacity: 0.6,
background: "rgba(255, 255, 255, 0.01)",
},
appointmentHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "1rem",
},
appointmentTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "0.25rem",
},
appointmentNote: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
marginBottom: "0.5rem",
},
statusBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.75rem",
borderRadius: "9999px",
display: "inline-block",
},
buttonGroup: {
display: "flex",
gap: "0.5rem",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "6px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
confirmButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(239, 68, 68, 0.2)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
color: "#f87171",
cursor: "pointer",
transition: "all 0.2s",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "center",
padding: "3rem",
},
emptyStateLink: {
color: "#a78bfa",
textDecoration: "none",
},
};
const styles = { ...sharedStyles, ...pageStyles };
export default function AppointmentsPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
@ -195,8 +66,8 @@ export default function AppointmentsPage() {
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -213,13 +84,13 @@ export default function AppointmentsPage() {
);
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="appointments" />
<div style={styles.content}>
<h1 style={styles.pageTitle}>My Appointments</h1>
<p style={styles.pageSubtitle}>View and manage your booked appointments</p>
<h1 style={typographyStyles.pageTitle}>My Appointments</h1>
<p style={typographyStyles.pageSubtitle}>View and manage your booked appointments</p>
{error && <div style={styles.errorBanner}>{error}</div>}
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{isLoadingAppointments ? (
<div style={styles.emptyState}>Loading appointments...</div>
@ -249,7 +120,7 @@ export default function AppointmentsPage() {
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span
style={{
...styles.statusBadge,
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
@ -271,7 +142,7 @@ export default function AppointmentsPage() {
</button>
<button
onClick={() => setConfirmCancelId(null)}
style={styles.cancelButton}
style={buttonStyles.secondaryButton}
>
No
</button>
@ -279,7 +150,7 @@ export default function AppointmentsPage() {
) : (
<button
onClick={() => setConfirmCancelId(apt.id)}
style={styles.cancelButton}
style={buttonStyles.secondaryButton}
>
Cancel
</button>
@ -297,7 +168,7 @@ export default function AppointmentsPage() {
{/* Past/Cancelled Appointments */}
{pastOrCancelledAppointments.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitleMuted}>
<h2 style={typographyStyles.sectionTitleMuted}>
Past & Cancelled ({pastOrCancelledAppointments.length})
</h2>
<div style={styles.appointmentList}>
@ -312,7 +183,7 @@ export default function AppointmentsPage() {
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span
style={{
...styles.statusBadge,
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
}}
@ -331,3 +202,84 @@ export default function AppointmentsPage() {
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
maxWidth: "800px",
margin: "0 auto",
width: "100%",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
appointmentList: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
},
appointmentCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.25rem",
transition: "all 0.2s",
},
appointmentCardPast: {
opacity: 0.6,
background: "rgba(255, 255, 255, 0.01)",
},
appointmentHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "1rem",
},
appointmentTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "0.25rem",
},
appointmentNote: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.5)",
marginBottom: "0.5rem",
},
buttonGroup: {
display: "flex",
gap: "0.5rem",
},
confirmButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.35rem 0.75rem",
fontSize: "0.75rem",
background: "rgba(239, 68, 68, 0.2)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "6px",
color: "#f87171",
cursor: "pointer",
transition: "all 0.2s",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "center",
padding: "3rem",
},
emptyStateLink: {
color: "#a78bfa",
textDecoration: "none",
},
};

View file

@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { layoutStyles, cardStyles, tableStyles, paginationStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
@ -68,8 +68,8 @@ export default function AuditPage() {
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -79,47 +79,47 @@ export default function AuditPage() {
}
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="audit" />
<div style={styles.content}>
<div style={layoutStyles.contentScrollable}>
<div style={styles.tablesContainer}>
{/* Counter Records Table */}
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Counter Activity</h2>
<span style={styles.totalCount}>{counterData?.total ?? 0} records</span>
<div style={cardStyles.tableCard}>
<div style={tableStyles.tableHeader}>
<h2 style={tableStyles.tableTitle}>Counter Activity</h2>
<span style={tableStyles.totalCount}>{counterData?.total ?? 0} records</span>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<div style={tableStyles.tableWrapper}>
<table style={tableStyles.table}>
<thead>
<tr>
<th style={styles.th}>User</th>
<th style={styles.th}>Before</th>
<th style={styles.th}>After</th>
<th style={styles.th}>Date</th>
<th style={tableStyles.th}>User</th>
<th style={tableStyles.th}>Before</th>
<th style={tableStyles.th}>After</th>
<th style={tableStyles.th}>Date</th>
</tr>
</thead>
<tbody>
{counterError && (
<tr>
<td colSpan={4} style={styles.errorRow}>
<td colSpan={4} style={tableStyles.errorRow}>
{counterError}
</td>
</tr>
)}
{!counterError &&
counterData?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.value_before}</td>
<td style={styles.tdNum}>{record.value_after}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<tr key={record.id} style={tableStyles.tr}>
<td style={tableStyles.td}>{record.user_email}</td>
<td style={tableStyles.tdNum}>{record.value_before}</td>
<td style={tableStyles.tdNum}>{record.value_after}</td>
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!counterError && (!counterData || counterData.records.length === 0) && (
<tr>
<td colSpan={4} style={styles.emptyRow}>
<td colSpan={4} style={tableStyles.emptyRow}>
No records yet
</td>
</tr>
@ -128,21 +128,21 @@ export default function AuditPage() {
</table>
</div>
{counterData && counterData.total_pages > 1 && (
<div style={styles.pagination}>
<div style={paginationStyles.pagination}>
<button
onClick={() => setCounterPage((p) => Math.max(1, p - 1))}
disabled={counterPage === 1}
style={styles.pageBtn}
style={paginationStyles.pageBtn}
>
</button>
<span style={styles.pageInfo}>
<span style={paginationStyles.pageInfo}>
{counterPage} / {counterData.total_pages}
</span>
<button
onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))}
disabled={counterPage === counterData.total_pages}
style={styles.pageBtn}
style={paginationStyles.pageBtn}
>
</button>
@ -151,43 +151,43 @@ export default function AuditPage() {
</div>
{/* Sum Records Table */}
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Sum Activity</h2>
<span style={styles.totalCount}>{sumData?.total ?? 0} records</span>
<div style={cardStyles.tableCard}>
<div style={tableStyles.tableHeader}>
<h2 style={tableStyles.tableTitle}>Sum Activity</h2>
<span style={tableStyles.totalCount}>{sumData?.total ?? 0} records</span>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<div style={tableStyles.tableWrapper}>
<table style={tableStyles.table}>
<thead>
<tr>
<th style={styles.th}>User</th>
<th style={styles.th}>A</th>
<th style={styles.th}>B</th>
<th style={styles.th}>Result</th>
<th style={styles.th}>Date</th>
<th style={tableStyles.th}>User</th>
<th style={tableStyles.th}>A</th>
<th style={tableStyles.th}>B</th>
<th style={tableStyles.th}>Result</th>
<th style={tableStyles.th}>Date</th>
</tr>
</thead>
<tbody>
{sumError && (
<tr>
<td colSpan={5} style={styles.errorRow}>
<td colSpan={5} style={tableStyles.errorRow}>
{sumError}
</td>
</tr>
)}
{!sumError &&
sumData?.records.map((record) => (
<tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.a}</td>
<td style={styles.tdNum}>{record.b}</td>
<tr key={record.id} style={tableStyles.tr}>
<td style={tableStyles.td}>{record.user_email}</td>
<td style={tableStyles.tdNum}>{record.a}</td>
<td style={tableStyles.tdNum}>{record.b}</td>
<td style={styles.tdResult}>{record.result}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!sumError && (!sumData || sumData.records.length === 0) && (
<tr>
<td colSpan={5} style={styles.emptyRow}>
<td colSpan={5} style={tableStyles.emptyRow}>
No records yet
</td>
</tr>
@ -196,21 +196,21 @@ export default function AuditPage() {
</table>
</div>
{sumData && sumData.total_pages > 1 && (
<div style={styles.pagination}>
<div style={paginationStyles.pagination}>
<button
onClick={() => setSumPage((p) => Math.max(1, p - 1))}
disabled={sumPage === 1}
style={styles.pageBtn}
style={paginationStyles.pageBtn}
>
</button>
<span style={styles.pageInfo}>
<span style={paginationStyles.pageInfo}>
{sumPage} / {sumData.total_pages}
</span>
<button
onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))}
disabled={sumPage === sumData.total_pages}
style={styles.pageBtn}
style={paginationStyles.pageBtn}
>
</button>
@ -223,13 +223,8 @@ export default function AuditPage() {
);
}
const pageStyles: Record<string, React.CSSProperties> = {
// Override content for audit-specific layout
content: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
// Page-specific styles only
const styles: Record<string, React.CSSProperties> = {
tablesContainer: {
display: "flex",
flexDirection: "column",
@ -237,64 +232,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
maxWidth: "1200px",
margin: "0 auto",
},
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",
},
tableTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "1.5rem",
fontWeight: 400,
color: "#fff",
margin: 0,
},
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)",
},
tdNum: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.9)",
fontFamily: "'DM Sans', monospace",
},
tdResult: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
@ -302,48 +239,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
fontWeight: 600,
fontFamily: "'DM Sans', monospace",
},
tdDate: {
padding: "0.875rem 1rem",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
},
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 };

View file

@ -9,7 +9,13 @@ import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
import constants from "../../../shared/constants.json";
import { formatDate, formatTime, getDateRange } from "../utils/date";
import { sharedStyles } from "../styles/shared";
import {
layoutStyles,
typographyStyles,
bannerStyles,
formStyles,
buttonStyles,
} from "../styles/shared";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
@ -17,205 +23,6 @@ type BookableSlot = components["schemas"]["BookableSlot"];
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
const pageStyles: Record<string, React.CSSProperties> = {
main: {
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
display: "flex",
flexDirection: "column",
},
loader: {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
},
content: {
flex: 1,
padding: "2rem",
maxWidth: "900px",
margin: "0 auto",
width: "100%",
},
pageTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.75rem",
fontWeight: 600,
color: "#fff",
marginBottom: "0.5rem",
},
pageSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.9rem",
marginBottom: "1.5rem",
},
successBanner: {
background: "rgba(34, 197, 94, 0.15)",
border: "1px solid rgba(34, 197, 94, 0.3)",
color: "#4ade80",
padding: "1rem",
borderRadius: "8px",
marginBottom: "1rem",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
dateGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
dateButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "10px",
cursor: "pointer",
minWidth: "90px",
textAlign: "center" as const,
transition: "all 0.2s",
},
dateButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
},
dateButtonDisabled: {
opacity: 0.4,
cursor: "not-allowed",
background: "rgba(255, 255, 255, 0.01)",
border: "1px solid rgba(255, 255, 255, 0.04)",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
fontSize: "0.875rem",
marginBottom: "0.25rem",
},
dateDay: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
slotGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
slotButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.6rem 1.25rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
fontSize: "0.9rem",
transition: "all 0.2s",
},
slotButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
padding: "1rem 0",
},
confirmCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
maxWidth: "400px",
},
confirmTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
confirmTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
marginBottom: "1rem",
},
inputLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
display: "block",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.875rem",
marginBottom: "0.5rem",
},
textarea: {
fontFamily: "'DM Sans', system-ui, sans-serif",
width: "100%",
padding: "0.75rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "#fff",
fontSize: "0.875rem",
minHeight: "80px",
resize: "vertical" as const,
},
charCount: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
textAlign: "right" as const,
marginTop: "0.25rem",
},
charCountWarning: {
color: "#f87171",
},
buttonRow: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
bookButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
flex: 1,
padding: "0.75rem",
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.2s",
},
bookButtonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1.25rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
};
const styles = { ...sharedStyles, ...pageStyles };
export default function BookingPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.BOOK_APPOINTMENT,
@ -345,8 +152,8 @@ export default function BookingPage() {
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -356,17 +163,17 @@ export default function BookingPage() {
}
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="booking" />
<div style={styles.content}>
<h1 style={styles.pageTitle}>Book an Appointment</h1>
<p style={styles.pageSubtitle}>
<h1 style={typographyStyles.pageTitle}>Book an Appointment</h1>
<p style={typographyStyles.pageSubtitle}>
Select a date to see available {slotDurationMinutes}-minute slots
</p>
{successMessage && <div style={styles.successBanner}>{successMessage}</div>}
{successMessage && <div style={bannerStyles.successBanner}>{successMessage}</div>}
{error && <div style={styles.errorBanner}>{error}</div>}
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{/* Date Selection */}
<div style={styles.section}>
@ -449,17 +256,17 @@ export default function BookingPage() {
</p>
<div>
<label style={styles.inputLabel}>Note (optional, max {noteMaxLength} chars)</label>
<label style={formStyles.label}>Note (optional, max {noteMaxLength} chars)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
placeholder="Add a note about your appointment..."
style={styles.textarea}
style={formStyles.textarea}
/>
<div
style={{
...styles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
...formStyles.charCount,
...(note.length >= noteMaxLength ? formStyles.charCountWarning : {}),
}}
>
{note.length}/{noteMaxLength}
@ -472,7 +279,7 @@ export default function BookingPage() {
disabled={isBooking}
style={{
...styles.bookButton,
...(isBooking ? styles.bookButtonDisabled : {}),
...(isBooking ? buttonStyles.buttonDisabled : {}),
}}
>
{isBooking ? "Booking..." : "Book Appointment"}
@ -491,3 +298,131 @@ export default function BookingPage() {
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
maxWidth: "900px",
margin: "0 auto",
width: "100%",
},
section: {
marginBottom: "2rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
dateGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
dateButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "10px",
cursor: "pointer",
minWidth: "90px",
textAlign: "center" as const,
transition: "all 0.2s",
},
dateButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
},
dateButtonDisabled: {
opacity: 0.4,
cursor: "not-allowed",
background: "rgba(255, 255, 255, 0.01)",
border: "1px solid rgba(255, 255, 255, 0.04)",
},
dateWeekday: {
color: "#fff",
fontWeight: 500,
fontSize: "0.875rem",
marginBottom: "0.25rem",
},
dateDay: {
color: "rgba(255, 255, 255, 0.5)",
fontSize: "0.8rem",
},
slotGrid: {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
},
slotButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.6rem 1.25rem",
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "8px",
color: "#fff",
cursor: "pointer",
fontSize: "0.9rem",
transition: "all 0.2s",
},
slotButtonSelected: {
background: "rgba(167, 139, 250, 0.15)",
border: "1px solid #a78bfa",
},
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
padding: "1rem 0",
},
confirmCard: {
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "12px",
padding: "1.5rem",
maxWidth: "400px",
},
confirmTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.1rem",
fontWeight: 500,
color: "#fff",
marginBottom: "1rem",
},
confirmTime: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
marginBottom: "1rem",
},
buttonRow: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
bookButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
flex: 1,
padding: "0.75rem",
background: "linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%)",
border: "none",
borderRadius: "8px",
color: "#fff",
fontWeight: 500,
cursor: "pointer",
transition: "all 0.2s",
},
cancelButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.75rem 1.25rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
transition: "all 0.2s",
},
};

View file

@ -2,11 +2,17 @@
import { useEffect, useState, useCallback } from "react";
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,
typographyStyles,
badgeStyles,
buttonStyles,
} from "../styles/shared";
// Use generated type from OpenAPI schema
type Invite = components["schemas"]["UserInviteResponse"];
@ -57,8 +63,8 @@ export default function InvitesPage() {
if (isLoading || isLoadingInvites) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -73,14 +79,16 @@ export default function InvitesPage() {
const revokedInvites = invites.filter((i) => i.status === REVOKED);
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="invites" />
<div style={styles.content}>
<div style={layoutStyles.contentCentered}>
<div style={styles.pageCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Invites</h1>
<p style={styles.cardSubtitle}>Share your invite codes with friends to let them join</p>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Invites</h1>
<p style={cardStyles.cardSubtitle}>
Share your invite codes with friends to let them join
</p>
</div>
{invites.length === 0 ? (
@ -93,14 +101,19 @@ export default function InvitesPage() {
{/* Ready Invites */}
{readyInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Available ({readyInvites.length})</h2>
<p style={styles.sectionHint}>Share these links with people you want to invite</p>
<h2 style={typographyStyles.sectionTitle}>Available ({readyInvites.length})</h2>
<p style={typographyStyles.sectionHint}>
Share these links with people you want to invite
</p>
<div style={styles.inviteList}>
{readyInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCard}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteActions}>
<button onClick={() => copyToClipboard(invite)} style={styles.copyButton}>
<button
onClick={() => copyToClipboard(invite)}
style={buttonStyles.accentButton}
>
{copiedId === invite.id ? "Copied!" : "Copy Link"}
</button>
</div>
@ -113,13 +126,15 @@ export default function InvitesPage() {
{/* Spent Invites */}
{spentInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Used ({spentInvites.length})</h2>
<h2 style={typographyStyles.sectionTitle}>Used ({spentInvites.length})</h2>
<div style={styles.inviteList}>
{spentInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}>
<span style={styles.statusBadgeSpent}>Used</span>
<span style={{ ...badgeStyles.badge, ...badgeStyles.badgeSuccess }}>
Used
</span>
<span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
</div>
</div>
@ -131,12 +146,14 @@ export default function InvitesPage() {
{/* Revoked Invites */}
{revokedInvites.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
<h2 style={typographyStyles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
<div style={styles.inviteList}>
{revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}>
<div style={styles.inviteCode}>{invite.identifier}</div>
<span style={styles.statusBadgeRevoked}>Revoked</span>
<span style={{ ...badgeStyles.badge, ...badgeStyles.badgeError }}>
Revoked
</span>
</div>
))}
</div>
@ -150,33 +167,12 @@ export default function InvitesPage() {
);
}
const pageStyles: Record<string, React.CSSProperties> = {
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
pageCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
padding: "2.5rem",
...cardStyles.card,
width: "100%",
maxWidth: "600px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
cardHeader: {
marginBottom: "2rem",
},
cardTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "2rem",
fontWeight: 400,
color: "#fff",
margin: 0,
letterSpacing: "-0.02em",
},
cardSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
marginTop: "0.5rem",
fontSize: "0.95rem",
},
emptyState: {
textAlign: "center",
@ -204,21 +200,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
flexDirection: "column",
gap: "0.75rem",
},
sectionTitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.8)",
margin: 0,
textTransform: "uppercase",
letterSpacing: "0.05em",
},
sectionHint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.4)",
margin: 0,
},
inviteList: {
display: "flex",
flexDirection: "column",
@ -262,48 +243,14 @@ const pageStyles: Record<string, React.CSSProperties> = {
display: "flex",
gap: "0.5rem",
},
copyButton: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
fontWeight: 500,
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",
transition: "all 0.2s",
},
inviteeMeta: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
statusBadgeSpent: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
background: "rgba(34, 197, 94, 0.2)",
color: "rgba(34, 197, 94, 0.9)",
borderRadius: "4px",
textTransform: "uppercase",
},
statusBadgeRevoked: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
background: "rgba(239, 68, 68, 0.2)",
color: "rgba(239, 68, 68, 0.9)",
borderRadius: "4px",
textTransform: "uppercase",
},
inviteeEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.6)",
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { Permission } from "./auth-context";
import { api } from "./api";
import { sharedStyles } from "./styles/shared";
import { layoutStyles, cardStyles, buttonStyles } from "./styles/shared";
import { Header } from "./components/Header";
import { useRequireAuth } from "./hooks/useRequireAuth";
@ -29,8 +29,8 @@ export default function Home() {
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -40,10 +40,10 @@ export default function Home() {
}
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
<Header currentPage="counter" />
<div style={styles.content}>
<div style={layoutStyles.contentCentered}>
<div style={styles.counterCard}>
<span style={styles.counterLabel}>Current Count</span>
<h1 style={styles.counter}>{count === null ? "..." : count}</h1>
@ -57,15 +57,13 @@ export default function Home() {
);
}
const pageStyles: Record<string, React.CSSProperties> = {
// Page-specific styles only - truly unique to this page
const styles: Record<string, React.CSSProperties> = {
counterCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
...cardStyles.card,
borderRadius: "32px",
padding: "4rem 5rem",
textAlign: "center",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
counterLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
@ -89,26 +87,17 @@ const pageStyles: Record<string, React.CSSProperties> = {
backgroundClip: "text",
},
incrementBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif",
...buttonStyles.primaryButton,
marginTop: "2.5rem",
padding: "1rem 2.5rem",
fontSize: "1.125rem",
fontWeight: 600,
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
color: "#fff",
border: "none",
borderRadius: "16px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
transition: "transform 0.2s, box-shadow 0.2s",
boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)",
},
plusIcon: {
fontSize: "1.5rem",
fontWeight: 400,
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -3,11 +3,18 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { bech32 } from "bech32";
import { api, ApiError } 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,
formStyles,
buttonStyles,
toastStyles,
utilityStyles,
} from "../styles/shared";
// Use generated type from OpenAPI schema
type ProfileData = components["schemas"]["ProfileResponse"];
@ -253,8 +260,8 @@ export default function ProfilePage() {
if (isLoading || isLoadingProfile) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
@ -266,13 +273,13 @@ export default function ProfilePage() {
const canSubmit = hasChanges() && isValid() && !isSubmitting;
return (
<main style={styles.main}>
<main style={layoutStyles.main}>
{/* Toast notification */}
{toast && (
<div
style={{
...styles.toast,
...(toast.type === "success" ? styles.toastSuccess : styles.toastError),
...toastStyles.toast,
...(toast.type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
}}
>
{toast.message}
@ -281,44 +288,46 @@ export default function ProfilePage() {
<Header currentPage="profile" />
<div style={styles.content}>
<div style={layoutStyles.contentCentered}>
<div style={styles.profileCard}>
<div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Profile</h1>
<p style={styles.cardSubtitle}>Manage your contact information</p>
<div style={cardStyles.cardHeader}>
<h1 style={cardStyles.cardTitle}>My Profile</h1>
<p style={cardStyles.cardSubtitle}>Manage your contact information</p>
</div>
<form onSubmit={handleSubmit} style={styles.form}>
<form onSubmit={handleSubmit} style={formStyles.form}>
{/* Login email - read only */}
<div style={styles.field}>
<label style={styles.label}>
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Login Email
<span style={styles.readOnlyBadge}>Read only</span>
<span style={utilityStyles.readOnlyBadge}>Read only</span>
</label>
<input
type="email"
value={user.email}
style={{ ...styles.input, ...styles.inputReadOnly }}
style={{ ...formStyles.input, ...formStyles.inputReadOnly }}
disabled
/>
<span style={styles.hint}>This is your login email and cannot be changed here.</span>
<span style={formStyles.hint}>
This is your login email and cannot be changed here.
</span>
</div>
{/* Godfather - shown if user was invited */}
{godfatherEmail && (
<div style={styles.field}>
<label style={styles.label}>
<div style={formStyles.field}>
<label style={styles.labelWithBadge}>
Invited By
<span style={styles.readOnlyBadge}>Read only</span>
<span style={utilityStyles.readOnlyBadge}>Read only</span>
</label>
<div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div>
<span style={styles.hint}>The user who invited you to join.</span>
<span style={formStyles.hint}>The user who invited you to join.</span>
</div>
)}
<div style={styles.divider} />
<div style={utilityStyles.divider} />
<p style={styles.sectionLabel}>Contact Details</p>
<p style={styles.sectionHint}>
@ -326,8 +335,8 @@ export default function ProfilePage() {
</p>
{/* Contact email */}
<div style={styles.field}>
<label htmlFor="contact_email" style={styles.label}>
<div style={formStyles.field}>
<label htmlFor="contact_email" style={formStyles.label}>
Contact Email
</label>
<input
@ -336,17 +345,19 @@ export default function ProfilePage() {
value={formData.contact_email}
onChange={handleInputChange("contact_email")}
style={{
...styles.input,
...(errors.contact_email ? styles.inputError : {}),
...formStyles.input,
...(errors.contact_email ? formStyles.inputError : {}),
}}
placeholder="alternate@example.com"
/>
{errors.contact_email && <span style={styles.errorText}>{errors.contact_email}</span>}
{errors.contact_email && (
<span style={formStyles.errorText}>{errors.contact_email}</span>
)}
</div>
{/* Telegram */}
<div style={styles.field}>
<label htmlFor="telegram" style={styles.label}>
<div style={formStyles.field}>
<label htmlFor="telegram" style={formStyles.label}>
Telegram
</label>
<input
@ -355,17 +366,17 @@ export default function ProfilePage() {
value={formData.telegram}
onChange={handleInputChange("telegram")}
style={{
...styles.input,
...(errors.telegram ? styles.inputError : {}),
...formStyles.input,
...(errors.telegram ? formStyles.inputError : {}),
}}
placeholder="@username"
/>
{errors.telegram && <span style={styles.errorText}>{errors.telegram}</span>}
{errors.telegram && <span style={formStyles.errorText}>{errors.telegram}</span>}
</div>
{/* Signal */}
<div style={styles.field}>
<label htmlFor="signal" style={styles.label}>
<div style={formStyles.field}>
<label htmlFor="signal" style={formStyles.label}>
Signal
</label>
<input
@ -374,17 +385,17 @@ export default function ProfilePage() {
value={formData.signal}
onChange={handleInputChange("signal")}
style={{
...styles.input,
...(errors.signal ? styles.inputError : {}),
...formStyles.input,
...(errors.signal ? formStyles.inputError : {}),
}}
placeholder="username.01"
/>
{errors.signal && <span style={styles.errorText}>{errors.signal}</span>}
{errors.signal && <span style={formStyles.errorText}>{errors.signal}</span>}
</div>
{/* Nostr npub */}
<div style={styles.field}>
<label htmlFor="nostr_npub" style={styles.label}>
<div style={formStyles.field}>
<label htmlFor="nostr_npub" style={formStyles.label}>
Nostr (npub)
</label>
<input
@ -393,19 +404,20 @@ export default function ProfilePage() {
value={formData.nostr_npub}
onChange={handleInputChange("nostr_npub")}
style={{
...styles.input,
...(errors.nostr_npub ? styles.inputError : {}),
...formStyles.input,
...(errors.nostr_npub ? formStyles.inputError : {}),
}}
placeholder="npub1..."
/>
{errors.nostr_npub && <span style={styles.errorText}>{errors.nostr_npub}</span>}
{errors.nostr_npub && <span style={formStyles.errorText}>{errors.nostr_npub}</span>}
</div>
<button
type="submit"
style={{
...styles.button,
...(!canSubmit ? styles.buttonDisabled : {}),
...buttonStyles.primaryButton,
marginTop: "1rem",
...(!canSubmit ? buttonStyles.buttonDisabled : {}),
}}
disabled={!canSubmit}
>
@ -418,45 +430,14 @@ export default function ProfilePage() {
);
}
const pageStyles: Record<string, React.CSSProperties> = {
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
profileCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
padding: "2.5rem",
...cardStyles.card,
width: "100%",
maxWidth: "480px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
},
cardHeader: {
marginBottom: "2rem",
},
cardTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "2rem",
fontWeight: 400,
color: "#fff",
margin: 0,
letterSpacing: "-0.02em",
},
cardSubtitle: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
marginTop: "0.5rem",
fontSize: "0.95rem",
},
form: {
display: "flex",
flexDirection: "column",
gap: "1.25rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
label: {
labelWithBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.875rem",
@ -465,32 +446,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
alignItems: "center",
gap: "0.5rem",
},
readOnlyBadge: {
fontSize: "0.7rem",
fontWeight: 500,
color: "rgba(255, 255, 255, 0.4)",
background: "rgba(255, 255, 255, 0.08)",
padding: "0.15rem 0.5rem",
borderRadius: "4px",
textTransform: "uppercase",
letterSpacing: "0.05em",
},
input: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "0.875rem 1rem",
fontSize: "1rem",
background: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "12px",
color: "#fff",
outline: "none",
transition: "border-color 0.2s, box-shadow 0.2s",
},
inputReadOnly: {
background: "rgba(255, 255, 255, 0.02)",
color: "rgba(255, 255, 255, 0.5)",
cursor: "not-allowed",
},
godfatherBox: {
padding: "0.875rem 1rem",
background: "rgba(99, 102, 241, 0.08)",
@ -502,26 +457,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
fontSize: "1rem",
color: "rgba(129, 140, 248, 0.9)",
},
inputError: {
border: "1px solid rgba(239, 68, 68, 0.5)",
boxShadow: "0 0 0 2px rgba(239, 68, 68, 0.1)",
},
hint: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
fontStyle: "italic",
},
errorText: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.75rem",
color: "#fca5a5",
},
divider: {
height: "1px",
background: "rgba(255, 255, 255, 0.08)",
margin: "0.75rem 0",
},
sectionLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
@ -538,46 +473,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
margin: 0,
marginBottom: "0.5rem",
},
button: {
fontFamily: "'DM Sans', system-ui, sans-serif",
marginTop: "1rem",
padding: "1rem",
fontSize: "1rem",
fontWeight: 600,
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
color: "#fff",
border: "none",
borderRadius: "12px",
cursor: "pointer",
transition: "transform 0.2s, box-shadow 0.2s",
boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)",
},
buttonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
boxShadow: "none",
},
toast: {
position: "fixed",
top: "1.5rem",
right: "1.5rem",
padding: "1rem 1.5rem",
borderRadius: "12px",
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
fontWeight: 500,
zIndex: 1000,
animation: "slideIn 0.3s ease-out",
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.3)",
},
toastSuccess: {
background: "rgba(34, 197, 94, 0.9)",
color: "#fff",
},
toastError: {
background: "rgba(239, 68, 68, 0.9)",
color: "#fff",
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -1,28 +1,113 @@
import React from "react";
/**
* Shared styles used across multiple pages.
* These styles define the common layout and theming for the app.
* Design tokens - centralized values for consistency.
*/
export const sharedStyles: Record<string, React.CSSProperties> = {
const tokens = {
// Font families
fontSans: "'DM Sans', system-ui, sans-serif",
fontSerif: "'Instrument Serif', Georgia, serif",
fontMono: "'DM Mono', monospace",
// Colors
white: "#fff",
textPrimary: "#fff",
textSecondary: "rgba(255, 255, 255, 0.7)",
textMuted: "rgba(255, 255, 255, 0.5)",
textDim: "rgba(255, 255, 255, 0.4)",
textDisabled: "rgba(255, 255, 255, 0.3)",
// Accent colors
accent: "#a78bfa",
accentIndigo: "rgba(99, 102, 241, 0.9)",
accentIndigoMuted: "rgba(129, 140, 248, 0.9)",
// Status colors
success: "rgba(34, 197, 94, 0.9)",
successBg: "rgba(34, 197, 94, 0.2)",
successBorder: "rgba(34, 197, 94, 0.3)",
error: "#f87171",
errorBg: "rgba(239, 68, 68, 0.15)",
errorBorder: "rgba(239, 68, 68, 0.3)",
// Surfaces
surfaceBg: "rgba(255, 255, 255, 0.03)",
surfaceBorder: "rgba(255, 255, 255, 0.08)",
surfaceHover: "rgba(255, 255, 255, 0.05)",
inputBg: "rgba(255, 255, 255, 0.05)",
inputBorder: "rgba(255, 255, 255, 0.1)",
// Gradients
pageGradient: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
primaryGradient: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
// Shadows
cardShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
buttonShadow: "0 4px 14px rgba(99, 102, 241, 0.4)",
// Border radius
radiusSm: "6px",
radiusMd: "8px",
radiusLg: "12px",
radiusXl: "16px",
radius2xl: "20px",
radius3xl: "24px",
} as const;
type StyleRecord = Record<string, React.CSSProperties>;
// =============================================================================
// Layout Styles
// =============================================================================
export const layoutStyles: StyleRecord = {
/** Full-page main container with gradient background */
main: {
minHeight: "100vh",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
background: tokens.pageGradient,
display: "flex",
flexDirection: "column",
},
/** Centered loading indicator */
loader: {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontFamily: tokens.fontSans,
color: tokens.textMuted,
fontSize: "1.125rem",
},
/** Content area - centered (for cards/forms) */
contentCentered: {
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
},
/** Content area - scrollable (for tables/lists) */
contentScrollable: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
/** Max-width container for page content */
pageContainer: {
maxWidth: "1200px",
margin: "0 auto",
width: "100%",
},
};
// =============================================================================
// Header Styles (for nav/app header - used by Header component)
// =============================================================================
export const headerStyles: StyleRecord = {
header: {
padding: "1.5rem 2rem",
borderBottom: "1px solid rgba(255, 255, 255, 0.06)",
borderBottom: `1px solid ${tokens.surfaceBorder}`,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
@ -33,8 +118,8 @@ export const sharedStyles: Record<string, React.CSSProperties> = {
gap: "0.75rem",
},
navLink: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.5)",
fontFamily: tokens.fontSans,
color: tokens.textMuted,
fontSize: "0.875rem",
textDecoration: "none",
transition: "color 0.2s",
@ -44,8 +129,8 @@ export const sharedStyles: Record<string, React.CSSProperties> = {
fontSize: "0.75rem",
},
navCurrent: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "#a78bfa",
fontFamily: tokens.fontSans,
color: tokens.accent,
fontSize: "0.875rem",
fontWeight: 600,
},
@ -55,55 +140,593 @@ export const sharedStyles: Record<string, React.CSSProperties> = {
gap: "1rem",
},
userEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontFamily: tokens.fontSans,
color: "rgba(255, 255, 255, 0.6)",
fontSize: "0.875rem",
},
logoutBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontFamily: tokens.fontSans,
padding: "0.5rem 1rem",
fontSize: "0.875rem",
fontWeight: 500,
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",
background: tokens.surfaceHover,
color: tokens.textSecondary,
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusMd,
cursor: "pointer",
transition: "all 0.2s",
},
content: {
flex: 1,
};
// =============================================================================
// Typography Styles
// =============================================================================
export const typographyStyles: StyleRecord = {
/** Large page title - serif font */
pageTitle: {
fontFamily: tokens.fontSerif,
fontSize: "2rem",
fontWeight: 400,
color: tokens.white,
margin: 0,
letterSpacing: "-0.02em",
},
/** Page subtitle text */
pageSubtitle: {
fontFamily: tokens.fontSans,
color: tokens.textMuted,
marginTop: "0.5rem",
fontSize: "0.95rem",
},
/** Section title - uppercase, smaller */
sectionTitle: {
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.8)",
margin: 0,
textTransform: "uppercase",
letterSpacing: "0.05em",
},
/** Section title - muted variant */
sectionTitleMuted: {
fontFamily: tokens.fontSans,
fontSize: "1.1rem",
fontWeight: 500,
color: tokens.textMuted,
marginBottom: "1rem",
},
/** Section hint text */
sectionHint: {
fontFamily: tokens.fontSans,
fontSize: "0.8rem",
color: tokens.textDim,
margin: 0,
},
};
// =============================================================================
// Card Styles
// =============================================================================
export const cardStyles: StyleRecord = {
/** Standard card container */
card: {
background: tokens.surfaceBg,
backdropFilter: "blur(10px)",
border: `1px solid ${tokens.surfaceBorder}`,
borderRadius: tokens.radius3xl,
padding: "2.5rem",
boxShadow: tokens.cardShadow,
},
/** Card header section */
cardHeader: {
marginBottom: "2rem",
},
/** Card title - serif, large */
cardTitle: {
fontFamily: tokens.fontSerif,
fontSize: "2rem",
fontWeight: 400,
color: tokens.white,
margin: 0,
letterSpacing: "-0.02em",
},
/** Card subtitle */
cardSubtitle: {
fontFamily: tokens.fontSans,
color: tokens.textMuted,
marginTop: "0.5rem",
fontSize: "0.95rem",
},
/** Smaller table/section card */
tableCard: {
background: tokens.surfaceBg,
backdropFilter: "blur(10px)",
border: `1px solid ${tokens.surfaceBorder}`,
borderRadius: tokens.radius2xl,
padding: "1.5rem",
boxShadow: tokens.cardShadow,
},
};
// =============================================================================
// Table Styles
// =============================================================================
export const tableStyles: StyleRecord = {
tableHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
flexWrap: "wrap",
gap: "1rem",
},
tableTitle: {
fontFamily: tokens.fontSerif,
fontSize: "1.5rem",
fontWeight: 400,
color: tokens.white,
margin: 0,
},
totalCount: {
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
color: tokens.textDim,
},
tableWrapper: {
overflowX: "auto",
},
table: {
width: "100%",
borderCollapse: "collapse",
fontFamily: tokens.fontSans,
},
th: {
textAlign: "left",
padding: "0.75rem 1rem",
fontSize: "0.75rem",
fontWeight: 600,
color: tokens.textDim,
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: `1px solid ${tokens.surfaceBorder}`,
},
tr: {
borderBottom: "1px solid rgba(255, 255, 255, 0.04)",
},
td: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: tokens.textSecondary,
},
tdMono: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: tokens.white,
fontFamily: tokens.fontMono,
},
tdNum: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.9)",
fontFamily: tokens.fontMono,
},
tdDate: {
padding: "0.875rem 1rem",
fontSize: "0.75rem",
color: tokens.textDim,
},
emptyRow: {
padding: "2rem 1rem",
textAlign: "center",
color: tokens.textDisabled,
fontSize: "0.875rem",
},
errorRow: {
padding: "2rem 1rem",
textAlign: "center",
color: tokens.error,
fontSize: "0.875rem",
},
};
// =============================================================================
// Pagination Styles
// =============================================================================
export const paginationStyles: StyleRecord = {
pagination: {
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "1rem",
marginTop: "1rem",
paddingTop: "1rem",
borderTop: "1px solid rgba(255, 255, 255, 0.06)",
},
pageBtn: {
fontFamily: tokens.fontSans,
padding: "0.5rem 1rem",
fontSize: "1rem",
background: tokens.surfaceHover,
color: tokens.textSecondary,
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusMd,
cursor: "pointer",
transition: "all 0.2s",
},
pageInfo: {
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
color: tokens.textMuted,
},
};
// =============================================================================
// Form/Input Styles
// =============================================================================
export const formStyles: StyleRecord = {
form: {
display: "flex",
flexDirection: "column",
gap: "1.25rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
label: {
fontFamily: tokens.fontSans,
color: tokens.textSecondary,
fontSize: "0.875rem",
fontWeight: 500,
},
input: {
fontFamily: tokens.fontSans,
padding: "0.875rem 1rem",
fontSize: "1rem",
background: tokens.inputBg,
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusLg,
color: tokens.white,
outline: "none",
transition: "border-color 0.2s, box-shadow 0.2s",
},
inputError: {
border: `1px solid ${tokens.errorBorder}`,
boxShadow: `0 0 0 2px ${tokens.errorBg}`,
},
inputReadOnly: {
background: "rgba(255, 255, 255, 0.02)",
color: tokens.textMuted,
cursor: "not-allowed",
},
textarea: {
fontFamily: tokens.fontSans,
width: "100%",
padding: "0.75rem",
background: tokens.inputBg,
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusMd,
color: tokens.white,
fontSize: "0.875rem",
minHeight: "80px",
resize: "vertical" as const,
},
select: {
fontFamily: tokens.fontSans,
fontSize: "0.9rem",
padding: "0.75rem",
background: tokens.inputBg,
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusMd,
color: tokens.white,
cursor: "pointer",
},
hint: {
fontFamily: tokens.fontSans,
fontSize: "0.75rem",
color: tokens.textDim,
fontStyle: "italic",
},
errorText: {
fontFamily: tokens.fontSans,
fontSize: "0.75rem",
color: "#fca5a5",
},
charCount: {
fontFamily: tokens.fontSans,
fontSize: "0.75rem",
color: tokens.textDim,
textAlign: "right" as const,
marginTop: "0.25rem",
},
charCountWarning: {
color: tokens.error,
},
};
// =============================================================================
// Button Styles
// =============================================================================
export const buttonStyles: StyleRecord = {
/** Primary action button with gradient */
primaryButton: {
fontFamily: tokens.fontSans,
padding: "1rem",
fontSize: "1rem",
fontWeight: 600,
background: tokens.primaryGradient,
color: tokens.white,
border: "none",
borderRadius: tokens.radiusLg,
cursor: "pointer",
transition: "transform 0.2s, box-shadow 0.2s",
boxShadow: tokens.buttonShadow,
},
/** Secondary/cancel button */
secondaryButton: {
fontFamily: tokens.fontSans,
fontSize: "0.85rem",
padding: "0.6rem 1rem",
background: tokens.surfaceHover,
color: tokens.textSecondary,
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusMd,
cursor: "pointer",
transition: "all 0.2s",
},
/** Accent button (indigo tinted) */
accentButton: {
fontFamily: tokens.fontSans,
fontSize: "0.9rem",
fontWeight: 500,
padding: "0.75rem 1.5rem",
background: "rgba(99, 102, 241, 0.3)",
color: tokens.white,
border: "1px solid rgba(99, 102, 241, 0.5)",
borderRadius: tokens.radiusMd,
cursor: "pointer",
},
/** Danger/destructive button */
dangerButton: {
fontFamily: tokens.fontSans,
fontSize: "0.75rem",
padding: "0.4rem 0.75rem",
background: tokens.errorBg,
color: "rgba(239, 68, 68, 0.9)",
border: `1px solid ${tokens.errorBorder}`,
borderRadius: tokens.radiusSm,
cursor: "pointer",
},
/** Disabled button modifier */
buttonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
boxShadow: "none",
},
};
// =============================================================================
// Badge/Status Styles
// =============================================================================
export const badgeStyles: StyleRecord = {
/** Base badge style */
badge: {
fontFamily: tokens.fontSans,
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
borderRadius: "4px",
textTransform: "uppercase",
display: "inline-block",
},
/** Ready/primary status */
badgeReady: {
background: "rgba(99, 102, 241, 0.2)",
color: tokens.accentIndigoMuted,
},
/** Success/spent status */
badgeSuccess: {
background: tokens.successBg,
color: tokens.success,
},
/** Error/revoked status */
badgeError: {
background: "rgba(239, 68, 68, 0.2)",
color: "rgba(239, 68, 68, 0.9)",
},
/** Booked/active status */
badgeBooked: {
background: "rgba(99, 102, 241, 0.15)",
color: tokens.accentIndigoMuted,
},
};
// =============================================================================
// Banner/Alert Styles
// =============================================================================
export const bannerStyles: StyleRecord = {
errorBanner: {
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
padding: "1rem",
background: tokens.errorBg,
border: `1px solid ${tokens.errorBorder}`,
borderRadius: tokens.radiusMd,
color: tokens.error,
marginBottom: "1rem",
},
successBanner: {
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
padding: "1rem",
background: "rgba(34, 197, 94, 0.15)",
border: `1px solid ${tokens.successBorder}`,
borderRadius: tokens.radiusMd,
color: "#4ade80",
marginBottom: "1rem",
},
};
// =============================================================================
// Modal Styles
// =============================================================================
export const modalStyles: StyleRecord = {
modalOverlay: {
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
zIndex: 1000,
},
// Common UI component styles
errorBanner: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
padding: "1rem",
background: "rgba(239, 68, 68, 0.15)",
border: "1px solid rgba(239, 68, 68, 0.3)",
borderRadius: "8px",
color: "#f87171",
modal: {
background: "#1a1a3e",
border: `1px solid ${tokens.inputBorder}`,
borderRadius: tokens.radiusXl,
padding: "2rem",
width: "90%",
maxWidth: "400px",
boxShadow: tokens.cardShadow,
},
modalTitle: {
fontFamily: tokens.fontSerif,
fontSize: "1.5rem",
fontWeight: 400,
color: tokens.white,
margin: "0 0 1.5rem 0",
},
modalError: {
fontFamily: tokens.fontSans,
fontSize: "0.85rem",
padding: "0.75rem",
background: tokens.errorBg,
border: `1px solid ${tokens.errorBorder}`,
borderRadius: tokens.radiusMd,
color: tokens.error,
marginBottom: "1rem",
},
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",
transition: "all 0.2s",
modalActions: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
},
modalActionsRight: {
display: "flex",
gap: "0.75rem",
},
};
// =============================================================================
// Toast Styles
// =============================================================================
export const toastStyles: StyleRecord = {
toast: {
position: "fixed",
top: "1.5rem",
right: "1.5rem",
padding: "1rem 1.5rem",
borderRadius: tokens.radiusLg,
fontFamily: tokens.fontSans,
fontSize: "0.875rem",
fontWeight: 500,
zIndex: 1000,
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.3)",
},
toastSuccess: {
background: "rgba(34, 197, 94, 0.9)",
color: tokens.white,
},
toastError: {
background: "rgba(239, 68, 68, 0.9)",
color: tokens.white,
},
};
// =============================================================================
// Misc Utility Styles
// =============================================================================
export const utilityStyles: StyleRecord = {
/** Horizontal divider */
divider: {
height: "1px",
background: tokens.surfaceBorder,
margin: "0.75rem 0",
},
/** Empty state container */
emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.4)",
fontFamily: tokens.fontSans,
color: tokens.textDim,
textAlign: "center" as const,
padding: "1rem 0",
},
/** Read-only badge for form labels */
readOnlyBadge: {
fontSize: "0.7rem",
fontWeight: 500,
color: tokens.textDim,
background: tokens.surfaceBorder,
padding: "0.15rem 0.5rem",
borderRadius: "4px",
textTransform: "uppercase",
letterSpacing: "0.05em",
},
/** Flex row with gap */
buttonRow: {
display: "flex",
gap: "0.75rem",
},
/** Filter group container */
filterGroup: {
display: "flex",
alignItems: "center",
gap: "1rem",
},
};
// =============================================================================
// Combined sharedStyles (backwards compatible export)
// =============================================================================
/**
* @deprecated Use individual style exports (layoutStyles, cardStyles, etc.) for better tree-shaking.
* This combined export is maintained for backwards compatibility during migration.
*/
export const sharedStyles: StyleRecord = {
// Layout
main: layoutStyles.main,
loader: layoutStyles.loader,
content: layoutStyles.contentCentered,
// Header
header: headerStyles.header,
nav: headerStyles.nav,
navLink: headerStyles.navLink,
navDivider: headerStyles.navDivider,
navCurrent: headerStyles.navCurrent,
userInfo: headerStyles.userInfo,
userEmail: headerStyles.userEmail,
logoutBtn: headerStyles.logoutBtn,
// Common
errorBanner: bannerStyles.errorBanner,
cancelButton: buttonStyles.secondaryButton,
emptyState: utilityStyles.emptyState,
};