Inicjalna wersja systemu zarządzania rezerwacjami (RMS)

SPA zbudowane w React 19 + Vite 8 z pełnym zestawem funkcjonalności:
autentykacja z 2FA, kreator rezerwacji, panel admina, analityka,
GraphQL (Apollo Client + SchemaLink), React Query, Storybook,
testy jednostkowe (Vitest + RTL) i e2e (Playwright).
This commit is contained in:
Krzysztof Cieślik
2026-06-21 06:08:47 +02:00
commit 2593e81498
99 changed files with 18702 additions and 0 deletions

313
src/pages/AdminPage.jsx Normal file
View File

@@ -0,0 +1,313 @@
// [REQ F7] Panel admina tabela rezerwacji, kalendarz, blokowanie terminów
// [REQ D11] React Concurrent Mode useTransition przy przełączaniu zakładek (nie blokuje UI)
// [REQ T1] Podstawowe hooki useState + useTransition
// useTransition to nowoczesny hook, który pozwala na oznaczenie pewnych stanów jako
// tymczasowe, co pozwala na lepsze zarządzanie wydajnością i płynnością interfejsu użytkownika
import { useState, useTransition } from 'react';
// gotowe ikony z lucide-react
import { Trash2, RefreshCw, CheckCircle2, XCircle, LayoutList, CalendarDays, BarChart2, Users, Lock, Unlock } from 'lucide-react';
import AdminCalendar from '../components/AdminCalendar';
import AnalyticsDashboard from '../components/AnalyticsDashboard';
import UserManagement from '../components/UserManagement';
import { useAuth } from '../context/AuthContext';
import { useReservations } from '../hooks/useReservations';
import { useDeleteReservation } from '../hooks/useDeleteReservation';
import { useUpdateReservation } from '../hooks/useUpdateReservation';
import Modal from '../components/Modal';
import ProfileView from '../components/ProfileView';
import StatusBadge from '../components/StatusBadge';
import ThemeToggle from '../components/ThemeToggle';
import ErrorBoundary from '../components/ErrorBoundary';
import styles from './AdminPage.module.scss';
const AdminPage = () => {
// wyciągniecie usera z kontekstu
const { user, logout } = useAuth();
// wyciągniecie danych rezerwacji z hooka useReservations, który pobiera dane z backendu
const { data: reservations = [], isLoading, isError, refetch } = useReservations();
// useState do przechowywania stanu aktywnej zakładki (table / calendar)
const [activeTab, setActiveTab] = useState('table');
// useState do przechowywania stanu głównej zakładki (reservations / analytics / users)
const [mainTab, setMainTab] = useState('reservations');
// useState do przechowywania stanu id rezerwacji, która ma być usunięta (do modala)
const [targetId, setTargetId] = useState(null);
// useState do przechowywania stanu zablokowanych dat w kalendarzu
const [blockedDates, setBlockedDates] = useState([]);
// useState do przechowywania stanu inputa do blokowania daty
const [blockInput, setBlockInput] = useState('');
// useTransition do oznaczenia stanu mainTab jako tymczasowego
// powoduje wykonanie tego w tle, a nie blokowanie interfejsu użytkownika podczas przełączania zakładek
// useTransition() zwraca tablicę dwóch elementów - bolean isPending, który mówi czy aktualnie trwa przejście
// , oraz funkcję startTransition, która służy do oznaczenia stanu jako tymczasowego
// w tym przypadku pomijamy pierwszy element nie jest potrzbny
const [, startTransition] = useTransition();
// const na każdy render
const isModalOpen = targetId !== null;
// tworzenie funkcji handleMainTab przy pomocy funkcji strzałkowej przymającej key
// następnie wywołanie setMainTab z key w funkcji startTransition, która oznacza stan jako tymczasowy
// co ustala że priorytet renderowania tego jest niższy niż innych stanów
const handleMainTab = (key) => startTransition(() => setMainTab(key));
const handleBlockDate = () => {
// jeśli nie ma daty lub jest już zablokowana igorujemy
if (!blockInput || blockedDates.includes(blockInput)) return;
// dodanie daty do zablokowanych dat i posortowanie ich rosnąco
setBlockedDates((d) => [...d, blockInput].sort());
// wyczyszczenie inputa po dodaniu daty do zablokowanych
setBlockInput('');
};
// funkcja do odblokowywania daty, która usuwa datę z tablicy zablokowanych dat
// bierze tablice zablokowanych dat, filtruję  tworząc nową tablicę bez wybranej daty i ustawia tę nową tablicę jako stan zablokowanych dat
const handleUnblockDate = (date) => setBlockedDates((d) => d.filter((x) => x !== date));
// użycie custom hooka useDeleteReservation, który zwraca funkcję mutate do usuwania rezerwacji oraz isPending do sprawdzania czy aktualnie trwa usuwanie
// zapis isPending jako isDeleting, aby nie mylić z innymi stanami ładowania
const { mutate: remove, isPending: isDeleting } = useDeleteReservation({
// jeśli uda się usnąć zamykamy modal ustawiając targetId na null
onSuccess: () => setTargetId(null),
});
// użycie custom hooka useUpdateReservation, który zwraca funkcję mutate do aktualizowania rezerwacji oraz isPending do sprawdzania czy aktualnie trwa aktualizacja
// zapis isPending jako isUpdating, aby nie mylić z innymi stanami ładowania
const { mutate: updateStatus, isPending: isUpdating } = useUpdateReservation();
// funkcje do obsługi modala - ustawienie targetId na id rezerwacji, która ma być usunięta
const handleDeleteClick = (id) => setTargetId(id);
// funkcje do obsługi modala - potwierdzenie usunięcia rezerwacji wywołuje funkcję remove z id rezerwacji
const handleConfirm = () => remove(targetId);
// funkcje do obsługi modala - anulowanie usunięcia rezerwacji ustawia targetId na null - zamyka modal
const handleCancel = () => setTargetId(null);
// funkcje do obsługi modala - potwierdzenie zmiany statusu rezerwacji wywołuje funkcję updateStatus z id rezerwacji i nowym statusem
const handleConfirmRes = (id) => updateStatus({ id, patch: { status: 'confirmed' } });
// funkcje do obsługi modala - anulowanie zmiany statusu rezerwacji wywołuje funkcję updateStatus z id rezerwacji i statusem 'cancelled'
const handleCancelStatus = (id) => updateStatus({ id, patch: { status: 'cancelled' } });
// renderowanie komponentu AdminPage
// styles zawiera klasy CSS z pliku AdminPage.module.scss, które są używane do stylowania komponentu
return (
<div className={styles['admin']}>
<header className={styles['admin__header']}>
<div className={styles['admin__header-inner']}>
<div>
<h1 className={styles['admin__heading']}>Admin Panel</h1>
<p className={styles['admin__subtitle']}>{user?.email}</p>
</div>
<div className={styles['admin__header-actions']}>
<ThemeToggle />
<button className={styles['admin__logout-btn']} onClick={logout}>Sign out</button>
</div>
</div>
</header>
<main className={styles['admin__main']}>
{/* Main navigation tabs */}
<div className={styles['admin__main-tabs']}>
{[
{ key: 'reservations', label: 'Reservations', icon: LayoutList },
{ key: 'analytics', label: 'Analytics', icon: BarChart2 },
{ key: 'users', label: 'Users', icon: Users },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
className={[styles['admin__main-tab'], mainTab === key ? styles['admin__main-tab--active'] : ''].join(' ')}
onClick={() => handleMainTab(key)}
>
<Icon size={15} /> {label}
</button>
))}
</div>
{mainTab === 'analytics' && (
<section className={styles['admin__section']}>
<div className={styles['admin__section-header']}>
<h2 className={styles['admin__section-title']}>Analytics & Reporting</h2>
</div>
<AnalyticsDashboard reservations={reservations} />
</section>
)}
{mainTab === 'users' && (
<section className={styles['admin__section']}>
<div className={styles['admin__section-header']}>
<h2 className={styles['admin__section-title']}>User Management</h2>
</div>
<UserManagement />
</section>
)}
{mainTab === 'reservations' && <div className={styles['admin__grid']}>
{/* Reservations table / calendar */}
<section className={styles['admin__section']}>
<div className={styles['admin__section-header']}>
<h2 className={styles['admin__section-title']}>All Reservations</h2>
<div className={styles['admin__tabs']}>
<button
className={[styles['admin__tab'], activeTab === 'table' ? styles['admin__tab--active'] : ''].join(' ')}
onClick={() => setActiveTab('table')}
>
<LayoutList size={14} /> Table
</button>
<button
className={[styles['admin__tab'], activeTab === 'calendar' ? styles['admin__tab--active'] : ''].join(' ')}
onClick={() => setActiveTab('calendar')}
>
<CalendarDays size={14} /> Calendar
</button>
</div>
<button
className={styles['admin__refresh-btn']}
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw size={15} />
Refresh
</button>
</div>
{isError && (
<p className={styles['admin__msg--error']}>
Could not load reservations. Make sure the API is running on port 3001.
</p>
)}
{activeTab === 'calendar' && !isLoading && (
<div className={styles['admin__calendar-wrap']}>
<AdminCalendar reservations={reservations} />
</div>
)}
{activeTab === 'table' && isLoading ? (
<p className={styles['admin__msg--loading']}>Loading reservations</p>
) : activeTab === 'table' && reservations.length === 0 ? (
<p className={styles['admin__msg--empty']}>No reservations found.</p>
) : activeTab === 'table' && (
<div className={styles['admin__table-wrapper']}>
<table className={styles['admin__table']}>
<thead>
<tr>
<th>ID</th>
<th>User ID</th>
<th>Service ID</th>
<th>Date</th>
<th>Time</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{reservations.map((r) => (
<tr key={r.id}>
<td className={styles['admin__cell--mono']}>{r.id}</td>
<td className={styles['admin__cell--mono']}>{r.userId}</td>
<td className={styles['admin__cell--mono']}>{r.serviceId}</td>
<td>{r.date}</td>
<td>{r.time}</td>
<td><StatusBadge status={r.status} size="sm" /></td>
<td>
<div className={styles['admin__actions']}>
{r.status === 'pending' && (
<button
className={styles['admin__confirm-btn']}
onClick={() => handleConfirmRes(r.id)}
disabled={isUpdating}
aria-label={`Confirm reservation ${r.id}`}
>
<CheckCircle2 size={15} />
</button>
)}
{(r.status === 'pending' || r.status === 'confirmed') && (
<button
className={styles['admin__cancel-status-btn']}
onClick={() => handleCancelStatus(r.id)}
disabled={isUpdating}
aria-label={`Cancel reservation ${r.id}`}
>
<XCircle size={15} />
</button>
)}
<button
className={styles['admin__delete-btn']}
onClick={() => handleDeleteClick(r.id)}
disabled={isDeleting || isUpdating}
aria-label={`Delete reservation ${r.id}`}
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Blocked dates + GraphQL User Profile */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<section className={styles['admin__section']}>
<div className={styles['admin__section-header']}>
<h2 className={styles['admin__section-title']}>Block / Release Dates</h2>
</div>
<div className={styles['admin__block-body']}>
<div className={styles['admin__block-row']}>
<input
type="date"
className={styles['admin__block-input']}
value={blockInput}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setBlockInput(e.target.value)}
/>
<button
className={styles['admin__block-btn']}
onClick={handleBlockDate}
disabled={!blockInput}
>
<Lock size={14} /> Block date
</button>
</div>
{blockedDates.length === 0 ? (
<p className={styles['admin__msg--empty']} style={{ padding: '1rem' }}>
No dates blocked.
</p>
) : (
<ul className={styles['admin__block-list']}>
{blockedDates.map((d) => (
<li key={d} className={styles['admin__block-item']}>
<span>{d}</span>
<button
className={styles['admin__unblock-btn']}
onClick={() => handleUnblockDate(d)}
>
<Unlock size={13} /> Release
</button>
</li>
))}
</ul>
)}
</div>
</section>
<section className={styles['admin__section']}>
<div className={styles['admin__section-header']}>
<h2 className={styles['admin__section-title']}>User Profile (GraphQL)</h2>
</div>
<ErrorBoundary>
<ProfileView userId={user?.id} />
</ErrorBoundary>
</section>
</div>
</div>}
</main>
<Modal
isOpen={isModalOpen}
onClose={handleCancel}
onConfirm={handleConfirm}
title="Delete Reservation"
message={`Are you sure you want to permanently delete reservation "${targetId}"? This action cannot be undone.`}
confirmLabel={isDeleting ? 'Deleting…' : 'Delete'}
danger
/>
</div>
);
};
export default AdminPage;

View File

@@ -0,0 +1,371 @@
@use '../styles/variables' as *;
// ── Block ─────────────────────────────────────────────────────────────────────
.admin {
min-height: 100vh;
background: var(--clr-bg);
// ── Elements ────────────────────────────────────────────────────────────────
&__header {
background: var(--clr-surface);
border-bottom: 1px solid var(--clr-border);
box-shadow: $shadow-sm;
}
&__header-inner {
max-width: $container-max-width;
margin-inline: auto;
padding: $spacing-4 $spacing-6;
display: flex;
align-items: center;
justify-content: space-between;
}
&__heading {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__subtitle {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
margin-top: $spacing-1;
}
&__header-actions {
display: flex;
align-items: center;
gap: $spacing-3;
}
&__logout-btn {
padding: $spacing-2 $spacing-4;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
background: transparent;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
}
&__main {
max-width: $container-max-width;
margin-inline: auto;
padding: $spacing-6 $spacing-6 $spacing-8;
display: flex;
flex-direction: column;
gap: $spacing-6;
}
&__main-tabs {
display: flex;
gap: $spacing-2;
border-bottom: 1px solid var(--clr-border);
padding-bottom: 0;
}
&__main-tab {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-5;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: color $transition-fast, border-color $transition-fast;
&:hover { color: var(--clr-text); }
&--active {
color: var(--clr-primary, #{$color-primary});
border-bottom-color: var(--clr-primary, #{$color-primary});
}
}
&__grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: $spacing-6;
align-items: start;
@media (max-width: $bp-lg) { grid-template-columns: 1fr; }
}
&__section {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-sm;
overflow: hidden;
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-5 $spacing-6;
border-bottom: 1px solid var(--clr-border);
}
&__section-title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__refresh-btn {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-3;
background: transparent;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast;
&:hover:not(:disabled) { background: var(--clr-surface-raised); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
&__table-wrapper { overflow-x: auto; }
&__table {
width: 100%;
border-collapse: collapse;
font-size: $font-size-sm;
th {
padding: $spacing-3 $spacing-4;
text-align: left;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--clr-text-muted);
background: var(--clr-surface-raised);
border-bottom: 1px solid var(--clr-border);
white-space: nowrap;
}
td {
padding: $spacing-3 $spacing-4;
border-bottom: 1px solid var(--clr-border);
color: var(--clr-text);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--clr-surface-raised); }
}
&__cell--mono {
font-family: $font-family-mono;
font-size: $font-size-xs;
color: var(--clr-text-secondary);
}
&__tabs {
display: flex;
gap: $spacing-1;
}
&__tab {
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
background: transparent;
cursor: pointer;
transition: background $transition-fast, color $transition-fast, border-color $transition-fast;
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
&--active {
background: var(--clr-primary);
border-color: var(--clr-primary);
color: #fff;
}
}
&__calendar-wrap {
padding: $spacing-5;
}
&__actions {
display: flex;
align-items: center;
gap: $spacing-2;
}
&__confirm-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $spacing-2;
background: transparent;
border: 1px solid #bbf7d0;
border-radius: $radius-base;
color: #15803d;
cursor: pointer;
transition: background $transition-fast;
&:hover:not(:disabled) { background: #f0fdf4; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
&__cancel-status-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $spacing-2;
background: transparent;
border: 1px solid #fed7aa;
border-radius: $radius-base;
color: #c2410c;
cursor: pointer;
transition: background $transition-fast;
&:hover:not(:disabled) { background: #fff7ed; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
&__delete-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $spacing-2;
background: transparent;
border: 1px solid #fecaca;
border-radius: $radius-base;
color: $color-error;
cursor: pointer;
transition: background $transition-fast;
&:hover:not(:disabled) { background: #fef2f2; }
&:disabled { opacity: 0.4; cursor: not-allowed; }
}
&__msg--loading,
&__msg--empty {
padding: $spacing-8 $spacing-6;
text-align: center;
font-size: $font-size-sm;
color: var(--clr-text-muted);
}
&__msg--error {
padding: $spacing-5 $spacing-6;
font-size: $font-size-sm;
color: $color-error;
background: #fef2f2;
border-bottom: 1px solid #fecaca;
}
// Block / release dates
&__block-body {
padding: $spacing-4 $spacing-5;
display: flex;
flex-direction: column;
gap: $spacing-3;
}
&__block-row {
display: flex;
gap: $spacing-3;
align-items: center;
}
&__block-input {
padding: $spacing-2 $spacing-3;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-sm;
font-family: $font-family-base;
color: var(--clr-text);
background: var(--clr-surface);
outline: none;
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 2px rgba($primary-400, 0.18);
}
}
&__block-btn {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-4;
background: $primary-600;
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: $font-family-base;
cursor: pointer;
transition: background $transition-fast, opacity $transition-fast;
&:hover:not(:disabled) { background: $primary-700; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
&__block-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $spacing-2;
}
&__block-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-2 $spacing-3;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: $radius-base;
font-size: $font-size-sm;
color: var(--clr-text);
}
&__unblock-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: $spacing-1 $spacing-3;
background: transparent;
border: 1px solid #bbf7d0;
border-radius: $radius-base;
font-size: $font-size-xs;
font-family: $font-family-base;
color: #15803d;
cursor: pointer;
transition: background $transition-fast;
&:hover { background: #f0fdf4; }
}
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react';
// Link jest potrzebny do nawigacji w aplikacji React - zmiana widoku na inną ścieżkę
import { Link } from 'react-router-dom';
// Ikony z lucide-react do wyświetlania ikon w aplikacji React
import { BookOpen, UserCog } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useUserReservations } from '../hooks/useUserReservations';
import SlotFinder from '../components/SlotFinder';
import AvailabilitySearch from '../components/AvailabilitySearch';
import BookingWizard from '../components/BookingWizard';
import MyReservations from '../components/MyReservations';
import ThemeToggle from '../components/ThemeToggle';
import ProfileEditModal from '../components/ProfileEditModal';
import styles from './DashboardPage.module.scss';
// funkcja DashboardPage to komponent strony głównej dla zalogowanego użytkownika, który wyświetla jego rezerwacje, pozwala na wyszukiwanie dostępnych slotów i zarządzanie profilem
const DashboardPage = () => {
// desrukturyzacja user i logout z useAuth - pobieramy zalogowanego użytkownika i funkcję wylogowania z kontekstu
const { user, logout } = useAuth();
// useState do przechowywania stanu prefillSlot, który jest używany do wstępnego wypełnienia formularza rezerwacji wybranym slotem
const [prefillSlot, setPrefillSlot] = useState(null);
// useState do przechowywania stanu profileOpen, który jest używany do otwierania i zamykania modala edycji profilu
const [profileOpen, setProfileOpen] = useState(false);
// custom hook useUserReservations pobiera rezerwacje zalogowanego użytkownika z API i zwraca je w data
const { data: reservations = [] } = useUserReservations(user?.id);
// activeCount to zmienna, która przechowuje liczbę aktywnych rezerwacji (pending lub confirmed) dla zalogowanego użytkownika
const activeCount = reservations.filter(
(r) => r.status === 'pending' || r.status === 'confirmed'
).length;
return (
<div className={styles['dashboard']}>
<header className={styles['dashboard__header']}>
<div className={styles['dashboard__header-inner']}>
<div>
<h1 className={styles['dashboard__title']}>Welcome back</h1>
<p className={styles['dashboard__subtitle']}>{user?.email}</p>
</div>
<div className={styles['dashboard__header-actions']}>
<Link to="/reservations" className={styles['dashboard__res-link']}>
<BookOpen size={15} />
My Reservations
{activeCount > 0 && (
<span className={styles['dashboard__res-badge']}>{activeCount}</span>
)}
</Link>
<button
className={styles['dashboard__logout-btn']}
onClick={() => setProfileOpen(true)}
>
<UserCog size={15} /> Profile
</button>
<ThemeToggle />
<button className={styles['dashboard__logout-btn']} onClick={logout}>Sign out</button>
</div>
</div>
</header>
<main className={styles['dashboard__main']}>
<AvailabilitySearch onSelectSlot={setPrefillSlot} />
<SlotFinder onSelectSlot={setPrefillSlot} />
<div className={styles['dashboard__grid']}>
<section className={styles['dashboard__booking']}>
<BookingWizard
prefillSlot={prefillSlot}
onPrefillUsed={() => setPrefillSlot(null)}
/>
</section>
<aside className={styles['dashboard__aside']}>
<MyReservations />
</aside>
</div>
</main>
<ProfileEditModal isOpen={profileOpen} onClose={() => setProfileOpen(false)} />
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,118 @@
@use '../styles/variables' as *;
// ── Block ─────────────────────────────────────────────────────────────────────
.dashboard {
min-height: 100vh;
background: var(--clr-bg);
// ── Elements ────────────────────────────────────────────────────────────────
&__header {
background: var(--clr-surface);
border-bottom: 1px solid var(--clr-border);
box-shadow: $shadow-sm;
}
&__header-inner {
max-width: $container-max-width;
margin-inline: auto;
padding: $spacing-4 $spacing-6;
display: flex;
align-items: center;
justify-content: space-between;
}
&__title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__subtitle {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
margin-top: $spacing-1;
}
&__header-actions {
display: flex;
align-items: center;
gap: $spacing-3;
}
&__res-link {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-4;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
background: transparent;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text-secondary);
text-decoration: none;
transition: background $transition-fast, color $transition-fast;
&:hover {
background: var(--clr-surface-raised);
color: var(--clr-text);
}
}
&__res-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: $primary-500;
color: #fff;
border-radius: $radius-full;
font-size: 11px;
font-weight: $font-weight-semibold;
}
&__logout-btn {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-4;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
background: transparent;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover {
background: var(--clr-surface-raised);
color: var(--clr-text);
}
}
&__main {
max-width: $container-max-width;
margin-inline: auto;
padding: $spacing-8 $spacing-6;
display: flex;
flex-direction: column;
gap: $spacing-6;
}
&__grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: $spacing-6;
align-items: start;
@media (max-width: $bp-lg) { grid-template-columns: 1fr; }
}
&__booking { min-width: 0; }
&__aside { }
}

222
src/pages/LoginPage.jsx Normal file
View File

@@ -0,0 +1,222 @@
// [REQ F1] System uwierzytelniania formularz logowania + ekran weryfikacji 2FA
// [REQ D2] Zaawansowane formularze Formik + Zod (toFormikValidationSchema)
// [REQ T8] Obsługa formularzy i walidacja walidacja e-mail i hasła, błędy inline
import { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { ShieldCheck, Loader2 } from 'lucide-react';
import { useFormik } from 'formik';
import { z } from 'zod';
import { toFormikValidationSchema } from 'zod-formik-adapter';
import { useAuth } from '../context/AuthContext';
import styles from './LoginPage.module.scss';
// kkonfiguracaj schematu walidacji formularza logowania dla Zod
const schema = z.object({
email: z.string().email('Enter a valid email address.'),
password: z.string().min(6, 'Password must be at least 6 characters.'),
});
// komponent LoginPage to strona logowania, która obsługuje zarówno formularz logowania jak i ekran weryfikacji 2FA
const LoginPage = () => {
// wyciągnięcie funkcji i stanów z useAuth - login do logowania,
// user do sprawdzania czy użytkownik jest zalogowany, error do wyświetlania błędów logowania,
// clearError do czyszczenia błędów, pendingUser do sprawdzania czy jesteśmy w trakcie weryfikacji
// 2FA, twoFACode do przechowywania kodu 2FA, verify2FA do weryfikacji kodu 2FA, cancel2FA do anulowania procesu 2FA
const { login, user, error, clearError, pendingUser, twoFACode, verify2FA, cancel2FA } = useAuth();
// useState do przechowywania stanu inputa 2FA oraz błędu 2FA, które są używane tylko w ekranie weryfikacji 2FA
const [twoFAInput, setTwoFAInput] = useState('');
const [twoFAError, setTwoFAError] = useState('');
// useNavigate i useLocation do nawigacji i sprawdzania aktualnej lokalizacji w aplikacji React Router
const navigate = useNavigate();
// useLocation zwraca obiekt reprezentujący aktualną lokalizację, który zawiera informacje o ścieżce, stanie i innych parametrach URL.
const location = useLocation();
// useEffect do przekierowywania zalogowanego użytkownika na odpowiednią stronę po zalogowaniu, wywoływany przy zmianie stanu user
useEffect(() => {
if (user) navigate(user.role === 'admin' ? '/admin' : '/dashboard', { replace: true });
}, [user, navigate]);
// konfiguracja hooka useFormik do obsługi formularza logowania, który przyjmuje initialValues, validationSchema i onSubmit
const formik = useFormik({
initialValues: { email: '', password: '' },
validationSchema: toFormikValidationSchema(schema),
onSubmit: async ({ email, password }) => {
clearError();
try {
await login(email, password);
// login returns { twoFAPending: true } — LoginPage will show 2FA step
} catch {
// error state is managed by AuthContext
}
},
});
// funkcja do obsługi weryfikacji 2FA, która jest wywoływana po submitowaniu formularza weryfikacji 2FA
const handle2FA = (e) => {
//e.preventDefault() zapobiega domyślnej akcji submitowania formularza, która powodowałaby przeładowanie strony, dzięki temu możemy obsłużyć weryfikację 2FA bez przeładowania strony
e.preventDefault();
// wyczyszczenie błędu 2FA przed próbą weryfikacji
setTwoFAError('');
try {
// loggedIn to wynik funkcji verify2FA, która sprawdza czy wprowadzony kod 2FA jest poprawny, jeśli jest poprawny zwraca obiekt zalogowanego użytkownika, jeśli nie jest poprawny rzuca błąd
const loggedIn = verify2FA(twoFAInput.trim());
// uzyskanie ścieżki, z której użytkownik został przekierowany na stronę logowania, jeśli istnieje, lub ustawienie domyślnej ścieżki w zależności od roli użytkownika (admin -> /admin, client -> /dashboard)
const from = location.state?.from?.pathname;
navigate(from || (loggedIn.role === 'admin' ? '/admin' : '/dashboard'), { replace: true });
} catch (err) {
setTwoFAError(err.message);
}
};
// 2FA screen
// pendingUser to stan z AuthContext, który jest ustawiany na obiekt użytkownika po poprawnym logowaniu hasła
// , ale przed weryfikacją 2FA, jeśli pendingUser jest ustawiony to wyświetlamy ekran weryfikacji 2FA zamiast formularza logowania
// jest to fake email
if (pendingUser) {
return (
<div className={styles['login']}>
<div className={styles['login__card']}>
<div className={styles['login__header']}>
<ShieldCheck size={36} className={styles['login__twofa-icon']} />
<h1 className={styles['login__title']}>Two-Factor Auth</h1>
<p className={styles['login__subtitle']}>
A 6-digit code was sent to <strong>{pendingUser.email}</strong>
</p>
</div>
{/* Fake email preview showing the code */}
<div className={styles['login__code-preview']}>
<div className={styles['login__code-bar']}>
<span className={styles['login__code-dot']} />
<span className={styles['login__code-dot']} />
<span className={styles['login__code-dot']} />
<span className={styles['login__code-bar-label']}>Security email</span>
</div>
<div className={styles['login__code-body']}>
<p>Hello {pendingUser.name ?? pendingUser.email},</p>
<p>Your verification code is:</p>
<p className={styles['login__code-number']}>{twoFACode}</p>
<p className={styles['login__code-note']}>This code expires in 5 minutes.</p>
</div>
</div>
<form onSubmit={handle2FA} className={styles['login__form']}>
{twoFAError && (
<div className={styles['login__alert']} role="alert">
<span>{twoFAError}</span>
</div>
)}
<div className={styles['login__field']}>
<label htmlFor="code" className={styles['login__label']}>Enter code</label>
<input
id="code"
type="text"
inputMode="numeric"
maxLength={6}
className={styles['login__input']}
placeholder="000000"
value={twoFAInput}
onChange={(e) => setTwoFAInput(e.target.value)}
autoFocus
/>
</div>
<button type="submit" className={styles['login__submit']}>
Verify & Sign in
</button>
<button
type="button"
className={styles['login__register-link']}
style={{ background: 'none', border: 'none', cursor: 'pointer', textAlign: 'center', width: '100%' }}
onClick={() => { cancel2FA(); setTwoFAInput(''); setTwoFAError(''); }}
>
Back to login
</button>
</form>
</div>
</div>
);
}
// renderujemy normalny ekran logowania jeśli nie jesteśmy w trakcie weryfikacji 2FA (pendingUser jest null)
return (
<div className={styles['login']}>
<div className={styles['login__card']}>
<div className={styles['login__header']}>
<h1 className={styles['login__title']}>Reservation System</h1>
<p className={styles['login__subtitle']}>Sign in to your account</p>
</div>
<form className={styles['login__form']} onSubmit={formik.handleSubmit} noValidate>
{location.state?.registered && (
<div className={styles['login__success']} role="status">
Account verified! You can now sign in.
</div>
)}
{error && (
<div className={styles['login__alert']} role="alert">
<span>{error}</span>
<button
type="button"
className={styles['login__alert-close']}
onClick={clearError}
>
x
</button>
</div>
)}
<div className={styles['login__field']}>
<label htmlFor="email" className={styles['login__label']}>Email</label>
<input
id="email"
type="email"
className={[
styles['login__input'],
formik.touched.email && formik.errors.email ? styles['login__input--error'] : '',
].join(' ')}
placeholder="you@example.com"
autoComplete="email"
{...formik.getFieldProps('email')}
/>
{formik.touched.email && formik.errors.email && (
<p className={styles['login__field-error']}>{formik.errors.email}</p>
)}
</div>
<div className={styles['login__field']}>
<label htmlFor="password" className={styles['login__label']}>Password</label>
<input
id="password"
type="password"
className={[
styles['login__input'],
formik.touched.password && formik.errors.password ? styles['login__input--error'] : '',
].join(' ')}
placeholder="password"
autoComplete="current-password"
{...formik.getFieldProps('password')}
/>
{formik.touched.password && formik.errors.password && (
<p className={styles['login__field-error']}>{formik.errors.password}</p>
)}
</div>
<button
type="submit"
className={styles['login__submit']}
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className={styles['login__register-link']}>
Don't have an account?{' '}
<Link to="/register" className={styles['login__link']}>Sign up</Link>
</p>
<div className={styles['login__hint']}>
<p><strong>Admin:</strong> admin@reservations.dev / Admin1234!</p>
<p><strong>Client:</strong> anna.kowalski@example.com / Client1234!</p>
</div>
</div>
</div>
);
};
// eksportowanie komponentu LoginPage jako domyślnego eksportu z tego pliku, dzięki czemu można go importować w innych częściach aplikacji
export default LoginPage;

View File

@@ -0,0 +1,227 @@
@use '../styles/variables' as *;
// ── Block ─────────────────────────────────────────────────────────────────────
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-800 0%, $primary-600 50%, $accent-600 100%);
padding: $spacing-4;
// ── Elements ────────────────────────────────────────────────────────────────
&__card {
width: 100%;
max-width: 420px;
background: var(--clr-surface, #{$color-surface});
border-radius: $radius-xl;
box-shadow: $shadow-xl;
padding: $spacing-10 $spacing-8;
}
&__header {
text-align: center;
margin-bottom: $spacing-8;
}
&__title {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $primary-700;
margin-bottom: $spacing-1;
}
&__subtitle {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
}
&__form {
display: flex;
flex-direction: column;
gap: $spacing-5;
}
&__alert {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-3 $spacing-4;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: $radius-base;
color: #991b1b;
font-size: $font-size-sm;
}
&__alert-close {
background: none;
border: none;
font-size: $font-size-lg;
line-height: 1;
color: #991b1b;
padding: 0 $spacing-1;
cursor: pointer;
&:hover { opacity: 0.7; }
}
&__field {
display: flex;
flex-direction: column;
gap: $spacing-1;
}
&__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text, #{$color-text-primary});
}
&__input {
width: 100%;
padding: $spacing-3 $spacing-4;
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-base;
font-size: $font-size-base;
color: var(--clr-text, #{$color-text-primary});
background: var(--clr-surface, #{$color-surface});
transition: border-color $transition-fast, box-shadow $transition-fast;
outline: none;
&::placeholder { color: var(--clr-text-muted, #{$color-text-muted}); }
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.2);
}
&--error {
border-color: $color-error;
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.2); }
}
}
&__field-error {
font-size: $font-size-xs;
color: $color-error;
margin-top: $spacing-1;
}
&__submit {
margin-top: $spacing-2;
width: 100%;
padding: $spacing-3 $spacing-4;
background: var(--clr-primary, #{$color-primary});
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
transition: background $transition-fast, opacity $transition-fast;
&:hover:not(:disabled) { background: var(--clr-primary-hover, #{$color-primary-hover}); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
&__success {
padding: $spacing-3 $spacing-4;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: $radius-base;
color: #15803d;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
&__register-link {
margin-top: $spacing-5;
text-align: center;
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
}
&__link {
color: var(--clr-primary, #{$color-primary});
font-weight: $font-weight-medium;
text-decoration: none;
&:hover { text-decoration: underline; }
}
&__hint {
margin-top: $spacing-6;
padding: $spacing-3 $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border-radius: $radius-base;
font-size: $font-size-xs;
color: var(--clr-text-secondary, #{$color-text-secondary});
line-height: $line-height-relaxed;
strong { color: var(--clr-text, #{$color-text-primary}); }
}
// ── 2FA elements ─────────────────────────────────────────────────────────────
&__twofa-icon {
color: $primary-600;
margin-bottom: $spacing-3;
}
&__code-preview {
margin-bottom: $spacing-6;
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
overflow: hidden;
font-size: $font-size-sm;
}
&__code-bar {
display: flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border-bottom: 1px solid var(--clr-border);
}
&__code-dot {
width: 10px;
height: 10px;
border-radius: $radius-full;
background: var(--clr-border);
}
&__code-bar-label {
font-size: $font-size-xs;
color: var(--clr-text-muted);
margin-left: auto;
}
&__code-body {
padding: $spacing-4;
color: var(--clr-text-secondary);
line-height: $line-height-relaxed;
display: flex;
flex-direction: column;
gap: $spacing-2;
p { margin: 0; }
}
&__code-number {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
letter-spacing: 0.2em;
color: $primary-600;
text-align: center;
padding: $spacing-3 0;
}
&__code-note {
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
}

269
src/pages/RegisterPage.jsx Normal file
View File

@@ -0,0 +1,269 @@
// [REQ F1] System uwierzytelniania formularz rejestracji z weryfikacją e-mail
// [REQ T8] Obsługa formularzy i walidacja Formik + Zod, błędy inline przy każdym polu
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useFormik } from 'formik';
import { z } from 'zod';
import { toFormikValidationSchema } from 'zod-formik-adapter';
import { Mail, CheckCircle2, Loader2 } from 'lucide-react';
import { registerUser, verifyUser } from '../api/users';
import styles from './RegisterPage.module.scss';
const schema = z
.object({
name: z.string().min(2, 'Name must be at least 2 characters.'),
email: z.string().email('Enter a valid email address.'),
password: z
.string()
.min(8, 'Password must be at least 8 characters.')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter.')
.regex(/\d/, 'Must contain at least one digit.'),
confirmPassword: z.string(),
})
// superRefine pozwala na dodanie niestandardowej walidacji, która porównuje dwa pola password i confirmPassword, jeśli nie są takie same to dodaje błąd do confirmPassword
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match.',
path: ['confirmPassword'],
});
}
});
const RegisterPage = () => {
const navigate = useNavigate();
const [stage, setStage] = useState('form'); // 'form' | 'sent' | 'done'
const [pending, setPending] = useState(null);
const [serverError, setServerError] = useState('');
const [verifying, setVerifying] = useState(false);
const formik = useFormik({
initialValues: { name: '', email: '', password: '', confirmPassword: '' },
validationSchema: toFormikValidationSchema(schema),
onSubmit: async ({ name, email, password }) => {
setServerError('');
try {
const result = await registerUser({ name, email, password });
setPending(result);
setStage('sent');
} catch (err) {
setServerError(err.message);
}
},
});
const handleVerify = async () => {
setVerifying(true);
try {
await verifyUser(pending.token);
setStage('done');
setTimeout(() => navigate('/login', { state: { registered: true } }), 2500);
} catch (err) {
setServerError(err.message);
} finally {
setVerifying(false);
}
};
// jeśli stage jest 'done' to wyświetlamy ekran sukcesu z informacją o weryfikacji konta i przekierowaniu do logowania
if (stage === 'done') {
return (
<div className={styles['reg']}>
<div className={styles['reg__card']}>
<div className={styles['reg__success']}>
<CheckCircle2 size={52} className={styles['reg__success-icon']} />
<h2 className={styles['reg__success-title']}>Account verified!</h2>
<p className={styles['reg__success-sub']}>Redirecting you to sign in...</p>
</div>
</div>
</div>
);
}
// jeśli stage jest 'sent' to wyświetlamy ekran z informacją o wysłaniu maila weryfikacyjnego oraz podglądem tego maila,
// z którego można kliknąć przycisk weryfikacji, który wywołuje funkcję handleVerify
if (stage === 'sent') {
return (
<div className={styles['reg']}>
<div className={[styles['reg__card'], styles['reg__card--wide']].join(' ')}>
<div className={styles['reg__header']}>
<Mail size={32} className={styles['reg__mail-icon']} />
<h1 className={styles['reg__title']}>Check your inbox</h1>
<p className={styles['reg__subtitle']}>
We sent a verification link to <strong>{pending.user.email}</strong>
</p>
</div>
<div className={styles['reg__email-preview']}>
<div className={styles['reg__email-bar']}>
<span className={styles['reg__email-dot']} />
<span className={styles['reg__email-dot']} />
<span className={styles['reg__email-dot']} />
<span className={styles['reg__email-bar-label']}>Email preview</span>
</div>
<div className={styles['reg__email-meta']}>
<div className={styles['reg__email-row']}>
<span className={styles['reg__email-key']}>From</span>
<span className={styles['reg__email-val']}>noreply@reservations.dev</span>
</div>
<div className={styles['reg__email-row']}>
<span className={styles['reg__email-key']}>To</span>
<span className={styles['reg__email-val']}>{pending.user.email}</span>
</div>
<div className={styles['reg__email-row']}>
<span className={styles['reg__email-key']}>Subject</span>
<span className={styles['reg__email-val']}>Verify your Reservation System account</span>
</div>
</div>
<div className={styles['reg__email-body']}>
<p className={styles['reg__email-greeting']}>Hello {pending.user.name},</p>
<p className={styles['reg__email-text']}>
Thanks for signing up! Click the button below to verify your email address
and activate your account.
</p>
<button
className={styles['reg__email-cta']}
onClick={handleVerify}
disabled={verifying}
>
{verifying
? <><Loader2 size={16} className={styles['reg__spinner']} /> Verifying...</>
: 'Verify my account'
}
</button>
<p className={styles['reg__email-footer']}>
This link expires in 24 hours. If you did not create an account, ignore this email.
</p>
</div>
</div>
{serverError && (
<p className={styles['reg__server-error']}>{serverError}</p>
)}
<p className={styles['reg__resend']}>
Wrong email?{' '}
<button
type="button"
className={styles['reg__link-btn']}
onClick={() => { setStage('form'); setPending(null); }}
>
Go back
</button>
</p>
</div>
</div>
);
}
// jeśli stage jest 'form' to wyświetlamy normalny formularz rejestracji, który jest obsługiwany przez hook useFormik
// , a błędy walidacji są wyświetlane inline przy każdym polu, a błąd serwera jest wyświetlany na górze formularza
return (
<div className={styles['reg']}>
<div className={styles['reg__card']}>
<div className={styles['reg__header']}>
<h1 className={styles['reg__title']}>Create account</h1>
<p className={styles['reg__subtitle']}>Join the Reservation System</p>
</div>
<form className={styles['reg__form']} onSubmit={formik.handleSubmit} noValidate>
{serverError && (
<div className={styles['reg__alert']} role="alert">{serverError}</div>
)}
<div className={styles['reg__field']}>
<label htmlFor="name" className={styles['reg__label']}>Full name</label>
<input
id="name"
type="text"
className={[
styles['reg__input'],
formik.touched.name && formik.errors.name ? styles['reg__input--error'] : '',
].join(' ')}
placeholder="Jan Kowalski"
autoComplete="name"
{...formik.getFieldProps('name')}
/>
{formik.touched.name && formik.errors.name && (
<p className={styles['reg__field-error']}>{formik.errors.name}</p>
)}
</div>
<div className={styles['reg__field']}>
<label htmlFor="email" className={styles['reg__label']}>Email</label>
<input
id="email"
type="email"
className={[
styles['reg__input'],
formik.touched.email && formik.errors.email ? styles['reg__input--error'] : '',
].join(' ')}
placeholder="you@example.com"
autoComplete="email"
{...formik.getFieldProps('email')}
/>
{formik.touched.email && formik.errors.email && (
<p className={styles['reg__field-error']}>{formik.errors.email}</p>
)}
</div>
<div className={styles['reg__field']}>
<label htmlFor="password" className={styles['reg__label']}>Password</label>
<input
id="password"
type="password"
className={[
styles['reg__input'],
formik.touched.password && formik.errors.password ? styles['reg__input--error'] : '',
].join(' ')}
placeholder="min. 8 characters"
autoComplete="new-password"
{...formik.getFieldProps('password')}
/>
{formik.touched.password && formik.errors.password && (
<p className={styles['reg__field-error']}>{formik.errors.password}</p>
)}
</div>
<div className={styles['reg__field']}>
<label htmlFor="confirmPassword" className={styles['reg__label']}>
Confirm password
</label>
<input
id="confirmPassword"
type="password"
className={[
styles['reg__input'],
formik.touched.confirmPassword && formik.errors.confirmPassword
? styles['reg__input--error']
: '',
].join(' ')}
placeholder="repeat password"
autoComplete="new-password"
{...formik.getFieldProps('confirmPassword')}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<p className={styles['reg__field-error']}>{formik.errors.confirmPassword}</p>
)}
</div>
<button
type="submit"
className={styles['reg__submit']}
disabled={formik.isSubmitting}
>
{formik.isSubmitting
? <><Loader2 size={16} className={styles['reg__spinner']} /> Creating account...</>
: 'Create account'
}
</button>
</form>
<p className={styles['reg__signin-link']}>
Already have an account?{' '}
<Link to="/login" className={styles['reg__link']}>Sign in</Link>
</p>
</div>
</div>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,305 @@
@use '../styles/variables' as *;
.reg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-800 0%, $primary-600 50%, $accent-600 100%);
padding: $spacing-4;
&__card {
width: 100%;
max-width: 440px;
background: var(--clr-surface, #{$color-surface});
border-radius: $radius-xl;
box-shadow: $shadow-xl;
padding: $spacing-10 $spacing-8;
&--wide { max-width: 560px; }
}
&__header {
text-align: center;
margin-bottom: $spacing-8;
}
&__mail-icon {
color: $primary-600;
margin-bottom: $spacing-3;
}
&__title {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $primary-700;
margin-bottom: $spacing-1;
}
&__subtitle {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
strong { color: var(--clr-text, #{$color-text-primary}); }
}
&__form {
display: flex;
flex-direction: column;
gap: $spacing-5;
}
&__alert {
padding: $spacing-3 $spacing-4;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: $radius-base;
color: #991b1b;
font-size: $font-size-sm;
}
&__field {
display: flex;
flex-direction: column;
gap: $spacing-1;
}
&__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text, #{$color-text-primary});
}
&__input {
width: 100%;
padding: $spacing-3 $spacing-4;
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-base;
font-size: $font-size-base;
color: var(--clr-text, #{$color-text-primary});
background: var(--clr-surface, #{$color-surface});
transition: border-color $transition-fast, box-shadow $transition-fast;
outline: none;
&::placeholder { color: var(--clr-text-muted, #{$color-text-muted}); }
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.2);
}
&--error {
border-color: $color-error;
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.2); }
}
}
&__field-error {
font-size: $font-size-xs;
color: $color-error;
}
&__submit {
margin-top: $spacing-2;
width: 100%;
padding: $spacing-3 $spacing-4;
background: var(--clr-primary, #{$color-primary});
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-2;
transition: background $transition-fast, opacity $transition-fast;
&:hover:not(:disabled) { background: var(--clr-primary-hover, #{$color-primary-hover}); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
&__spinner {
animation: reg-spin 0.8s linear infinite;
}
&__signin-link {
margin-top: $spacing-6;
text-align: center;
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
}
&__link {
color: var(--clr-primary, #{$color-primary});
font-weight: $font-weight-medium;
text-decoration: none;
&:hover { text-decoration: underline; }
}
&__link-btn {
background: none;
border: none;
padding: 0;
color: var(--clr-primary, #{$color-primary});
font-weight: $font-weight-medium;
font-size: $font-size-sm;
cursor: pointer;
&:hover { text-decoration: underline; }
}
// ── Email preview ─────────────────────────────────────────────────────────────
&__email-preview {
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-xl;
overflow: hidden;
margin-bottom: $spacing-6;
box-shadow: $shadow-sm;
}
&__email-bar {
display: flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-4;
background: var(--clr-surface-raised, #{$gray-100});
border-bottom: 1px solid var(--clr-border, #{$color-border});
}
&__email-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: $gray-300;
&:nth-child(1) { background: #fc5f5a; }
&:nth-child(2) { background: #fdbe2c; }
&:nth-child(3) { background: #27c840; }
}
&__email-bar-label {
margin-left: auto;
font-size: $font-size-xs;
color: var(--clr-text-muted, #{$color-text-muted});
letter-spacing: 0.03em;
}
&__email-meta {
padding: $spacing-3 $spacing-5;
background: var(--clr-surface-raised, #{$gray-50});
border-bottom: 1px solid var(--clr-border, #{$color-border});
display: flex;
flex-direction: column;
gap: $spacing-1;
}
&__email-row {
display: flex;
gap: $spacing-3;
font-size: $font-size-xs;
}
&__email-key {
color: var(--clr-text-muted, #{$color-text-muted});
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 52px;
flex-shrink: 0;
}
&__email-val {
color: var(--clr-text-secondary, #{$color-text-secondary});
}
&__email-body {
padding: $spacing-6 $spacing-6 $spacing-5;
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__email-greeting {
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: var(--clr-text, #{$color-text-primary});
}
&__email-text {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
line-height: $line-height-relaxed;
}
&__email-cta {
align-self: center;
padding: $spacing-3 $spacing-8;
background: var(--clr-primary, #{$color-primary});
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: $spacing-2;
transition: background $transition-fast;
&:hover:not(:disabled) { background: var(--clr-primary-hover, #{$color-primary-hover}); }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
&__email-footer {
font-size: $font-size-xs;
color: var(--clr-text-muted, #{$color-text-muted});
text-align: center;
border-top: 1px solid var(--clr-border, #{$color-border});
padding-top: $spacing-4;
margin-top: $spacing-2;
}
&__server-error {
font-size: $font-size-sm;
color: $color-error;
text-align: center;
}
&__resend {
text-align: center;
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
}
// ── Success state ─────────────────────────────────────────────────────────────
&__success {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-4;
padding: $spacing-6 0;
text-align: center;
}
&__success-icon { color: #16a34a; }
&__success-title {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: var(--clr-text, #{$color-text-primary});
}
&__success-sub {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
}
}
@keyframes reg-spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,237 @@
// [REQ F6] Zarządzanie rezerwacjami lista rezerwacji użytkownika, zmiana terminu, anulowanie
// [REQ F10] Powiadomienia toast na potwierdzenie akcji (zmiana, anulowanie)
// [REQ T1] Podstawowe hooki useState, useEffect, useRef
// [REQ T3] React Router useSearchParams do filtrowania listy po statusie z URL
import { useState, useEffect, useRef } from 'react';
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import {
BookOpen, ChevronLeft, ChevronDown, ChevronUp,
XCircle, AlertTriangle, CalendarDays, Clock,
CreditCard, Hash, FileText, Tag, RefreshCw, ExternalLink, RotateCcw,
} from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useUserReservations } from '../hooks/useUserReservations';
import { useUpdateReservation } from '../hooks/useUpdateReservation';
import { useServices } from '../hooks/useServices';
import ThemeToggle from '../components/ThemeToggle';
import StatusBadge from '../components/StatusBadge';
import ReviewsSection from '../components/ReviewsSection';
import RescheduleModal from '../components/RescheduleModal';
import styles from './ReservationsPage.module.scss';
const todayISO = () => new Date().toISOString().split('T')[0];
const FILTERS = ['all', 'upcoming', 'past', 'cancelled'];
const FILTER_LABELS = { all: 'All', upcoming: 'Upcoming', past: 'Past', cancelled: 'Cancelled' };
const ReservationCard = ({ reservation: r, service, onCancel, cancelPending, highlighted, user, onRebook }) => {
const [open, setOpen] = useState(highlighted);
const [confirmCancel, setConfirmCancel] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const cardRef = useRef(null);
useEffect(() => {
if (highlighted && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [highlighted]);
const isUpcoming = r.date >= todayISO() && r.status !== 'cancelled';
const isPast = r.date < todayISO() && r.status !== 'cancelled';
const canCancel = r.status === 'pending' || r.status === 'confirmed';
// gcUrl to zmienna, która przechowuje link do dodania rezerwacji do Google Calendar, jeśli rezerwacja jest nadchodząca, jeśli rezerwacja jest przeszła to gcUrl jest null
const gcUrl = (() => {
if (!isUpcoming) return null;
const [year, month, day] = r.date.split('-');
const [h, m] = (r.time ?? '09:00').split(':');
const pad = (n) => String(n).padStart(2, '0');
const start = `${year}${month}${day}T${pad(h)}${pad(m)}00`;
const end = `${year}${month}${day}T${pad((+h + 1) % 24)}${pad(m)}00`;
return `https://calendar.google.com/calendar/r/eventedit?text=${encodeURIComponent(service?.name ?? 'Reservation')}&dates=${start}/${end}&details=${encodeURIComponent(`Reservation #${r.id}`)}`;
})();
return (
<div
ref={cardRef}
className={[
styles['res-card'],
r.status === 'cancelled' ? styles['res-card--cancelled'] : '',
isUpcoming ? styles['res-card--upcoming'] : '',
highlighted ? styles['res-card--highlighted'] : '',
].join(' ')}
>
<button
className={styles['res-card__toggle']}
onClick={() => { setOpen((v) => !v); setConfirmCancel(false); }}
aria-expanded={open}
>
<div className={styles['res-card__summary']}>
<span className={styles['res-card__name']}>{service?.name ?? r.serviceId}</span>
<div className={styles['res-card__meta']}>
<span className={styles['res-card__meta-item']}><CalendarDays size={12} /> {r.date}</span>
<span className={styles['res-card__meta-item']}><Clock size={12} /> {r.time}</span>
</div>
</div>
<div className={styles['res-card__right']}>
<StatusBadge status={r.status} size="sm" />
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{open && (
<div className={styles['res-card__body']}>
<dl className={styles['res-card__dl']}>
<div className={styles['res-card__dl-row']}><dt><Hash size={12} /> Reference</dt><dd>#{r.id}</dd></div>
<div className={styles['res-card__dl-row']}><dt><Tag size={12} /> Service</dt><dd>{service?.name ?? r.serviceId}</dd></div>
{service && (
<div className={styles['res-card__dl-row']}><dt><CreditCard size={12} /> Price</dt><dd>${service.price} ({service.duration} min)</dd></div>
)}
<div className={styles['res-card__dl-row']}><dt><CalendarDays size={12} /> Date</dt><dd>{r.date} · {r.time}</dd></div>
{r.depositPaid != null && (
<div className={styles['res-card__dl-row']}><dt><CreditCard size={12} /> Deposit paid</dt><dd>${Number(r.depositPaid).toFixed(2)}</dd></div>
)}
{r.transactionId && (
<div className={styles['res-card__dl-row']}><dt><Hash size={12} /> Transaction</dt><dd className={styles['res-card__mono']}>{r.transactionId}</dd></div>
)}
{r.specialRequirements && (
<div className={styles['res-card__dl-row']}><dt><FileText size={12} /> Notes</dt><dd>{r.specialRequirements}</dd></div>
)}
</dl>
<div className={styles['res-card__actions']}>
{gcUrl && (
<a href={gcUrl} target="_blank" rel="noopener noreferrer" className={styles['res-card__cal-btn']}>
<ExternalLink size={13} /> Add to Google Calendar
</a>
)}
{canCancel && (
<button className={styles['res-card__reschedule-btn']} onClick={() => setRescheduleOpen(true)}>
<RefreshCw size={14} /> Reschedule
</button>
)}
{(isPast || r.status === 'cancelled') && (
<button className={styles['res-card__rebook-btn']} onClick={() => onRebook(r)}>
<RotateCcw size={14} /> Book again
</button>
)}
{canCancel && !confirmCancel && (
<button className={styles['res-card__cancel-trigger']} onClick={() => setConfirmCancel(true)}>
<XCircle size={14} /> Cancel reservation
</button>
)}
</div>
{confirmCancel && (
<div className={styles['res-card__confirm']}>
<AlertTriangle size={16} className={styles['res-card__confirm-icon']} />
<p className={styles['res-card__confirm-text']}>Are you sure you want to cancel this reservation?</p>
<div className={styles['res-card__confirm-btns']}>
<button className={styles['res-card__confirm-yes']} onClick={() => { onCancel(r.id); setConfirmCancel(false); }} disabled={cancelPending}>
{cancelPending ? 'Cancelling…' : 'Yes, cancel it'}
</button>
<button className={styles['res-card__confirm-no']} onClick={() => setConfirmCancel(false)}>Keep it</button>
</div>
</div>
)}
{isPast && r.serviceId && (
<div className={styles['res-card__reviews']}>
<ReviewsSection serviceId={r.serviceId} reservationId={r.id} userId={user?.id ?? ''} userName={user?.name ?? user?.email ?? ''} />
</div>
)}
</div>
)}
<RescheduleModal isOpen={rescheduleOpen} onClose={() => setRescheduleOpen(false)} reservation={r} serviceName={service?.name} />
</div>
);
};
const ReservationsPage = () => {
const { user, logout } = useAuth();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const highlightId = searchParams.get('id');
const { data: reservations = [], isLoading } = useUserReservations(user?.id);
const { data: services = [] } = useServices();
const { mutate: updateStatus, isPending: cancelPending } = useUpdateReservation();
const [filter, setFilter] = useState('all');
const getService = (id) => services.find((s) => s.id === id);
const filtered = reservations.filter((r) => {
if (filter === 'upcoming') return r.date >= todayISO() && r.status !== 'cancelled';
if (filter === 'past') return r.date < todayISO() && r.status !== 'cancelled';
if (filter === 'cancelled') return r.status === 'cancelled';
return true;
});
const sorted = [...filtered].sort((a, b) =>
filter === 'past'
? (b.date + b.time).localeCompare(a.date + a.time)
: (a.date + a.time).localeCompare(b.date + b.time)
);
const counts = {
all: reservations.length,
upcoming: reservations.filter((r) => r.date >= todayISO() && r.status !== 'cancelled').length,
past: reservations.filter((r) => r.date < todayISO() && r.status !== 'cancelled').length,
cancelled: reservations.filter((r) => r.status === 'cancelled').length,
};
return (
<div className={styles['rp']}>
<header className={styles['rp__header']}>
<div className={styles['rp__header-inner']}>
<div className={styles['rp__header-left']}>
<Link to="/dashboard" className={styles['rp__back']}><ChevronLeft size={16} /> Dashboard</Link>
<div>
<h1 className={styles['rp__title']}><BookOpen size={20} /> My Reservations</h1>
<p className={styles['rp__subtitle']}>{user?.email}</p>
</div>
</div>
<div className={styles['rp__header-actions']}>
<ThemeToggle />
<button className={styles['rp__logout']} onClick={logout}>Sign out</button>
</div>
</div>
</header>
<main className={styles['rp__main']}>
<div className={styles['rp__tabs']}>
{FILTERS.map((f) => (
<button key={f} className={[styles['rp__tab'], filter === f ? styles['rp__tab--active'] : ''].join(' ')} onClick={() => setFilter(f)}>
{FILTER_LABELS[f]}
{counts[f] > 0 && <span className={styles['rp__tab-count']}>{counts[f]}</span>}
</button>
))}
</div>
{isLoading ? (
<p className={styles['rp__msg']}>Loading reservations</p>
) : sorted.length === 0 ? (
<p className={styles['rp__msg']}>
{filter === 'all' ? 'You have no reservations yet.' : `No ${FILTER_LABELS[filter].toLowerCase()} reservations.`}
</p>
) : (
<div className={styles['rp__list']}>
{sorted.map((r) => (
<ReservationCard
key={r.id}
reservation={r}
service={getService(r.serviceId)}
onCancel={(id) => updateStatus({ id, patch: { status: 'cancelled' } })}
cancelPending={cancelPending}
highlighted={r.id === highlightId}
user={user}
onRebook={(r) => navigate('/dashboard', { state: { rebookServiceId: r.serviceId } })}
/>
))}
</div>
)}
</main>
</div>
);
};
export default ReservationsPage;

View File

@@ -0,0 +1,477 @@
@use '../styles/variables' as *;
.rp {
min-height: 100vh;
background: var(--clr-bg);
// ── Header ────────────────────────────────────────────────────────────────
&__header {
background: var(--clr-surface);
border-bottom: 1px solid var(--clr-border);
box-shadow: $shadow-sm;
}
&__header-inner {
max-width: $container-max-width;
margin-inline: auto;
padding: $spacing-4 $spacing-6;
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-4;
}
&__header-left {
display: flex;
align-items: center;
gap: $spacing-5;
}
&__back {
display: inline-flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text-secondary);
text-decoration: none;
padding: $spacing-1 $spacing-2;
border-radius: $radius-base;
transition: color $transition-fast, background $transition-fast;
white-space: nowrap;
&:hover {
color: var(--clr-text);
background: var(--clr-surface-raised);
}
}
&__title {
display: flex;
align-items: center;
gap: $spacing-2;
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text);
margin: 0;
}
&__subtitle {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
margin: $spacing-1 0 0;
}
&__header-actions {
display: flex;
align-items: center;
gap: $spacing-3;
}
&__logout {
padding: $spacing-2 $spacing-4;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
background: transparent;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
}
// ── Main ──────────────────────────────────────────────────────────────────
&__main {
max-width: 780px;
margin-inline: auto;
padding: $spacing-8 $spacing-6;
}
// ── Filter tabs ───────────────────────────────────────────────────────────
&__tabs {
display: flex;
gap: $spacing-2;
margin-bottom: $spacing-6;
border-bottom: 1px solid var(--clr-border);
padding-bottom: 0;
}
&__tab {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-4;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: color $transition-fast, border-color $transition-fast;
&:hover { color: var(--clr-text); }
&--active {
color: var(--clr-primary, #{$color-primary});
border-bottom-color: var(--clr-primary, #{$color-primary});
}
}
&__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--clr-surface-raised, #{$gray-100});
border-radius: $radius-full;
font-size: 11px;
font-weight: $font-weight-semibold;
color: var(--clr-text-secondary);
.rp__tab--active & {
background: rgba($primary-500, 0.12);
color: $primary-600;
}
}
&__msg {
text-align: center;
padding: $spacing-16 0;
font-size: $font-size-sm;
color: var(--clr-text-secondary);
}
&__list {
display: flex;
flex-direction: column;
gap: $spacing-3;
}
}
// ── Reservation card ─────────────────────────────────────────────────────────
.res-card {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
overflow: hidden;
transition: box-shadow $transition-fast;
&:hover { box-shadow: $shadow-md; }
&--cancelled {
opacity: 0.7;
}
&--upcoming {
border-left: 3px solid $primary-400;
}
&--highlighted {
box-shadow: 0 0 0 2px $primary-400, $shadow-md;
animation: res-card-pulse 1.2s ease-out;
}
// @keyframes z wieloma klatkami
// które zmieniają box-shadow z 0 0 0 4px rgba($primary-400, 0.5),
// $shadow-md na 0 0 0 2px $primary-400, $shadow-md
// animacja trwa 1.2s i jest ease-out
// jest to animacja pulsowania karty rezerwacji, która jest wyróżniona
@keyframes res-card-pulse {
0% { box-shadow: 0 0 0 4px rgba($primary-400, 0.5), $shadow-md; }
100% { box-shadow: 0 0 0 2px $primary-400, $shadow-md; }
}
// ── Toggle button (collapsed header) ──────────────────────────────────────
&__toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-4;
padding: $spacing-4 $spacing-5;
background: none;
border: none;
font-family: $font-family-base;
cursor: pointer;
text-align: left;
color: var(--clr-text);
transition: background $transition-fast;
&:hover { background: var(--clr-surface-raised, #{$gray-50}); }
}
&__summary {
display: flex;
flex-direction: column;
gap: $spacing-1;
flex: 1;
min-width: 0;
}
&__name {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: $spacing-3;
}
&__meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: $font-size-xs;
color: var(--clr-text-secondary);
}
&__right {
display: flex;
align-items: center;
gap: $spacing-3;
flex-shrink: 0;
color: var(--clr-text-secondary);
}
// ── Expanded body ─────────────────────────────────────────────────────────
&__body {
border-top: 1px solid var(--clr-border);
padding: $spacing-5;
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__dl {
display: grid;
grid-template-columns: 1fr;
gap: 0;
}
&__dl-row {
display: grid;
grid-template-columns: 160px 1fr;
gap: $spacing-3;
padding: $spacing-2 0;
border-bottom: 1px solid var(--clr-border);
&:last-child { border-bottom: none; }
dt {
display: inline-flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: var(--clr-text-secondary);
}
dd {
font-size: $font-size-xs;
color: var(--clr-text);
word-break: break-all;
}
}
&__mono {
font-family: $font-family-mono;
font-size: 11px;
}
// ── Actions row ───────────────────────────────────────────────────────────
&__actions {
display: flex;
align-items: center;
gap: $spacing-3;
flex-wrap: wrap;
}
&__pdf-btn {
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
background: transparent;
border: 1px solid var(--clr-border);
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover {
background: var(--clr-surface-raised);
color: var(--clr-text);
}
}
// ── Google Calendar link ──────────────────────────────────────────────────
&__cal-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
background: transparent;
border: 1px solid $accent-200;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $accent-700;
text-decoration: none;
transition: background $transition-fast;
&:hover { background: $accent-50; }
}
// ── Re-book trigger ───────────────────────────────────────────────────────
&__rebook-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
background: transparent;
border: 1px solid $accent-200;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: $accent-700;
cursor: pointer;
transition: background $transition-fast;
&:hover { background: $accent-50; }
}
// ── Reschedule trigger ────────────────────────────────────────────────────
&__reschedule-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
background: transparent;
border: 1px solid $primary-200;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: $primary-600;
cursor: pointer;
transition: background $transition-fast;
&:hover { background: $primary-50; }
}
// ── Cancel trigger ────────────────────────────────────────────────────────
&__cancel-trigger {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
background: transparent;
border: 1px solid #fecaca;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: $color-error;
cursor: pointer;
transition: background $transition-fast;
&:hover { background: #fef2f2; }
}
// ── Cancel confirmation ───────────────────────────────────────────────────
&__confirm {
display: flex;
flex-direction: column;
gap: $spacing-3;
padding: $spacing-4;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: $radius-lg;
}
&__confirm-icon {
color: #d97706;
flex-shrink: 0;
}
&__confirm-text {
font-size: $font-size-sm;
color: #92400e;
margin: 0;
}
&__confirm-btns {
display: flex;
gap: $spacing-3;
}
&__confirm-yes {
padding: $spacing-2 $spacing-4;
background: $color-error;
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
transition: opacity $transition-fast;
&:hover:not(:disabled) { opacity: 0.9; }
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
&__confirm-no {
padding: $spacing-2 $spacing-4;
background: transparent;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast;
&:hover { background: var(--clr-surface-raised); }
}
&__reviews {
margin-top: $spacing-4;
padding-top: $spacing-4;
border-top: 1px solid var(--clr-border);
}
}