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:
parent
4b394b0698
commit
37de6f70e0
44 changed files with 906 additions and 856 deletions
|
|
@ -189,7 +189,7 @@ export default function AdminAppointmentsPage() {
|
|||
const handleCancel = async (appointmentId: number) => {
|
||||
setCancellingId(appointmentId);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
await api.post<AppointmentResponse>(`/api/admin/appointments/${appointmentId}/cancel`, {});
|
||||
await fetchAppointments();
|
||||
|
|
@ -225,13 +225,9 @@ export default function AdminAppointmentsPage() {
|
|||
<Header currentPage="admin-appointments" />
|
||||
<div style={styles.content}>
|
||||
<h1 style={styles.pageTitle}>All Appointments</h1>
|
||||
<p style={styles.pageSubtitle}>
|
||||
View and manage all user appointments
|
||||
</p>
|
||||
<p style={styles.pageSubtitle}>View and manage all user appointments</p>
|
||||
|
||||
{error && (
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Status Filter */}
|
||||
<div style={styles.filterRow}>
|
||||
|
|
@ -269,26 +265,20 @@ export default function AdminAppointmentsPage() {
|
|||
>
|
||||
<div style={styles.appointmentHeader}>
|
||||
<div>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
<div style={styles.appointmentUser}>
|
||||
{apt.user_email}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={styles.appointmentNote}>
|
||||
"{apt.note}"
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
|
||||
<div style={styles.appointmentUser}>{apt.user_email}</div>
|
||||
{apt.note && <div style={styles.appointmentNote}>"{apt.note}"</div>}
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div style={styles.buttonGroup}>
|
||||
{confirmCancelId === apt.id ? (
|
||||
|
|
@ -327,4 +317,3 @@ export default function AdminAppointmentsPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import { Header } from "../../components/Header";
|
|||
import { useRequireAuth } from "../../hooks/useRequireAuth";
|
||||
import { components } from "../../generated/api";
|
||||
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;
|
||||
|
||||
|
|
@ -57,14 +63,14 @@ export default function AdminAvailabilityPage() {
|
|||
const fetchAvailability = useCallback(async () => {
|
||||
const dateRange = getDateRange(minAdvanceDays, maxAdvanceDays);
|
||||
if (!dateRange.length) return;
|
||||
|
||||
|
||||
try {
|
||||
const fromDate = formatDate(dateRange[0]);
|
||||
const toDate = formatDate(dateRange[dateRange.length - 1]);
|
||||
const data = await api.get<AvailabilityResponse>(
|
||||
`/api/admin/availability?from=${fromDate}&to=${toDate}`
|
||||
);
|
||||
|
||||
|
||||
const map = new Map<string, TimeSlot[]>();
|
||||
for (const day of data.days) {
|
||||
map.set(day.date, day.slots);
|
||||
|
|
@ -118,21 +124,21 @@ export default function AdminAvailabilityPage() {
|
|||
|
||||
const saveAvailability = async () => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const slots = editSlots.map((s) => ({
|
||||
start_time: s.start_time + ":00",
|
||||
end_time: s.end_time + ":00",
|
||||
}));
|
||||
|
||||
|
||||
await api.put("/api/admin/availability", {
|
||||
date: formatDate(selectedDate),
|
||||
slots,
|
||||
});
|
||||
|
||||
|
||||
await fetchAvailability();
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
|
|
@ -144,16 +150,16 @@ export default function AdminAvailabilityPage() {
|
|||
|
||||
const clearAvailability = async () => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
await api.put("/api/admin/availability", {
|
||||
date: formatDate(selectedDate),
|
||||
slots: [],
|
||||
});
|
||||
|
||||
|
||||
await fetchAvailability();
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
|
|
@ -186,16 +192,16 @@ export default function AdminAvailabilityPage() {
|
|||
|
||||
const executeCopy = async () => {
|
||||
if (!copySource || copyTargets.size === 0) return;
|
||||
|
||||
|
||||
setIsCopying(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
await api.post("/api/admin/availability/copy", {
|
||||
source_date: copySource,
|
||||
target_dates: Array.from(copyTargets),
|
||||
});
|
||||
|
||||
|
||||
await fetchAvailability();
|
||||
cancelCopyMode();
|
||||
} catch (err) {
|
||||
|
|
@ -236,10 +242,12 @@ export default function AdminAvailabilityPage() {
|
|||
</div>
|
||||
{copySource && (
|
||||
<div style={styles.copyActions}>
|
||||
<span style={styles.copyHint}>
|
||||
Select days to copy to, then click Copy
|
||||
</span>
|
||||
<button onClick={executeCopy} disabled={copyTargets.size === 0 || isCopying} style={styles.copyButton}>
|
||||
<span style={styles.copyHint}>Select days to copy to, then click Copy</span>
|
||||
<button
|
||||
onClick={executeCopy}
|
||||
disabled={copyTargets.size === 0 || isCopying}
|
||||
style={styles.copyButton}
|
||||
>
|
||||
{isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`}
|
||||
</button>
|
||||
<button onClick={cancelCopyMode} style={styles.cancelButton}>
|
||||
|
|
@ -249,9 +257,7 @@ export default function AdminAvailabilityPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{error && !selectedDate && (
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
{error && !selectedDate && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<div style={styles.calendar}>
|
||||
{dates.map((date) => {
|
||||
|
|
@ -318,9 +324,7 @@ export default function AdminAvailabilityPage() {
|
|||
{selectedDate && (
|
||||
<div style={styles.modalOverlay} onClick={closeModal}>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<h2 style={styles.modalTitle}>
|
||||
Edit Availability - {formatDisplayDate(selectedDate)}
|
||||
</h2>
|
||||
<h2 style={styles.modalTitle}>Edit Availability - {formatDisplayDate(selectedDate)}</h2>
|
||||
|
||||
{error && <div style={styles.modalError}>{error}</div>}
|
||||
|
||||
|
|
@ -333,7 +337,9 @@ export default function AdminAvailabilityPage() {
|
|||
style={styles.timeSelect}
|
||||
>
|
||||
{TIME_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.slotDash}>→</span>
|
||||
|
|
@ -343,7 +349,9 @@ export default function AdminAvailabilityPage() {
|
|||
style={styles.timeSelect}
|
||||
>
|
||||
{TIME_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
|
|
@ -629,4 +637,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
|
|||
};
|
||||
|
||||
const styles = { ...sharedStyles, ...pageStyles };
|
||||
|
||||
|
|
|
|||
|
|
@ -66,10 +66,10 @@ export default function AdminInvitesPage() {
|
|||
setCreateError("Please select a godfather");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
|
||||
|
||||
try {
|
||||
await api.post("/api/admin/invites", {
|
||||
godfather_id: parseInt(newGodfatherId),
|
||||
|
|
@ -185,12 +185,10 @@ export default function AdminInvitesPage() {
|
|||
<option value={SPENT}>Spent</option>
|
||||
<option value={REVOKED}>Revoked</option>
|
||||
</select>
|
||||
<span style={styles.totalCount}>
|
||||
{data?.total ?? 0} invites
|
||||
</span>
|
||||
<span style={styles.totalCount}>{data?.total ?? 0} invites</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={styles.tableWrapper}>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
|
|
@ -206,43 +204,48 @@ export default function AdminInvitesPage() {
|
|||
<tbody>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={6} style={styles.errorRow}>{error}</td>
|
||||
<td colSpan={6} style={styles.errorRow}>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!error && data?.records.map((record) => (
|
||||
<tr key={record.id} style={styles.tr}>
|
||||
<td style={styles.tdCode}>{record.identifier}</td>
|
||||
<td style={styles.td}>{record.godfather_email}</td>
|
||||
<td style={styles.td}>
|
||||
<span style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}>
|
||||
{record.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
{record.used_by_email || "-"}
|
||||
</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
<td style={styles.td}>
|
||||
{record.status === READY && (
|
||||
<button
|
||||
onClick={() => handleRevoke(record.id)}
|
||||
style={styles.revokeButton}
|
||||
{!error &&
|
||||
data?.records.map((record) => (
|
||||
<tr key={record.id} style={styles.tr}>
|
||||
<td style={styles.tdCode}>{record.identifier}</td>
|
||||
<td style={styles.td}>{record.godfather_email}</td>
|
||||
<td style={styles.td}>
|
||||
<span
|
||||
style={{ ...styles.statusBadge, ...getStatusBadgeStyle(record.status) }}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{record.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={styles.td}>{record.used_by_email || "-"}</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
<td style={styles.td}>
|
||||
{record.status === READY && (
|
||||
<button
|
||||
onClick={() => handleRevoke(record.id)}
|
||||
style={styles.revokeButton}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!error && (!data || data.records.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} style={styles.emptyRow}>No invites yet</td>
|
||||
<td colSpan={6} style={styles.emptyRow}>
|
||||
No invites yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
{data && data.total_pages > 1 && (
|
||||
<div style={styles.pagination}>
|
||||
<button
|
||||
|
|
@ -500,4 +503,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
|
|||
};
|
||||
|
||||
const styles = { ...sharedStyles, ...pageStyles };
|
||||
|
||||
|
|
|
|||
|
|
@ -16,26 +16,23 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
|
||||
if (options.body && typeof options.body === "string") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
let data: unknown;
|
||||
try {
|
||||
|
|
@ -43,13 +40,9 @@ async function request<T>(
|
|||
} catch {
|
||||
// Response wasn't JSON
|
||||
}
|
||||
throw new ApiError(
|
||||
`Request failed: ${res.status}`,
|
||||
res.status,
|
||||
data
|
||||
);
|
||||
throw new ApiError(`Request failed: ${res.status}`, res.status, data);
|
||||
}
|
||||
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -57,14 +50,14 @@ export const api = {
|
|||
get<T>(endpoint: string): Promise<T> {
|
||||
return request<T>(endpoint);
|
||||
},
|
||||
|
||||
|
||||
post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
return request<T>(endpoint, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
return request<T>(endpoint, {
|
||||
method: "PUT",
|
||||
|
|
@ -72,4 +65,3 @@ export const api = {
|
|||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ export default function AppointmentsPage() {
|
|||
const handleCancel = async (appointmentId: number) => {
|
||||
setCancellingId(appointmentId);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
await api.post<AppointmentResponse>(`/api/appointments/${appointmentId}/cancel`, {});
|
||||
await fetchAppointments();
|
||||
|
|
@ -217,29 +217,25 @@ export default function AppointmentsPage() {
|
|||
<Header currentPage="appointments" />
|
||||
<div style={styles.content}>
|
||||
<h1 style={styles.pageTitle}>My Appointments</h1>
|
||||
<p style={styles.pageSubtitle}>
|
||||
View and manage your booked appointments
|
||||
</p>
|
||||
<p style={styles.pageSubtitle}>View and manage your booked appointments</p>
|
||||
|
||||
{error && (
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{isLoadingAppointments ? (
|
||||
<div style={styles.emptyState}>Loading appointments...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div style={styles.emptyState}>
|
||||
<p>You don'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>
|
||||
) : (
|
||||
<>
|
||||
{/* Upcoming Appointments */}
|
||||
{upcomingAppointments.length > 0 && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</h2>
|
||||
<h2 style={styles.sectionTitle}>Upcoming ({upcomingAppointments.length})</h2>
|
||||
<div style={styles.appointmentList}>
|
||||
{upcomingAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status, true);
|
||||
|
|
@ -250,20 +246,18 @@ export default function AppointmentsPage() {
|
|||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={styles.appointmentNote}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
{apt.status === "booked" && (
|
||||
<div style={styles.buttonGroup}>
|
||||
{confirmCancelId === apt.id ? (
|
||||
|
|
@ -310,20 +304,19 @@ export default function AppointmentsPage() {
|
|||
{pastOrCancelledAppointments.map((apt) => {
|
||||
const status = getStatusDisplay(apt.status, true);
|
||||
return (
|
||||
<div key={apt.id} style={{...styles.appointmentCard, ...styles.appointmentCardPast}}>
|
||||
<div style={styles.appointmentTime}>
|
||||
{formatDateTime(apt.slot_start)}
|
||||
</div>
|
||||
{apt.note && (
|
||||
<div style={styles.appointmentNote}>
|
||||
{apt.note}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}>
|
||||
<div
|
||||
key={apt.id}
|
||||
style={{ ...styles.appointmentCard, ...styles.appointmentCardPast }}
|
||||
>
|
||||
<div style={styles.appointmentTime}>{formatDateTime(apt.slot_start)}</div>
|
||||
{apt.note && <div style={styles.appointmentNote}>{apt.note}</div>}
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
background: status.bgColor,
|
||||
color: status.textColor,
|
||||
}}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -338,4 +331,3 @@ export default function AppointmentsPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,10 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
|
|||
};
|
||||
let mockIsLoading = false;
|
||||
const mockLogout = vi.fn();
|
||||
const mockHasPermission = vi.fn((permission: string) =>
|
||||
mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
const mockHasRole = vi.fn((role: string) =>
|
||||
mockUser?.roles.includes(role) ?? false
|
||||
const mockHasPermission = vi.fn(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
|
||||
vi.mock("../auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
|
|
@ -53,12 +51,10 @@ beforeEach(() => {
|
|||
permissions: ["view_audit"],
|
||||
};
|
||||
mockIsLoading = false;
|
||||
mockHasPermission.mockImplementation((permission: string) =>
|
||||
mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
mockHasRole.mockImplementation((role: string) =>
|
||||
mockUser?.roles.includes(role) ?? false
|
||||
mockHasPermission.mockImplementation(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
// Default: successful empty response
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
@ -142,7 +138,7 @@ describe("AuditPage", () => {
|
|||
per_page: 10,
|
||||
total_pages: 1,
|
||||
};
|
||||
|
||||
|
||||
const sumResponse = {
|
||||
records: [],
|
||||
total: 0,
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ export default function AuditPage() {
|
|||
const fetchSumRecords = useCallback(async (page: number) => {
|
||||
setSumError(null);
|
||||
try {
|
||||
const data = await api.get<PaginatedSumRecords>(
|
||||
`/api/audit/sum?page=${page}&per_page=10`
|
||||
);
|
||||
const data = await api.get<PaginatedSumRecords>(`/api/audit/sum?page=${page}&per_page=10`);
|
||||
setSumData(data);
|
||||
} catch (err) {
|
||||
setSumData(null);
|
||||
|
|
@ -90,9 +88,7 @@ export default function AuditPage() {
|
|||
<div style={styles.tableCard}>
|
||||
<div style={styles.tableHeader}>
|
||||
<h2 style={styles.tableTitle}>Counter Activity</h2>
|
||||
<span style={styles.totalCount}>
|
||||
{counterData?.total ?? 0} records
|
||||
</span>
|
||||
<span style={styles.totalCount}>{counterData?.total ?? 0} records</span>
|
||||
</div>
|
||||
<div style={styles.tableWrapper}>
|
||||
<table style={styles.table}>
|
||||
|
|
@ -107,20 +103,25 @@ export default function AuditPage() {
|
|||
<tbody>
|
||||
{counterError && (
|
||||
<tr>
|
||||
<td colSpan={4} style={styles.errorRow}>{counterError}</td>
|
||||
<td colSpan={4} style={styles.errorRow}>
|
||||
{counterError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!counterError && counterData?.records.map((record) => (
|
||||
<tr key={record.id} style={styles.tr}>
|
||||
<td style={styles.td}>{record.user_email}</td>
|
||||
<td style={styles.tdNum}>{record.value_before}</td>
|
||||
<td style={styles.tdNum}>{record.value_after}</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!counterError &&
|
||||
counterData?.records.map((record) => (
|
||||
<tr key={record.id} style={styles.tr}>
|
||||
<td style={styles.td}>{record.user_email}</td>
|
||||
<td style={styles.tdNum}>{record.value_before}</td>
|
||||
<td style={styles.tdNum}>{record.value_after}</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!counterError && (!counterData || counterData.records.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={4} style={styles.emptyRow}>No records yet</td>
|
||||
<td colSpan={4} style={styles.emptyRow}>
|
||||
No records yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
|
@ -153,9 +154,7 @@ export default function AuditPage() {
|
|||
<div style={styles.tableCard}>
|
||||
<div style={styles.tableHeader}>
|
||||
<h2 style={styles.tableTitle}>Sum Activity</h2>
|
||||
<span style={styles.totalCount}>
|
||||
{sumData?.total ?? 0} records
|
||||
</span>
|
||||
<span style={styles.totalCount}>{sumData?.total ?? 0} records</span>
|
||||
</div>
|
||||
<div style={styles.tableWrapper}>
|
||||
<table style={styles.table}>
|
||||
|
|
@ -171,21 +170,26 @@ export default function AuditPage() {
|
|||
<tbody>
|
||||
{sumError && (
|
||||
<tr>
|
||||
<td colSpan={5} style={styles.errorRow}>{sumError}</td>
|
||||
<td colSpan={5} style={styles.errorRow}>
|
||||
{sumError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!sumError && sumData?.records.map((record) => (
|
||||
<tr key={record.id} style={styles.tr}>
|
||||
<td style={styles.td}>{record.user_email}</td>
|
||||
<td style={styles.tdNum}>{record.a}</td>
|
||||
<td style={styles.tdNum}>{record.b}</td>
|
||||
<td style={styles.tdResult}>{record.result}</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!sumError &&
|
||||
sumData?.records.map((record) => (
|
||||
<tr key={record.id} style={styles.tr}>
|
||||
<td style={styles.td}>{record.user_email}</td>
|
||||
<td style={styles.tdNum}>{record.a}</td>
|
||||
<td style={styles.tdNum}>{record.b}</td>
|
||||
<td style={styles.tdResult}>{record.result}</td>
|
||||
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!sumError && (!sumData || sumData.records.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={5} style={styles.emptyRow}>No records yet</td>
|
||||
<td colSpan={5} style={styles.emptyRow}>
|
||||
No records yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const Permission = {
|
|||
CANCEL_ANY_APPOINTMENT: "cancel_any_appointment",
|
||||
} as const;
|
||||
|
||||
export type PermissionType = typeof Permission[keyof typeof Permission];
|
||||
export type PermissionType = (typeof Permission)[keyof typeof Permission];
|
||||
|
||||
// Use generated type from OpenAPI schema
|
||||
type User = components["schemas"]["UserResponse"];
|
||||
|
|
@ -100,13 +100,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setUser(null);
|
||||
};
|
||||
|
||||
const hasPermission = useCallback((permission: PermissionType): boolean => {
|
||||
return user?.permissions.includes(permission) ?? false;
|
||||
}, [user]);
|
||||
const hasPermission = useCallback(
|
||||
(permission: PermissionType): boolean => {
|
||||
return user?.permissions.includes(permission) ?? false;
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const hasRole = useCallback((role: string): boolean => {
|
||||
return user?.roles.includes(role) ?? false;
|
||||
}, [user]);
|
||||
const hasRole = useCallback(
|
||||
(role: string): boolean => {
|
||||
return user?.roles.includes(role) ?? false;
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
|
|
|
|||
|
|
@ -234,14 +234,17 @@ export default function BookingPage() {
|
|||
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
|
||||
|
||||
// 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) => {
|
||||
setIsLoadingSlots(true);
|
||||
setError(null);
|
||||
setAvailableSlots([]);
|
||||
setSelectedSlot(null);
|
||||
|
||||
|
||||
try {
|
||||
const dateStr = formatDate(date);
|
||||
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
||||
|
|
@ -261,7 +264,7 @@ export default function BookingPage() {
|
|||
const fetchAllAvailability = async () => {
|
||||
setIsLoadingAvailability(true);
|
||||
const availabilitySet = new Set<string>();
|
||||
|
||||
|
||||
// Fetch availability for all dates in parallel
|
||||
const promises = dates.map(async (date) => {
|
||||
try {
|
||||
|
|
@ -275,7 +278,7 @@ export default function BookingPage() {
|
|||
console.error(`Failed to fetch availability for ${formatDate(date)}:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
await Promise.all(promises);
|
||||
setDatesWithAvailability(availabilitySet);
|
||||
setIsLoadingAvailability(false);
|
||||
|
|
@ -307,20 +310,22 @@ export default function BookingPage() {
|
|||
|
||||
const handleBook = async () => {
|
||||
if (!selectedSlot) return;
|
||||
|
||||
|
||||
setIsBooking(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const appointment = await api.post<AppointmentResponse>("/api/booking", {
|
||||
slot_start: selectedSlot.start_time,
|
||||
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);
|
||||
setNote("");
|
||||
|
||||
|
||||
// Refresh slots to show the booked one is gone
|
||||
if (selectedDate) {
|
||||
await fetchSlots(selectedDate);
|
||||
|
|
@ -359,13 +364,9 @@ export default function BookingPage() {
|
|||
Select a date to see available {slotDurationMinutes}-minute slots
|
||||
</p>
|
||||
|
||||
{successMessage && (
|
||||
<div style={styles.successBanner}>{successMessage}</div>
|
||||
)}
|
||||
{successMessage && <div style={styles.successBanner}>{successMessage}</div>}
|
||||
|
||||
{error && (
|
||||
<div style={styles.errorBanner}>{error}</div>
|
||||
)}
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Date Selection */}
|
||||
<div style={styles.section}>
|
||||
|
|
@ -376,7 +377,7 @@ export default function BookingPage() {
|
|||
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
|
||||
const hasAvailability = datesWithAvailability.has(dateStr);
|
||||
const isDisabled = !hasAvailability || isLoadingAvailability;
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
|
|
@ -404,13 +405,14 @@ export default function BookingPage() {
|
|||
{selectedDate && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Available Slots for {selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
Available Slots for{" "}
|
||||
{selectedDate.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</h2>
|
||||
|
||||
|
||||
{isLoadingSlots ? (
|
||||
<div style={styles.emptyState}>Loading slots...</div>
|
||||
) : availableSlots.length === 0 ? (
|
||||
|
|
@ -442,27 +444,28 @@ export default function BookingPage() {
|
|||
<div style={styles.confirmCard}>
|
||||
<h3 style={styles.confirmTitle}>Confirm Booking</h3>
|
||||
<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>
|
||||
|
||||
|
||||
<div>
|
||||
<label style={styles.inputLabel}>
|
||||
Note (optional, max {noteMaxLength} chars)
|
||||
</label>
|
||||
<label style={styles.inputLabel}>Note (optional, max {noteMaxLength} chars)</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
|
||||
placeholder="Add a note about your appointment..."
|
||||
style={styles.textarea}
|
||||
/>
|
||||
<div style={{
|
||||
...styles.charCount,
|
||||
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
...styles.charCount,
|
||||
...(note.length >= noteMaxLength ? styles.charCountWarning : {}),
|
||||
}}
|
||||
>
|
||||
{note.length}/{noteMaxLength}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={styles.buttonRow}>
|
||||
<button
|
||||
onClick={handleBook}
|
||||
|
|
@ -488,4 +491,3 @@ export default function BookingPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,17 @@ import constants from "../../../shared/constants.json";
|
|||
|
||||
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 {
|
||||
currentPage: PageId;
|
||||
|
|
@ -82,4 +92,3 @@ export function Header({ currentPage }: HeaderProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
|||
|
||||
if (!isAuthorized) {
|
||||
// Redirect to the most appropriate page based on permissions
|
||||
const redirect = fallbackRedirect ?? (
|
||||
hasPermission(Permission.VIEW_AUDIT) ? "/audit" :
|
||||
hasPermission(Permission.VIEW_COUNTER) ? "/" :
|
||||
"/login"
|
||||
);
|
||||
const redirect =
|
||||
fallbackRedirect ??
|
||||
(hasPermission(Permission.VIEW_AUDIT)
|
||||
? "/audit"
|
||||
: hasPermission(Permission.VIEW_COUNTER)
|
||||
? "/"
|
||||
: "/login");
|
||||
router.push(redirect);
|
||||
}
|
||||
}, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]);
|
||||
|
|
@ -61,4 +63,3 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
|||
isAuthorized,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,38 +80,27 @@ export default function InvitesPage() {
|
|||
<div style={styles.pageCard}>
|
||||
<div style={styles.cardHeader}>
|
||||
<h1 style={styles.cardTitle}>My Invites</h1>
|
||||
<p style={styles.cardSubtitle}>
|
||||
Share your invite codes with friends to let them join
|
||||
</p>
|
||||
<p style={styles.cardSubtitle}>Share your invite codes with friends to let them join</p>
|
||||
</div>
|
||||
|
||||
{invites.length === 0 ? (
|
||||
<div style={styles.emptyState}>
|
||||
<p style={styles.emptyText}>You don't have any invites yet.</p>
|
||||
<p style={styles.emptyHint}>
|
||||
Contact an admin if you need invite codes to share.
|
||||
</p>
|
||||
<p style={styles.emptyHint}>Contact an admin if you need invite codes to share.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.sections}>
|
||||
{/* Ready Invites */}
|
||||
{readyInvites.length > 0 && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Available ({readyInvites.length})
|
||||
</h2>
|
||||
<p style={styles.sectionHint}>
|
||||
Share these links with people you want to invite
|
||||
</p>
|
||||
<h2 style={styles.sectionTitle}>Available ({readyInvites.length})</h2>
|
||||
<p style={styles.sectionHint}>Share these links with people you want to invite</p>
|
||||
<div style={styles.inviteList}>
|
||||
{readyInvites.map((invite) => (
|
||||
<div key={invite.id} style={styles.inviteCard}>
|
||||
<div style={styles.inviteCode}>{invite.identifier}</div>
|
||||
<div style={styles.inviteActions}>
|
||||
<button
|
||||
onClick={() => copyToClipboard(invite)}
|
||||
style={styles.copyButton}
|
||||
>
|
||||
<button onClick={() => copyToClipboard(invite)} style={styles.copyButton}>
|
||||
{copiedId === invite.id ? "Copied!" : "Copy Link"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -124,18 +113,14 @@ export default function InvitesPage() {
|
|||
{/* Spent Invites */}
|
||||
{spentInvites.length > 0 && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Used ({spentInvites.length})
|
||||
</h2>
|
||||
<h2 style={styles.sectionTitle}>Used ({spentInvites.length})</h2>
|
||||
<div style={styles.inviteList}>
|
||||
{spentInvites.map((invite) => (
|
||||
<div key={invite.id} style={styles.inviteCardSpent}>
|
||||
<div style={styles.inviteCode}>{invite.identifier}</div>
|
||||
<div style={styles.inviteeMeta}>
|
||||
<span style={styles.statusBadgeSpent}>Used</span>
|
||||
<span style={styles.inviteeEmail}>
|
||||
by {invite.used_by_email}
|
||||
</span>
|
||||
<span style={styles.inviteeEmail}>by {invite.used_by_email}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -146,9 +131,7 @@ export default function InvitesPage() {
|
|||
{/* Revoked Invites */}
|
||||
{revokedInvites.length > 0 && (
|
||||
<div style={styles.section}>
|
||||
<h2 style={styles.sectionTitle}>
|
||||
Revoked ({revokedInvites.length})
|
||||
</h2>
|
||||
<h2 style={styles.sectionTitle}>Revoked ({revokedInvites.length})</h2>
|
||||
<div style={styles.inviteList}>
|
||||
{revokedInvites.map((invite) => (
|
||||
<div key={invite.id} style={styles.inviteCardRevoked}>
|
||||
|
|
@ -324,4 +307,3 @@ const pageStyles: Record<string, React.CSSProperties> = {
|
|||
};
|
||||
|
||||
const styles = { ...sharedStyles, ...pageStyles };
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ export default function LoginPage() {
|
|||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="email" style={styles.label}>Email</label>
|
||||
<label htmlFor="email" style={styles.label}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
|
|
@ -54,7 +56,9 @@ export default function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="password" style={styles.label}>Password</label>
|
||||
<label htmlFor="password" style={styles.label}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
|
|
|
|||
|
|
@ -19,12 +19,10 @@ let mockUser: { id: number; email: string; roles: string[]; permissions: string[
|
|||
};
|
||||
let mockIsLoading = false;
|
||||
const mockLogout = vi.fn();
|
||||
const mockHasPermission = vi.fn((permission: string) =>
|
||||
mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
const mockHasRole = vi.fn((role: string) =>
|
||||
mockUser?.roles.includes(role) ?? false
|
||||
const mockHasPermission = vi.fn(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
|
||||
vi.mock("./auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
|
|
@ -52,12 +50,10 @@ beforeEach(() => {
|
|||
permissions: ["view_counter", "increment_counter", "use_sum"],
|
||||
};
|
||||
mockIsLoading = false;
|
||||
mockHasPermission.mockImplementation((permission: string) =>
|
||||
mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
mockHasRole.mockImplementation((role: string) =>
|
||||
mockUser?.roles.includes(role) ?? false
|
||||
mockHasPermission.mockImplementation(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -101,7 +97,7 @@ describe("Home - Authenticated", () => {
|
|||
|
||||
render(<Home />);
|
||||
fireEvent.click(screen.getByText("Sign out"));
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
|
|
@ -238,7 +234,7 @@ describe("Home - Navigation", () => {
|
|||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Profile")).toBeDefined();
|
||||
});
|
||||
|
|
@ -257,7 +253,7 @@ describe("Home - Navigation", () => {
|
|||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
|
||||
|
||||
// Wait for render - admin sees admin nav (Audit, Invites) not regular nav
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Audit")).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export default function Home() {
|
|||
|
||||
useEffect(() => {
|
||||
if (user && isAuthorized) {
|
||||
api.get<{ value: number }>("/api/counter")
|
||||
api
|
||||
.get<{ value: number }>("/api/counter")
|
||||
.then((data) => setCount(data.value))
|
||||
.catch(() => setCount(null));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,12 +162,13 @@ describe("ProfilePage - Display", () => {
|
|||
test("displays empty fields when profile has null values", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
|
@ -291,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||
});
|
||||
|
|
@ -308,78 +309,83 @@ describe("ProfilePage - Form Behavior", () => {
|
|||
test("auto-prepends @ to telegram when user starts with letter", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
|
||||
|
||||
// Type a letter without @ - should auto-prepend @
|
||||
fireEvent.change(telegramInput, { target: { value: "myhandle" } });
|
||||
|
||||
|
||||
expect(telegramInput.value).toBe("@myhandle");
|
||||
});
|
||||
|
||||
test("does not auto-prepend @ if user types @ first", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||
|
||||
|
||||
// User types @ first - no auto-prepend
|
||||
fireEvent.change(telegramInput, { target: { value: "@myhandle" } });
|
||||
|
||||
|
||||
expect(telegramInput.value).toBe("@myhandle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePage - Form Submission", () => {
|
||||
test("shows success toast after successful save", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch")
|
||||
const fetchSpy = vi
|
||||
.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
|
@ -410,27 +416,29 @@ describe("ProfilePage - Form Submission", () => {
|
|||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: () => Promise.resolve({
|
||||
detail: {
|
||||
field_errors: {
|
||||
telegram: "Backend error: invalid handle",
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
detail: {
|
||||
field_errors: {
|
||||
telegram: "Backend error: invalid handle",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
|
@ -452,17 +460,18 @@ describe("ProfilePage - Form Submission", () => {
|
|||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
|
@ -489,17 +498,18 @@ describe("ProfilePage - Form Submission", () => {
|
|||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: null,
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response)
|
||||
.mockReturnValueOnce(submitPromise as Promise<Response>);
|
||||
|
||||
render(<ProfilePage />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||
});
|
||||
|
|
@ -519,12 +529,13 @@ describe("ProfilePage - Form Submission", () => {
|
|||
// Resolve the promise
|
||||
resolveSubmit!({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact_email: "new@example.com",
|
||||
telegram: null,
|
||||
signal: null,
|
||||
nostr_npub: null,
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -532,4 +543,3 @@ describe("ProfilePage - Form Submission", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ function validateEmail(value: string): string | undefined {
|
|||
if (!value) return undefined;
|
||||
// More comprehensive email regex that matches email-validator behavior
|
||||
// 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)) {
|
||||
return "Please enter a valid email address";
|
||||
}
|
||||
|
|
@ -72,7 +73,7 @@ function validateNostrNpub(value: string): string | undefined {
|
|||
if (!value.startsWith(npubRules.prefix)) {
|
||||
return `Nostr npub must start with '${npubRules.prefix}'`;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const decoded = bech32.decode(value);
|
||||
if (decoded.prefix !== "npub") {
|
||||
|
|
@ -186,7 +187,7 @@ export default function ProfilePage() {
|
|||
|
||||
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value;
|
||||
|
||||
|
||||
// For telegram: auto-prepend @ if user starts with a valid letter
|
||||
if (field === "telegram" && value && !value.startsWith("@")) {
|
||||
// Check if first char is a valid telegram handle start (letter)
|
||||
|
|
@ -194,14 +195,14 @@ export default function ProfilePage() {
|
|||
value = "@" + value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
|
||||
// Clear any pending validation timeout
|
||||
if (validationTimeoutRef.current) {
|
||||
clearTimeout(validationTimeoutRef.current);
|
||||
}
|
||||
|
||||
|
||||
// Debounce validation - wait 500ms after user stops typing
|
||||
validationTimeoutRef.current = setTimeout(() => {
|
||||
const newFormData = { ...formData, [field]: value };
|
||||
|
|
@ -212,11 +213,11 @@ export default function ProfilePage() {
|
|||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
// Validate all fields
|
||||
const validationErrors = validateForm(formData);
|
||||
setErrors(validationErrors);
|
||||
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -300,9 +301,7 @@ export default function ProfilePage() {
|
|||
style={{ ...styles.input, ...styles.inputReadOnly }}
|
||||
disabled
|
||||
/>
|
||||
<span style={styles.hint}>
|
||||
This is your login email and cannot be changed here.
|
||||
</span>
|
||||
<span style={styles.hint}>This is your login email and cannot be changed here.</span>
|
||||
</div>
|
||||
|
||||
{/* Godfather - shown if user was invited */}
|
||||
|
|
@ -315,9 +314,7 @@ export default function ProfilePage() {
|
|||
<div style={styles.godfatherBox}>
|
||||
<span style={styles.godfatherEmail}>{godfatherEmail}</span>
|
||||
</div>
|
||||
<span style={styles.hint}>
|
||||
The user who invited you to join.
|
||||
</span>
|
||||
<span style={styles.hint}>The user who invited you to join.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -344,9 +341,7 @@ export default function ProfilePage() {
|
|||
}}
|
||||
placeholder="alternate@example.com"
|
||||
/>
|
||||
{errors.contact_email && (
|
||||
<span style={styles.errorText}>{errors.contact_email}</span>
|
||||
)}
|
||||
{errors.contact_email && <span style={styles.errorText}>{errors.contact_email}</span>}
|
||||
</div>
|
||||
|
||||
{/* Telegram */}
|
||||
|
|
@ -365,9 +360,7 @@ export default function ProfilePage() {
|
|||
}}
|
||||
placeholder="@username"
|
||||
/>
|
||||
{errors.telegram && (
|
||||
<span style={styles.errorText}>{errors.telegram}</span>
|
||||
)}
|
||||
{errors.telegram && <span style={styles.errorText}>{errors.telegram}</span>}
|
||||
</div>
|
||||
|
||||
{/* Signal */}
|
||||
|
|
@ -386,9 +379,7 @@ export default function ProfilePage() {
|
|||
}}
|
||||
placeholder="username.01"
|
||||
/>
|
||||
{errors.signal && (
|
||||
<span style={styles.errorText}>{errors.signal}</span>
|
||||
)}
|
||||
{errors.signal && <span style={styles.errorText}>{errors.signal}</span>}
|
||||
</div>
|
||||
|
||||
{/* Nostr npub */}
|
||||
|
|
@ -407,9 +398,7 @@ export default function ProfilePage() {
|
|||
}}
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
{errors.nostr_npub && (
|
||||
<span style={styles.errorText}>{errors.nostr_npub}</span>
|
||||
)}
|
||||
{errors.nostr_npub && <span style={styles.errorText}>{errors.nostr_npub}</span>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function SignupWithCodePage() {
|
|||
useEffect(() => {
|
||||
// Wait for auth check to complete before redirecting
|
||||
if (isLoading) return;
|
||||
|
||||
|
||||
if (user) {
|
||||
// Already logged in, redirect to home
|
||||
router.replace("/");
|
||||
|
|
@ -25,15 +25,17 @@ export default function SignupWithCodePage() {
|
|||
}, [user, isLoading, code, router]);
|
||||
|
||||
return (
|
||||
<main style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
}}>
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #0f0f23 100%)",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
Redirecting...
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,19 +15,19 @@ interface InviteCheckResponse {
|
|||
function SignupContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialCode = searchParams.get("code") || "";
|
||||
|
||||
|
||||
const [inviteCode, setInviteCode] = useState(initialCode);
|
||||
const [inviteValid, setInviteValid] = useState<boolean | null>(null);
|
||||
const [inviteError, setInviteError] = useState("");
|
||||
const [isCheckingInvite, setIsCheckingInvite] = useState(false);
|
||||
const [isCheckingInitialCode, setIsCheckingInitialCode] = useState(!!initialCode);
|
||||
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
|
||||
const { user, register } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ function SignupContent() {
|
|||
const response = await api.get<InviteCheckResponse>(
|
||||
`/api/invites/${encodeURIComponent(code.trim())}/check`
|
||||
);
|
||||
|
||||
|
||||
if (response.valid) {
|
||||
setInviteValid(true);
|
||||
setInviteError("");
|
||||
|
|
@ -141,7 +141,9 @@ function SignupContent() {
|
|||
{inviteError && <div style={styles.error}>{inviteError}</div>}
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="inviteCode" style={styles.label}>Invite Code</label>
|
||||
<label htmlFor="inviteCode" style={styles.label}>
|
||||
Invite Code
|
||||
</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
type="text"
|
||||
|
|
@ -156,7 +158,14 @@ function SignupContent() {
|
|||
required
|
||||
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
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -193,12 +202,17 @@ function SignupContent() {
|
|||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>Create account</h1>
|
||||
<p style={styles.subtitle}>
|
||||
Using invite: <code style={{
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "4px",
|
||||
fontSize: "0.85rem"
|
||||
}}>{inviteCode}</code>
|
||||
Using invite:{" "}
|
||||
<code
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
padding: "0.2rem 0.5rem",
|
||||
borderRadius: "4px",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{inviteCode}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -206,7 +220,9 @@ function SignupContent() {
|
|||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="email" style={styles.label}>Email</label>
|
||||
<label htmlFor="email" style={styles.label}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
|
|
@ -220,7 +236,9 @@ function SignupContent() {
|
|||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="password" style={styles.label}>Password</label>
|
||||
<label htmlFor="password" style={styles.label}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
|
|
@ -233,7 +251,9 @@ function SignupContent() {
|
|||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="confirmPassword" style={styles.label}>Confirm Password</label>
|
||||
<label htmlFor="confirmPassword" style={styles.label}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
|
|
@ -282,17 +302,17 @@ function SignupContent() {
|
|||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<main style={styles.main}>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
|
||||
Loading...
|
||||
<Suspense
|
||||
fallback={
|
||||
<main style={styles.main}>
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<SignupContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -103,4 +103,3 @@ export const authFormStyles: Record<string, CSSProperties> = {
|
|||
fontWeight: 500,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function SumPage() {
|
|||
const numA = parseFloat(a) || 0;
|
||||
const numB = parseFloat(b) || 0;
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB });
|
||||
setResult(data.result);
|
||||
|
|
@ -58,7 +58,7 @@ export default function SumPage() {
|
|||
<div style={styles.content}>
|
||||
<div style={styles.card}>
|
||||
<span style={styles.label}>Sum Calculator</span>
|
||||
|
||||
|
||||
{!showResult ? (
|
||||
<div style={styles.inputSection}>
|
||||
<div style={styles.inputRow}>
|
||||
|
|
@ -84,17 +84,11 @@ export default function SumPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSum}
|
||||
style={styles.sumBtn}
|
||||
disabled={a === "" && b === ""}
|
||||
>
|
||||
<button onClick={handleSum} style={styles.sumBtn} disabled={a === "" && b === ""}>
|
||||
<span style={styles.equalsIcon}>=</span>
|
||||
Calculate
|
||||
</button>
|
||||
{error && (
|
||||
<div style={styles.error}>{error}</div>
|
||||
)}
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.resultSection}>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface StatusDisplay {
|
|||
|
||||
/**
|
||||
* Get display information for an appointment status.
|
||||
*
|
||||
*
|
||||
* @param status - The appointment status string
|
||||
* @param isOwnView - If true, uses "Cancelled by you" instead of "Cancelled by user"
|
||||
*/
|
||||
|
|
@ -25,9 +25,12 @@ export function getStatusDisplay(status: string, isOwnView: boolean = false): St
|
|||
textColor: "#f87171",
|
||||
};
|
||||
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:
|
||||
return { text: status, bgColor: "rgba(255,255,255,0.1)", textColor: "rgba(255,255,255,0.6)" };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ export function formatDate(d: Date): string {
|
|||
*/
|
||||
export function formatTime(isoString: string): string {
|
||||
const d = new Date(isoString);
|
||||
return d.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
return d.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
|
@ -81,4 +81,3 @@ export function isWeekend(date: Date): boolean {
|
|||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue