Add Prettier for TypeScript formatting

- Install prettier
- Configure .prettierrc.json and .prettierignore
- Add npm scripts: format, format:check
- Add Makefile target: format-frontend
- Format all frontend files
This commit is contained in:
counterweight 2025-12-21 21:59:26 +01:00
parent 4b394b0698
commit 37de6f70e0
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
44 changed files with 906 additions and 856 deletions

View file

@ -1,4 +1,4 @@
.PHONY: install-backend install-frontend install setup-hooks backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend .PHONY: install-backend install-frontend install setup-hooks backend frontend db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend format-frontend
-include .env -include .env
export export
@ -111,3 +111,6 @@ lint-frontend:
fix-frontend: fix-frontend:
cd frontend && npm run lint:fix cd frontend && npm run lint:fix
format-frontend:
cd frontend && npm run format

4
frontend/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
.next/
node_modules/
app/generated/

View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

View file

@ -225,13 +225,9 @@ export default function AdminAppointmentsPage() {
<Header currentPage="admin-appointments" /> <Header currentPage="admin-appointments" />
<div style={styles.content}> <div style={styles.content}>
<h1 style={styles.pageTitle}>All Appointments</h1> <h1 style={styles.pageTitle}>All Appointments</h1>
<p style={styles.pageSubtitle}> <p style={styles.pageSubtitle}>View and manage all user appointments</p>
View and manage all user appointments
</p>
{error && ( {error && <div style={styles.errorBanner}>{error}</div>}
<div style={styles.errorBanner}>{error}</div>
)}
{/* Status Filter */} {/* Status Filter */}
<div style={styles.filterRow}> <div style={styles.filterRow}>
@ -269,22 +265,16 @@ export default function AdminAppointmentsPage() {
> >
<div style={styles.appointmentHeader}> <div style={styles.appointmentHeader}>
<div> <div>
<div style={styles.appointmentTime}> <div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
{formatDateTime(apt.slot_start)} <div style={styles.appointmentUser}>{apt.user_email}</div>
</div> {apt.note && <div style={styles.appointmentNote}>&quot;{apt.note}&quot;</div>}
<div style={styles.appointmentUser}> <span
{apt.user_email} style={{
</div>
{apt.note && (
<div style={styles.appointmentNote}>
&quot;{apt.note}&quot;
</div>
)}
<span style={{
...styles.statusBadge, ...styles.statusBadge,
background: status.bgColor, background: status.bgColor,
color: status.textColor, color: status.textColor,
}}> }}
>
{status.text} {status.text}
</span> </span>
</div> </div>
@ -327,4 +317,3 @@ export default function AdminAppointmentsPage() {
</main> </main>
); );
} }

View file

