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 { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context"; import { Permission } from "../../auth-context";
import { api } from "../../api"; import { api } from "../../api";
import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header"; import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth"; import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api"; import { components } from "../../generated/api";
@ -15,6 +14,13 @@ import {
formatTimeString, formatTimeString,
isWeekend, isWeekend,
} from "../../utils/date"; } from "../../utils/date";
import {
layoutStyles,
typographyStyles,
bannerStyles,
buttonStyles,
modalStyles,
} from "../../styles/shared";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking; const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking;
@ -217,8 +223,8 @@ export default function AdminAvailabilityPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -228,15 +234,15 @@ export default function AdminAvailabilityPage() {
} }
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<Header currentPage="admin-availability" /> <Header currentPage="admin-availability" />
<div style={styles.content}> <div style={layoutStyles.contentScrollable}>
<div style={styles.pageContainer}> <div style={styles.pageContainer}>
<div style={styles.headerRow}> <div style={styles.headerRow}>
<div> <div>
<h1 style={styles.pageTitle}>Availability</h1> <h1 style={typographyStyles.pageTitle}>Availability</h1>
<p style={styles.pageSubtitle}> <p style={typographyStyles.pageSubtitle}>
Configure your available time slots for the next {maxAdvanceDays} days Configure your available time slots for the next {maxAdvanceDays} days
</p> </p>
</div> </div>
@ -246,18 +252,18 @@ export default function AdminAvailabilityPage() {
<button <button
onClick={executeCopy} onClick={executeCopy}
disabled={copyTargets.size === 0 || isCopying} disabled={copyTargets.size === 0 || isCopying}
style={styles.copyButton} style={buttonStyles.accentButton}
> >
{isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`} {isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`}
</button> </button>
<button onClick={cancelCopyMode} style={styles.cancelButton}> <button onClick={cancelCopyMode} style={buttonStyles.secondaryButton}>
Cancel Cancel
</button> </button>
</div> </div>
)} )}
</div> </div>
{error && !selectedDate && <div style={styles.errorBanner}>{error}</div>} {error && !selectedDate && <div style={bannerStyles.errorBanner}>{error}</div>}
<div style={styles.calendar}> <div style={styles.calendar}>
{dates.map((date) => { {dates.map((date) => {
@ -322,11 +328,13 @@ export default function AdminAvailabilityPage() {
{/* Edit Modal */} {/* Edit Modal */}
{selectedDate && ( {selectedDate && (
<div style={styles.modalOverlay} onClick={closeModal}> <div style={modalStyles.modalOverlay} onClick={closeModal}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}> <div style={modalStyles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.modalTitle}>Edit Availability - {formatDisplayDate(selectedDate)}</h2> <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}> <div style={styles.slotsEditor}>
{editSlots.map((slot, index) => ( {editSlots.map((slot, index) => (
@ -368,15 +376,19 @@ export default function AdminAvailabilityPage() {
</button> </button>
</div> </div>
<div style={styles.modalActions}> <div style={modalStyles.modalActions}>
<button onClick={clearAvailability} disabled={isSaving} style={styles.clearButton}> <button onClick={clearAvailability} disabled={isSaving} style={styles.clearButton}>
Clear All Clear All
</button> </button>
<div style={styles.modalActionsRight}> <div style={modalStyles.modalActionsRight}>
<button onClick={closeModal} style={styles.cancelButton}> <button onClick={closeModal} style={buttonStyles.secondaryButton}>
Cancel Cancel
</button> </button>
<button onClick={saveAvailability} disabled={isSaving} style={styles.saveButton}> <button
onClick={saveAvailability}
disabled={isSaving}
style={buttonStyles.accentButton}
>
{isSaving ? "Saving..." : "Save"} {isSaving ? "Saving..." : "Save"}
</button> </button>
</div> </div>
@ -388,12 +400,8 @@ export default function AdminAvailabilityPage() {
); );
} }
const pageStyles: Record<string, React.CSSProperties> = { // Page-specific styles
content: { const styles: Record<string, React.CSSProperties> = {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
pageContainer: { pageContainer: {
maxWidth: "1200px", maxWidth: "1200px",
margin: "0 auto", margin: "0 auto",
@ -406,19 +414,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
flexWrap: "wrap", flexWrap: "wrap",
gap: "1rem", 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: { copyActions: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -429,16 +424,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
fontSize: "0.85rem", fontSize: "0.85rem",
color: "rgba(255, 255, 255, 0.6)", 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: { calendar: {
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
@ -506,44 +491,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
borderRadius: "4px", borderRadius: "4px",
color: "rgba(129, 140, 248, 0.9)", 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: { slotsEditor: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -593,16 +540,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
borderRadius: "6px", borderRadius: "6px",
cursor: "pointer", cursor: "pointer",
}, },
modalActions: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
},
modalActionsRight: {
display: "flex",
gap: "0.75rem",
},
clearButton: { clearButton: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem", fontSize: "0.85rem",
@ -613,27 +550,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
borderRadius: "8px", borderRadius: "8px",
cursor: "pointer", 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 { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context"; import { Permission } from "../../auth-context";
import { api } from "../../api"; import { api } from "../../api";
import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header"; import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth"; import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api"; import { components } from "../../generated/api";
import constants from "../../../../shared/constants.json"; import constants from "../../../../shared/constants.json";
import {
layoutStyles,
cardStyles,
tableStyles,
paginationStyles,
formStyles,
buttonStyles,
badgeStyles,
utilityStyles,
} from "../../styles/shared";
const { READY, SPENT, REVOKED } = constants.inviteStatuses; const { READY, SPENT, REVOKED } = constants.inviteStatuses;
@ -101,11 +110,11 @@ export default function AdminInvitesPage() {
const getStatusBadgeStyle = (status: string) => { const getStatusBadgeStyle = (status: string) => {
switch (status) { switch (status) {
case READY: case READY:
return styles.statusReady; return badgeStyles.badgeReady;
case SPENT: case SPENT:
return styles.statusSpent; return badgeStyles.badgeSuccess;
case REVOKED: case REVOKED:
return styles.statusRevoked; return badgeStyles.badgeError;
default: default:
return {}; return {};
} }
@ -113,8 +122,8 @@ export default function AdminInvitesPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -124,21 +133,21 @@ export default function AdminInvitesPage() {
} }
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<Header currentPage="admin-invites" /> <Header currentPage="admin-invites" />
<div style={styles.content}> <div style={layoutStyles.contentScrollable}>
<div style={styles.pageContainer}> <div style={styles.pageContainer}>
{/* Create Invite Section */} {/* Create Invite Section */}
<div style={styles.createCard}> <div style={styles.createCard}>
<h2 style={styles.createTitle}>Create Invite</h2> <h2 style={styles.createTitle}>Create Invite</h2>
<div style={styles.createForm}> <div style={styles.createForm}>
<div style={styles.inputGroup}> <div style={formStyles.field}>
<label style={styles.inputLabel}>Godfather (user who can share this invite)</label> <label style={styles.inputLabel}>Godfather (user who can share this invite)</label>
<select <select
value={newGodfatherId} value={newGodfatherId}
onChange={(e) => setNewGodfatherId(e.target.value)} onChange={(e) => setNewGodfatherId(e.target.value)}
style={styles.select} style={{ ...formStyles.select, maxWidth: "400px" }}
> >
<option value="">Select a user...</option> <option value="">Select a user...</option>
{users.map((u) => ( {users.map((u) => (
@ -148,7 +157,7 @@ export default function AdminInvitesPage() {
))} ))}
</select> </select>
{users.length === 0 && ( {users.length === 0 && (
<span style={styles.inputHint}> <span style={formStyles.hint}>
No users loaded yet. Create at least one invite to populate the list. No users loaded yet. Create at least one invite to populate the list.
</span> </span>
)} )}
@ -158,8 +167,9 @@ export default function AdminInvitesPage() {
onClick={handleCreateInvite} onClick={handleCreateInvite}
disabled={isCreating || !newGodfatherId} disabled={isCreating || !newGodfatherId}
style={{ style={{
...styles.createButton, ...buttonStyles.accentButton,
...(!newGodfatherId ? styles.createButtonDisabled : {}), alignSelf: "flex-start",
...(!newGodfatherId ? buttonStyles.buttonDisabled : {}),
}} }}
> >
{isCreating ? "Creating..." : "Create Invite"} {isCreating ? "Creating..." : "Create Invite"}
@ -168,10 +178,10 @@ export default function AdminInvitesPage() {
</div> </div>
{/* Invites Table */} {/* Invites Table */}
<div style={styles.tableCard}> <div style={cardStyles.tableCard}>
<div style={styles.tableHeader}> <div style={tableStyles.tableHeader}>
<h2 style={styles.tableTitle}>All Invites</h2> <h2 style={tableStyles.tableTitle}>All Invites</h2>
<div style={styles.filterGroup}> <div style={utilityStyles.filterGroup}>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => { onChange={(e) => {
@ -185,49 +195,49 @@ export default function AdminInvitesPage() {
<option value={SPENT}>Spent</option> <option value={SPENT}>Spent</option>
<option value={REVOKED}>Revoked</option> <option value={REVOKED}>Revoked</option>
</select> </select>
<span style={styles.totalCount}>{data?.total ?? 0} invites</span> <span style={tableStyles.totalCount}>{data?.total ?? 0} invites</span>
</div> </div>
</div> </div>
<div style={styles.tableWrapper}> <div style={tableStyles.tableWrapper}>
<table style={styles.table}> <table style={tableStyles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>Code</th> <th style={tableStyles.th}>Code</th>
<th style={styles.th}>Godfather</th> <th style={tableStyles.th}>Godfather</th>
<th style={styles.th}>Status</th> <th style={tableStyles.th}>Status</th>
<th style={styles.th}>Used By</th> <th style={tableStyles.th}>Used By</th>
<th style={styles.th}>Created</th> <th style={tableStyles.th}>Created</th>
<th style={styles.th}>Actions</th> <th style={tableStyles.th}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{error && ( {error && (
<tr> <tr>
<td colSpan={6} style={styles.errorRow}> <td colSpan={6} style={tableStyles.errorRow}>
{error} {error}
</td> </td>
</tr> </tr>
)} )}
{!error && {!error &&
data?.records.map((record) => ( data?.records.map((record) => (
<tr key={record.id} style={styles.tr}> <tr key={record.id} style={tableStyles.tr}>
<td style={styles.tdCode}>{record.identifier}</td> <td style={tableStyles.tdMono}>{record.identifier}</td>
<td style={styles.td}>{record.godfather_email}</td> <td style={tableStyles.td}>{record.godfather_email}</td>
<td style={styles.td}> <td style={tableStyles.td}>
<span <span
style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }} style={{ ...badgeStyles.badge, ...getStatusBadgeStyle(record.status) }}
> >
{record.status} {record.status}
</span> </span>
</td> </td>
<td style={styles.td}>{record.used_by_email || "-"}</td> <td style={tableStyles.td}>{record.used_by_email || "-"}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td> <td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
<td style={styles.td}> <td style={tableStyles.td}>
{record.status === READY && ( {record.status === READY && (
<button <button
onClick={() => handleRevoke(record.id)} onClick={() => handleRevoke(record.id)}
style={styles.revokeButton} style={buttonStyles.dangerButton}
> >
Revoke Revoke
</button> </button>
@ -237,7 +247,7 @@ export default function AdminInvitesPage() {
))} ))}
{!error && (!data || data.records.length === 0) && ( {!error && (!data || data.records.length === 0) && (
<tr> <tr>
<td colSpan={6} style={styles.emptyRow}> <td colSpan={6} style={tableStyles.emptyRow}>
No invites yet No invites yet
</td> </td>
</tr> </tr>
@ -247,21 +257,21 @@ export default function AdminInvitesPage() {
</div> </div>
{data && data.total_pages > 1 && ( {data && data.total_pages > 1 && (
<div style={styles.pagination}> <div style={paginationStyles.pagination}>
<button <button
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1} disabled={page === 1}
style={styles.pageBtn} style={paginationStyles.pageBtn}
> >
</button> </button>
<span style={styles.pageInfo}> <span style={paginationStyles.pageInfo}>
{page} / {data.total_pages} {page} / {data.total_pages}
</span> </span>
<button <button
onClick={() => setPage((p) => Math.min(data.total_pages, p + 1))} onClick={() => setPage((p) => Math.min(data.total_pages, p + 1))}
disabled={page === data.total_pages} disabled={page === data.total_pages}
style={styles.pageBtn} style={paginationStyles.pageBtn}
> >
</button> </button>
@ -274,12 +284,8 @@ export default function AdminInvitesPage() {
); );
} }
const pageStyles: Record<string, React.CSSProperties> = { // Page-specific styles
content: { const styles: Record<string, React.CSSProperties> = {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
pageContainer: { pageContainer: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -305,82 +311,16 @@ const pageStyles: Record<string, React.CSSProperties> = {
flexDirection: "column", flexDirection: "column",
gap: "1rem", gap: "1rem",
}, },
inputGroup: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
},
inputLabel: { inputLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem", fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.5)", 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: { createError: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem", fontSize: "0.85rem",
color: "#f87171", 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: { filterSelect: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.85rem", fontSize: "0.85rem",
@ -391,115 +331,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
color: "#fff", color: "#fff",
cursor: "pointer", 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 { components } from "../generated/api";
import { formatDateTime } from "../utils/date"; import { formatDateTime } from "../utils/date";
import { getStatusDisplay } from "../utils/appointment"; import { getStatusDisplay } from "../utils/appointment";
import { sharedStyles } from "../styles/shared"; import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
} from "../styles/shared";
type AppointmentResponse = components["schemas"]["AppointmentResponse"]; 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() { export default function AppointmentsPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({ const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_OWN_APPOINTMENTS, requiredPermission: Permission.VIEW_OWN_APPOINTMENTS,
@ -195,8 +66,8 @@ export default function AppointmentsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -213,13 +84,13 @@ export default function AppointmentsPage() {
); );
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<Header currentPage="appointments" /> <Header currentPage="appointments" />
<div style={styles.content}> <div style={styles.content}>
<h1 style={styles.pageTitle}>My Appointments</h1> <h1 style={typographyStyles.pageTitle}>My Appointments</h1>
<p style={styles.pageSubtitle}>View and manage your booked appointments</p> <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 ? ( {isLoadingAppointments ? (
<div style={styles.emptyState}>Loading appointments...</div> <div style={styles.emptyState}>Loading appointments...</div>
@ -249,7 +120,7 @@ export default function AppointmentsPage() {
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>} {apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span <span
style={{ style={{
...styles.statusBadge, ...badgeStyles.badge,
background: status.bgColor, background: status.bgColor,
color: status.textColor, color: status.textColor,
}} }}
@ -271,7 +142,7 @@ export default function AppointmentsPage() {
</button> </button>
<button <button
onClick={() => setConfirmCancelId(null)} onClick={() => setConfirmCancelId(null)}
style={styles.cancelButton} style={buttonStyles.secondaryButton}
> >
No No
</button> </button>
@ -279,7 +150,7 @@ export default function AppointmentsPage() {
) : ( ) : (
<button <button
onClick={() => setConfirmCancelId(apt.id)} onClick={() => setConfirmCancelId(apt.id)}
style={styles.cancelButton} style={buttonStyles.secondaryButton}
> >
Cancel Cancel
</button> </button>
@ -297,7 +168,7 @@ export default function AppointmentsPage() {
{/* Past/Cancelled Appointments */} {/* Past/Cancelled Appointments */}
{pastOrCancelledAppointments.length > 0 && ( {pastOrCancelledAppointments.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitleMuted}> <h2 style={typographyStyles.sectionTitleMuted}>
Past & Cancelled ({pastOrCancelledAppointments.length}) Past & Cancelled ({pastOrCancelledAppointments.length})
</h2> </h2>
<div style={styles.appointmentList}> <div style={styles.appointmentList}>
@ -312,7 +183,7 @@ export default function AppointmentsPage() {
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>} {apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<span <span
style={{ style={{
...styles.statusBadge, ...badgeStyles.badge,
background: status.bgColor, background: status.bgColor,
color: status.textColor, color: status.textColor,
}} }}
@ -331,3 +202,84 @@ export default function AppointmentsPage() {
</main> </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 { useEffect, useState, useCallback } from "react";
import { Permission } from "../auth-context"; import { Permission } from "../auth-context";
import { api } from "../api"; import { api } from "../api";
import { sharedStyles } from "../styles/shared"; import { layoutStyles, cardStyles, tableStyles, paginationStyles } from "../styles/shared";
import { Header } from "../components/Header"; import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth"; import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api"; import { components } from "../generated/api";
@ -68,8 +68,8 @@ export default function AuditPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -79,47 +79,47 @@ export default function AuditPage() {
} }
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<Header currentPage="audit" /> <Header currentPage="audit" />
<div style={styles.content}> <div style={layoutStyles.contentScrollable}>
<div style={styles.tablesContainer}> <div style={styles.tablesContainer}>
{/* Counter Records Table */} {/* Counter Records Table */}
<div style={styles.tableCard}> <div style={cardStyles.tableCard}>
<div style={styles.tableHeader}> <div style={tableStyles.tableHeader}>
<h2 style={styles.tableTitle}>Counter Activity</h2> <h2 style={tableStyles.tableTitle}>Counter Activity</h2>
<span style={styles.totalCount}>{counterData?.total ?? 0} records</span> <span style={tableStyles.totalCount}>{counterData?.total ?? 0} records</span>
</div> </div>
<div style={styles.tableWrapper}> <div style={tableStyles.tableWrapper}>
<table style={styles.table}> <table style={tableStyles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>User</th> <th style={tableStyles.th}>User</th>
<th style={styles.th}>Before</th> <th style={tableStyles.th}>Before</th>
<th style={styles.th}>After</th> <th style={tableStyles.th}>After</th>
<th style={styles.th}>Date</th> <th style={tableStyles.th}>Date</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{counterError && ( {counterError && (
<tr> <tr>
<td colSpan={4} style={styles.errorRow}> <td colSpan={4} style={tableStyles.errorRow}>
{counterError} {counterError}
</td> </td>
</tr> </tr>
)} )}
{!counterError && {!counterError &&
counterData?.records.map((record) => ( counterData?.records.map((record) => (
<tr key={record.id} style={styles.tr}> <tr key={record.id} style={tableStyles.tr}>
<td style={styles.td}>{record.user_email}</td> <td style={tableStyles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.value_before}</td> <td style={tableStyles.tdNum}>{record.value_before}</td>
<td style={styles.tdNum}>{record.value_after}</td> <td style={tableStyles.tdNum}>{record.value_after}</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td> <td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
</tr> </tr>
))} ))}
{!counterError && (!counterData || counterData.records.length === 0) && ( {!counterError && (!counterData || counterData.records.length === 0) && (
<tr> <tr>
<td colSpan={4} style={styles.emptyRow}> <td colSpan={4} style={tableStyles.emptyRow}>
No records yet No records yet
</td> </td>
</tr> </tr>
@ -128,21 +128,21 @@ export default function AuditPage() {
</table> </table>
</div> </div>
{counterData && counterData.total_pages > 1 && ( {counterData && counterData.total_pages > 1 && (
<div style={styles.pagination}> <div style={paginationStyles.pagination}>
<button <button
onClick={() => setCounterPage((p) => Math.max(1, p - 1))} onClick={() => setCounterPage((p) => Math.max(1, p - 1))}
disabled={counterPage === 1} disabled={counterPage === 1}
style={styles.pageBtn} style={paginationStyles.pageBtn}
> >
</button> </button>
<span style={styles.pageInfo}> <span style={paginationStyles.pageInfo}>
{counterPage} / {counterData.total_pages} {counterPage} / {counterData.total_pages}
</span> </span>
<button <button
onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))} onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))}
disabled={counterPage === counterData.total_pages} disabled={counterPage === counterData.total_pages}
style={styles.pageBtn} style={paginationStyles.pageBtn}
> >
</button> </button>
@ -151,43 +151,43 @@ export default function AuditPage() {
</div> </div>
{/* Sum Records Table */} {/* Sum Records Table */}
<div style={styles.tableCard}> <div style={cardStyles.tableCard}>
<div style={styles.tableHeader}> <div style={tableStyles.tableHeader}>
<h2 style={styles.tableTitle}>Sum Activity</h2> <h2 style={tableStyles.tableTitle}>Sum Activity</h2>
<span style={styles.totalCount}>{sumData?.total ?? 0} records</span> <span style={tableStyles.totalCount}>{sumData?.total ?? 0} records</span>
</div> </div>
<div style={styles.tableWrapper}> <div style={tableStyles.tableWrapper}>
<table style={styles.table}> <table style={tableStyles.table}>
<thead> <thead>
<tr> <tr>
<th style={styles.th}>User</th> <th style={tableStyles.th}>User</th>
<th style={styles.th}>A</th> <th style={tableStyles.th}>A</th>
<th style={styles.th}>B</th> <th style={tableStyles.th}>B</th>
<th style={styles.th}>Result</th> <th style={tableStyles.th}>Result</th>
<th style={styles.th}>Date</th> <th style={tableStyles.th}>Date</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sumError && ( {sumError && (
<tr> <tr>
<td colSpan={5} style={styles.errorRow}> <td colSpan={5} style={tableStyles.errorRow}>
{sumError} {sumError}
</td> </td>
</tr> </tr>
)} )}
{!sumError && {!sumError &&
sumData?.records.map((record) => ( sumData?.records.map((record) => (
<tr key={record.id} style={styles.tr}> <tr key={record.id} style={tableStyles.tr}>
<td style={styles.td}>{record.user_email}</td> <td style={tableStyles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.a}</td> <td style={tableStyles.tdNum}>{record.a}</td>
<td style={styles.tdNum}>{record.b}</td> <td style={tableStyles.tdNum}>{record.b}</td>
<td style={styles.tdResult}>{record.result}</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> </tr>
))} ))}
{!sumError && (!sumData || sumData.records.length === 0) && ( {!sumError && (!sumData || sumData.records.length === 0) && (
<tr> <tr>
<td colSpan={5} style={styles.emptyRow}> <td colSpan={5} style={tableStyles.emptyRow}>
No records yet No records yet
</td> </td>
</tr> </tr>
@ -196,21 +196,21 @@ export default function AuditPage() {
</table> </table>
</div> </div>
{sumData && sumData.total_pages > 1 && ( {sumData && sumData.total_pages > 1 && (
<div style={styles.pagination}> <div style={paginationStyles.pagination}>
<button <button
onClick={() => setSumPage((p) => Math.max(1, p - 1))} onClick={() => setSumPage((p) => Math.max(1, p - 1))}
disabled={sumPage === 1} disabled={sumPage === 1}
style={styles.pageBtn} style={paginationStyles.pageBtn}
> >
</button> </button>
<span style={styles.pageInfo}> <span style={paginationStyles.pageInfo}>
{sumPage} / {sumData.total_pages} {sumPage} / {sumData.total_pages}
</span> </span>
<button <button
onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))} onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))}
disabled={sumPage === sumData.total_pages} disabled={sumPage === sumData.total_pages}
style={styles.pageBtn} style={paginationStyles.pageBtn}
> >
</button> </button>
@ -223,13 +223,8 @@ export default function AuditPage() {
); );
} }
const pageStyles: Record<string, React.CSSProperties> = { // Page-specific styles only
// Override content for audit-specific layout const styles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
tablesContainer: { tablesContainer: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -237,64 +232,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
maxWidth: "1200px", maxWidth: "1200px",
margin: "0 auto", 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: { tdResult: {
padding: "0.875rem 1rem", padding: "0.875rem 1rem",
fontSize: "0.875rem", fontSize: "0.875rem",
@ -302,48 +239,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
fontWeight: 600, fontWeight: 600,
fontFamily: "'DM Sans', monospace", 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 { components } from "../generated/api";
import constants from "../../../shared/constants.json"; import constants from "../../../shared/constants.json";
import { formatDate, formatTime, getDateRange } from "../utils/date"; 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; const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
@ -17,205 +23,6 @@ type BookableSlot = components["schemas"]["BookableSlot"];
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
type AppointmentResponse = components["schemas"]["AppointmentResponse"]; 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() { export default function BookingPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({ const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.BOOK_APPOINTMENT, requiredPermission: Permission.BOOK_APPOINTMENT,
@ -345,8 +152,8 @@ export default function BookingPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -356,17 +163,17 @@ export default function BookingPage() {
} }
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<Header currentPage="booking" /> <Header currentPage="booking" />
<div style={styles.content}> <div style={styles.content}>
<h1 style={styles.pageTitle}>Book an Appointment</h1> <h1 style={typographyStyles.pageTitle}>Book an Appointment</h1>
<p style={styles.pageSubtitle}> <p style={typographyStyles.pageSubtitle}>
Select a date to see available {slotDurationMinutes}-minute slots Select a date to see available {slotDurationMinutes}-minute slots
</p> </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 */} {/* Date Selection */}
<div style={styles.section}> <div style={styles.section}>
@ -449,17 +256,17 @@ export default function BookingPage() {
</p> </p>
<div> <div>
<label style={styles.inputLabel}>Note (optional, max {noteMaxLength} chars)</label> <label style={formStyles.label}>Note (optional, max {noteMaxLength} chars)</label>
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))} onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
placeholder="Add a note about your appointment..." placeholder="Add a note about your appointment..."
style={styles.textarea} style={formStyles.textarea}
/> />
<div <div
style={{ style={{
...styles.charCount, ...formStyles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}), ...(note.length >= noteMaxLength ? formStyles.charCountWarning : {}),
}} }}
> >
{note.length}/{noteMaxLength} {note.length}/{noteMaxLength}
@ -472,7 +279,7 @@ export default function BookingPage() {
disabled={isBooking} disabled={isBooking}
style={{ style={{
...styles.bookButton, ...styles.bookButton,
...(isBooking ? styles.bookButtonDisabled : {}), ...(isBooking ? buttonStyles.buttonDisabled : {}),
}} }}
> >
{isBooking ? "Booking..." : "Book Appointment"} {isBooking ? "Booking..." : "Book Appointment"}
@ -491,3 +298,131 @@ export default function BookingPage() {
</main> </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 { useEffect, useState, useCallback } from "react";
import { api } from "../api"; import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header"; import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth"; import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api"; import { components } from "../generated/api";
import constants from "../../../shared/constants.json"; import constants from "../../../shared/constants.json";
import {
layoutStyles,
cardStyles,
typographyStyles,
badgeStyles,
buttonStyles,
} from "../styles/shared";
// Use generated type from OpenAPI schema // Use generated type from OpenAPI schema
type Invite = components["schemas"]["UserInviteResponse"]; type Invite = components["schemas"]["UserInviteResponse"];
@ -57,8 +63,8 @@ export default function InvitesPage() {
if (isLoading || isLoadingInvites) { if (isLoading || isLoadingInvites) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -73,14 +79,16 @@ export default function InvitesPage() {
const revokedInvites = invites.filter((i) => i.status === REVOKED); const revokedInvites = invites.filter((i) => i.status === REVOKED);
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<Header currentPage="invites" /> <Header currentPage="invites" />
<div style={styles.content}> <div style={layoutStyles.contentCentered}>
<div style={styles.pageCard}> <div style={styles.pageCard}>
<div style={styles.cardHeader}> <div style={cardStyles.cardHeader}>
<h1 style={styles.cardTitle}>My Invites</h1> <h1 style={cardStyles.cardTitle}>My Invites</h1>
<p style={styles.cardSubtitle}>Share your invite codes with friends to let them join</p> <p style={cardStyles.cardSubtitle}>
Share your invite codes with friends to let them join
</p>
</div> </div>
{invites.length === 0 ? ( {invites.length === 0 ? (
@ -93,14 +101,19 @@ export default function InvitesPage() {
{/* Ready Invites */} {/* Ready Invites */}
{readyInvites.length > 0 && ( {readyInvites.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}>Available ({readyInvites.length})</h2> <h2 style={typographyStyles.sectionTitle}>Available ({readyInvites.length})</h2>
<p style={styles.sectionHint}>Share these links with people you want to invite</p> <p style={typographyStyles.sectionHint}>
Share these links with people you want to invite
</p>
<div style={styles.inviteList}> <div style={styles.inviteList}>
{readyInvites.map((invite) => ( {readyInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCard}> <div key={invite.id} style={styles.inviteCard}>
<div style={styles.inviteCode}>{invite.identifier}</div> <div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteActions}> <div style={styles.inviteActions}>
<button onClick={() => copyToClipboard(invite)} style={styles.copyButton}> <button
onClick={() => copyToClipboard(invite)}
style={buttonStyles.accentButton}
>
{copiedId === invite.id ? "Copied!" : "Copy Link"} {copiedId === invite.id ? "Copied!" : "Copy Link"}
</button> </button>
</div> </div>
@ -113,13 +126,15 @@ export default function InvitesPage() {
{/* Spent Invites */} {/* Spent Invites */}
{spentInvites.length > 0 && ( {spentInvites.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}>Used ({spentInvites.length})</h2> <h2 style={typographyStyles.sectionTitle}>Used ({spentInvites.length})</h2>
<div style={styles.inviteList}> <div style={styles.inviteList}>
{spentInvites.map((invite) => ( {spentInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardSpent}> <div key={invite.id} style={styles.inviteCardSpent}>
<div style={styles.inviteCode}>{invite.identifier}</div> <div style={styles.inviteCode}>{invite.identifier}</div>
<div style={styles.inviteeMeta}> <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> <span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
</div> </div>
</div> </div>
@ -131,12 +146,14 @@ export default function InvitesPage() {
{/* Revoked Invites */} {/* Revoked Invites */}
{revokedInvites.length > 0 && ( {revokedInvites.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}>Revoked ({revokedInvites.length})</h2> <h2 style={typographyStyles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
<div style={styles.inviteList}> <div style={styles.inviteList}>
{revokedInvites.map((invite) => ( {revokedInvites.map((invite) => (
<div key={invite.id} style={styles.inviteCardRevoked}> <div key={invite.id} style={styles.inviteCardRevoked}>
<div style={styles.inviteCode}>{invite.identifier}</div> <div style={styles.inviteCode}>{invite.identifier}</div>
<span style={styles.statusBadgeRevoked}>Revoked</span> <span style={{ ...badgeStyles.badge, ...badgeStyles.badgeError }}>
Revoked
</span>
</div> </div>
))} ))}
</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: { pageCard: {
background: "rgba(255, 255, 255, 0.03)", ...cardStyles.card,
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
padding: "2.5rem",
width: "100%", width: "100%",
maxWidth: "600px", 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: { emptyState: {
textAlign: "center", textAlign: "center",
@ -204,21 +200,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
flexDirection: "column", flexDirection: "column",
gap: "0.75rem", 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: { inviteList: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -262,48 +243,14 @@ const pageStyles: Record<string, React.CSSProperties> = {
display: "flex", display: "flex",
gap: "0.5rem", 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: { inviteeMeta: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "0.5rem", 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: { inviteeEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.8rem", fontSize: "0.8rem",
color: "rgba(255, 255, 255, 0.6)", color: "rgba(255, 255, 255, 0.6)",
}, },
}; };
const styles = { ...sharedStyles, ...pageStyles };

View file

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

View file

@ -3,11 +3,18 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { bech32 } from "bech32"; import { bech32 } from "bech32";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header"; import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth"; import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api"; import { components } from "../generated/api";
import constants from "../../../shared/constants.json"; import constants from "../../../shared/constants.json";
import {
layoutStyles,
cardStyles,
formStyles,
buttonStyles,
toastStyles,
utilityStyles,
} from "../styles/shared";
// Use generated type from OpenAPI schema // Use generated type from OpenAPI schema
type ProfileData = components["schemas"]["ProfileResponse"]; type ProfileData = components["schemas"]["ProfileResponse"];
@ -253,8 +260,8 @@ export default function ProfilePage() {
if (isLoading || isLoadingProfile) { if (isLoading || isLoadingProfile) {
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
<div style={styles.loader}>Loading...</div> <div style={layoutStyles.loader}>Loading...</div>
</main> </main>
); );
} }
@ -266,13 +273,13 @@ export default function ProfilePage() {
const canSubmit = hasChanges() && isValid() && !isSubmitting; const canSubmit = hasChanges() && isValid() && !isSubmitting;
return ( return (
<main style={styles.main}> <main style={layoutStyles.main}>
{/* Toast notification */} {/* Toast notification */}
{toast && ( {toast && (
<div <div
style={{ style={{
...styles.toast, ...toastStyles.toast,
...(toast.type === "success" ? styles.toastSuccess : styles.toastError), ...(toast.type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
}} }}
> >
{toast.message} {toast.message}
@ -281,44 +288,46 @@ export default function ProfilePage() {
<Header currentPage="profile" /> <Header currentPage="profile" />
<div style={styles.content}> <div style={layoutStyles.contentCentered}>
<div style={styles.profileCard}> <div style={styles.profileCard}>
<div style={styles.cardHeader}> <div style={cardStyles.cardHeader}>
<h1 style={styles.cardTitle}>My Profile</h1> <h1 style={cardStyles.cardTitle}>My Profile</h1>
<p style={styles.cardSubtitle}>Manage your contact information</p> <p style={cardStyles.cardSubtitle}>Manage your contact information</p>
</div> </div>
<form onSubmit={handleSubmit} style={styles.form}> <form onSubmit={handleSubmit} style={formStyles.form}>
{/* Login email - read only */} {/* Login email - read only */}
<div style={styles.field}> <div style={formStyles.field}>
<label style={styles.label}> <label style={styles.labelWithBadge}>
Login Email Login Email
<span style={styles.readOnlyBadge}>Read only</span> <span style={utilityStyles.readOnlyBadge}>Read only</span>
</label> </label>
<input <input
type="email" type="email"
value={user.email} value={user.email}
style={{ ...styles.input, ...styles.inputReadOnly }} style={{ ...formStyles.input, ...formStyles.inputReadOnly }}
disabled 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> </div>
{/* Godfather - shown if user was invited */} {/* Godfather - shown if user was invited */}
{godfatherEmail && ( {godfatherEmail && (
<div style={styles.field}> <div style={formStyles.field}>
<label style={styles.label}> <label style={styles.labelWithBadge}>
Invited By Invited By
<span style={styles.readOnlyBadge}>Read only</span> <span style={utilityStyles.readOnlyBadge}>Read only</span>
</label> </label>
<div style={styles.godfatherBox}> <div style={styles.godfatherBox}>
<span style={styles.godfatherEmail}>{godfatherEmail}</span> <span style={styles.godfatherEmail}>{godfatherEmail}</span>
</div> </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>
)} )}
<div style={styles.divider} /> <div style={utilityStyles.divider} />
<p style={styles.sectionLabel}>Contact Details</p> <p style={styles.sectionLabel}>Contact Details</p>
<p style={styles.sectionHint}> <p style={styles.sectionHint}>
@ -326,8 +335,8 @@ export default function ProfilePage() {
</p> </p>
{/* Contact email */} {/* Contact email */}
<div style={styles.field}> <div style={formStyles.field}>
<label htmlFor="contact_email" style={styles.label}> <label htmlFor="contact_email" style={formStyles.label}>
Contact Email Contact Email
</label> </label>
<input <input
@ -336,17 +345,19 @@ export default function ProfilePage() {
value={formData.contact_email} value={formData.contact_email}
onChange={handleInputChange("contact_email")} onChange={handleInputChange("contact_email")}
style={{ style={{
...styles.input, ...formStyles.input,
...(errors.contact_email ? styles.inputError : {}), ...(errors.contact_email ? formStyles.inputError : {}),
}} }}
placeholder="alternate@example.com" 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> </div>
{/* Telegram */} {/* Telegram */}
<div style={styles.field}> <div style={formStyles.field}>
<label htmlFor="telegram" style={styles.label}> <label htmlFor="telegram" style={formStyles.label}>
Telegram Telegram
</label> </label>
<input <input
@ -355,17 +366,17 @@ export default function ProfilePage() {
value={formData.telegram} value={formData.telegram}
onChange={handleInputChange("telegram")} onChange={handleInputChange("telegram")}
style={{ style={{
...styles.input, ...formStyles.input,
...(errors.telegram ? styles.inputError : {}), ...(errors.telegram ? formStyles.inputError : {}),
}} }}
placeholder="@username" placeholder="@username"
/> />
{errors.telegram && <span style={styles.errorText}>{errors.telegram}</span>} {errors.telegram && <span style={formStyles.errorText}>{errors.telegram}</span>}
</div> </div>
{/* Signal */} {/* Signal */}
<div style={styles.field}> <div style={formStyles.field}>
<label htmlFor="signal" style={styles.label}> <label htmlFor="signal" style={formStyles.label}>
Signal Signal
</label> </label>
<input <input
@ -374,17 +385,17 @@ export default function ProfilePage() {
value={formData.signal} value={formData.signal}
onChange={handleInputChange("signal")} onChange={handleInputChange("signal")}
style={{ style={{
...styles.input, ...formStyles.input,
...(errors.signal ? styles.inputError : {}), ...(errors.signal ? formStyles.inputError : {}),
}} }}
placeholder="username.01" placeholder="username.01"
/> />
{errors.signal && <span style={styles.errorText}>{errors.signal}</span>} {errors.signal && <span style={formStyles.errorText}>{errors.signal}</span>}
</div> </div>
{/* Nostr npub */} {/* Nostr npub */}
<div style={styles.field}> <div style={formStyles.field}>
<label htmlFor="nostr_npub" style={styles.label}> <label htmlFor="nostr_npub" style={formStyles.label}>
Nostr (npub) Nostr (npub)
</label> </label>
<input <input
@ -393,19 +404,20 @@ export default function ProfilePage() {
value={formData.nostr_npub} value={formData.nostr_npub}
onChange={handleInputChange("nostr_npub")} onChange={handleInputChange("nostr_npub")}
style={{ style={{
...styles.input, ...formStyles.input,
...(errors.nostr_npub ? styles.inputError : {}), ...(errors.nostr_npub ? formStyles.inputError : {}),
}} }}
placeholder="npub1..." 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> </div>
<button <button
type="submit" type="submit"
style={{ style={{
...styles.button, ...buttonStyles.primaryButton,
...(!canSubmit ? styles.buttonDisabled : {}), marginTop: "1rem",
...(!canSubmit ? buttonStyles.buttonDisabled : {}),
}} }}
disabled={!canSubmit} 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: { profileCard: {
background: "rgba(255, 255, 255, 0.03)", ...cardStyles.card,
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
padding: "2.5rem",
width: "100%", width: "100%",
maxWidth: "480px", maxWidth: "480px",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
}, },
cardHeader: { labelWithBadge: {
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: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
color: "rgba(255, 255, 255, 0.7)", color: "rgba(255, 255, 255, 0.7)",
fontSize: "0.875rem", fontSize: "0.875rem",
@ -465,32 +446,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
alignItems: "center", alignItems: "center",
gap: "0.5rem", 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: { godfatherBox: {
padding: "0.875rem 1rem", padding: "0.875rem 1rem",
background: "rgba(99, 102, 241, 0.08)", background: "rgba(99, 102, 241, 0.08)",
@ -502,26 +457,6 @@ const pageStyles: Record<string, React.CSSProperties> = {
fontSize: "1rem", fontSize: "1rem",
color: "rgba(129, 140, 248, 0.9)", 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: { sectionLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem", fontSize: "0.875rem",
@ -538,46 +473,4 @@ const pageStyles: Record<string, React.CSSProperties> = {
margin: 0, margin: 0,
marginBottom: "0.5rem", 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"; import React from "react";
/** /**
* Shared styles used across multiple pages. * Design tokens - centralized values for consistency.
* These styles define the common layout and theming for the app.
*/ */
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: { main: {
minHeight: "100vh", minHeight: "100vh",
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", background: tokens.pageGradient,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}, },
/** Centered loading indicator */
loader: { loader: {
flex: 1, flex: 1,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: tokens.fontSans,
color: "rgba(255, 255, 255, 0.5)", color: tokens.textMuted,
fontSize: "1.125rem", 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: { header: {
padding: "1.5rem 2rem", padding: "1.5rem 2rem",
borderBottom: "1px solid rgba(255, 255, 255, 0.06)", borderBottom: `1px solid ${tokens.surfaceBorder}`,
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
@ -33,8 +118,8 @@ export const sharedStyles: Record<string, React.CSSProperties> = {
gap: "0.75rem", gap: "0.75rem",
}, },
navLink: { navLink: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: tokens.fontSans,
color: "rgba(255, 255, 255, 0.5)", color: tokens.textMuted,
fontSize: "0.875rem", fontSize: "0.875rem",
textDecoration: "none", textDecoration: "none",
transition: "color 0.2s", transition: "color 0.2s",
@ -44,8 +129,8 @@ export const sharedStyles: Record<string, React.CSSProperties> = {
fontSize: "0.75rem", fontSize: "0.75rem",
}, },
navCurrent: { navCurrent: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: tokens.fontSans,
color: "#a78bfa", color: tokens.accent,
fontSize: "0.875rem", fontSize: "0.875rem",
fontWeight: 600, fontWeight: 600,
}, },
@ -55,55 +140,593 @@ export const sharedStyles: Record<string, React.CSSProperties> = {
gap: "1rem", gap: "1rem",
}, },
userEmail: { userEmail: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: tokens.fontSans,
color: "rgba(255, 255, 255, 0.6)", color: "rgba(255, 255, 255, 0.6)",
fontSize: "0.875rem", fontSize: "0.875rem",
}, },
logoutBtn: { logoutBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: tokens.fontSans,
padding: "0.5rem 1rem", padding: "0.5rem 1rem",
fontSize: "0.875rem", fontSize: "0.875rem",
fontWeight: 500, fontWeight: 500,
background: "rgba(255, 255, 255, 0.05)", background: tokens.surfaceHover,
color: "rgba(255, 255, 255, 0.7)", color: tokens.textSecondary,
border: "1px solid rgba(255, 255, 255, 0.1)", border: `1px solid ${tokens.inputBorder}`,
borderRadius: "8px", borderRadius: tokens.radiusMd,
cursor: "pointer", cursor: "pointer",
transition: "all 0.2s", 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", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
padding: "2rem", zIndex: 1000,
}, },
// Common UI component styles modal: {
errorBanner: { background: "#1a1a3e",
fontFamily: "'DM Sans', system-ui, sans-serif", border: `1px solid ${tokens.inputBorder}`,
fontSize: "0.875rem", borderRadius: tokens.radiusXl,
padding: "1rem", padding: "2rem",
background: "rgba(239, 68, 68, 0.15)", width: "90%",
border: "1px solid rgba(239, 68, 68, 0.3)", maxWidth: "400px",
borderRadius: "8px", boxShadow: tokens.cardShadow,
color: "#f87171", },
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", marginBottom: "1rem",
}, },
cancelButton: { modalActions: {
fontFamily: "'DM Sans', system-ui, sans-serif", display: "flex",
fontSize: "0.85rem", justifyContent: "space-between",
padding: "0.6rem 1rem", alignItems: "center",
background: "rgba(255, 255, 255, 0.05)", gap: "1rem",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
}, },
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: { emptyState: {
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: tokens.fontSans,
color: "rgba(255, 255, 255, 0.4)", color: tokens.textDim,
textAlign: "center" as const, textAlign: "center" as const,
padding: "1rem 0", 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,
}; };