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
5
Makefile
5
Makefile
|
|
@ -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
4
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
|
app/generated/
|
||||||
|
|
||||||
7
frontend/.prettierrc.json
Normal file
7
frontend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
|
|
@ -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}>"{apt.note}"</div>}
|
||||||
<div style={styles.appointmentUser}>
|
<span
|
||||||
{apt.user_email}
|
style={{
|
||||||
</div>
|
|
||||||
{apt.note && (
|
|
||||||
<div style={styles.appointmentNote}>
|
|
||||||
"{apt.note}"
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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't have any appointments yet.</p>
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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't have any invites yet.</p>
|
<p style={styles.emptyText}>You don'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 };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -103,4 +103,3 @@ export const authFormStyles: Record<string, CSSProperties> = {
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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)" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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("/");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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("/");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,3 @@ export function getTomorrowDateStr(): string {
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
return formatDateLocal(tomorrow);
|
return formatDateLocal(tomorrow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/**',
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,3 @@ import type { NextConfig } from "next";
|
||||||
const nextConfig: NextConfig = {};
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
||||||
|
|
|
||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,3 @@ export default defineConfig({
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: "http://localhost:3000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,3 @@ export default defineConfig({
|
||||||
include: ["app/**/*.test.{ts,tsx}"],
|
include: ["app/**/*.test.{ts,tsx}"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue