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:
313
src/pages/AdminPage.jsx
Normal file
313
src/pages/AdminPage.jsx
Normal 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ę ją 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;
|
||||
371
src/pages/AdminPage.module.scss
Normal file
371
src/pages/AdminPage.module.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
82
src/pages/DashboardPage.jsx
Normal file
82
src/pages/DashboardPage.jsx
Normal 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;
|
||||
118
src/pages/DashboardPage.module.scss
Normal file
118
src/pages/DashboardPage.module.scss
Normal 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
222
src/pages/LoginPage.jsx
Normal 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;
|
||||
227
src/pages/LoginPage.module.scss
Normal file
227
src/pages/LoginPage.module.scss
Normal 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
269
src/pages/RegisterPage.jsx
Normal 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;
|
||||
305
src/pages/RegisterPage.module.scss
Normal file
305
src/pages/RegisterPage.module.scss
Normal 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); } }
|
||||
237
src/pages/ReservationsPage.jsx
Normal file
237
src/pages/ReservationsPage.jsx
Normal 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;
|
||||
477
src/pages/ReservationsPage.module.scss
Normal file
477
src/pages/ReservationsPage.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user