@ -8,7 +8,13 @@ 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 { formatDate, formatDisplayDate, getDateRange, formatTimeString, isWeekend } from "../../utils/date"; import {
formatDate,
formatDisplayDate,
getDateRange,
formatTimeString,
isWeekend,
} from "../../utils/date";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking; const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.booking;
@ -236,10 +242,12 @@ export default function AdminAvailabilityPage() {
</div> </div>
{copySource && ( {copySource && (
<div style={styles.copyActions}> <div style={styles.copyActions}>
<span style={styles.copyHint}> <span style={styles.copyHint}>Select days to copy to, then click Copy</span>
Select days to copy to, then click Copy <button
</span> onClick={executeCopy}
<button onClick={executeCopy} disabled={copyTargets.size === 0 || isCopying} style={styles.copyButton}> disabled={copyTargets.size === 0 || isCopying}
style={styles.copyButton}
>
{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={styles.cancelButton}>
@ -249,9 +257,7 @@ export default function AdminAvailabilityPage() {
)} )}
</div> </div>
{error && !selectedDate && ( {error && !selectedDate && <div style={styles.errorBanner}>{error}</div>}
<div style={styles.errorBanner}>{error}</div>
)}
<div style={styles.calendar}> <div style={styles.calendar}>
{dates.map((date) => { {dates.map((date) => {
@ -318,9 +324,7 @@ export default function AdminAvailabilityPage() {
{selectedDate && ( {selectedDate && (
<div style={styles.modalOverlay} onClick={closeModal}> <div style={styles.modalOverlay} onClick={closeModal}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}> <div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.modalTitle}> <h2 style={styles.modalTitle}>Edit Availability - {formatDisplayDate(selectedDate)}</h2>
Edit Availability - {formatDisplayDate(selectedDate)}
</h2>
{error && <div style={styles.modalError}>{error}</div>} {error && <div style={styles.modalError}>{error}</div>}
@ -333,7 +337,9 @@ export default function AdminAvailabilityPage() {
style={styles.timeSelect} style={styles.timeSelect}
> >
{TIME_OPTIONS.map((t) => ( {TIME_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>
{t}
</option>
))} ))}
</select> </select>
<span style={styles.slotDash}></span> <span style={styles.slotDash}></span>
@ -343,7 +349,9 @@ export default function AdminAvailabilityPage() {
style={styles.timeSelect} style={styles.timeSelect}
> >
{TIME_OPTIONS.map((t) => ( {TIME_OPTIONS.map((t) => (
<option key={t} value={t}>{t}</option> <option key={t} value={t}>
{t}
</option>
))} ))}
</select> </select>
<button <button
@ -629,4 +637,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
}; };
const styles = { ...sharedStyles, ...pageStyles }; const styles = { ...sharedStyles, ...pageStyles };

View file

@ -185,9 +185,7 @@ 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}> <span style={styles.totalCount}>{data?.total ?? 0} invites</span>
{data?.total ?? 0} invites
</span>
</div> </div>
</div> </div>
@ -206,21 +204,24 @@ export default function AdminInvitesPage() {
<tbody> <tbody>
{error && ( {error && (
<tr> <tr>
<td colSpan={6} style={styles.errorRow}>{error}</td> <td colSpan={6} style={styles.errorRow}>
{error}
</td>
</tr> </tr>
)} )}
{!error && data?.records.map((record) => ( {!error &&
data?.records.map((record) => (
<tr key={record.id} style={styles.tr}> <tr key={record.id} style={styles.tr}>
<td style={styles.tdCode}>{record.identifier}</td> <td style={styles.tdCode}>{record.identifier}</td>
<td style={styles.td}>{record.godfather_email}</td> <td style={styles.td}>{record.godfather_email}</td>
<td style={styles.td}> <td style={styles.td}>
<span style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}> <span
style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}
>
{record.status} {record.status}
</span> </span>
</td> </td>
<td style={styles.td}> <td style={styles.td}>{record.used_by_email || "-"}</td>
{record.used_by_email || "-"}
</td>
<td style={styles.tdDate}>{formatDate(record.created_at)}</td> <td style={styles.tdDate}>{formatDate(record.created_at)}</td>
<td style={styles.td}> <td style={styles.td}>
{record.status === READY && ( {record.status === READY && (
@ -236,7 +237,9 @@ export default function AdminInvitesPage() {
))} ))}
{!error && (!data || data.records.length === 0) && ( {!error && (!data || data.records.length === 0) && (
<tr> <tr>
<td colSpan={6} style={styles.emptyRow}>No invites yet</td> <td colSpan={6} style={styles.emptyRow}>
No invites yet
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@ -500,4 +503,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
}; };
const styles = { ...sharedStyles, ...pageStyles }; const styles = { ...sharedStyles, ...pageStyles };

View file

@ -16,10 +16,7 @@ export class ApiError extends Error {
} }
} }
async function request<T>( async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_URL}${endpoint}`; const url = `${API_URL}${endpoint}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -43,11 +40,7 @@ async function request<T>(
} catch { } catch {
// Response wasn't JSON // Response wasn't JSON
} }
throw new ApiError( throw new ApiError(`Request failed: ${res.status}`, res.status, data);
`Request failed: ${res.status}`,
res.status,
data
);
} }
return res.json(); return res.json();
@ -72,4 +65,3 @@ export const api = {
}); });
}, },
}; };

View file

@ -217,29 +217,25 @@ export default function AppointmentsPage() {
<Header currentPage="appointments" /> <Header currentPage="appointments" />
<div style={styles.content}> <div style={styles.content}>
<h1 style={styles.pageTitle}>My Appointments</h1> <h1 style={styles.pageTitle}>My Appointments</h1>
<p style={styles.pageSubtitle}> <p style={styles.pageSubtitle}>View and manage your booked appointments</p>
View and manage your booked appointments
</p>
{error && ( {error && <div style={styles.errorBanner}>{error}</div>}
<div style={styles.errorBanner}>{error}</div>
)}
{isLoadingAppointments ? ( {isLoadingAppointments ? (
<div style={styles.emptyState}>Loading appointments...</div> <div style={styles.emptyState}>Loading appointments...</div>
) : appointments.length === 0 ? ( ) : appointments.length === 0 ? (
<div style={styles.emptyState}> <div style={styles.emptyState}>
<p>You don&apos;t have any appointments yet.</p> <p>You don&apos;t have any appointments yet.</p>
<a href="/booking" style={styles.emptyStateLink}>Book an appointment</a> <a href="/booking" style={styles.emptyStateLink}>
Book an appointment
</a>
</div> </div>
) : ( ) : (
<> <>
{/* Upcoming Appointments */} {/* Upcoming Appointments */}
{upcomingAppointments.length > 0 && ( {upcomingAppointments.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}> <h2 style={styles.sectionTitle}>Upcoming ({upcomingAppointments.length})</h2>
Upcoming ({upcomingAppointments.length})
</h2>
<div style={styles.appointmentList}> <div style={styles.appointmentList}>
{upcomingAppointments.map((apt) => { {upcomingAppointments.map((apt) => {
const status = getStatusDisplay(apt.status, true); const status = getStatusDisplay(apt.status, true);
@ -250,16 +246,14 @@ export default function AppointmentsPage() {
<div style={styles.appointmentTime}> <div style={styles.appointmentTime}>
{formatDateTime(apt.slot_start)} {formatDateTime(apt.slot_start)}
</div> </div>
{apt.note && ( {apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
<div style={styles.appointmentNote}> <span
{apt.note} style={{
</div>
)}
<span style={{
...styles.statusBadge, ...styles.statusBadge,
background: status.bgColor, background: status.bgColor,
color: status.textColor, color: status.textColor,
}}> }}
>
{status.text} {status.text}
</span> </span>
</div> </div>
@ -310,20 +304,19 @@ export default function AppointmentsPage() {
{pastOrCancelledAppointments.map((apt) => { {pastOrCancelledAppointments.map((apt) => {
const status = getStatusDisplay(apt.status, true); const status = getStatusDisplay(apt.status, true);
return ( return (
<div key={apt.id} style={{...styles.appointmentCard, ...styles.appointmentCardPast}}> <div
<div style={styles.appointmentTime}> key={apt.id}
{formatDateTime(apt.slot_start)} style={{ ...styles.appointmentCard, ...styles.appointmentCardPast }}
</div> >
{apt.note && ( <div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
<div style={styles.appointmentNote}> {apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
{apt.note} <span
</div> style={{
)}
<span style={{
...styles.statusBadge, ...styles.statusBadge,
background: status.bgColor, background: status.bgColor,
color: status.textColor, color: status.textColor,
}}> }}
>
{status.text} {status.text}
</span> </span>
</div> </div>
@ -338,4 +331,3 @@ export default function AppointmentsPage() {
</main> </main>
); );
} }

View file

@ -17,12 +17,10 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
}; };
let mockIsLoading = false; let mockIsLoading = false;
const mockLogout = vi.fn(); const mockLogout = vi.fn();
const mockHasPermission = vi.fn((permission: string) => const mockHasPermission = vi.fn(
mockUser?.permissions.includes(permission) ?? false (permission: string) => mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) =>
mockUser?.roles.includes(role) ?? false
); );
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
vi.mock("../auth-context", () => ({ vi.mock("../auth-context", () => ({
useAuth: () => ({ useAuth: () => ({
@ -53,12 +51,10 @@ beforeEach(() => {
permissions: ["view_audit"], permissions: ["view_audit"],
}; };
mockIsLoading = false; mockIsLoading = false;
mockHasPermission.mockImplementation((permission: string) => mockHasPermission.mockImplementation(
mockUser?.permissions.includes(permission) ?? false (permission: string) => mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) =>
mockUser?.roles.includes(role) ?? false
); );
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
// Default: successful empty response // Default: successful empty response
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,

View file

@ -42,9 +42,7 @@ export default function AuditPage() {
const fetchSumRecords = useCallback(async (page: number) => { const fetchSumRecords = useCallback(async (page: number) => {
setSumError(null); setSumError(null);
try { try {
const data = await api.get<PaginatedSumRecords>( const data = await api.get<PaginatedSumRecords>(`/api/audit/sum?page=${page}&per_page=10`);
`/api/audit/sum?page=${page}&per_page=10`
);
setSumData(data); setSumData(data);
} catch (err) { } catch (err) {
setSumData(null); setSumData(null);
@ -90,9 +88,7 @@ export default function AuditPage() {
<div style={styles.tableCard}> <div style={styles.tableCard}>
<div style={styles.tableHeader}> <div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Counter Activity</h2> <h2 style={styles.tableTitle}>Counter Activity</h2>
<span style={styles.totalCount}> <span style={styles.totalCount}>{counterData?.total ?? 0} records</span>
{counterData?.total ?? 0} records
</span>
</div> </div>
<div style={styles.tableWrapper}> <div style={styles.tableWrapper}>
<table style={styles.table}> <table style={styles.table}>
@ -107,10 +103,13 @@ export default function AuditPage() {
<tbody> <tbody>
{counterError && ( {counterError && (
<tr> <tr>
<td colSpan={4} style={styles.errorRow}>{counterError}</td> <td colSpan={4} style={styles.errorRow}>
{counterError}
</td>
</tr> </tr>
)} )}
{!counterError && counterData?.records.map((record) => ( {!counterError &&
counterData?.records.map((record) => (
<tr key={record.id} style={styles.tr}> <tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td> <td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.value_before}</td> <td style={styles.tdNum}>{record.value_before}</td>
@ -120,7 +119,9 @@ export default function AuditPage() {
))} ))}
{!counterError && (!counterData || counterData.records.length === 0) && ( {!counterError && (!counterData || counterData.records.length === 0) && (
<tr> <tr>
<td colSpan={4} style={styles.emptyRow}>No records yet</td> <td colSpan={4} style={styles.emptyRow}>
No records yet
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@ -153,9 +154,7 @@ export default function AuditPage() {
<div style={styles.tableCard}> <div style={styles.tableCard}>
<div style={styles.tableHeader}> <div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Sum Activity</h2> <h2 style={styles.tableTitle}>Sum Activity</h2>
<span style={styles.totalCount}> <span style={styles.totalCount}>{sumData?.total ?? 0} records</span>
{sumData?.total ?? 0} records
</span>
</div> </div>
<div style={styles.tableWrapper}> <div style={styles.tableWrapper}>
<table style={styles.table}> <table style={styles.table}>
@ -171,10 +170,13 @@ export default function AuditPage() {
<tbody> <tbody>
{sumError && ( {sumError && (
<tr> <tr>
<td colSpan={5} style={styles.errorRow}>{sumError}</td> <td colSpan={5} style={styles.errorRow}>
{sumError}
</td>
</tr> </tr>
)} )}
{!sumError && sumData?.records.map((record) => ( {!sumError &&
sumData?.records.map((record) => (
<tr key={record.id} style={styles.tr}> <tr key={record.id} style={styles.tr}>
<td style={styles.td}>{record.user_email}</td> <td style={styles.td}>{record.user_email}</td>
<td style={styles.tdNum}>{record.a}</td> <td style={styles.tdNum}>{record.a}</td>
@ -185,7 +187,9 @@ export default function AuditPage() {
))} ))}
{!sumError && (!sumData || sumData.records.length === 0) && ( {!sumError && (!sumData || sumData.records.length === 0) && (
<tr> <tr>
<td colSpan={5} style={styles.emptyRow}>No records yet</td> <td colSpan={5} style={styles.emptyRow}>
No records yet
</td>
</tr> </tr>
)} )}
</tbody> </tbody>

View file

@ -25,7 +25,7 @@ export const Permission = {
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment", CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
} as const; } as const;
export type PermissionType = typeof Permission[keyof typeof Permission]; export type PermissionType = (typeof Permission)[keyof typeof Permission];
// Use generated type from OpenAPI schema // Use generated type from OpenAPI schema
type User = components["schemas"]["UserResponse"]; type User = components["schemas"]["UserResponse"];
@ -100,13 +100,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}; };
const hasPermission = useCallback((permission: PermissionType): boolean => { const hasPermission = useCallback(
(permission: PermissionType): boolean => {
return user?.permissions.includes(permission) ?? false; return user?.permissions.includes(permission) ?? false;
}, [user]); },
[user]
);
const hasRole = useCallback((role: string): boolean => { const hasRole = useCallback(
(role: string): boolean => {
return user?.roles.includes(role) ?? false; return user?.roles.includes(role) ?? false;
}, [user]); },
[user]
);
return ( return (
<AuthContext.Provider <AuthContext.Provider

View file

@ -234,7 +234,10 @@ export default function BookingPage() {
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
// Memoize dates to prevent infinite re-renders // Memoize dates to prevent infinite re-renders
const dates = useMemo(() => getDateRange(minAdvanceDays, maxAdvanceDays), [minAdvanceDays, maxAdvanceDays]); const dates = useMemo(
() => getDateRange(minAdvanceDays, maxAdvanceDays),
[minAdvanceDays, maxAdvanceDays]
);
const fetchSlots = useCallback(async (date: Date) => { const fetchSlots = useCallback(async (date: Date) => {
setIsLoadingSlots(true); setIsLoadingSlots(true);
@ -317,7 +320,9 @@ export default function BookingPage() {
note: note || null, note: note || null,
}); });
setSuccessMessage(`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`); setSuccessMessage(
`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`
);
setSelectedSlot(null); setSelectedSlot(null);
setNote(""); setNote("");
@ -359,13 +364,9 @@ export default function BookingPage() {
Select a date to see available {slotDurationMinutes}-minute slots Select a date to see available {slotDurationMinutes}-minute slots
</p> </p>
{successMessage && ( {successMessage && <div style={styles.successBanner}>{successMessage}</div>}
<div style={styles.successBanner}>{successMessage}</div>
)}
{error && ( {error && <div style={styles.errorBanner}>{error}</div>}
<div style={styles.errorBanner}>{error}</div>
)}
{/* Date Selection */} {/* Date Selection */}
<div style={styles.section}> <div style={styles.section}>
@ -404,10 +405,11 @@ export default function BookingPage() {
{selectedDate && ( {selectedDate && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}> <h2 style={styles.sectionTitle}>
Available Slots for {selectedDate.toLocaleDateString("en-US", { Available Slots for{" "}
{selectedDate.toLocaleDateString("en-US", {
weekday: "long", weekday: "long",
month: "long", month: "long",
day: "numeric" day: "numeric",
})} })}
</h2> </h2>
@ -442,23 +444,24 @@ export default function BookingPage() {
<div style={styles.confirmCard}> <div style={styles.confirmCard}>
<h3 style={styles.confirmTitle}>Confirm Booking</h3> <h3 style={styles.confirmTitle}>Confirm Booking</h3>
<p style={styles.confirmTime}> <p style={styles.confirmTime}>
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} <strong>Time:</strong> {formatTime(selectedSlot.start_time)} -{" "}
{formatTime(selectedSlot.end_time)}
</p> </p>
<div> <div>
<label style={styles.inputLabel}> <label style={styles.inputLabel}>Note (optional, max {noteMaxLength} chars)</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={styles.textarea}
/> />
<div style={{ <div
style={{
...styles.charCount, ...styles.charCount,
...(note.length >= noteMaxLength ? styles.charCountWarning : {}), ...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
}}> }}
>
{note.length}/{noteMaxLength} {note.length}/{noteMaxLength}
</div> </div>
</div> </div>
@ -488,4 +491,3 @@ export default function BookingPage() {
</main> </main>
); );
} }

View file

@ -7,7 +7,17 @@ import constants from "../../../shared/constants.json";
const { ADMIN, REGULAR } = constants.roles; const { ADMIN, REGULAR } = constants.roles;
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "appointments" | "audit" | "admin-invites" | "admin-availability" | "admin-appointments"; type PageId =
| "counter"
| "sum"
| "profile"
| "invites"
| "booking"
| "appointments"
| "audit"
| "admin-invites"
| "admin-availability"
| "admin-appointments";
interface HeaderProps { interface HeaderProps {
currentPage: PageId; currentPage: PageId;
@ -82,4 +92,3 @@ export function Header({ currentPage }: HeaderProps) {
</div> </div>
); );
} }

View file

@ -1,2 +1 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";

View file

@ -46,11 +46,13 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
if (!isAuthorized) { if (!isAuthorized) {
// Redirect to the most appropriate page based on permissions // Redirect to the most appropriate page based on permissions
const redirect = fallbackRedirect ?? ( const redirect =
hasPermission(Permission.VIEW_AUDIT) ? "/audit" : fallbackRedirect ??
hasPermission(Permission.VIEW_COUNTER) ? "/" : (hasPermission(Permission.VIEW_AUDIT)
"/login" ? "/audit"
); : hasPermission(Permission.VIEW_COUNTER)
? "/"
: "/login");
router.push(redirect); router.push(redirect);
} }
}, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]); }, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]);
@ -61,4 +63,3 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
isAuthorized, isAuthorized,
}; };
} }

View file

@ -80,38 +80,27 @@ export default function InvitesPage() {
<div style={styles.pageCard}> <div style={styles.pageCard}>
<div style={styles.cardHeader}> <div style={styles.cardHeader}>
<h1 style={styles.cardTitle}>My Invites</h1> <h1 style={styles.cardTitle}>My Invites</h1>
<p style={styles.cardSubtitle}> <p style={styles.cardSubtitle}>Share your invite codes with friends to let them join</p>
Share your invite codes with friends to let them join
</p>
</div> </div>
{invites.length === 0 ? ( {invites.length === 0 ? (
<div style={styles.emptyState}> <div style={styles.emptyState}>
<p style={styles.emptyText}>You don&apos;t have any invites yet.</p> <p style={styles.emptyText}>You don&apos;t have any invites yet.</p>
<p style={styles.emptyHint}> <p style={styles.emptyHint}>Contact an admin if you need invite codes to share.</p>
Contact an admin if you need invite codes to share.
</p>
</div> </div>
) : ( ) : (
<div style={styles.sections}> <div style={styles.sections}>
{/* Ready Invites */} {/* Ready Invites */}
{readyInvites.length > 0 && ( {readyInvites.length > 0 && (
<div style={styles.section}> <div style={styles.section}>
<h2 style={styles.sectionTitle}> <h2 style={styles.sectionTitle}>Available ({readyInvites.length})</h2>
Available ({readyInvites.length}) <p style={styles.sectionHint}>Share these links with people you want to invite</p>
</h2>
<p style={styles.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 <button onClick={() => copyToClipboard(invite)} style={styles.copyButton}>
onClick={() => copyToClipboard(invite)}
style={styles.copyButton}
>
{copiedId === invite.id ? "Copied!" : "Copy Link"} {copiedId === invite.id ? "Copied!" : "Copy Link"}
</button> </button>
</div> </div>
@ -124,18 +113,14 @@ 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}> <h2 style={styles.sectionTitle}>Used ({spentInvites.length})</h2>
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={styles.statusBadgeSpent}>Used</span>
<span style={styles.inviteeEmail}> <span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
by {invite.used_by_email}
</span>
</div> </div>
</div> </div>
))} ))}
@ -146,9 +131,7 @@ 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}> <h2 style={styles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
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}>
@ -324,4 +307,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
}; };
const styles = { ...sharedStyles, ...pageStyles }; const styles = { ...sharedStyles, ...pageStyles };

View file

@ -41,7 +41,9 @@ export default function LoginPage() {
{error && <div style={styles.error}>{error}</div>} {error && <div style={styles.error}>{error}</div>}
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="email" style={styles.label}>Email</label> <label htmlFor="email" style={styles.label}>
Email
</label>
<input <input
id="email" id="email"
type="email" type="email"
@ -54,7 +56,9 @@ export default function LoginPage() {
</div> </div>
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="password" style={styles.label}>Password</label> <label htmlFor="password" style={styles.label}>
Password
</label>
<input <input
id="password" id="password"
type="password" type="password"

View file

@ -19,12 +19,10 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
}; };
let mockIsLoading = false; let mockIsLoading = false;
const mockLogout = vi.fn(); const mockLogout = vi.fn();
const mockHasPermission = vi.fn((permission: string) => const mockHasPermission = vi.fn(
mockUser?.permissions.includes(permission) ?? false (permission: string) => mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) =>
mockUser?.roles.includes(role) ?? false
); );
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
vi.mock("./auth-context", () => ({ vi.mock("./auth-context", () => ({
useAuth: () => ({ useAuth: () => ({
@ -52,12 +50,10 @@ beforeEach(() => {
permissions: ["view_counter", "increment_counter", "use_sum"], permissions: ["view_counter", "increment_counter", "use_sum"],
}; };
mockIsLoading = false; mockIsLoading = false;
mockHasPermission.mockImplementation((permission: string) => mockHasPermission.mockImplementation(
mockUser?.permissions.includes(permission) ?? false (permission: string) => mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) =>
mockUser?.roles.includes(role) ?? false
); );
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
}); });
afterEach(() => { afterEach(() => {

View file

@ -15,7 +15,8 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (user && isAuthorized) { if (user && isAuthorized) {
api.get<{ value: number }>("/api/counter") api
.get<{ value: number }>("/api/counter")
.then((data) => setCount(data.value)) .then((data) => setCount(data.value))
.catch(() => setCount(null)); .catch(() => setCount(null));
} }

View file

@ -162,7 +162,8 @@ describe("ProfilePage - Display", () => {
test("displays empty fields when profile has null values", async () => { test("displays empty fields when profile has null values", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -308,7 +309,8 @@ describe("ProfilePage - Form Behavior", () => {
test("auto-prepends @ to telegram when user starts with letter", async () => { test("auto-prepends @ to telegram when user starts with letter", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -333,7 +335,8 @@ describe("ProfilePage - Form Behavior", () => {
test("does not auto-prepend @ if user types @ first", async () => { test("does not auto-prepend @ if user types @ first", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({ vi.spyOn(global, "fetch").mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -358,10 +361,12 @@ describe("ProfilePage - Form Behavior", () => {
describe("ProfilePage - Form Submission", () => { describe("ProfilePage - Form Submission", () => {
test("shows success toast after successful save", async () => { test("shows success toast after successful save", async () => {
const fetchSpy = vi.spyOn(global, "fetch") const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -370,7 +375,8 @@ describe("ProfilePage - Form Submission", () => {
} as Response) } as Response)
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: "new@example.com", contact_email: "new@example.com",
telegram: null, telegram: null,
signal: null, signal: null,
@ -410,7 +416,8 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch") vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -420,7 +427,8 @@ describe("ProfilePage - Form Submission", () => {
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: false, ok: false,
status: 422, status: 422,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
detail: { detail: {
field_errors: { field_errors: {
telegram: "Backend error: invalid handle", telegram: "Backend error: invalid handle",
@ -452,7 +460,8 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch") vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -489,7 +498,8 @@ describe("ProfilePage - Form Submission", () => {
vi.spyOn(global, "fetch") vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ .mockResolvedValueOnce({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: null, contact_email: null,
telegram: null, telegram: null,
signal: null, signal: null,
@ -519,7 +529,8 @@ describe("ProfilePage - Form Submission", () => {
// Resolve the promise // Resolve the promise
resolveSubmit!({ resolveSubmit!({
ok: true, ok: true,
json: () => Promise.resolve({ json: () =>
Promise.resolve({
contact_email: "new@example.com", contact_email: "new@example.com",
telegram: null, telegram: null,
signal: null, signal: null,
@ -532,4 +543,3 @@ describe("ProfilePage - Form Submission", () => {
}); });
}); });
}); });

View file

@ -34,7 +34,8 @@ function validateEmail(value: string): string | undefined {
if (!value) return undefined; if (!value) return undefined;
// More comprehensive email regex that matches email-validator behavior // More comprehensive email regex that matches email-validator behavior
// Checks for: local part, @, domain with at least one dot, valid TLD // Checks for: local part, @, domain with at least one dot, valid TLD
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/; const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
if (!emailRegex.test(value)) { if (!emailRegex.test(value)) {
return "Please enter a valid email address"; return "Please enter a valid email address";
} }
@ -300,9 +301,7 @@ export default function ProfilePage() {
style={{ ...styles.input, ...styles.inputReadOnly }} style={{ ...styles.input, ...styles.inputReadOnly }}
disabled disabled
/> />
<span style={styles.hint}> <span style={styles.hint}>This is your login email and cannot be changed here.</span>
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 */}
@ -315,9 +314,7 @@ export default function ProfilePage() {
<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}> <span style={styles.hint}>The user who invited you to join.</span>
The user who invited you to join.
</span>
</div> </div>
)} )}
@ -344,9 +341,7 @@ export default function ProfilePage() {
}} }}
placeholder="alternate@example.com" placeholder="alternate@example.com"
/> />
{errors.contact_email && ( {errors.contact_email && <span style={styles.errorText}>{errors.contact_email}</span>}
<span style={styles.errorText}>{errors.contact_email}</span>
)}
</div> </div>
{/* Telegram */} {/* Telegram */}
@ -365,9 +360,7 @@ export default function ProfilePage() {
}} }}
placeholder="@username" placeholder="@username"
/> />
{errors.telegram && ( {errors.telegram && <span style={styles.errorText}>{errors.telegram}</span>}
<span style={styles.errorText}>{errors.telegram}</span>
)}
</div> </div>
{/* Signal */} {/* Signal */}
@ -386,9 +379,7 @@ export default function ProfilePage() {
}} }}
placeholder="username.01" placeholder="username.01"
/> />
{errors.signal && ( {errors.signal && <span style={styles.errorText}>{errors.signal}</span>}
<span style={styles.errorText}>{errors.signal}</span>
)}
</div> </div>
{/* Nostr npub */} {/* Nostr npub */}
@ -407,9 +398,7 @@ export default function ProfilePage() {
}} }}
placeholder="npub1..." placeholder="npub1..."
/> />
{errors.nostr_npub && ( {errors.nostr_npub && <span style={styles.errorText}>{errors.nostr_npub}</span>}
<span style={styles.errorText}>{errors.nostr_npub}</span>
)}
</div> </div>
<button <button

View file

@ -25,7 +25,8 @@ export default function SignupWithCodePage() {
}, [user, isLoading, code, router]); }, [user, isLoading, code, router]);
return ( return (
<main style={{ <main
style={{
minHeight: "100vh", minHeight: "100vh",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -33,7 +34,8 @@ export default function SignupWithCodePage() {
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)", background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)",
color: "rgba(255,255,255,0.6)", color: "rgba(255,255,255,0.6)",
fontFamily: "'DM Sans', system-ui, sans-serif", fontFamily: "'DM Sans', system-ui, sans-serif",
}}> }}
>
Redirecting... Redirecting...
</main> </main>
); );

View file

@ -141,7 +141,9 @@ function SignupContent() {
{inviteError && <div style={styles.error}>{inviteError}</div>} {inviteError && <div style={styles.error}>{inviteError}</div>}
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="inviteCode" style={styles.label}>Invite Code</label> <label htmlFor="inviteCode" style={styles.label}>
Invite Code
</label>
<input <input
id="inviteCode" id="inviteCode"
type="text" type="text"
@ -156,7 +158,14 @@ function SignupContent() {
required required
autoFocus autoFocus
/> />
<span style={{ ...styles.link, fontSize: "0.8rem", marginTop: "0.5rem", display: "block" }}> <span
style={{
...styles.link,
fontSize: "0.8rem",
marginTop: "0.5rem",
display: "block",
}}
>
Ask your inviter for this code Ask your inviter for this code
</span> </span>
</div> </div>
@ -193,12 +202,17 @@ function SignupContent() {
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>Create account</h1> <h1 style={styles.title}>Create account</h1>
<p style={styles.subtitle}> <p style={styles.subtitle}>
Using invite: <code style={{ Using invite:{" "}
<code
style={{
background: "rgba(255,255,255,0.1)", background: "rgba(255,255,255,0.1)",
padding: "0.2rem 0.5rem", padding: "0.2rem 0.5rem",
borderRadius: "4px", borderRadius: "4px",
fontSize: "0.85rem" fontSize: "0.85rem",
}}>{inviteCode}</code> }}
>
{inviteCode}
</code>
</p> </p>
</div> </div>
@ -206,7 +220,9 @@ function SignupContent() {
{error && <div style={styles.error}>{error}</div>} {error && <div style={styles.error}>{error}</div>}
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="email" style={styles.label}>Email</label> <label htmlFor="email" style={styles.label}>
Email
</label>
<input <input
id="email" id="email"
type="email" type="email"
@ -220,7 +236,9 @@ function SignupContent() {
</div> </div>
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="password" style={styles.label}>Password</label> <label htmlFor="password" style={styles.label}>
Password
</label>
<input <input
id="password" id="password"
type="password" type="password"
@ -233,7 +251,9 @@ function SignupContent() {
</div> </div>
<div style={styles.field}> <div style={styles.field}>
<label htmlFor="confirmPassword" style={styles.label}>Confirm Password</label> <label htmlFor="confirmPassword" style={styles.label}>
Confirm Password
</label>
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
@ -282,17 +302,17 @@ function SignupContent() {
export default function SignupPage() { export default function SignupPage() {
return ( return (
<Suspense fallback={ <Suspense
fallback={
<main style={styles.main}> <main style={styles.main}>
<div style={styles.container}> <div style={styles.container}>
<div style={styles.card}> <div style={styles.card}>
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}> <div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>Loading...</div>
Loading...
</div>
</div> </div>
</div> </div>
</main> </main>
}> }
>
<SignupContent /> <SignupContent />
</Suspense> </Suspense>
); );

View file

@ -103,4 +103,3 @@ export const authFormStyles: Record<string, CSSProperties> = {
fontWeight: 500, fontWeight: 500,
}, },
}; };

View file

@ -84,17 +84,11 @@ export default function SumPage() {
/> />
</div> </div>
</div> </div>
<button <button onClick={handleSum} style={styles.sumBtn} disabled={a === "" && b === ""}>
onClick={handleSum}
style={styles.sumBtn}
disabled={a === "" && b === ""}
>
<span style={styles.equalsIcon}>=</span> <span style={styles.equalsIcon}>=</span>
Calculate Calculate
</button> </button>
{error && ( {error && <div style={styles.error}>{error}</div>}
<div style={styles.error}>{error}</div>
)}
</div> </div>
) : ( ) : (
<div style={styles.resultSection}> <div style={styles.resultSection}>

View file

@ -25,9 +25,12 @@ export function getStatusDisplay(status: string, isOwnView: boolean = false): St
textColor: "#f87171", textColor: "#f87171",
}; };
case "cancelled_by_admin": case "cancelled_by_admin":
return { text: "Cancelled by admin", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "#f87171" }; return {
text: "Cancelled by admin",
bgColor: "rgba(239, 68, 68, 0.2)",
textColor: "#f87171",
};
default: default:
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" }; return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
} }
} }

View file

@ -81,4 +81,3 @@ export function isWeekend(date: Date): boolean {
const day = date.getDay(); const day = date.getDay();
return day === 0 || day === 6; return day === 0 || day === 6;
} }

View file

@ -99,7 +99,11 @@ test.describe("Admin Invites Page", () => {
// Wait for the new invite to appear and capture its code // Wait for the new invite to appear and capture its code
// The new invite should be the first row with godfather = REGULAR_USER_EMAIL and status = ready // The new invite should be the first row with godfather = REGULAR_USER_EMAIL and status = ready
const newInviteRow = page.locator("tr").filter({ hasText: REGULAR_USER_EMAIL }).filter({ hasText: "ready" }).first(); const newInviteRow = page
.locator("tr")
.filter({ hasText: REGULAR_USER_EMAIL })
.filter({ hasText: "ready" })
.first();
await expect(newInviteRow).toBeVisible(); await expect(newInviteRow).toBeVisible();
// Get the invite code from this row (first cell) // Get the invite code from this row (first cell)
@ -161,4 +165,3 @@ test.describe("Admin Invites Access Control", () => {
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
}); });

View file

@ -17,7 +17,7 @@ async function createTestBooking(page: Page) {
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const adminCookies = await page.context().cookies(); const adminCookies = await page.context().cookies();
const adminAuthCookie = adminCookies.find(c => c.name === "auth_token"); const adminAuthCookie = adminCookies.find((c) => c.name === "auth_token");
if (!adminAuthCookie) throw new Error("No admin auth cookie"); if (!adminAuthCookie) throw new Error("No admin auth cookie");
@ -37,7 +37,7 @@ async function createTestBooking(page: Page) {
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const userCookies = await page.context().cookies(); const userCookies = await page.context().cookies();
const userAuthCookie = userCookies.find(c => c.name === "auth_token"); const userAuthCookie = userCookies.find((c) => c.name === "auth_token");
if (!userAuthCookie) throw new Error("No user auth cookie"); if (!userAuthCookie) throw new Error("No user auth cookie");
@ -45,7 +45,7 @@ async function createTestBooking(page: Page) {
const randomMinute = Math.floor(Math.random() * 11) * 15; // 0, 15, 30, 45 etc up to 165 min const randomMinute = Math.floor(Math.random() * 11) * 15; // 0, 15, 30, 45 etc up to 165 min
const hour = 9 + Math.floor(randomMinute / 60); const hour = 9 + Math.floor(randomMinute / 60);
const minute = randomMinute % 60; const minute = randomMinute % 60;
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`; const timeStr = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
const response = await page.request.post(`${API_URL}/api/booking`, { const response = await page.request.post(`${API_URL}/api/booking`, {
headers: { headers: {
@ -174,7 +174,7 @@ test.describe("Appointments API", () => {
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
const response = await page.request.get(`${API_URL}/api/appointments`, { const response = await page.request.get(`${API_URL}/api/appointments`, {
@ -193,19 +193,16 @@ test.describe("Appointments API", () => {
const booking = await createTestBooking(page); const booking = await createTestBooking(page);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie && booking && booking.id) { if (authCookie && booking && booking.id) {
const response = await page.request.post( const response = await page.request.post(`${API_URL}/api/appointments/${booking.id}/cancel`, {
`${API_URL}/api/appointments/${booking.id}/cancel`,
{
headers: { headers: {
Cookie: `auth_token=${authCookie.value}`, Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
data: {}, data: {},
} });
);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
const data = await response.json(); const data = await response.json();
@ -218,7 +215,7 @@ test.describe("Appointments API", () => {
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
const response = await page.request.get(`${API_URL}/api/appointments`, { const response = await page.request.get(`${API_URL}/api/appointments`, {
@ -231,4 +228,3 @@ test.describe("Appointments API", () => {
} }
}); });
}); });

View file

@ -61,7 +61,7 @@ test.describe("Authentication Flow", () => {
test("signup page has invite code form", async ({ page }) => { test("signup page has invite code form", async ({ page }) => {
await page.goto("/signup"); await page.goto("/signup");
await expect(page.locator("h1")).toHaveText("Join with Invite"); await expect(page.locator("h1")).toHaveText("Join with Invite");
await expect(page.locator('input#inviteCode')).toBeVisible(); await expect(page.locator("input#inviteCode")).toBeVisible();
await expect(page.locator('button[type="submit"]')).toHaveText("Continue"); await expect(page.locator('button[type="submit"]')).toHaveText("Continue");
await expect(page.locator('a[href="/login"]')).toBeVisible(); await expect(page.locator('a[href="/login"]')).toBeVisible();
}); });
@ -80,19 +80,22 @@ test.describe("Authentication Flow", () => {
}); });
test.describe("Logged-in User Visiting Invite URL", () => { test.describe("Logged-in User Visiting Invite URL", () => {
test("redirects to home when logged-in user visits direct invite URL", async ({ page, request }) => { test("redirects to home when logged-in user visits direct invite URL", async ({
page,
request,
}) => {
const email = uniqueEmail(); const email = uniqueEmail();
const inviteCode = await createInvite(request); const inviteCode = await createInvite(request);
// First sign up to create a user // First sign up to create a user
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -110,13 +113,13 @@ test.describe("Logged-in User Visiting Invite URL", () => {
// Sign up and stay logged in // Sign up and stay logged in
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -138,16 +141,16 @@ test.describe("Signup with Invite", () => {
await page.goto("/signup"); await page.goto("/signup");
// Step 1: Enter invite code // Step 1: Enter invite code
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Wait for form to transition to registration form // Wait for form to transition to registration form
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
// Step 2: Fill registration form // Step 2: Fill registration form
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Should redirect to home after signup // Should redirect to home after signup
@ -170,9 +173,9 @@ test.describe("Signup with Invite", () => {
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration form // Fill registration form
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Should redirect to home // Should redirect to home
@ -181,7 +184,7 @@ test.describe("Signup with Invite", () => {
test("shows error for invalid invite code", async ({ page }) => { test("shows error for invalid invite code", async ({ page }) => {
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', "fake-code-99"); await page.fill("input#inviteCode", "fake-code-99");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
// Should show error // Should show error
@ -192,14 +195,14 @@ test.describe("Signup with Invite", () => {
const inviteCode = await createInvite(request); const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', uniqueEmail()); await page.fill("input#email", uniqueEmail());
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "differentpassword"); await page.fill("input#confirmPassword", "differentpassword");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.getByText("Passwords do not match")).toBeVisible(); await expect(page.getByText("Passwords do not match")).toBeVisible();
@ -209,14 +212,14 @@ test.describe("Signup with Invite", () => {
const inviteCode = await createInvite(request); const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', uniqueEmail()); await page.fill("input#email", uniqueEmail());
await page.fill('input#password', "short"); await page.fill("input#password", "short");
await page.fill('input#confirmPassword', "short"); await page.fill("input#confirmPassword", "short");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.getByText("Password must be at least 6 characters")).toBeVisible(); await expect(page.getByText("Password must be at least 6 characters")).toBeVisible();
@ -292,13 +295,13 @@ test.describe("Logout", () => {
// Sign up // Sign up
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -315,13 +318,13 @@ test.describe("Logout", () => {
// Sign up // Sign up
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -342,13 +345,13 @@ test.describe("Session Persistence", () => {
// Sign up // Sign up
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
await expect(page.getByText(email)).toBeVisible(); await expect(page.getByText(email)).toBeVisible();
@ -366,13 +369,13 @@ test.describe("Session Persistence", () => {
const inviteCode = await createInvite(request); const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -388,13 +391,13 @@ test.describe("Session Persistence", () => {
const inviteCode = await createInvite(request); const inviteCode = await createInvite(request);
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");

View file

@ -68,20 +68,23 @@ test.describe("Availability Page - Admin Access", () => {
// Find a day card with "No availability" and click on it // Find a day card with "No availability" and click on it
// This ensures we're clicking on a day without existing slots // This ensures we're clicking on a day without existing slots
const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({ const dayCardWithNoAvailability = page
has: page.getByText("No availability") .locator('[data-testid^="day-card-"]')
}).first(); .filter({
has: page.getByText("No availability"),
})
.first();
await dayCardWithNoAvailability.click(); await dayCardWithNoAvailability.click();
// Wait for modal // Wait for modal
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// Set up listeners for both PUT and GET before clicking Save to avoid race condition // Set up listeners for both PUT and GET before clicking Save to avoid race condition
const putPromise = page.waitForResponse(resp => const putPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
); );
const getPromise = page.waitForResponse(resp => const getPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
); );
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await putPromise; await putPromise;
@ -101,12 +104,15 @@ test.describe("Availability Page - Admin Access", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Find a day card with "No availability" and click on it // Find a day card with "No availability" and click on it
const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({ const dayCardWithNoAvailability = page
has: page.getByText("No availability") .locator('[data-testid^="day-card-"]')
}).first(); .filter({
has: page.getByText("No availability"),
})
.first();
// Get the testid so we can find the same card later // Get the testid so we can find the same card later
const testId = await dayCardWithNoAvailability.getAttribute('data-testid'); const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
const targetCard = page.locator(`[data-testid="${testId}"]`); const targetCard = page.locator(`[data-testid="${testId}"]`);
// First add availability // First add availability
@ -114,11 +120,11 @@ test.describe("Availability Page - Admin Access", () => {
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// Set up listeners for both PUT and GET before clicking Save to avoid race condition // Set up listeners for both PUT and GET before clicking Save to avoid race condition
const savePutPromise = page.waitForResponse(resp => const savePutPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
); );
const saveGetPromise = page.waitForResponse(resp => const saveGetPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
); );
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await savePutPromise; await savePutPromise;
@ -133,11 +139,11 @@ test.describe("Availability Page - Admin Access", () => {
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
// Set up listeners for both PUT and GET before clicking Clear to avoid race condition // Set up listeners for both PUT and GET before clicking Clear to avoid race condition
const clearPutPromise = page.waitForResponse(resp => const clearPutPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
); );
const clearGetPromise = page.waitForResponse(resp => const clearGetPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
); );
await page.getByRole("button", { name: "Clear All" }).click(); await page.getByRole("button", { name: "Clear All" }).click();
await clearPutPromise; await clearPutPromise;
@ -157,10 +163,13 @@ test.describe("Availability Page - Admin Access", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Find a day card with "No availability" and click on it (to avoid conflicts with booking tests) // Find a day card with "No availability" and click on it (to avoid conflicts with booking tests)
const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({ const dayCardWithNoAvailability = page
has: page.getByText("No availability") .locator('[data-testid^="day-card-"]')
}).first(); .filter({
const testId = await dayCardWithNoAvailability.getAttribute('data-testid'); has: page.getByText("No availability"),
})
.first();
const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
const targetCard = page.locator(`[data-testid="${testId}"]`); const targetCard = page.locator(`[data-testid="${testId}"]`);
await dayCardWithNoAvailability.click(); await dayCardWithNoAvailability.click();
@ -178,11 +187,11 @@ test.describe("Availability Page - Admin Access", () => {
await timeSelects.nth(3).selectOption("17:00"); // Second slot end await timeSelects.nth(3).selectOption("17:00"); // Second slot end
// Set up listeners for both PUT and GET before clicking Save to avoid race condition // Set up listeners for both PUT and GET before clicking Save to avoid race condition
const putPromise = page.waitForResponse(resp => const putPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
); );
const getPromise = page.waitForResponse(resp => const getPromise = page.waitForResponse(
resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" (resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
); );
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await putPromise; await putPromise;
@ -231,7 +240,7 @@ test.describe("Availability API", () => {
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
const dateStr = getTomorrowDateStr(); const dateStr = getTomorrowDateStr();
@ -259,7 +268,7 @@ test.describe("Availability API", () => {
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
const dateStr = getTomorrowDateStr(); const dateStr = getTomorrowDateStr();
@ -277,4 +286,3 @@ test.describe("Availability API", () => {
} }
}); });
}); });

View file

@ -11,7 +11,7 @@ import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpe
// Set up availability for a date using the API with retry logic // Set up availability for a date using the API with retry logic
async function setAvailability(page: Page, dateStr: string, maxRetries = 3) { async function setAvailability(page: Page, dateStr: string, maxRetries = 3) {
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (!authCookie) { if (!authCookie) {
throw new Error("No auth cookie found when trying to set availability"); throw new Error("No auth cookie found when trying to set availability");
@ -76,7 +76,9 @@ test.describe("Booking Page - Regular User Access", () => {
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
// Should see multiple date buttons // Should see multiple date buttons
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ }); const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
await expect(dateButtons.first()).toBeVisible(); await expect(dateButtons.first()).toBeVisible();
}); });
@ -94,7 +96,9 @@ test.describe("Booking Page - Regular User Access", () => {
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
// Find an enabled date button (one with availability) // Find an enabled date button (one with availability)
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ }); const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
let enabledButton = null; let enabledButton = null;
const buttonCount = await dateButtons.count(); const buttonCount = await dateButtons.count();
for (let i = 0; i < buttonCount; i++) { for (let i = 0; i < buttonCount; i++) {
@ -122,7 +126,9 @@ test.describe("Booking Page - Regular User Access", () => {
// Find an enabled date button (one that has availability or is still loading) // Find an enabled date button (one that has availability or is still loading)
// If all dates are disabled, we can't test clicking, so verify disabled state // If all dates are disabled, we can't test clicking, so verify disabled state
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ }); const dateButtons = page
.locator("button")
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
const enabledButtons = dateButtons.filter({ hasNotText: /disabled/ }); const enabledButtons = dateButtons.filter({ hasNotText: /disabled/ });
const enabledCount = await enabledButtons.count(); const enabledCount = await enabledButtons.count();
@ -173,7 +179,10 @@ test.describe("Booking Page - With Availability", () => {
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date using the weekday name // Click tomorrow's date using the weekday name
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first(); const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click(); await dateButton.click();
// Wait for "Available Slots" section to appear // Wait for "Available Slots" section to appear
@ -197,7 +206,10 @@ test.describe("Booking Page - With Availability", () => {
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date // Click tomorrow's date
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first(); const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click(); await dateButton.click();
// Wait for any slot to appear // Wait for any slot to appear
@ -222,7 +234,10 @@ test.describe("Booking Page - With Availability", () => {
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date // Click tomorrow's date
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first(); const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click(); await dateButton.click();
// Wait for slots to load // Wait for slots to load
@ -252,7 +267,10 @@ test.describe("Booking Page - With Availability", () => {
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" }); const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
// Click tomorrow's date // Click tomorrow's date
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first(); const dateButton = page
.locator("button")
.filter({ hasText: new RegExp(`^${weekday}`) })
.first();
await dateButton.click(); await dateButton.click();
// Wait for slots to load // Wait for slots to load
@ -272,7 +290,9 @@ test.describe("Booking Page - With Availability", () => {
await page.getByRole("button", { name: "Book Appointment" }).click(); await page.getByRole("button", { name: "Book Appointment" }).click();
// Wait for booking form to disappear (indicates booking completed) // Wait for booking form to disappear (indicates booking completed)
await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({ timeout: 10000 }); await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({
timeout: 10000,
});
// Wait for success message // Wait for success message
await expect(page.getByText(/Appointment booked/)).toBeVisible(); await expect(page.getByText(/Appointment booked/)).toBeVisible();
@ -325,7 +345,7 @@ test.describe("Booking API", () => {
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
// Use 11:45 to avoid conflicts with other tests using 10:00 // Use 11:45 to avoid conflicts with other tests using 10:00
@ -354,7 +374,7 @@ test.describe("Booking API", () => {
await setAvailability(page, dateStr); await setAvailability(page, dateStr);
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
const response = await request.post(`${API_URL}/api/booking`, { const response = await request.post(`${API_URL}/api/booking`, {
@ -371,4 +391,3 @@ test.describe("Booking API", () => {
} }
}); });
}); });

View file

@ -38,7 +38,7 @@ async function authenticate(page: Page, request: APIRequestContext): Promise<str
await page.goto("/signup"); await page.goto("/signup");
// Enter invite code first // Enter invite code first
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
// Click and wait for invite check API to complete // Click and wait for invite check API to complete
await Promise.all([ await Promise.all([
@ -50,9 +50,9 @@ async function authenticate(page: Page, request: APIRequestContext): Promise<str
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
// Fill registration // Fill registration
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");
@ -177,13 +177,13 @@ test.describe("Counter - Session Integration", () => {
// Sign up with invite // Sign up with invite
await page.goto("/signup"); await page.goto("/signup");
await page.fill('input#inviteCode', inviteCode); await page.fill("input#inviteCode", inviteCode);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page.locator("h1")).toHaveText("Create account"); await expect(page.locator("h1")).toHaveText("Create account");
await page.fill('input#email', email); await page.fill("input#email", email);
await page.fill('input#password', "password123"); await page.fill("input#password", "password123");
await page.fill('input#confirmPassword', "password123"); await page.fill("input#confirmPassword", "password123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL("/"); await expect(page).toHaveURL("/");

View file

@ -37,4 +37,3 @@ export async function loginUser(page: Page, email: string, password: string) {
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 }); await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
} }

View file

@ -20,4 +20,3 @@ export function getTomorrowDateStr(): string {
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
return formatDateLocal(tomorrow); return formatDateLocal(tomorrow);
} }

View file

@ -18,7 +18,9 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
function getRequiredEnv(name: string): string { function getRequiredEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
if (!value) { if (!value) {
throw new Error(`Required environment variable ${name} is not set. Run 'source .env' or set it in your environment.`); throw new Error(
`Required environment variable ${name} is not set. Run 'source .env' or set it in your environment.`
);
} }
return value; return value;
} }
@ -231,7 +233,7 @@ test.describe("Permission Boundary via API", () => {
// Get cookies // Get cookies
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
// Try to call audit API directly // Try to call audit API directly
@ -252,7 +254,7 @@ test.describe("Permission Boundary via API", () => {
// Get cookies // Get cookies
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
// Try to call counter API directly // Try to call counter API directly
@ -301,4 +303,3 @@ test.describe("Session and Logout", () => {
await expect(page).toHaveURL("/login"); await expect(page).toHaveURL("/login");
}); });
}); });

View file

@ -51,7 +51,7 @@ async function loginUser(page: Page, email: string, password: string) {
// Helper to clear profile data via API // Helper to clear profile data via API
async function clearProfileData(page: Page) { async function clearProfileData(page: Page) {
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
await page.request.put(`${API_URL}/api/profile`, { await page.request.put(`${API_URL}/api/profile`, {
@ -332,7 +332,7 @@ test.describe("Profile - Admin User Access", () => {
test("admin API call to profile returns 403", async ({ page, request }) => { test("admin API call to profile returns 403", async ({ page, request }) => {
const cookies = await page.context().cookies(); const cookies = await page.context().cookies();
const authCookie = cookies.find(c => c.name === "auth_token"); const authCookie = cookies.find((c) => c.name === "auth_token");
if (authCookie) { if (authCookie) {
// Try to call profile API directly // Try to call profile API directly
@ -362,4 +362,3 @@ test.describe("Profile - Unauthenticated Access", () => {
expect(response.status()).toBe(401); expect(response.status()).toBe(401);
}); });
}); });

View file

@ -1,31 +1,26 @@
import eslint from '@eslint/js'; import eslint from "@eslint/js";
import tseslint from 'typescript-eslint'; import tseslint from "typescript-eslint";
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config( export default tseslint.config(
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
{ {
plugins: { plugins: {
'react-hooks': reactHooks, "react-hooks": reactHooks,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [ "@typescript-eslint/no-unused-vars": [
'error', "error",
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
], ],
// Downgrade to warnings - existing patterns use these // Downgrade to warnings - existing patterns use these
'react-hooks/exhaustive-deps': 'warn', "react-hooks/exhaustive-deps": "warn",
'react-hooks/set-state-in-effect': 'off', "react-hooks/set-state-in-effect": "off",
}, },
}, },
{ {
ignores: [ ignores: [".next/**", "node_modules/**", "app/generated/**"],
'.next/**',
'node_modules/**',
'app/generated/**',
],
} }
); );

View file

@ -3,4 +3,3 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {}; const nextConfig: NextConfig = {};
export default nextConfig; export default nextConfig;

View file

@ -24,6 +24,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"openapi-typescript": "^7.10.1", "openapi-typescript": "^7.10.1",
"prettier": "^3.7.4",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "^8.50.0", "typescript-eslint": "^8.50.0",
"vitest": "^2.1.8" "vitest": "^2.1.8"
@ -4293,6 +4294,22 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

View file

@ -11,7 +11,9 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"generate-api-types": "openapi-typescript http://localhost:8000/openapi.json -o app/generated/api.ts", "generate-api-types": "openapi-typescript http://localhost:8000/openapi.json -o app/generated/api.ts",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix" "lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"bech32": "^2.0.0", "bech32": "^2.0.0",
@ -30,6 +32,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"openapi-typescript": "^7.10.1", "openapi-typescript": "^7.10.1",
"prettier": "^3.7.4",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "^8.50.0", "typescript-eslint": "^8.50.0",
"vitest": "^2.1.8" "vitest": "^2.1.8"

View file

@ -15,4 +15,3 @@ export default defineConfig({
baseURL: "http://localhost:3000", baseURL: "http://localhost:3000",
}, },
}); });

View file

@ -19,4 +19,3 @@
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View file

@ -8,4 +8,3 @@ export default defineConfig({
include: ["app/**/*.test.{ts,tsx}"], include: ["app/**/*.test.{ts,tsx}"],
}, },
}); });