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

91
src/App.jsx Normal file
View File

@@ -0,0 +1,91 @@
// [REQ D1] React Suspense i Error Boundaries Suspense + ErrorBoundary opakowują całą aplikację
// [REQ D5] React.lazy i dynamic imports DashboardPage i AdminPage ładowane on-demand
// [REQ T3] React Router BrowserRouter, Routes, Route, Navigate, ProtectedRoute
// lazy - funkcja reacta do dzielenia kodu i ładowania ich w danym momencie - żeby nie ładować wszystkiego na raz
// Suspense - komponent reacta do wyświetlania ekranu ładowania podczas ładowania komponentu lazy
import { lazy, Suspense } from 'react';
// BrowserRouter jest potrzebny do routingu w aplikacji React - sychronizacja url z widokiem
// Routers jest potrzebny do definiowania ścieżek w aplikacji React - grupowanie ścieżek w aplikacji
// Route jest potrzebny do definiowania pojedynczej ścieżki w aplikacji React - definiowanie widoku dla danej ścieżki
// Navigate jest potrzebny do przekierowywania w aplikacji React - zmiana widoku na inną ścieżkę
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
// AuthProvider jest potrzebny do przechowywania stanu zalogowanego użytkownika
//useAuth jest potrzebny do pobierania stanu zalogowanego użytkownika
import { AuthProvider, useAuth } from './context/AuthContext';
// ThemeProvider jest potrzbny do przechowywania stanu motywu aplikacji
import { ThemeProvider } from './context/ThemeContext';
// ProtectedRoute jest potrzebny do ochrony ścieżek w aplikacji React - sprawdzanie czy użytkownik ma dostęp do danej ścieżki
import ProtectedRoute from './components/ProtectedRoute';
// ErrorBoundary jest potrzebny do obsługi błędów - wyświetlanie komunikatu o błędzie zamiast crashowania aplikacji
import ErrorBoundary from './components/ErrorBoundary';
// LoadingSpinner jest potrzebny do wyświetlania animacji ładowania podczas ładowania komponentu lazy
import LoadingSpinner from './components/LoadingSpinner';
// LoginPage to komponent strony logowania
// RegisterPage to komponent strony rejestracji
// ReservationsPage to komponent strony rezerwacji
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import ReservationsPage from './pages/ReservationsPage';
// Importowanie stylów głównych aplikacji z scss - po zbudowaniu css zostanie wstrzyknięty do html
import './styles/main.scss';
// Lazy loading komponentów stron, aby nie ładować ich od razu przy starcie aplikacji
// Ładowane w momencie wejścia na daną ścieżkę
// Dostępne tylko dla admina lub klienta w zależności od roli użytkownika
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
// Komponent RootRedirect sprawdza czy użytkownik jest zalogowany i przekierowuje go na odpowiednią stronę
// useAuth custom hook do pobierania stanu zalogowanego użytkownika z AuthContext
const RootRedirect = () => {
// desrukturyzacja user z useAuth - pobieramy zalogowanego użytkownika z kontekstu
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
// Jeśli użytkownik jest zalogowany, sprawdzamy jego rolę i przekierowujemy go na odpowiednią stronę
// replace podmienia URL w historii przeglądarki żeby cofnij nie nie wracało do strony root
return <Navigate to={user.role === 'admin' ? '/admin' : '/dashboard'} replace />;
};
// Główny komponent aplikacji React, który zawiera wszystkie ścieżki i contexty
const App = () => (
// BrowserRouter jest potrzebny do routingu w aplikacji React - sychronizacja url z widokiem
<BrowserRouter>
{/* // ThemeProvider jest potrzebny do przechowywania stanu motywu aplikacji */}
<ThemeProvider>
{/* // AuthProvider jest potrzebny do przechowywania stanu zalogowanego użytkownika */}
<AuthProvider>
{/* // ErrorBoundary jest potrzebny do obsługi błędów - wyświetlanie komunikatu o błędzie zamiast crashowania aplikacji */}
<ErrorBoundary>
{/* // Suspense jest potrzebny do wyświetlania ekranu ładowania podczas ładowania komponentu lazy */}
{/* // Inline określamy jaki ma być ekran ładowania w tym przypadku jest to spinner i napis "Loading page…" */}
<Suspense fallback={<LoadingSpinner message="Loading page…" />}>
{/* // Routes jest potrzebny do definiowania ścieżek w aplikacji React - grupowanie ścieżek w aplikacji */}
<Routes>
{/* //Route pojedyncze ścieżki w aplikacji React - definiowanie widoku dla danej ścieżki */}
{/* // Powiązuje URL z komponentem React, który ma być renderowany w momencie wejścia na daną ścieżkę */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* // Ścieżki chronione przez ProtectedRoute
// określamy jedną scieżke i pakujemy w nie wszsytkie które mają byc chronione
// */}
<Route element={<ProtectedRoute allowedRoles={['client']} />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/reservations" element={<ReservationsPage />} />
</Route>
<Route element={<ProtectedRoute allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
<Route path="/" element={<RootRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</ErrorBoundary>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
);
// export aby móc importować komponent App w innych plikach
export default App;

22
src/api/reservations.js Normal file
View File

@@ -0,0 +1,22 @@
// [REQ T5] Obsługa zapytań API CRUD rezerwacji przez Axios
// [REQ D7] React Query te funkcje są queryFn/mutationFn w custom hookach
import axios from 'axios';
import { API_URL } from '../config';
export const createReservation = (data) =>
axios.post(`${API_URL}/reservations`, data).then((res) => res.data);
export const getAllReservations = () =>
axios.get(`${API_URL}/reservations`).then((res) => res.data);
export const getReservationsBySlot = (serviceId, date) =>
axios.get(`${API_URL}/reservations`, { params: { serviceId, date } }).then((res) => res.data);
export const getUserReservations = (userId) =>
axios.get(`${API_URL}/reservations`, { params: { userId } }).then((res) => res.data);
export const updateReservation = (id, patch) =>
axios.patch(`${API_URL}/reservations/${id}`, patch).then((res) => res.data);
export const deleteReservation = (id) =>
axios.delete(`${API_URL}/reservations/${id}`).then((res) => res.data);

7
src/api/services.js Normal file
View File

@@ -0,0 +1,7 @@
// [REQ T5] Obsługa zapytań API pobieranie listy usług
// [REQ D7] React Query queryFn dla useServices hook
import axios from 'axios';
import { API_URL } from '../config';
export const getServices = () =>
axios.get(`${API_URL}/services`).then((res) => res.data);

37
src/api/users.js Normal file
View File

@@ -0,0 +1,37 @@
// [REQ F1] System uwierzytelniania rejestracja i weryfikacja e-mail
// [REQ T5] Obsługa zapytań API zarządzanie użytkownikami
import axios from 'axios';
import { API_URL } from '../config';
const makeToken = () =>
Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
export const registerUser = async ({ name, email, password }) => {
const { data: existing } = await axios.get(`${API_URL}/users`, { params: { email } });
if (existing.length > 0) throw new Error('An account with this email already exists.');
const verificationToken = makeToken();
const { data } = await axios.post(`${API_URL}/users`, {
name,
email,
password,
role: 'client',
avatarUrl: `https://i.pravatar.cc/150?u=${encodeURIComponent(email)}`,
verified: false,
verificationToken,
});
return { user: data, token: verificationToken };
};
export const verifyUser = async (token) => {
const { data } = await axios.get(`${API_URL}/users`, {
params: { verificationToken: token },
});
if (!data.length) throw new Error('Invalid or expired verification link.');
const user = data[0];
await axios.patch(`${API_URL}/users/${user.id}`, {
verified: true,
verificationToken: null,
});
return user;
};

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,155 @@
// [REQ F7] Panel admina widok kalendarza rezerwacji (miesięczny), blokowanie terminów
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import StatusBadge from './StatusBadge';
import styles from './AdminCalendar.module.scss';
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const toISO = (year, month, day) =>
`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const AdminCalendar = ({ reservations = [] }) => {
const now = new Date();
const [cur, setCur] = useState({ year: now.getFullYear(), month: now.getMonth() });
const [selectedDate, setSelectedDate] = useState(null);
const { year, month } = cur;
const prevMonth = () =>
setCur(({ year, month }) =>
month === 0 ? { year: year - 1, month: 11 } : { year, month: month - 1 }
);
const nextMonth = () =>
setCur(({ year, month }) =>
month === 11 ? { year: year + 1, month: 0 } : { year, month: month + 1 }
);
// (getDay() + 6) % 7 converts Sun=0 → Mon=0
const startPad = (new Date(year, month, 1).getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrev = new Date(year, month, 0).getDate();
const byDate = reservations.reduce((acc, r) => {
if (!acc[r.date]) acc[r.date] = [];
acc[r.date].push(r);
return acc;
}, {});
const todayISO = now.toISOString().split('T')[0];
const monthLabel = new Date(year, month, 1).toLocaleString('en-US', {
month: 'long',
year: 'numeric',
});
const cells = [];
for (let i = 0; i < startPad; i++)
cells.push({ day: daysInPrev - startPad + 1 + i, iso: null });
for (let d = 1; d <= daysInMonth; d++)
cells.push({ day: d, iso: toISO(year, month, d) });
const tail = 7 - (cells.length % 7);
if (tail < 7)
for (let d = 1; d <= tail; d++) cells.push({ day: d, iso: null });
const selectedRes = selectedDate ? (byDate[selectedDate] ?? []) : [];
return (
<div className={styles['cal']}>
{/* Calendar grid */}
<div className={styles['cal__grid-wrap']}>
<div className={styles['cal__nav']}>
<button className={styles['cal__nav-btn']} onClick={prevMonth} aria-label="Previous month">
<ChevronLeft size={18} />
</button>
<h3 className={styles['cal__month']}>{monthLabel}</h3>
<button className={styles['cal__nav-btn']} onClick={nextMonth} aria-label="Next month">
<ChevronRight size={18} />
</button>
</div>
<div className={styles['cal__weekdays']}>
{WEEKDAYS.map((d) => (
<div key={d} className={styles['cal__weekday']}>{d}</div>
))}
</div>
<div className={styles['cal__cells']}>
{cells.map((cell, idx) => {
if (!cell.iso) {
return (
<div key={`ov-${idx}`} className={styles['cal__cell-overflow']}>
<span className={styles['cal__day-num']}>{cell.day}</span>
</div>
);
}
const count = byDate[cell.iso]?.length ?? 0;
const isToday = cell.iso === todayISO;
const isSelected = cell.iso === selectedDate;
return (
<div
key={cell.iso}
className={[
styles['cal__cell'],
isSelected ? styles['cal__cell--selected'] : '',
count > 0 ? styles['cal__cell--busy'] : '',
].join(' ')}
onClick={() => setSelectedDate((p) => p === cell.iso ? null : cell.iso)}
>
<span className={[
styles['cal__day-num'],
isToday ? styles['cal__day-num--today'] : '',
].join(' ')}>
{cell.day}
</span>
{count > 0 && (
<span className={[
styles['cal__badge'],
count >= 3 ? styles['cal__badge--high'] : '',
].join(' ')}>
{count}
</span>
)}
</div>
);
})}
</div>
</div>
{/* Side panel */}
{selectedDate && (
<div className={styles['cal__panel']}>
<div className={styles['cal__panel-header']}>
<span className={styles['cal__panel-date']}>{selectedDate}</span>
<span className={styles['cal__panel-count']}>
{selectedRes.length} reservation{selectedRes.length !== 1 ? 's' : ''}
</span>
</div>
{selectedRes.length === 0 ? (
<p className={styles['cal__panel-empty']}>No reservations on this day.</p>
) : (
<ul className={styles['cal__panel-list']}>
{selectedRes
.slice()
.sort((a, b) => a.time.localeCompare(b.time))
.map((r) => (
<li key={r.id} className={styles['cal__panel-item']}>
<div className={styles['cal__panel-info']}>
<span className={styles['cal__panel-time']}>{r.time}</span>
<span className={styles['cal__panel-meta']}>
#{r.id} · {r.userId}
</span>
<span className={styles['cal__panel-svc']}>{r.serviceId}</span>
</div>
<StatusBadge status={r.status} size="sm" />
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
export default AdminCalendar;

View File

@@ -0,0 +1,207 @@
@use '../styles/variables' as *;
.cal {
display: grid;
grid-template-columns: 1fr 280px;
gap: $spacing-5;
align-items: start;
@media (max-width: $bp-lg) { grid-template-columns: 1fr; }
// ── Calendar grid ─────────────────────────────────────────────────────────
&__grid-wrap {
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
overflow: hidden;
background: var(--clr-surface);
}
&__nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-4 $spacing-5;
border-bottom: 1px solid var(--clr-border);
}
&__nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
background: transparent;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
}
&__month {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--clr-surface-raised, #{$gray-50});
border-bottom: 1px solid var(--clr-border);
}
&__weekday {
padding: $spacing-2;
text-align: center;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--clr-text-muted);
}
&__cells {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--clr-border);
}
&__cell {
background: var(--clr-surface);
min-height: 60px;
padding: $spacing-2;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $spacing-1;
cursor: pointer;
transition: background $transition-fast;
&:hover { background: var(--clr-surface-raised, #{$gray-50}); }
&--selected { background: $primary-50 !important; }
&--busy {}
}
&__cell-overflow {
background: var(--clr-surface-raised, #{$gray-50});
min-height: 60px;
padding: $spacing-2;
display: flex;
flex-direction: column;
}
&__day-num {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: var(--clr-text);
line-height: 22px;
width: 22px;
text-align: center;
border-radius: $radius-full;
flex-shrink: 0;
.cal__cell-overflow & { color: var(--clr-text-muted); }
&--today {
background: var(--clr-primary);
color: #fff;
}
}
&__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 $spacing-1;
background: $primary-100;
color: $primary-700;
border-radius: $radius-full;
font-size: 10px;
font-weight: $font-weight-semibold;
&--high { background: #fef3c7; color: #92400e; }
}
// ── Side panel ─────────────────────────────────────────────────────────────
&__panel {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
overflow: hidden;
}
&__panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-4 $spacing-5;
border-bottom: 1px solid var(--clr-border);
background: var(--clr-surface-raised, #{$gray-50});
}
&__panel-date {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
font-family: $font-family-mono;
}
&__panel-count {
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
&__panel-empty {
padding: $spacing-6 $spacing-5;
font-size: $font-size-sm;
color: var(--clr-text-muted);
text-align: center;
}
&__panel-list { list-style: none; padding: 0; margin: 0; }
&__panel-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-3;
padding: $spacing-3 $spacing-5;
border-bottom: 1px solid var(--clr-border);
&:last-child { border-bottom: none; }
&:hover { background: var(--clr-surface-raised, #{$gray-50}); }
}
&__panel-info {
display: flex;
flex-direction: column;
gap: $spacing-1;
min-width: 0;
}
&__panel-time {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
font-family: $font-family-mono;
}
&__panel-meta,
&__panel-svc {
font-size: $font-size-xs;
color: var(--clr-text-muted);
font-family: $font-family-mono;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,168 @@
// [REQ F9] Analityka i raportowanie statystyki rezerwacji, wykresy, eksport CSV
// [REQ D6] Render props DataRenderer z children jako funkcja
// [REQ T6] Optymalizacja useMemo dla obliczania statystyk
// [REQ T7] Wzorce kompozycji DataRenderer (render props) użyty do pobierania danych
import { useMemo } from 'react';
import { TrendingUp, DollarSign, Calendar, Users, CheckCircle2, XCircle, Clock, Download } from 'lucide-react';
import DataRenderer from './DataRenderer';
import { API_URL } from '../config';
import styles from './AnalyticsDashboard.module.scss';
const fetchServices = () =>
fetch(`${API_URL}/services`).then((r) => r.json());
const StatCard = ({ icon: Icon, label, value, sub, accent }) => (
<div className={[styles['stat'], accent ? styles[`stat--${accent}`] : ''].join(' ')}>
<div className={styles['stat__icon']}><Icon size={20} /></div>
<div className={styles['stat__body']}>
<p className={styles['stat__value']}>{value}</p>
<p className={styles['stat__label']}>{label}</p>
{sub && <p className={styles['stat__sub']}>{sub}</p>}
</div>
</div>
);
const BarChart = ({ data, maxValue }) => (
<div className={styles['bar-chart']}>
{data.map(({ label, value, color }) => (
<div key={label} className={styles['bar-chart__row']}>
<span className={styles['bar-chart__label']}>{label}</span>
<div className={styles['bar-chart__track']}>
<div
className={styles['bar-chart__fill']}
style={{ width: `${maxValue ? (value / maxValue) * 100 : 0}%`, background: color }}
/>
</div>
<span className={styles['bar-chart__val']}>{value}</span>
</div>
))}
</div>
);
const AnalyticsDashboard = ({ reservations }) => {
// useMemo do obliczania statystyk rezerwacji na podstawie danych reservations
// , aby nie przeliczać ich przy każdym renderze komponentu
// , tylko wtedy gdy zmieni się props reservations
const stats = useMemo(() => {
const total = reservations.length;
const pending = reservations.filter((r) => r.status === 'pending').length;
const confirmed= reservations.filter((r) => r.status === 'confirmed').length;
const cancelled= reservations.filter((r) => r.status === 'cancelled').length;
const revenue = reservations.reduce((s, r) => s + Number(r.depositPaid ?? 0), 0);
// Bookings per month (last 6 months)
const monthly = {};
reservations.forEach((r) => {
const month = r.date?.slice(0, 7);
if (month) monthly[month] = (monthly[month] ?? 0) + 1;
});
const monthlyData = Object.entries(monthly)
.sort(([a], [b]) => a.localeCompare(b))
.slice(-6)
.map(([month, count]) => ({
label: month,
value: count,
color: 'var(--clr-primary, #1e5f9a)',
}));
// By service
const byService = {};
reservations.forEach((r) => {
byService[r.serviceId] = (byService[r.serviceId] ?? 0) + 1;
});
return { total, pending, confirmed, cancelled, revenue, monthlyData, byService };
}, [reservations]);
const maxMonthly = Math.max(...(stats.monthlyData.map((d) => d.value)), 1);
const exportCSV = () => {
const rows = [
['ID', 'User ID', 'Service ID', 'Date', 'Time', 'Status', 'Deposit Paid'],
...reservations.map((r) => [r.id, r.userId, r.serviceId, r.date, r.time, r.status, r.depositPaid ?? 0]),
];
const csv = rows.map((r) => r.join(',')).join('\n');
const a = document.createElement('a');
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv);
a.download = `reservations-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
};
return (
<DataRenderer
queryKey={['services']}
queryFn={fetchServices}
>
{({ data: services = [] }) => {
const serviceData = Object.entries(stats.byService).map(([id, count]) => ({
label: services.find((s) => s.id === id)?.name ?? id,
value: count,
color: '#059669',
})).sort((a, b) => b.value - a.value);
return (
<div className={styles['analytics']}>
{/* Export */}
<div className={styles['analytics__toolbar']}>
<button className={styles['analytics__export-btn']} onClick={exportCSV}>
<Download size={14} /> Export CSV
</button>
</div>
{/* Stat cards */}
<div className={styles['analytics__cards']}>
<StatCard icon={Calendar} label="Total reservations" value={stats.total} accent="blue" />
<StatCard icon={Clock} label="Pending" value={stats.pending} accent="yellow" />
<StatCard icon={CheckCircle2}label="Confirmed" value={stats.confirmed} accent="green" />
<StatCard icon={XCircle} label="Cancelled" value={stats.cancelled} accent="red" />
<StatCard
icon={DollarSign}
label="Total deposit revenue"
value={`$${stats.revenue.toFixed(2)}`}
sub="Sum of all deposit payments"
accent="blue"
/>
<StatCard
icon={TrendingUp}
label="Occupancy rate"
value={`${stats.total ? Math.round(((stats.confirmed + stats.pending) / stats.total) * 100) : 0}%`}
sub="Non-cancelled reservations"
accent="green"
/>
</div>
{/* Charts */}
<div className={styles['analytics__charts']}>
<div className={styles['analytics__chart-box']}>
<h4 className={styles['analytics__chart-title']}>
<TrendingUp size={14} /> Bookings by month
</h4>
{stats.monthlyData.length > 0 ? (
<BarChart data={stats.monthlyData} maxValue={maxMonthly} />
) : (
<p className={styles['analytics__empty']}>No data yet.</p>
)}
</div>
<div className={styles['analytics__chart-box']}>
<h4 className={styles['analytics__chart-title']}>
<Users size={14} /> Bookings by service
</h4>
{serviceData.length > 0 ? (
<BarChart
data={serviceData}
maxValue={Math.max(...serviceData.map((d) => d.value), 1)}
/>
) : (
<p className={styles['analytics__empty']}>No data yet.</p>
)}
</div>
</div>
</div>
);
}}
</DataRenderer>
);
};
export default AnalyticsDashboard;

View File

@@ -0,0 +1,162 @@
@use '../styles/variables' as *;
.analytics {
display: flex;
flex-direction: column;
gap: $spacing-6;
&__toolbar {
display: flex;
justify-content: flex-end;
}
&__export-btn {
display: inline-flex;
align-items: center;
gap: $spacing-2;
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); color: var(--clr-text); }
}
&__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: $spacing-4;
}
&__charts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-4;
@media (max-width: 800px) { grid-template-columns: 1fr; }
}
&__chart-box {
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
padding: $spacing-5;
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__chart-title {
display: flex;
align-items: center;
gap: $spacing-2;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
margin: 0;
}
&__empty {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
font-style: italic;
}
}
// Stat card
.stat {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
padding: $spacing-4;
display: flex;
align-items: flex-start;
gap: $spacing-3;
&__icon {
width: 40px;
height: 40px;
border-radius: $radius-base;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: $gray-100;
color: $gray-600;
}
&--blue &__icon { background: $primary-100; color: $primary-600; }
&--green &__icon { background: $accent-100; color: $accent-600; }
&--yellow &__icon { background: #fef9c3; color: #ca8a04; }
&--red &__icon { background: #fee2e2; color: #dc2626; }
&__body { min-width: 0; }
&__value {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: var(--clr-text);
margin: 0;
line-height: 1.2;
}
&__label {
font-size: $font-size-xs;
color: var(--clr-text-secondary);
margin: 2px 0 0;
}
&__sub {
font-size: 11px;
color: var(--clr-text-muted);
margin: 2px 0 0;
}
}
// Bar chart
.bar-chart {
display: flex;
flex-direction: column;
gap: $spacing-2;
&__row {
display: grid;
grid-template-columns: 100px 1fr 36px;
align-items: center;
gap: $spacing-2;
}
&__label {
font-size: 11px;
color: var(--clr-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__track {
height: 10px;
background: var(--clr-border);
border-radius: $radius-full;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: $radius-full;
transition: width 0.5s ease;
}
&__val {
font-size: 11px;
font-weight: $font-weight-semibold;
color: var(--clr-text);
text-align: right;
}
}

View File

@@ -0,0 +1,121 @@
// [REQ F3] Wyszukiwanie dostępności siatka slotów z synchronizacją parametrów URL
// [REQ T3] React Router useSearchParams do synchronizacji filtrów z URL
import { useSearchParams } from 'react-router-dom';
import { Search, CalendarDays, Tag } from 'lucide-react';
import { useServices } from '../hooks/useServices';
import { useSlotReservations } from '../hooks/useSlotReservations';
import styles from './AvailabilitySearch.module.scss';
const todayISO = () => new Date().toISOString().split('T')[0];
const TIME_SLOTS = Array.from({ length: 18 }, (_, i) => {
const mins = 9 * 60 + i * 30;
return `${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`;
});
const AvailabilitySearch = ({ onSelectSlot }) => {
const [searchParams, setSearchParams] = useSearchParams();
const filterDate = searchParams.get('date') || '';
const filterService = searchParams.get('service') || '';
const { data: services = [] } = useServices();
const { data: slotRes = [], isFetching } = useSlotReservations(filterService, filterDate);
const bookedTimes = slotRes.filter((r) => r.status !== 'cancelled').map((r) => r.time);
const showSlots = !!filterDate && !!filterService;
const setParam = (key, value) =>
setSearchParams((prev) => {
if (value) prev.set(key, value);
else prev.delete(key);
return prev;
});
const selectedSvcName = services.find((s) => s.id === filterService)?.name;
return (
<div className={styles['avail']}>
<div className={styles['avail__header']}>
<Search size={18} className={styles['avail__header-icon']} />
<h2 className={styles['avail__title']}>Check Availability</h2>
<span className={styles['avail__header-sub']}>Select a date and service to see open slots</span>
</div>
<div className={styles['avail__filters']}>
<div className={styles['avail__filter-group']}>
<label className={styles['avail__label']}>
<CalendarDays size={14} />
Date
</label>
<input
type="date"
className={styles['avail__input']}
min={todayISO()}
value={filterDate}
onChange={(e) => setParam('date', e.target.value)}
/>
</div>
<div className={styles['avail__filter-group']}>
<label className={styles['avail__label']}>
<Tag size={14} />
Service
</label>
<select
className={styles['avail__select']}
value={filterService}
onChange={(e) => setParam('service', e.target.value)}
>
<option value=""> Select a service </option>
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
{showSlots && (
<div className={styles['avail__slots']}>
{isFetching ? (
<p className={styles['avail__msg']}>Loading slots</p>
) : (
<>
<p className={styles['avail__slots-label']}>
{selectedSvcName} · {filterDate}
{bookedTimes.length > 0 && (
<span className={styles['avail__slots-hint']}>
{bookedTimes.length} slot(s) occupied
</span>
)}
</p>
<div className={styles['avail__grid']}>
{TIME_SLOTS.map((t) => {
const taken = bookedTimes.includes(t);
return (
<button
key={t}
className={[
styles['avail__slot'],
taken ? styles['avail__slot--taken'] : styles['avail__slot--free'],
].join(' ')}
disabled={taken}
onClick={() => onSelectSlot?.({ serviceId: filterService, date: filterDate, time: t })}
title={taken ? 'Already booked' : 'Click to pre-fill booking form'}
>
{t}
</button>
);
})}
</div>
<div className={styles['avail__legend']}>
<span className={styles['avail__legend-free']}>Available click to book</span>
<span className={styles['avail__legend-taken']}>Occupied</span>
</div>
</>
)}
</div>
)}
</div>
);
};
export default AvailabilitySearch;

View File

@@ -0,0 +1,174 @@
@use '../styles/variables' as *;
.avail {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-sm;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: $spacing-3;
padding: $spacing-4 $spacing-6;
background: linear-gradient(135deg, $primary-700, $primary-500);
color: #fff;
}
&__header-icon { flex-shrink: 0; opacity: 0.9; }
&__title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: #fff;
}
&__header-sub {
font-size: $font-size-xs;
color: rgba(255,255,255,0.75);
margin-left: auto;
@media (max-width: $bp-sm) { display: none; }
}
&__filters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-4;
padding: $spacing-5 $spacing-6;
border-bottom: 1px solid var(--clr-border);
@media (max-width: $bp-sm) { grid-template-columns: 1fr; }
}
&__filter-group {
display: flex;
flex-direction: column;
gap: $spacing-2;
}
&__label {
display: flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
}
&__input,
&__select {
padding: $spacing-3 $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-sm;
color: var(--clr-text);
font-family: $font-family-base;
outline: none;
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.18);
}
}
&__select { cursor: pointer; }
&__slots { padding: $spacing-5 $spacing-6; }
&__slots-label {
display: flex;
align-items: center;
gap: $spacing-2;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
margin-bottom: $spacing-4;
}
&__slots-hint {
font-size: $font-size-xs;
font-weight: $font-weight-normal;
color: var(--clr-text-muted);
}
&__grid {
display: flex;
flex-wrap: wrap;
gap: $spacing-2;
margin-bottom: $spacing-4;
}
&__slot {
padding: $spacing-2 $spacing-3;
border-radius: $radius-base;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-mono;
border: 1px solid transparent;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast, box-shadow $transition-fast;
&--free {
background: $accent-50;
border-color: $accent-200;
color: $accent-700;
&:hover {
background: $accent-100;
transform: translateY(-1px);
box-shadow: $shadow-sm;
}
}
&--taken {
background: var(--clr-surface-raised, #{$gray-50});
border-color: var(--clr-border);
color: var(--clr-text-muted);
cursor: not-allowed;
text-decoration: line-through;
}
}
&__legend {
display: flex;
align-items: center;
gap: $spacing-5;
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
&__legend-free,
&__legend-taken {
display: inline-flex;
align-items: center;
gap: $spacing-2;
&::before {
content: '';
display: inline-block;
width: 12px;
height: 12px;
border-radius: $radius-sm;
}
}
&__legend-free::before {
background: $accent-50;
border: 1px solid $accent-200;
}
&__legend-taken::before {
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
}
&__msg {
font-size: $font-size-sm;
color: var(--clr-text-muted);
padding: $spacing-4 0;
}
}

View File

@@ -0,0 +1,243 @@
import { useState } from 'react';
import { useFormik } from 'formik';
import { z } from 'zod';
import { CalendarDays, Clock, ClipboardList, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useCreateReservation } from '../hooks/useCreateReservation';
import { useServices } from '../hooks/useServices';
import { useSlotReservations } from '../hooks/useSlotReservations';
import styles from './BookingForm.module.scss';
const todayISO = () => new Date().toISOString().split('T')[0];
const baseSchema = z.object({
serviceId: z.string().min(1, 'Please select a service.'),
date: z
.string()
.min(1, 'Date is required.')
.refine((v) => v >= todayISO(), 'Date cannot be in the past.'),
time: z.string().min(1, 'Time is required.'),
specialRequirements: z.string().max(500, 'Maximum 500 characters allowed.'),
});
const BookingForm = () => {
const { user } = useAuth();
const [submitStatus, setSubmitStatus] = useState(null);
const { data: services = [], isLoading: servicesLoading, isError: servicesError } = useServices();
const { mutate: createReservation, isPending } = useCreateReservation({
onSuccess: () => { setSubmitStatus('success'); formik.resetForm(); },
onError: () => setSubmitStatus('error'),
});
const formik = useFormik({
initialValues: { serviceId: '', date: '', time: '', specialRequirements: '' },
validate: (values) => {
const result = baseSchema.safeParse(values);
const errors = {};
if (!result.success) {
result.error.issues.forEach(({ path, message }) => {
const key = path[0];
if (key && !errors[key]) errors[key] = message;
});
}
if (values.time && bookedTimes.includes(values.time)) {
errors.time = `${values.time} is already booked for this service and date.`;
}
return errors;
},
onSubmit: ({ serviceId, date, time, specialRequirements }) => {
setSubmitStatus(null);
createReservation({
userId: user.id,
serviceId,
date,
time,
specialRequirements: specialRequirements.trim() || null,
status: 'pending',
});
},
});
const { data: slotReservations = [] } = useSlotReservations(
formik.values.serviceId,
formik.values.date,
);
const bookedTimes = slotReservations
.filter((r) => r.status !== 'cancelled')
.map((r) => r.time);
return (
<div className={styles['booking-form']}>
<div className={styles['booking-form__header']}>
<ClipboardList size={24} className={styles['booking-form__icon']} />
<div>
<h2 className={styles['booking-form__title']}>Book a Service</h2>
<p className={styles['booking-form__subtitle']}>
Fill in the details below to request a reservation.
</p>
</div>
</div>
{submitStatus === 'success' && (
<div
className={[styles['booking-form__banner'], styles['booking-form__banner--success']].join(' ')}
role="status"
>
<CheckCircle2 size={18} />
<span>
Your reservation was submitted successfully and is{' '}
<strong>pending confirmation</strong>.
</span>
</div>
)}
{submitStatus === 'error' && (
<div
className={[styles['booking-form__banner'], styles['booking-form__banner--error']].join(' ')}
role="alert"
>
<AlertCircle size={18} />
<span>Something went wrong. Make sure the API server is running and try again.</span>
</div>
)}
<form
className={styles['booking-form__body']}
onSubmit={formik.handleSubmit}
noValidate
>
{/* Service */}
<div className={styles['booking-form__field']}>
<label htmlFor="serviceId" className={styles['booking-form__label']}>
Service <span className={styles['booking-form__required']}>*</span>
</label>
{servicesError ? (
<p className={styles['booking-form__field-error']}>
Failed to load services. Start the API server.
</p>
) : (
<div className={styles['booking-form__select-wrapper']}>
<select
id="serviceId"
className={[
styles['booking-form__select'],
formik.touched.serviceId && formik.errors.serviceId
? styles['booking-form__select--error']
: '',
].join(' ')}
disabled={servicesLoading}
{...formik.getFieldProps('serviceId')}
>
<option value="">
{servicesLoading ? 'Loading services...' : '-- Select a service --'}
</option>
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.name} -- ${s.price} / {s.duration} min
</option>
))}
</select>
</div>
)}
{formik.touched.serviceId && formik.errors.serviceId && (
<p className={styles['booking-form__field-error']}>{formik.errors.serviceId}</p>
)}
</div>
{/* Date + Time */}
<div className={styles['booking-form__row']}>
<div className={styles['booking-form__field']}>
<label htmlFor="date" className={styles['booking-form__label']}>
<CalendarDays size={15} className={styles['booking-form__label-icon']} />
Date <span className={styles['booking-form__required']}>*</span>
</label>
<input
id="date"
type="date"
className={[
styles['booking-form__input'],
formik.touched.date && formik.errors.date ? styles['booking-form__input--error'] : '',
].join(' ')}
min={todayISO()}
{...formik.getFieldProps('date')}
/>
{formik.touched.date && formik.errors.date && (
<p className={styles['booking-form__field-error']}>{formik.errors.date}</p>
)}
</div>
<div className={styles['booking-form__field']}>
<label htmlFor="time" className={styles['booking-form__label']}>
<Clock size={15} className={styles['booking-form__label-icon']} />
Time <span className={styles['booking-form__required']}>*</span>
</label>
<input
id="time"
type="time"
className={[
styles['booking-form__input'],
formik.touched.time && formik.errors.time ? styles['booking-form__input--error'] : '',
].join(' ')}
{...formik.getFieldProps('time')}
/>
{formik.touched.time && formik.errors.time && (
<p className={styles['booking-form__field-error']}>{formik.errors.time}</p>
)}
{bookedTimes.length > 0 && !(formik.touched.time && formik.errors.time) && (
<p className={styles['booking-form__hint']}>
Occupied: {bookedTimes.join(', ')}
</p>
)}
</div>
</div>
{/* Special Requirements */}
<div className={styles['booking-form__field']}>
<label htmlFor="specialRequirements" className={styles['booking-form__label']}>
Special Requirements
<span className={styles['booking-form__optional']}>(optional)</span>
</label>
<textarea
id="specialRequirements"
className={styles['booking-form__textarea']}
rows={4}
placeholder="Any accessibility needs, preferences, or notes for the service provider..."
{...formik.getFieldProps('specialRequirements')}
/>
{formik.touched.specialRequirements && formik.errors.specialRequirements && (
<p className={styles['booking-form__field-error']}>
{formik.errors.specialRequirements}
</p>
)}
</div>
<div className={styles['booking-form__actions']}>
<button
type="button"
className={[styles['booking-form__btn'], styles['booking-form__btn--secondary']].join(' ')}
onClick={() => { formik.resetForm(); setSubmitStatus(null); }}
disabled={isPending}
>
Clear
</button>
<button
type="submit"
className={[styles['booking-form__btn'], styles['booking-form__btn--primary']].join(' ')}
disabled={isPending || servicesLoading || formik.isSubmitting}
>
{isPending ? (
<>
<Loader2 size={16} className={styles['booking-form__spinner']} />
Submitting...
</>
) : 'Request Booking'}
</button>
</div>
</form>
</div>
);
};
export default BookingForm;

View File

@@ -0,0 +1,246 @@
@use '../styles/variables' as *;
@mixin control-base {
width: 100%;
padding: $spacing-3 $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-base;
font-size: $font-size-sm;
color: var(--clr-text, #{$color-text-primary});
transition: border-color $transition-fast, box-shadow $transition-fast, background $transition-fast;
outline: none;
&::placeholder { color: var(--clr-text-muted, #{$color-text-muted}); }
&:focus {
background: var(--clr-surface, #{$color-surface});
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.18);
}
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
@mixin btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-6;
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
border: 1px solid transparent;
cursor: pointer;
transition: background $transition-fast, color $transition-fast, border-color $transition-fast;
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
// ── Block ─────────────────────────────────────────────────────────────────────
.booking-form {
background: var(--clr-surface, #{$color-surface});
border-radius: $radius-xl;
box-shadow: $shadow-md;
border: 1px solid var(--clr-border, #{$color-border});
overflow: hidden;
width: 100%;
max-width: 680px;
margin-inline: auto;
// ── Elements ────────────────────────────────────────────────────────────────
&__header {
display: flex;
align-items: center;
gap: $spacing-4;
padding: $spacing-6 $spacing-8;
background: linear-gradient(135deg, $primary-700, $primary-500);
color: #fff;
}
&__icon { flex-shrink: 0; opacity: 0.9; }
&__title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: #fff;
margin-bottom: $spacing-1;
}
&__subtitle {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.8);
}
&__banner {
display: flex;
align-items: center;
gap: $spacing-3;
padding: $spacing-3 $spacing-8;
font-size: $font-size-sm;
border-bottom: 1px solid transparent;
&--success {
background: $accent-50;
color: $accent-700;
border-bottom-color: $accent-200;
}
&--error {
background: #fef2f2;
color: #991b1b;
border-bottom-color: #fecaca;
}
}
&__body {
display: flex;
flex-direction: column;
gap: $spacing-6;
padding: $spacing-8;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-5;
@media (max-width: $bp-sm) { grid-template-columns: 1fr; }
}
&__field {
display: flex;
flex-direction: column;
gap: $spacing-2;
}
&__label {
display: flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text, #{$color-text-primary});
}
&__label-icon { color: $primary-500; }
&__required { color: $color-error; margin-left: $spacing-1; }
&__optional {
font-size: $font-size-xs;
font-weight: $font-weight-normal;
color: var(--clr-text-muted, #{$color-text-muted});
margin-left: $spacing-2;
}
&__select-wrapper {
position: relative;
&::after {
content: '';
position: absolute;
right: $spacing-4;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid $gray-500;
pointer-events: none;
}
}
&__select {
@include control-base;
appearance: none;
padding-right: $spacing-10;
cursor: pointer;
&--error {
border-color: $color-error;
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.18); }
}
}
&__input {
@include control-base;
font-family: $font-family-base;
&::-webkit-calendar-picker-indicator { opacity: 0.6; cursor: pointer; }
&--error {
border-color: $color-error;
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.18); }
}
}
&__textarea {
@include control-base;
resize: vertical;
min-height: 100px;
line-height: $line-height-relaxed;
font-family: $font-family-base;
}
&__field-error {
font-size: $font-size-xs;
color: $color-error;
}
&__hint {
font-size: $font-size-xs;
color: #92400e;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: $radius-base;
padding: $spacing-2 $spacing-3;
}
&__actions {
display: flex;
justify-content: flex-end;
gap: $spacing-3;
padding-top: $spacing-2;
border-top: 1px solid var(--clr-border, #{$color-border});
@media (max-width: $bp-sm) { flex-direction: column-reverse; }
}
&__btn {
@include btn-base;
&--primary {
background: var(--clr-primary, #{$color-primary});
color: #fff;
border-color: var(--clr-primary, #{$color-primary});
&:hover:not(:disabled) {
background: var(--clr-primary-hover, #{$color-primary-hover});
border-color: var(--clr-primary-hover, #{$color-primary-hover});
}
}
&--secondary {
background: transparent;
color: var(--clr-text-secondary, #{$color-text-secondary});
border-color: var(--clr-border, #{$color-border});
&:hover:not(:disabled) {
background: var(--clr-surface-raised, #{$gray-100});
color: var(--clr-text, #{$color-text-primary});
}
}
}
&__spinner { animation: booking-form-spin 0.8s linear infinite; }
}
@keyframes booking-form-spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,344 @@
// [REQ F4] Wieloetapowy kreator rezerwacji wybór usługi, daty, godziny, płatność, potwierdzenie
// [REQ T1] Podstawowe hooki useState (activeStep, selections), useEffect (prefill slotu)
import { useState, useEffect } from 'react';
import { CheckCircle2, ChevronRight, ChevronLeft, Loader2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useServices } from '../hooks/useServices';
import { useSlotReservations } from '../hooks/useSlotReservations';
import { useCreateReservation } from '../hooks/useCreateReservation';
import PaymentModal from './PaymentModal';
import styles from './BookingWizard.module.scss';
const STEPS = ['Service', 'Date & Time', 'Requirements', 'Summary'];
const todayISO = () => new Date().toISOString().split('T')[0];
const BookingWizard = ({ prefillSlot, onPrefillUsed }) => {
const { user } = useAuth();
const [step, setStep] = useState(1);
const [data, setData] = useState({ serviceId: '', date: '', time: '', specialRequirements: '' });
const [fieldErrors, setFieldErrors] = useState({});
const [paymentOpen, setPaymentOpen] = useState(false);
const [done, setDone] = useState(false);
const [receipt, setReceipt] = useState(null);
const { data: services = [], isLoading: svcLoading } = useServices();
const { data: slotRes = [] } = useSlotReservations(data.serviceId, data.date);
const bookedTimes = slotRes.filter((r) => r.status !== 'cancelled').map((r) => r.time);
const { mutate: createReservation, isPending } = useCreateReservation({
onSuccess: (newRes) => {
setReceipt(newRes);
setDone(true);
setPaymentOpen(false);
},
});
useEffect(() => {
if (prefillSlot) {
setData((prev) => ({ ...prev, ...prefillSlot }));
setStep(3);
onPrefillUsed?.();
}
}, [prefillSlot]);
const set = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const validate = () => {
const errs = {};
if (step === 1 && !data.serviceId)
errs.serviceId = 'Please select a service.';
if (step === 2) {
if (!data.date) errs.date = 'Date is required.';
else if (data.date < todayISO()) errs.date = 'Date cannot be in the past.';
if (!data.time) errs.time = 'Time is required.';
else if (bookedTimes.includes(data.time)) errs.time = `${data.time} is already booked.`;
}
setFieldErrors(errs);
return Object.keys(errs).length === 0;
};
const next = () => { if (validate()) setStep((s) => s + 1); };
const back = () => { setFieldErrors({}); setStep((s) => s - 1); };
const handlePaymentSuccess = (paymentData) => {
createReservation({
userId: user.id,
serviceId: data.serviceId,
date: data.date,
time: data.time,
specialRequirements: data.specialRequirements.trim() || null,
status: 'pending',
depositPaid: paymentData.amount,
transactionId: paymentData.transactionId,
});
};
const resetWizard = () => {
setStep(1);
setData({ serviceId: '', date: '', time: '', specialRequirements: '' });
setFieldErrors({});
setDone(false);
setReceipt(null);
};
const selectedService = services.find((s) => s.id === data.serviceId);
if (done) {
return (
<div className={styles['wizard']}>
<div className={styles['wizard__success']}>
<CheckCircle2 size={52} className={styles['wizard__success-icon']} />
<h2 className={styles['wizard__success-title']}>Booking Confirmed!</h2>
<p className={styles['wizard__success-sub']}>
Your reservation is pending confirmation by our team.
</p>
{receipt && (
<dl className={styles['wizard__receipt']}>
<dt>Reference</dt> <dd>#{receipt.id}</dd>
<dt>Service</dt> <dd>{selectedService?.name}</dd>
<dt>Date</dt> <dd>{receipt.date} · {receipt.time}</dd>
<dt>Deposit Paid</dt><dd>${Number(receipt.depositPaid ?? 0).toFixed(2)}</dd>
<dt>Transaction</dt> <dd>{receipt.transactionId ?? '—'}</dd>
</dl>
)}
<div className={styles['wizard__receipt-actions']}>
<button className={styles['wizard__btn-primary']} onClick={resetWizard}>
Book Another
</button>
</div>
</div>
</div>
);
}
return (
<>
<div className={styles['wizard']}>
{/* ── Progress indicator ─────────────────────────────────────────── */}
<div className={styles['wizard__progress']}>
<div className={styles['wizard__progress-track']}>
<div
className={styles['wizard__progress-fill']}
style={{ width: `${((step - 1) / (STEPS.length - 1)) * 100}%` }}
/>
</div>
{STEPS.map((label, i) => (
<div
key={label}
className={[
styles['wizard__step'],
step === i + 1 ? styles['wizard__step--active'] : '',
step > i + 1 ? styles['wizard__step--done'] : '',
].join(' ')}
>
<div className={styles['wizard__step-dot']}>
{step > i + 1 ? <CheckCircle2 size={13} /> : i + 1}
</div>
<span className={styles['wizard__step-label']}>{label}</span>
</div>
))}
</div>
{/* ── Step 1: Service ───────────────────────────────────────────── */}
{step === 1 && (
<div className={styles['wizard__body']}>
<h3 className={styles['wizard__step-title']}>Select a Service</h3>
{svcLoading ? (
<p className={styles['wizard__msg']}>Loading services</p>
) : (
<div className={styles['wizard__service-grid']}>
{services.map((s) => (
<label
key={s.id}
className={[
styles['wizard__service-card'],
data.serviceId === s.id ? styles['wizard__service-card--selected'] : '',
].join(' ')}
>
<input
type="radio"
name="serviceId"
value={s.id}
checked={data.serviceId === s.id}
onChange={() => { set('serviceId', s.id); setFieldErrors({}); }}
className={styles['wizard__radio']}
/>
<span className={styles['wizard__service-name']}>{s.name}</span>
<span className={styles['wizard__service-desc']}>{s.description}</span>
<div className={styles['wizard__service-meta']}>
<span className={styles['wizard__service-price']}>${s.price}</span>
<span className={styles['wizard__service-duration']}>{s.duration} min</span>
</div>
</label>
))}
</div>
)}
{fieldErrors.serviceId && (
<p className={styles['wizard__error']}>{fieldErrors.serviceId}</p>
)}
</div>
)}
{/* ── Step 2: Date & Time ───────────────────────────────────────── */}
{step === 2 && (
<div className={styles['wizard__body']}>
<h3 className={styles['wizard__step-title']}>Choose Date & Time</h3>
{selectedService && (
<div className={styles['wizard__service-pill']}>
{selectedService.name} ${selectedService.price} / {selectedService.duration} min
</div>
)}
<div className={styles['wizard__row']}>
<div className={styles['wizard__field']}>
<label className={styles['wizard__label']}>Date *</label>
<input
type="date"
className={[
styles['wizard__input'],
fieldErrors.date ? styles['wizard__input--error'] : '',
].join(' ')}
min={todayISO()}
value={data.date}
onChange={(e) => { set('date', e.target.value); setFieldErrors({}); }}
/>
{fieldErrors.date && <p className={styles['wizard__error']}>{fieldErrors.date}</p>}
</div>
<div className={styles['wizard__field']}>
<label className={styles['wizard__label']}>Time *</label>
<input
type="time"
className={[
styles['wizard__input'],
fieldErrors.time ? styles['wizard__input--error'] : '',
].join(' ')}
value={data.time}
onChange={(e) => { set('time', e.target.value); setFieldErrors({}); }}
/>
{fieldErrors.time && (
<p className={styles['wizard__error']}>{fieldErrors.time}</p>
)}
{bookedTimes.length > 0 && !fieldErrors.time && (
<p className={styles['wizard__hint']}>Occupied: {bookedTimes.join(', ')}</p>
)}
</div>
</div>
</div>
)}
{/* ── Step 3: Requirements ─────────────────────────────────────── */}
{step === 3 && (
<div className={styles['wizard__body']}>
<h3 className={styles['wizard__step-title']}>Special Requirements</h3>
<div className={styles['wizard__field']}>
<label className={styles['wizard__label']}>
Additional notes
<span className={styles['wizard__optional']}>(optional)</span>
</label>
<textarea
className={styles['wizard__textarea']}
rows={5}
maxLength={500}
placeholder="Accessibility needs, preferences, or notes for the service provider…"
value={data.specialRequirements}
onChange={(e) => set('specialRequirements', e.target.value)}
/>
<p className={styles['wizard__char-count']}>
{data.specialRequirements.length} / 500
</p>
</div>
<div className={styles['wizard__additional']}>
<h4 className={styles['wizard__additional-title']}>Additional Services</h4>
<p className={styles['wizard__additional-note']}>
Extra add-ons can be requested in the notes above.
Our team will confirm availability before your appointment.
</p>
</div>
</div>
)}
{/* ── Step 4: Summary ──────────────────────────────────────────── */}
{step === 4 && (
<div className={styles['wizard__body']}>
<h3 className={styles['wizard__step-title']}>Booking Summary</h3>
<dl className={styles['wizard__summary']}>
<div className={styles['wizard__summary-row']}>
<dt>Service</dt>
<dd>{selectedService?.name}</dd>
</div>
<div className={styles['wizard__summary-row']}>
<dt>Duration</dt>
<dd>{selectedService?.duration} min</dd>
</div>
<div className={styles['wizard__summary-row']}>
<dt>Date</dt>
<dd>{data.date}</dd>
</div>
<div className={styles['wizard__summary-row']}>
<dt>Time</dt>
<dd>{data.time}</dd>
</div>
<div className={styles['wizard__summary-row']}>
<dt>Requirements</dt>
<dd>{data.specialRequirements || '—'}</dd>
</div>
<div className={[
styles['wizard__summary-row'],
styles['wizard__summary-row--total'],
].join(' ')}>
<dt>Total Price</dt>
<dd>${selectedService?.price}</dd>
</div>
<div className={styles['wizard__summary-row']}>
<dt>Deposit Due Now</dt>
<dd className={styles['wizard__deposit']}>
${(selectedService?.price * 0.5).toFixed(2)}
</dd>
</div>
</dl>
<p className={styles['wizard__summary-note']}>
The remaining balance is collected at your appointment.
Cancellations must be made at least 24 hours in advance.
</p>
</div>
)}
{/* ── Navigation ────────────────────────────────────────────────── */}
<div className={styles['wizard__nav']}>
{step > 1 ? (
<button className={styles['wizard__btn-secondary']} onClick={back}>
<ChevronLeft size={16} /> Back
</button>
) : <span />}
{step < 4 ? (
<button className={styles['wizard__btn-primary']} onClick={next}>
Next <ChevronRight size={16} />
</button>
) : (
<button
className={styles['wizard__btn-primary']}
onClick={() => setPaymentOpen(true)}
disabled={isPending}
>
{isPending
? <><Loader2 size={16} className={styles['wizard__spinner']} /> Processing</>
: 'Confirm & Pay Deposit'
}
</button>
)}
</div>
</div>
<PaymentModal
isOpen={paymentOpen}
onClose={() => setPaymentOpen(false)}
onSuccess={handlePaymentSuccess}
service={selectedService}
date={data.date}
time={data.time}
/>
</>
);
};
export default BookingWizard;

View File

@@ -0,0 +1,436 @@
@use '../styles/variables' as *;
@mixin control {
width: 100%;
padding: $spacing-3 $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-sm;
font-family: $font-family-base;
color: var(--clr-text);
outline: none;
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.18);
}
}
// ── Block ─────────────────────────────────────────────────────────────────────
.wizard {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-md;
overflow: hidden;
// ── Progress ──────────────────────────────────────────────────────────────
&__progress {
position: relative;
display: flex;
justify-content: space-between;
padding: $spacing-6 $spacing-8;
border-bottom: 1px solid var(--clr-border);
background: var(--clr-surface);
}
&__progress-track {
position: absolute;
top: 50%;
left: $spacing-8;
right: $spacing-8;
height: 2px;
background: var(--clr-border);
transform: translateY(-50%);
z-index: 0;
margin-top: -10px;
}
&__progress-fill {
height: 100%;
background: var(--clr-primary);
transition: width $transition-slow;
border-radius: $radius-full;
}
&__step {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-2;
}
&__step-dot {
width: 30px;
height: 30px;
border-radius: $radius-full;
border: 2px solid var(--clr-border);
background: var(--clr-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: var(--clr-text-muted);
transition: background $transition-base, border-color $transition-base, color $transition-base;
.wizard__step--active & {
border-color: var(--clr-primary);
background: var(--clr-primary);
color: #fff;
}
.wizard__step--done & {
border-color: $accent-500;
background: $accent-500;
color: #fff;
}
}
&__step-label {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: var(--clr-text-muted);
white-space: nowrap;
.wizard__step--active & { color: var(--clr-primary); font-weight: $font-weight-semibold; }
.wizard__step--done & { color: $accent-600; }
}
// ── Body ──────────────────────────────────────────────────────────────────
&__body {
padding: $spacing-8;
min-height: 320px;
}
&__step-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text);
margin-bottom: $spacing-6;
}
&__msg {
font-size: $font-size-sm;
color: var(--clr-text-muted);
}
// ── Service grid (step 1) ──────────────────────────────────────────────────
&__service-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $spacing-4;
@media (max-width: $bp-sm) { grid-template-columns: 1fr; }
}
&__service-card {
position: relative;
display: flex;
flex-direction: column;
gap: $spacing-2;
padding: $spacing-5;
border: 2px solid var(--clr-border);
border-radius: $radius-lg;
cursor: pointer;
transition: border-color $transition-fast, box-shadow $transition-fast, background $transition-fast;
&:hover { border-color: $primary-300; box-shadow: $shadow-base; }
&--selected {
border-color: var(--clr-primary);
background: $primary-50;
box-shadow: $shadow-base;
}
}
&__radio { position: absolute; opacity: 0; width: 0; height: 0; }
&__service-name {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__service-desc {
font-size: $font-size-xs;
color: var(--clr-text-secondary);
line-height: $line-height-relaxed;
flex: 1;
}
&__service-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: $spacing-3;
padding-top: $spacing-3;
border-top: 1px solid var(--clr-border);
}
&__service-price {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: var(--clr-primary);
}
&__service-duration {
font-size: $font-size-xs;
color: var(--clr-text-muted);
background: var(--clr-surface-raised, #{$gray-100});
padding: $spacing-1 $spacing-2;
border-radius: $radius-full;
}
// ── Date/time (step 2) ────────────────────────────────────────────────────
&__service-pill {
display: inline-flex;
padding: $spacing-2 $spacing-4;
background: $primary-50;
border: 1px solid $primary-200;
border-radius: $radius-full;
font-size: $font-size-sm;
color: $primary-700;
margin-bottom: $spacing-6;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-5;
@media (max-width: $bp-sm) { grid-template-columns: 1fr; }
}
&__field {
display: flex;
flex-direction: column;
gap: $spacing-2;
}
&__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
}
&__input {
@include control;
&--error {
border-color: $color-error;
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.18); }
}
}
&__hint {
font-size: $font-size-xs;
color: #92400e;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: $radius-base;
padding: $spacing-2 $spacing-3;
}
&__error { font-size: $font-size-xs; color: $color-error; }
// ── Requirements (step 3) ─────────────────────────────────────────────────
&__textarea {
@include control;
resize: vertical;
min-height: 130px;
line-height: $line-height-relaxed;
}
&__char-count {
font-size: $font-size-xs;
color: var(--clr-text-muted);
text-align: right;
}
&__optional {
font-size: $font-size-xs;
color: var(--clr-text-muted);
margin-left: $spacing-2;
font-weight: $font-weight-normal;
}
&__additional {
margin-top: $spacing-5;
padding: $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px dashed var(--clr-border);
border-radius: $radius-lg;
}
&__additional-title {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
margin-bottom: $spacing-2;
}
&__additional-note {
font-size: $font-size-xs;
color: var(--clr-text-secondary);
line-height: $line-height-relaxed;
}
// ── Summary (step 4) ──────────────────────────────────────────────────────
&__summary {
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
overflow: hidden;
margin-bottom: $spacing-4;
}
&__summary-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: $spacing-4;
padding: $spacing-3 $spacing-5;
& + & { border-top: 1px solid var(--clr-border); }
dt {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
flex-shrink: 0;
}
dd {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
text-align: right;
}
&--total {
background: var(--clr-surface);
dt { font-weight: $font-weight-semibold; color: var(--clr-text); font-size: $font-size-base; }
dd { font-size: $font-size-lg; color: var(--clr-primary); font-weight: $font-weight-bold; }
}
}
&__deposit { color: $accent-600; font-weight: $font-weight-semibold; }
&__summary-note {
font-size: $font-size-xs;
color: var(--clr-text-muted);
line-height: $line-height-relaxed;
}
// ── Navigation ────────────────────────────────────────────────────────────
&__nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-5 $spacing-8;
border-top: 1px solid var(--clr-border);
}
&__btn-primary {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-6;
background: var(--clr-primary);
color: #fff;
border: 1px solid var(--clr-primary);
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
transition: background $transition-fast;
&:hover:not(:disabled) { background: var(--clr-primary-hover); }
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
&__btn-secondary {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-5;
background: transparent;
color: var(--clr-text-secondary);
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;
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
}
&__spinner { animation: wizard-spin 0.8s linear infinite; }
// ── Done / receipt ────────────────────────────────────────────────────────
&__success {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: $spacing-4;
padding: $spacing-12 $spacing-8;
}
&__success-icon { color: $accent-500; }
&__success-title {
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: var(--clr-text);
}
&__success-sub {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
}
&__receipt {
display: grid;
grid-template-columns: auto 1fr;
gap: $spacing-2 $spacing-6;
width: 100%;
max-width: 360px;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
padding: $spacing-5;
text-align: left;
dt {
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);
font-family: $font-family-mono;
text-align: right;
}
}
&__receipt-actions {
display: flex;
gap: $spacing-3;
flex-wrap: wrap;
justify-content: center;
margin-top: $spacing-2;
}
}
@keyframes wizard-spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,19 @@
// [REQ D6] Render props DataRenderer przekazuje dane i stan jako children(data, isLoading, error)
// [REQ T7] Render props i HOC wzorzec render props do abstrakcji logiki pobierania danych
import { useQuery } from '@tanstack/react-query';
/**
* Render-prop component — fetches data via React Query and delegates
* rendering entirely to `children`, which is called as a function.
*
* Usage:
* <DataRenderer queryKey={['services']} queryFn={fetchServices}>
* {({ data, isLoading, isError }) => ( ... )}
* </DataRenderer>
*/
const DataRenderer = ({ queryKey, queryFn, staleTime, children }) => {
const result = useQuery({ queryKey, queryFn, staleTime: staleTime ?? 60_000 });
return children(result);
};
export default DataRenderer;

View File

@@ -0,0 +1,39 @@
// [REQ D1] React Suspense i Error Boundaries class component łapiący błędy runtime
// React error boundaries require a class component — getDerivedStateFromError
// and componentDidCatch have no hook equivalents.
import { Component } from 'react';
import styles from './ErrorBoundary.module.scss';
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('[ErrorBoundary]', error, info.componentStack);
}
handleReset = () => this.setState({ hasError: false, error: null });
render() {
if (!this.state.hasError) return this.props.children;
if (this.props.fallback) return this.props.fallback;
return (
<div className={styles['error-boundary']} role="alert">
<div className={styles['error-boundary__box']}>
<h2 className={styles['error-boundary__title']}>Something went wrong</h2>
<p className={styles['error-boundary__message']}>
{this.state.error?.message || 'An unexpected error occurred in this section.'}
</p>
<button className={styles['error-boundary__btn']} onClick={this.handleReset}>
Try again
</button>
</div>
</div>
);
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,55 @@
@use '../styles/variables' as *;
// ── Block ─────────────────────────────────────────────────────────────────────
.error-boundary {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
padding: $spacing-8;
// ── Elements ────────────────────────────────────────────────────────────────
&__box {
text-align: center;
max-width: 420px;
padding: $spacing-10 $spacing-8;
background: var(--clr-surface, #{$color-surface});
border: 1px solid #fecaca;
border-radius: $radius-xl;
box-shadow: $shadow-md;
}
&__icon {
font-size: 2.5rem;
margin-bottom: $spacing-4;
}
&__title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text, #{$color-text-primary});
margin-bottom: $spacing-3;
}
&__message {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
line-height: $line-height-relaxed;
margin-bottom: $spacing-6;
}
&__btn {
padding: $spacing-2 $spacing-6;
background: var(--clr-primary, #{$color-primary});
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;
&:hover { background: var(--clr-primary-hover, #{$color-primary-hover}); }
}
}

View File

@@ -0,0 +1,10 @@
import styles from './LoadingSpinner.module.scss';
const LoadingSpinner = ({ message = 'Loading…' }) => (
<div className={styles['spinner']} role="status" aria-label={message}>
<span className={styles['spinner__ring']} />
<span className={styles['spinner__label']}>{message}</span>
</div>
);
export default LoadingSpinner;

View File

@@ -0,0 +1,31 @@
@use '../styles/variables' as *;
// ── Block ─────────────────────────────────────────────────────────────────────
.spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-4;
min-height: 240px;
// ── Elements ────────────────────────────────────────────────────────────────
&__ring {
display: block;
width: 44px;
height: 44px;
border: 3px solid var(--clr-border, #{$gray-200});
border-top-color: $primary-500;
border-radius: 50%;
animation: spinner-spin 0.75s linear infinite;
}
&__label {
font-size: $font-size-sm;
color: var(--clr-text-muted, #{$color-text-muted});
}
}
@keyframes spinner-spin {
to { transform: rotate(360deg); }
}

78
src/components/Modal.jsx Normal file
View File

@@ -0,0 +1,78 @@
// [REQ D4] React Portals modal montowany w #portal-root poza drzewem DOM aplikacji
// [REQ T6] Optymalizacja useCallback dla handlera klawisza Escape
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { X, AlertTriangle } from 'lucide-react';
import styles from './Modal.module.scss';
const Modal = ({ isOpen, onClose, onConfirm, title, message, confirmLabel = 'Confirm', danger = false }) => {
const handleKeyDown = useCallback(
(e) => { if (e.key === 'Escape') onClose(); },
[onClose]
);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
// Resolved lazily so this is safe in tests and SSR-like environments
const portalRoot = document.getElementById('portal-root') ?? document.body;
return createPortal(
<div
className={styles['modal__overlay']}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={onClose}
>
<div className={styles['modal__panel']} onClick={(e) => e.stopPropagation()}>
<button className={styles['modal__close-btn']} onClick={onClose} aria-label="Close modal">
<X size={18} />
</button>
<div className={styles['modal__icon-wrapper']}>
<span className={[
styles['modal__icon'],
danger ? styles['modal__icon--danger'] : '',
].join(' ')}>
<AlertTriangle size={22} />
</span>
</div>
<h2 id="modal-title" className={styles['modal__title']}>{title}</h2>
<p className={styles['modal__body']}>{message}</p>
<div className={styles['modal__footer']}>
<button
className={[styles['modal__btn'], styles['modal__btn--cancel']].join(' ')}
onClick={onClose}
>
Cancel
</button>
<button
className={[
styles['modal__btn'],
styles['modal__btn--confirm'],
danger ? styles['modal__btn--danger'] : '',
].join(' ')}
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
portalRoot
);
};
export default Modal;

View File

@@ -0,0 +1,142 @@
@use '../styles/variables' as *;
// ─── Block ────────────────────────────────────────────────────────────────────
// ─── Elements ────────────────────────────────────────────────────────────────
.modal__overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: $spacing-4;
animation: modal-fade-in 150ms ease;
}
.modal__panel {
position: relative;
background: var(--clr-surface, #{$color-surface});
border-radius: $radius-xl;
box-shadow: $shadow-xl;
padding: $spacing-8;
width: 100%;
max-width: 440px;
animation: modal-slide-up 180ms ease;
}
.modal__close-btn {
position: absolute;
top: $spacing-4;
right: $spacing-4;
background: none;
border: none;
color: $color-text-muted;
cursor: pointer;
padding: $spacing-1;
border-radius: $radius-base;
display: flex;
align-items: center;
transition: color $transition-fast, background $transition-fast;
&:hover {
color: $color-text-primary;
background: $gray-100;
}
}
.modal__icon-wrapper {
display: flex;
justify-content: center;
margin-bottom: $spacing-5;
}
// ── Modifiers on modal__icon ───────────────────────────────────────────────
.modal__icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: $primary-50;
color: $primary-600;
&--danger {
background: #fef2f2;
color: $color-error;
}
}
.modal__title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text, #{$color-text-primary});
text-align: center;
margin-bottom: $spacing-3;
}
.modal__body {
font-size: $font-size-sm;
color: $color-text-secondary;
text-align: center;
line-height: $line-height-relaxed;
margin-bottom: $spacing-8;
}
.modal__footer {
display: flex;
gap: $spacing-3;
> button { flex: 1; }
}
// ── Modifiers on modal__btn ────────────────────────────────────────────────
.modal__btn {
padding: $spacing-3 $spacing-4;
border-radius: $radius-base;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
border: 1px solid transparent;
cursor: pointer;
transition: background $transition-fast, border-color $transition-fast;
&--cancel {
background: var(--clr-surface, #{$color-surface});
color: $color-text-secondary;
border-color: $color-border;
&:hover { background: $gray-100; }
}
&--confirm {
background: $color-primary;
color: #fff;
border-color: $color-primary;
&:hover { background: $color-primary-hover; border-color: $color-primary-hover; }
}
&--danger {
background: $color-error;
border-color: $color-error;
&:hover { background: #dc2626; border-color: #dc2626; }
}
}
// ─── Animations ───────────────────────────────────────────────────────────────
@keyframes modal-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-slide-up {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}

View File

@@ -0,0 +1,85 @@
// [REQ F6] Zarządzanie rezerwacjami widget "Moje rezerwacje" na dashboardzie (ostatnie 3)
// [REQ D7] React Query useUserReservations (useQuery) do pobierania rezerwacji zalogowanego użytkownika
import { Link } from 'react-router-dom';
import { BookOpen, XCircle, ChevronRight } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useUserReservations } from '../hooks/useUserReservations';
import { useUpdateReservation } from '../hooks/useUpdateReservation';
import { useServices } from '../hooks/useServices';
import StatusBadge from './StatusBadge';
import styles from './MyReservations.module.scss';
const MyReservations = () => {
const { user } = useAuth();
const { data: reservations = [], isLoading } = useUserReservations(user?.id);
const { data: services = [] } = useServices();
const { mutate: updateStatus, isPending } = useUpdateReservation();
const getService = (id) => services.find((s) => s.id === id);
const sorted = [...reservations].sort((a, b) =>
(a.date + a.time).localeCompare(b.date + b.time)
);
return (
<div className={styles['my-res']}>
<div className={styles['my-res__header']}>
<BookOpen size={17} className={styles['my-res__header-icon']} />
<h2 className={styles['my-res__title']}>My Reservations</h2>
{reservations.length > 0 && (
<span className={styles['my-res__count']}>{reservations.length}</span>
)}
</div>
{isLoading ? (
<p className={styles['my-res__msg']}>Loading</p>
) : sorted.length === 0 ? (
<p className={styles['my-res__msg']}>No reservations yet. Make your first booking!</p>
) : (
<ul className={styles['my-res__list']}>
{sorted.map((r) => {
const svc = getService(r.serviceId);
const canCancel = r.status === 'pending' || r.status === 'confirmed';
return (
<li key={r.id} className={styles['my-res__item']}>
<Link
to={`/reservations?id=${r.id}`}
className={styles['my-res__item-link']}
>
<div className={styles['my-res__item-top']}>
<span className={styles['my-res__service-name']}>
{svc?.name ?? r.serviceId}
</span>
<div className={styles['my-res__item-top-right']}>
<StatusBadge status={r.status} size="sm" />
<ChevronRight size={14} className={styles['my-res__chevron']} />
</div>
</div>
<div className={styles['my-res__item-meta']}>
<span>{r.date} · {r.time}</span>
{svc && <span className={styles['my-res__price']}>${svc.price}</span>}
</div>
{r.specialRequirements && (
<p className={styles['my-res__item-req']}>{r.specialRequirements}</p>
)}
</Link>
{canCancel && (
<button
className={styles['my-res__cancel-btn']}
onClick={() => updateStatus({ id: r.id, patch: { status: 'cancelled' } })}
disabled={isPending}
>
<XCircle size={13} />
Cancel
</button>
)}
</li>
);
})}
</ul>
)}
</div>
);
};
export default MyReservations;

View File

@@ -0,0 +1,141 @@
@use '../styles/variables' as *;
.my-res {
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-sm;
overflow: hidden;
height: fit-content;
&__header {
display: flex;
align-items: center;
gap: $spacing-3;
padding: $spacing-4 $spacing-5;
background: linear-gradient(135deg, $accent-700, $accent-500);
color: #fff;
}
&__header-icon { opacity: 0.9; flex-shrink: 0; }
&__title {
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: #fff;
flex: 1;
}
&__count {
background: rgba(255,255,255,0.25);
color: #fff;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
padding: 2px $spacing-2;
border-radius: $radius-full;
}
&__msg {
padding: $spacing-8 $spacing-5;
font-size: $font-size-sm;
color: var(--clr-text-muted);
text-align: center;
line-height: $line-height-relaxed;
}
&__list { list-style: none; padding: 0; margin: 0; }
&__item {
display: flex;
flex-direction: column;
gap: 0;
border-bottom: 1px solid var(--clr-border);
&:last-child { border-bottom: none; }
}
&__item-link {
display: flex;
flex-direction: column;
gap: $spacing-2;
padding: $spacing-4 $spacing-5;
text-decoration: none;
color: inherit;
transition: background $transition-fast;
&:hover {
background: var(--clr-surface-raised, #{$gray-50});
.my-res__chevron { opacity: 1; transform: translateX(2px); }
}
}
&__item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-2;
}
&__item-top-right {
display: flex;
align-items: center;
gap: $spacing-2;
flex-shrink: 0;
}
&__chevron {
color: var(--clr-text-muted, #{$color-text-muted});
opacity: 0.4;
transition: opacity $transition-fast, transform $transition-fast;
}
&__service-name {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: $font-size-xs;
color: var(--clr-text-secondary);
}
&__price {
font-weight: $font-weight-medium;
color: var(--clr-text-secondary);
}
&__item-req {
font-size: $font-size-xs;
color: var(--clr-text-muted);
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__cancel-btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-1;
margin: 0 $spacing-5 $spacing-3;
padding: $spacing-1 $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:not(:disabled) { background: #fef2f2; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}

View File

@@ -0,0 +1,287 @@
// [REQ F5] System płatności online karta kredytowa, Google Pay, przelew bankowy, depozyt
// [REQ D4] React Portals modal montowany w #portal-root
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { CreditCard, CheckCircle2, Loader2, X, Smartphone, Landmark } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import styles from './PaymentModal.module.scss';
const makeTxnId = () => 'TXN-' + Math.random().toString(36).slice(2, 10).toUpperCase();
const fmtCard = (v) =>
v.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim();
const METHODS = [
{ id: 'card', label: 'Card', icon: CreditCard },
{ id: 'google-pay', label: 'Google Pay', icon: Smartphone },
{ id: 'bank', label: 'Bank Transfer', icon: Landmark },
];
const CardForm = ({ phase, onPay, deposit }) => {
const [card, setCard] = useState('');
const [expiry, setExpiry] = useState('');
const [cvv, setCvv] = useState('');
const [name, setName] = useState('');
const [errs, setErrs] = useState({});
const validate = () => {
const e = {};
if (!/^\d{16}$/.test(card.replace(/\s/g, '')))
e.card = 'Enter a valid 16-digit card number.';
if (!/^\d{2}\/\d{2}$/.test(expiry))
e.expiry = 'Enter expiry as MM/YY.';
if (!/^\d{3,4}$/.test(cvv))
e.cvv = 'Enter a 34 digit CVV.';
if (!name.trim())
e.name = 'Cardholder name is required.';
setErrs(e);
return Object.keys(e).length === 0;
};
const handleExpiry = (v) => {
let digits = v.replace(/\D/g, '').slice(0, 4);
if (digits.length > 2) digits = digits.slice(0, 2) + '/' + digits.slice(2);
setExpiry(digits);
};
return (
<div className={styles['pay__form']}>
<div className={styles['pay__field']}>
<label className={styles['pay__label']}>Card Number</label>
<input
className={[styles['pay__input'], errs.card ? styles['pay__input--error'] : ''].join(' ')}
placeholder="1234 5678 9012 3456"
value={card}
maxLength={19}
onChange={(e) => setCard(fmtCard(e.target.value))}
disabled={phase === 'processing'}
/>
{errs.card && <p className={styles['pay__error']}>{errs.card}</p>}
</div>
<div className={styles['pay__row']}>
<div className={styles['pay__field']}>
<label className={styles['pay__label']}>Expiry</label>
<input
className={[styles['pay__input'], errs.expiry ? styles['pay__input--error'] : ''].join(' ')}
placeholder="MM/YY"
value={expiry}
maxLength={5}
onChange={(e) => handleExpiry(e.target.value)}
disabled={phase === 'processing'}
/>
{errs.expiry && <p className={styles['pay__error']}>{errs.expiry}</p>}
</div>
<div className={styles['pay__field']}>
<label className={styles['pay__label']}>CVV</label>
<input
className={[styles['pay__input'], errs.cvv ? styles['pay__input--error'] : ''].join(' ')}
placeholder="•••"
type="password"
value={cvv}
maxLength={4}
onChange={(e) => setCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
disabled={phase === 'processing'}
/>
{errs.cvv && <p className={styles['pay__error']}>{errs.cvv}</p>}
</div>
</div>
<div className={styles['pay__field']}>
<label className={styles['pay__label']}>Cardholder Name</label>
<input
className={[styles['pay__input'], errs.name ? styles['pay__input--error'] : ''].join(' ')}
placeholder="John Smith"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={phase === 'processing'}
/>
{errs.name && <p className={styles['pay__error']}>{errs.name}</p>}
</div>
<button
className={styles['pay__pay-btn']}
onClick={() => validate() && onPay()}
disabled={phase === 'processing'}
>
{phase === 'processing'
? <><Loader2 size={16} className={styles['pay__spinner']} /> Processing</>
: `Pay Deposit $${deposit.toFixed(2)}`
}
</button>
<p className={styles['pay__secure-note']}>
Simulated payment no real transaction will occur.
</p>
</div>
);
};
const GooglePayForm = ({ phase, onPay, deposit, userEmail }) => (
<div className={styles['pay__form']}>
<div className={styles['pay__gpay-preview']}>
<div className={styles['pay__gpay-logo']}>
<span style={{ color: '#4285F4', fontWeight: 700 }}>G</span>
<span style={{ color: '#EA4335', fontWeight: 700 }}>o</span>
<span style={{ color: '#FBBC05', fontWeight: 700 }}>o</span>
<span style={{ color: '#4285F4', fontWeight: 700 }}>g</span>
<span style={{ color: '#34A853', fontWeight: 700 }}>l</span>
<span style={{ color: '#EA4335', fontWeight: 700 }}>e</span>
<span style={{ marginLeft: 4, fontWeight: 700 }}> Pay</span>
</div>
<p className={styles['pay__gpay-account']}>{userEmail}</p>
<p className={styles['pay__gpay-card']}>Visa 4242</p>
</div>
<button
className={[styles['pay__pay-btn'], styles['pay__pay-btn--gpay']].join(' ')}
onClick={onPay}
disabled={phase === 'processing'}
>
{phase === 'processing'
? <><Loader2 size={16} className={styles['pay__spinner']} /> Processing</>
: <><span style={{ fontWeight: 700 }}>G</span> Pay ${deposit.toFixed(2)}</>
}
</button>
<p className={styles['pay__secure-note']}>
Simulated payment no real transaction will occur.
</p>
</div>
);
const BankTransferForm = ({ phase, onPay, deposit }) => (
<div className={styles['pay__form']}>
<div className={styles['pay__bank-info']}>
<p className={styles['pay__bank-label']}>Transfer details</p>
<div className={styles['pay__bank-row']}>
<span>Account name</span><span>Reservation Services Sp. z o.o.</span>
</div>
<div className={styles['pay__bank-row']}>
<span>IBAN</span><span className={styles['pay__bank-mono']}>PL61 1090 1014 0000 0712 1981 2874</span>
</div>
<div className={styles['pay__bank-row']}>
<span>BIC/SWIFT</span><span className={styles['pay__bank-mono']}>WBKPPLPP</span>
</div>
<div className={[styles['pay__bank-row'], styles['pay__bank-row--total']].join(' ')}>
<span>Amount due</span><span>${deposit.toFixed(2)}</span>
</div>
</div>
<div className={styles['pay__field']}>
<label className={styles['pay__label']}>Reference / Transfer title</label>
<input
className={styles['pay__input']}
defaultValue={`Deposit reservation`}
disabled={phase === 'processing'}
/>
</div>
<button
className={styles['pay__pay-btn']}
onClick={onPay}
disabled={phase === 'processing'}
>
{phase === 'processing'
? <><Loader2 size={16} className={styles['pay__spinner']} /> Processing</>
: `Confirm Transfer $${deposit.toFixed(2)}`
}
</button>
<p className={styles['pay__secure-note']}>
Simulated payment no real transaction will occur.
</p>
</div>
);
const PaymentModal = ({ isOpen, onClose, onSuccess, service, date, time }) => {
const { user } = useAuth();
const [method, setMethod] = useState('card');
const [phase, setPhase] = useState('idle'); // 'idle' | 'processing' | 'done'
const deposit = service ? +(service.price * 0.5).toFixed(2) : 0;
if (!isOpen) return null;
const handlePay = () => {
setPhase('processing');
setTimeout(() => {
const txn = { amount: deposit, transactionId: makeTxnId(), method };
setPhase('done');
onSuccess?.(txn);
}, 1500);
};
const portalRoot = document.getElementById('portal-root') ?? document.body;
return createPortal(
<div className={styles['pay']} role="dialog" aria-modal="true" aria-label="Secure payment">
<div
className={styles['pay__overlay']}
onClick={phase !== 'processing' ? onClose : undefined}
/>
<div className={styles['pay__panel']}>
{/* Header */}
<div className={styles['pay__header']}>
<div className={styles['pay__header-left']}>
<CreditCard size={20} className={styles['pay__header-icon']} />
<h2 className={styles['pay__title']}>Secure Payment</h2>
</div>
{phase !== 'processing' && (
<button className={styles['pay__close']} onClick={onClose} aria-label="Close">
<X size={18} />
</button>
)}
</div>
{/* Booking summary */}
<div className={styles['pay__summary']}>
<p className={styles['pay__summary-label']}>Booking Summary</p>
<div className={styles['pay__summary-row']}>
<span>{service?.name}</span>
<span>${service?.price}</span>
</div>
<div className={styles['pay__summary-row']}>
<span>{date} at {time}</span>
</div>
<div className={[styles['pay__summary-row'], styles['pay__summary-row--total']].join(' ')}>
<span>Deposit Due (50%)</span>
<span className={styles['pay__deposit-amt']}>${deposit.toFixed(2)}</span>
</div>
</div>
{/* Done state */}
{phase === 'done' ? (
<div className={styles['pay__done']}>
<CheckCircle2 size={44} className={styles['pay__done-icon']} />
<p className={styles['pay__done-title']}>Payment successful!</p>
<p className={styles['pay__done-sub']}>Creating your reservation</p>
</div>
) : (
<>
{/* Payment method tabs */}
<div className={styles['pay__methods']}>
{METHODS.map(({ id, label, icon: Icon }) => (
<button
key={id}
className={[styles['pay__method-btn'], method === id ? styles['pay__method-btn--active'] : ''].join(' ')}
onClick={() => setMethod(id)}
disabled={phase === 'processing'}
>
<Icon size={15} /> {label}
</button>
))}
</div>
{method === 'card' && (
<CardForm phase={phase} onPay={handlePay} deposit={deposit} />
)}
{method === 'google-pay' && (
<GooglePayForm phase={phase} onPay={handlePay} deposit={deposit} userEmail={user?.email} />
)}
{method === 'bank' && (
<BankTransferForm phase={phase} onPay={handlePay} deposit={deposit} />
)}
</>
)}
</div>
</div>,
portalRoot
);
};
export default PaymentModal;

View File

@@ -0,0 +1,315 @@
@use '../styles/variables' as *;
.pay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-4;
&__overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(3px);
}
&__panel {
position: relative;
z-index: 1;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-xl;
width: 100%;
max-width: 460px;
overflow: hidden;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-4 $spacing-6;
background: linear-gradient(135deg, $primary-700, $primary-500);
color: #fff;
}
&__header-left { display: flex; align-items: center; gap: $spacing-3; }
&__header-icon { opacity: 0.9; }
&__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: #fff;
}
&__close {
background: transparent;
border: none;
color: rgba(255,255,255,0.8);
cursor: pointer;
padding: $spacing-1;
border-radius: $radius-base;
display: flex;
align-items: center;
transition: color $transition-fast;
&:hover { color: #fff; }
}
&__summary {
padding: $spacing-4 $spacing-6;
background: var(--clr-surface-raised, #{$gray-50});
border-bottom: 1px solid var(--clr-border);
}
&__summary-label {
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--clr-text-muted);
margin-bottom: $spacing-2;
}
&__summary-row {
display: flex;
justify-content: space-between;
font-size: $font-size-sm;
color: var(--clr-text-secondary);
padding: $spacing-1 0;
&--total {
margin-top: $spacing-3;
padding-top: $spacing-3;
border-top: 1px solid var(--clr-border);
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
}
&__deposit-amt {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: var(--clr-primary);
}
&__form {
padding: $spacing-6;
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-4;
}
&__field { display: flex; flex-direction: column; gap: $spacing-2; }
&__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
}
&__input {
padding: $spacing-3 $spacing-4;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-sm;
font-family: $font-family-base;
color: var(--clr-text);
outline: none;
transition: border-color $transition-fast, box-shadow $transition-fast;
&::placeholder { color: var(--clr-text-muted); }
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.18);
}
&:disabled { opacity: 0.55; cursor: not-allowed; }
&--error {
border-color: $color-error;
&:focus { box-shadow: 0 0 0 3px rgba($color-error, 0.18); }
}
}
&__error { font-size: $font-size-xs; color: $color-error; }
&__pay-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-2;
padding: $spacing-4;
background: var(--clr-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;
margin-top: $spacing-2;
&:hover:not(:disabled) { background: var(--clr-primary-hover); }
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
&__spinner { animation: pay-spin 0.8s linear infinite; }
&__secure-note {
text-align: center;
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
// Payment method tabs
&__methods {
display: flex;
border-bottom: 1px solid var(--clr-border);
}
&__method-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-2;
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:not(:disabled) { color: var(--clr-text); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
&--active {
color: var(--clr-primary);
border-bottom-color: var(--clr-primary);
}
}
// Google Pay preview
&__gpay-preview {
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
padding: $spacing-5;
text-align: center;
display: flex;
flex-direction: column;
gap: $spacing-2;
align-items: center;
}
&__gpay-logo {
font-size: $font-size-xl;
font-family: $font-family-base;
letter-spacing: 0;
}
&__gpay-account {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
}
&__gpay-card {
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
&__pay-btn--gpay {
background: #1a73e8;
&:hover:not(:disabled) { background: #1557b0; }
}
// Bank transfer info
&__bank-info {
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
overflow: hidden;
}
&__bank-label {
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--clr-text-muted);
padding: $spacing-3 $spacing-4;
background: var(--clr-border);
}
&__bank-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-2 $spacing-4;
font-size: $font-size-sm;
border-bottom: 1px solid var(--clr-border);
gap: $spacing-4;
&:last-child { border-bottom: none; }
span:first-child {
color: var(--clr-text-secondary);
flex-shrink: 0;
}
span:last-child {
color: var(--clr-text);
text-align: right;
}
&--total {
font-weight: $font-weight-semibold;
background: var(--clr-surface);
}
}
&__bank-mono {
font-family: $font-family-mono;
font-size: $font-size-xs !important;
}
// Done state
&__done {
padding: $spacing-10 $spacing-6;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-3;
}
&__done-icon { color: $accent-500; }
&__done-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__done-sub { font-size: $font-size-sm; color: var(--clr-text-secondary); }
}
@keyframes pay-spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,215 @@
// [REQ F2] Zarządzanie profilami edycja danych osobowych i preferencji powiadomień
// [REQ D2] Zaawansowane formularze Formik + Zod (toFormikValidationSchema)
// [REQ D4] React Portals modal montowany w #portal-root
// [REQ T8] Obsługa formularzy i walidacja schemat Zod, błędy inline
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useFormik } from 'formik';
import { z } from 'zod';
import { toFormikValidationSchema } from 'zod-formik-adapter';
import { X, User, Bell, CheckCircle2 } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useAuth } from '../context/AuthContext';
import { API_URL } from '../config';
import styles from './ProfileEditModal.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.'),
});
const TABS = [
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'notifications', label: 'Notifications', icon: Bell },
];
const defaultPrefs = {
emailOnConfirm: true,
emailOnCancelled: true,
emailOnReminder: true,
emailOnReschedule: true,
emailOnPromo: false,
};
const ProfileEditModal = ({ isOpen, onClose }) => {
const { user, logout } = useAuth();
const queryClient = useQueryClient();
const [tab, setTab] = useState('profile');
const [saved, setSaved] = useState(false);
const [prefs, setPrefs] = useState(() => ({
...defaultPrefs,
...(user?.notificationPrefs ?? {}),
}));
const { mutate: saveProfile, isPending } = useMutation({
mutationFn: ({ name, email }) =>
axios.patch(`${API_URL}/users/${user.id}`, { name, email }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSaved(true);
setTimeout(() => setSaved(false), 2500);
},
});
const { mutate: savePrefs, isPending: prefsPending } = useMutation({
mutationFn: (notificationPrefs) =>
axios.patch(`${API_URL}/users/${user.id}`, { notificationPrefs }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSaved(true);
setTimeout(() => setSaved(false), 2500);
},
});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: user?.name ?? '',
email: user?.email ?? '',
},
validationSchema: toFormikValidationSchema(schema),
onSubmit: (values) => saveProfile(values),
});
if (!isOpen) return null;
const portalRoot = document.getElementById('portal-root') ?? document.body;
const togglePref = (key) => setPrefs((p) => ({ ...p, [key]: !p[key] }));
return createPortal(
<div className={styles['pem']} role="dialog" aria-modal="true" aria-label="Edit profile">
<div className={styles['pem__overlay']} onClick={onClose} />
<div className={styles['pem__panel']}>
{/* Header */}
<div className={styles['pem__header']}>
<h2 className={styles['pem__title']}>Account Settings</h2>
<button className={styles['pem__close']} onClick={onClose} aria-label="Close">
<X size={18} />
</button>
</div>
{/* Tabs */}
<div className={styles['pem__tabs']}>
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
className={[styles['pem__tab'], tab === id ? styles['pem__tab--active'] : ''].join(' ')}
onClick={() => { setTab(id); setSaved(false); }}
>
<Icon size={14} /> {label}
</button>
))}
</div>
{saved && (
<div className={styles['pem__saved']}>
<CheckCircle2 size={14} /> Saved!
</div>
)}
{/* Profile tab */}
{tab === 'profile' && (
<form className={styles['pem__form']} onSubmit={formik.handleSubmit} noValidate>
<div className={styles['pem__field']}>
<label className={styles['pem__label']}>Display name</label>
<input
className={[styles['pem__input'], formik.touched.name && formik.errors.name ? styles['pem__input--error'] : ''].join(' ')}
placeholder="John Smith"
{...formik.getFieldProps('name')}
/>
{formik.touched.name && formik.errors.name && (
<p className={styles['pem__error']}>{formik.errors.name}</p>
)}
</div>
<div className={styles['pem__field']}>
<label className={styles['pem__label']}>Email address</label>
<input
type="email"
className={[styles['pem__input'], formik.touched.email && formik.errors.email ? styles['pem__input--error'] : ''].join(' ')}
placeholder="you@example.com"
{...formik.getFieldProps('email')}
/>
{formik.touched.email && formik.errors.email && (
<p className={styles['pem__error']}>{formik.errors.email}</p>
)}
</div>
<div className={styles['pem__field']}>
<label className={styles['pem__label']}>Role</label>
<input
className={styles['pem__input']}
value={user?.role ?? ''}
disabled
/>
<p className={styles['pem__hint']}>Role changes require admin approval.</p>
</div>
<div className={styles['pem__actions']}>
<button type="button" className={styles['pem__cancel-btn']} onClick={onClose}>
Cancel
</button>
<button
type="submit"
className={styles['pem__save-btn']}
disabled={isPending || !formik.dirty}
>
{isPending ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
)}
{/* Notifications tab */}
{tab === 'notifications' && (
<div className={styles['pem__form']}>
<p className={styles['pem__section-label']}>Email notifications</p>
{[
{ key: 'emailOnConfirm', label: 'Booking confirmed', desc: 'When your reservation is confirmed by staff' },
{ key: 'emailOnCancelled', label: 'Booking cancelled', desc: 'When a reservation is cancelled' },
{ key: 'emailOnReminder', label: 'Reminder 24h before', desc: 'A reminder email the day before your appointment' },
{ key: 'emailOnReschedule', label: 'Reschedule confirmed', desc: 'When your reservation date or time is changed' },
{ key: 'emailOnPromo', label: 'Promotions & offers', desc: 'Occasional news, offers and discounts' },
].map(({ key, label, desc }) => (
<label key={key} className={styles['pem__toggle-row']}>
<div className={styles['pem__toggle-info']}>
<span className={styles['pem__toggle-label']}>{label}</span>
<span className={styles['pem__toggle-desc']}>{desc}</span>
</div>
<button
type="button"
role="switch"
aria-checked={prefs[key]}
className={[styles['pem__switch'], prefs[key] ? styles['pem__switch--on'] : ''].join(' ')}
onClick={() => togglePref(key)}
>
<span className={styles['pem__switch-thumb']} />
</button>
</label>
))}
<div className={styles['pem__actions']}>
<button type="button" className={styles['pem__cancel-btn']} onClick={onClose}>
Cancel
</button>
<button
type="button"
className={styles['pem__save-btn']}
disabled={prefsPending}
onClick={() => savePrefs(prefs)}
>
{prefsPending ? 'Saving…' : 'Save preferences'}
</button>
</div>
</div>
)}
</div>
</div>,
portalRoot
);
};
export default ProfileEditModal;

View File

@@ -0,0 +1,260 @@
@use '../styles/variables' as *;
.pem {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-4;
&__overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(3px);
}
&__panel {
position: relative;
z-index: 1;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-xl;
width: 100%;
max-width: 480px;
overflow: hidden;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-5 $spacing-6;
border-bottom: 1px solid var(--clr-border);
}
&__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__close {
background: transparent;
border: none;
color: var(--clr-text-secondary);
cursor: pointer;
padding: $spacing-1;
border-radius: $radius-base;
display: flex;
align-items: center;
transition: color $transition-fast;
&:hover { color: var(--clr-text); }
}
&__tabs {
display: flex;
border-bottom: 1px solid var(--clr-border);
}
&__tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-2;
padding: $spacing-3;
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);
border-bottom-color: var(--clr-primary);
}
}
&__saved {
display: flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-6;
background: #f0fdf4;
border-bottom: 1px solid #bbf7d0;
color: #15803d;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
&__form {
padding: $spacing-6;
display: flex;
flex-direction: column;
gap: $spacing-5;
}
&__field { display: flex; flex-direction: column; gap: $spacing-2; }
&__label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
}
&__input {
padding: $spacing-3 $spacing-4;
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;
transition: border-color $transition-fast, box-shadow $transition-fast;
&::placeholder { color: var(--clr-text-muted); }
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.18);
}
&:disabled {
opacity: 0.6;
background: var(--clr-surface-raised);
cursor: not-allowed;
}
&--error {
border-color: $color-error;
&:focus { box-shadow: 0 0 0 3px rgba($color-error, 0.18); }
}
}
&__error { font-size: $font-size-xs; color: $color-error; }
&__hint {
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
&__section-label {
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--clr-text-muted);
margin-bottom: -$spacing-2;
}
// Toggle rows
&__toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-4;
padding: $spacing-3 0;
border-bottom: 1px solid var(--clr-border);
cursor: pointer;
&:last-of-type { border-bottom: none; }
}
&__toggle-info {
display: flex;
flex-direction: column;
gap: 2px;
}
&__toggle-label {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
}
&__toggle-desc {
font-size: $font-size-xs;
color: var(--clr-text-muted);
}
// iOS-style switch
&__switch {
flex-shrink: 0;
position: relative;
width: 40px;
height: 22px;
border-radius: $radius-full;
background: var(--clr-border);
border: none;
cursor: pointer;
transition: background $transition-base;
&--on { background: var(--clr-primary); }
}
&__switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: $radius-full;
background: #fff;
box-shadow: $shadow-sm;
transition: transform $transition-base;
.pem__switch--on & { transform: translateX(18px); }
}
&__actions {
display: flex;
justify-content: flex-end;
gap: $spacing-3;
padding-top: $spacing-2;
border-top: 1px solid var(--clr-border);
}
&__cancel-btn {
padding: $spacing-2 $spacing-5;
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); }
}
&__save-btn {
padding: $spacing-2 $spacing-5;
background: var(--clr-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;
transition: background $transition-fast, opacity $transition-fast;
&:hover:not(:disabled) { background: var(--clr-primary-hover); }
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
}

View File

@@ -0,0 +1,78 @@
// [REQ F2] Profil użytkownika widok danych przez GraphQL (Apollo Client + SchemaLink)
// [REQ D3] Apollo Client / GraphQL zapytanie gql, useQuery z Apollo
import { useQuery } from '@apollo/client/react';
import { gql } from '@apollo/client/core';
import { User, Mail, Shield, Calendar, ClipboardList } from 'lucide-react';
import styles from './ProfileView.module.scss';
const GET_USER_PROFILE = gql`
query GetUserProfile($id: ID!) {
userProfile(id: $id) {
id
name
email
role
joinedAt
reservationsCount
avatarUrl
}
}
`;
// The mock schema returns deterministic data regardless of ID.
const ProfileView = ({ userId = '1' }) => {
const { data, loading, error } = useQuery(GET_USER_PROFILE, {
variables: { id: userId },
});
if (loading) return <p className={styles['profile__state']}>Loading profile</p>;
if (error) return <p className={[styles['profile__state'], styles['profile__state--error']].join(' ')}>Failed to load profile: {error.message}</p>;
const p = data.userProfile;
return (
<div className={styles['profile']}>
<div className={styles['profile__banner']}>
<img src={p.avatarUrl} alt={p.name} className={styles['profile__avatar']} />
<div>
<h3 className={styles['profile__name']}>{p.name}</h3>
<span className={[
styles['profile__role-badge'],
styles[`profile__role-badge--${p.role}`],
].join(' ')}>
{p.role}
</span>
</div>
</div>
<ul className={styles['profile__list']}>
<li className={styles['profile__item']}>
<Mail size={15} className={styles['profile__item-icon']} />
<span className={styles['profile__item-label']}>Email</span>
<span className={styles['profile__item-value']}>{p.email}</span>
</li>
<li className={styles['profile__item']}>
<Shield size={15} className={styles['profile__item-icon']} />
<span className={styles['profile__item-label']}>Role</span>
<span className={styles['profile__item-value']}>{p.role}</span>
</li>
<li className={styles['profile__item']}>
<Calendar size={15} className={styles['profile__item-icon']} />
<span className={styles['profile__item-label']}>Member since</span>
<span className={styles['profile__item-value']}>{p.joinedAt}</span>
</li>
<li className={styles['profile__item']}>
<ClipboardList size={15} className={styles['profile__item-icon']} />
<span className={styles['profile__item-label']}>Total reservations</span>
<span className={styles['profile__item-value']}>{p.reservationsCount}</span>
</li>
</ul>
<p className={styles['profile__footer']}>
<User size={12} /> Data served via Apollo Client + GraphQL mock (no backend)
</p>
</div>
);
};
export default ProfileView;

View File

@@ -0,0 +1,103 @@
@use '../styles/variables' as *;
// ── Block ─────────────────────────────────────────────────────────────────────
.profile {
background: var(--clr-surface, #{$color-surface});
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-xl;
box-shadow: $shadow-sm;
overflow: hidden;
// ── Elements ────────────────────────────────────────────────────────────────
&__banner {
display: flex;
align-items: center;
gap: $spacing-4;
padding: $spacing-6;
background: linear-gradient(135deg, $primary-50, $accent-50);
border-bottom: 1px solid var(--clr-border, #{$color-border});
}
&__avatar {
width: 64px;
height: 64px;
border-radius: 50%;
border: 3px solid var(--clr-surface, #{$color-surface});
box-shadow: $shadow-base;
object-fit: cover;
}
&__name {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: var(--clr-text, #{$color-text-primary});
margin-bottom: $spacing-2;
}
&__role-badge {
display: inline-block;
padding: 2px $spacing-3;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
&--admin { background: $primary-100; color: $primary-700; }
&--client { background: $accent-100; color: $accent-700; }
}
&__list {
list-style: none;
padding: $spacing-4 $spacing-6;
display: flex;
flex-direction: column;
}
&__item {
display: flex;
align-items: center;
gap: $spacing-3;
padding: $spacing-3 0;
border-bottom: 1px solid var(--clr-border, #{$gray-100});
&:last-child { border-bottom: none; }
}
&__item-icon { color: $primary-400; flex-shrink: 0; }
&__item-label {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
width: 110px;
flex-shrink: 0;
}
&__item-value {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text, #{$color-text-primary});
flex: 1;
min-width: 0;
word-break: break-all;
}
&__footer {
display: flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-6;
font-size: $font-size-xs;
color: var(--clr-text-muted, #{$color-text-muted});
background: var(--clr-surface-raised, #{$gray-50});
border-top: 1px solid var(--clr-border, #{$color-border});
}
&__state {
padding: $spacing-6;
color: var(--clr-text-secondary, #{$color-text-secondary});
font-size: $font-size-sm;
&--error { color: $color-error; }
}
}

View File

@@ -0,0 +1,22 @@
// [REQ F1] System uwierzytelniania ochrona tras, przekierowanie niezalogowanych do /login
// [REQ T3] React Router Outlet + Navigate, zachowanie ścieżki powrotu w location.state
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const ProtectedRoute = ({ allowedRoles }) => {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (allowedRoles && !allowedRoles.includes(user.role)) {
const fallback = user.role === 'admin' ? '/admin' : '/dashboard';
return <Navigate to={fallback} replace />;
}
return <Outlet />;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,134 @@
// [REQ F6] Zarządzanie rezerwacjami zmiana daty/godziny istniejącej rezerwacji
// [REQ D4] React Portals modal montowany w #portal-root
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, CalendarDays, Clock, CheckCircle2, Loader2 } from 'lucide-react';
import { useUpdateReservation } from '../hooks/useUpdateReservation';
import styles from './RescheduleModal.module.scss';
const TIME_SLOTS = [
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
'12:00', '12:30', '13:00', '13:30', '14:00', '14:30',
'15:00', '15:30', '16:00', '16:30', '17:00',
];
const todayISO = () => new Date().toISOString().split('T')[0];
const RescheduleModal = ({ isOpen, onClose, reservation, serviceName }) => {
const [date, setDate] = useState(reservation?.date ?? '');
const [time, setTime] = useState(reservation?.time ?? '');
const [done, setDone] = useState(false);
const { mutate: updateStatus, isPending } = useUpdateReservation({
onSuccess: () => setDone(true),
});
if (!isOpen) return null;
const handleConfirm = () => {
if (!date || !time) return;
updateStatus({
id: reservation.id,
patch: { date, time },
});
};
const handleClose = () => {
setDone(false);
onClose();
};
const portalRoot = document.getElementById('portal-root') ?? document.body;
return createPortal(
<div className={styles['rs']} role="dialog" aria-modal="true" aria-label="Reschedule reservation">
<div className={styles['rs__overlay']} onClick={!isPending ? handleClose : undefined} />
<div className={styles['rs__panel']}>
<div className={styles['rs__header']}>
<div className={styles['rs__header-left']}>
<CalendarDays size={18} className={styles['rs__header-icon']} />
<h2 className={styles['rs__title']}>Reschedule</h2>
</div>
{!isPending && (
<button className={styles['rs__close']} onClick={handleClose} aria-label="Close">
<X size={18} />
</button>
)}
</div>
{done ? (
<div className={styles['rs__done']}>
<CheckCircle2 size={40} className={styles['rs__done-icon']} />
<p className={styles['rs__done-title']}>Reservation rescheduled!</p>
<p className={styles['rs__done-sub']}>
{serviceName} moved to {date} at {time}
</p>
<button className={styles['rs__confirm-btn']} onClick={handleClose}>
Done
</button>
</div>
) : (
<div className={styles['rs__body']}>
<p className={styles['rs__service']}>
{serviceName ?? reservation?.serviceId} &mdash; #{reservation?.id}
</p>
<div className={styles['rs__field']}>
<label className={styles['rs__label']}>
<CalendarDays size={13} /> New date
</label>
<input
type="date"
className={styles['rs__input']}
value={date}
min={todayISO()}
onChange={(e) => setDate(e.target.value)}
disabled={isPending}
/>
</div>
<div className={styles['rs__field']}>
<label className={styles['rs__label']}>
<Clock size={13} /> New time
</label>
<div className={styles['rs__slots']}>
{TIME_SLOTS.map((t) => (
<button
key={t}
type="button"
className={[
styles['rs__slot'],
time === t ? styles['rs__slot--active'] : '',
].join(' ')}
onClick={() => setTime(t)}
disabled={isPending}
>
{t}
</button>
))}
</div>
</div>
<div className={styles['rs__footer']}>
<button className={styles['rs__cancel-btn']} onClick={handleClose} disabled={isPending}>
Cancel
</button>
<button
className={styles['rs__confirm-btn']}
onClick={handleConfirm}
disabled={isPending || !date || !time}
>
{isPending
? <><Loader2 size={14} className={styles['rs__spinner']} /> Saving</>
: 'Confirm reschedule'}
</button>
</div>
</div>
)}
</div>
</div>,
portalRoot
);
};
export default RescheduleModal;

View File

@@ -0,0 +1,207 @@
@use '../styles/variables' as *;
.rs {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-4;
&__overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(3px);
}
&__panel {
position: relative;
z-index: 1;
background: var(--clr-surface);
border: 1px solid var(--clr-border);
border-radius: $radius-xl;
box-shadow: $shadow-xl;
width: 100%;
max-width: 460px;
overflow: hidden;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-4 $spacing-6;
background: linear-gradient(135deg, $primary-700, $primary-500);
color: #fff;
}
&__header-left { display: flex; align-items: center; gap: $spacing-3; }
&__header-icon { opacity: 0.9; }
&__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: #fff;
}
&__close {
background: transparent;
border: none;
color: rgba(255,255,255,0.8);
cursor: pointer;
padding: $spacing-1;
border-radius: $radius-base;
display: flex;
align-items: center;
transition: color $transition-fast;
&:hover { color: #fff; }
}
&__body {
padding: $spacing-6;
display: flex;
flex-direction: column;
gap: $spacing-5;
}
&__service {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
}
&__field { display: flex; flex-direction: column; gap: $spacing-2; }
&__label {
display: flex;
align-items: center;
gap: $spacing-2;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: var(--clr-text);
}
&__input {
padding: $spacing-3 $spacing-4;
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;
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.18);
}
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
&__slots {
display: flex;
flex-wrap: wrap;
gap: $spacing-2;
}
&__slot {
padding: $spacing-2 $spacing-3;
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-xs;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast, border-color $transition-fast, color $transition-fast;
&:hover:not(:disabled) {
background: $primary-50;
border-color: $primary-300;
color: $primary-700;
}
&:disabled { opacity: 0.5; cursor: not-allowed; }
&--active {
background: var(--clr-primary);
border-color: var(--clr-primary);
color: #fff;
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: $spacing-3;
padding-top: $spacing-2;
border-top: 1px solid var(--clr-border);
}
&__cancel-btn {
padding: $spacing-2 $spacing-5;
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:not(:disabled) { background: var(--clr-surface-raised); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
&__confirm-btn {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-5;
background: var(--clr-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;
transition: background $transition-fast, opacity $transition-fast;
&:hover:not(:disabled) { background: var(--clr-primary-hover); }
&:disabled { opacity: 0.55; cursor: not-allowed; }
}
&__spinner { animation: rs-spin 0.8s linear infinite; }
// Done state
&__done {
padding: $spacing-10 $spacing-6;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-3;
}
&__done-icon { color: $accent-500; }
&__done-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: var(--clr-text);
}
&__done-sub {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
margin-bottom: $spacing-3;
}
}
@keyframes rs-spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,152 @@
// [REQ F11] System opinii i ocen ocena gwiazdkowa + komentarz po zrealizowanej rezerwacji
// [REQ D3] GraphQL z Apollo Client useQuery (serviceReviews) + useMutation (submitReview)
import { useState } from 'react';
import { useQuery, useMutation } from '@apollo/client/react';
import { gql } from '@apollo/client/core';
import { Star, Send, MessageSquare } from 'lucide-react';
import styles from './ReviewsSection.module.scss';
const GET_SERVICE_REVIEWS = gql`
query GetServiceReviews($serviceId: String!) {
serviceReviews(serviceId: $serviceId) {
id
userName
rating
comment
createdAt
}
}
`;
const SUBMIT_REVIEW = gql`
mutation SubmitReview(
$reservationId: String!
$serviceId: String!
$userId: String!
$userName: String!
$rating: Int!
$comment: String
) {
submitReview(
reservationId: $reservationId
serviceId: $serviceId
userId: $userId
userName: $userName
rating: $rating
comment: $comment
) {
id
rating
comment
createdAt
}
}
`;
const StarRating = ({ value, onChange, readOnly = false }) => (
<div className={styles['stars']}>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={[styles['stars__star'], n <= value ? styles['stars__star--on'] : ''].join(' ')}
onClick={() => !readOnly && onChange?.(n)}
disabled={readOnly}
aria-label={`${n} star${n !== 1 ? 's' : ''}`}
>
<Star size={16} fill={n <= value ? 'currentColor' : 'none'} />
</button>
))}
</div>
);
const ReviewsSection = ({ serviceId, reservationId, userId, userName, alreadyReviewed }) => {
const [rating, setRating] = useState(0);
const [comment, setComment] = useState('');
const [submitted, setSubmitted] = useState(alreadyReviewed);
const { data, loading } = useQuery(GET_SERVICE_REVIEWS, {
variables: { serviceId },
skip: !serviceId,
});
const [submitReview, { loading: submitting }] = useMutation(SUBMIT_REVIEW, {
refetchQueries: [{ query: GET_SERVICE_REVIEWS, variables: { serviceId } }],
onCompleted: () => setSubmitted(true),
});
const reviews = data?.serviceReviews ?? [];
const avgRating = reviews.length
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
: null;
const handleSubmit = (e) => {
e.preventDefault();
if (!rating) return;
submitReview({
variables: { reservationId, serviceId, userId, userName, rating, comment: comment.trim() || null },
});
};
return (
<div className={styles['rev']}>
<div className={styles['rev__header']}>
<MessageSquare size={15} className={styles['rev__icon']} />
<span className={styles['rev__title']}>Reviews</span>
{avgRating && (
<span className={styles['rev__avg']}>
<Star size={12} fill="currentColor" /> {avgRating} ({reviews.length})
</span>
)}
</div>
{/* Existing reviews */}
{loading ? (
<p className={styles['rev__msg']}>Loading reviews</p>
) : reviews.length > 0 ? (
<ul className={styles['rev__list']}>
{reviews.map((r) => (
<li key={r.id} className={styles['rev__item']}>
<div className={styles['rev__item-top']}>
<span className={styles['rev__item-user']}>{r.userName}</span>
<StarRating value={r.rating} readOnly />
<span className={styles['rev__item-date']}>{r.createdAt}</span>
</div>
{r.comment && <p className={styles['rev__item-comment']}>{r.comment}</p>}
</li>
))}
</ul>
) : (
<p className={styles['rev__msg']}>No reviews yet for this service.</p>
)}
{/* Submit form */}
{reservationId && !submitted && (
<form className={styles['rev__form']} onSubmit={handleSubmit}>
<p className={styles['rev__form-label']}>Rate this service:</p>
<StarRating value={rating} onChange={setRating} />
<textarea
className={styles['rev__textarea']}
rows={3}
placeholder="Leave a comment (optional)…"
value={comment}
onChange={(e) => setComment(e.target.value)}
maxLength={400}
/>
<button
type="submit"
className={styles['rev__submit']}
disabled={!rating || submitting}
>
<Send size={13} /> {submitting ? 'Sending…' : 'Submit review'}
</button>
</form>
)}
{submitted && reservationId && (
<p className={styles['rev__thanks']}>Thank you for your review!</p>
)}
</div>
);
};
export default ReviewsSection;

View File

@@ -0,0 +1,153 @@
@use '../styles/variables' as *;
.rev {
border-top: 1px solid var(--clr-border);
padding-top: $spacing-4;
display: flex;
flex-direction: column;
gap: $spacing-3;
&__header {
display: flex;
align-items: center;
gap: $spacing-2;
}
&__icon { color: $primary-500; }
&__title {
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: var(--clr-text);
flex: 1;
}
&__avg {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: #d97706;
}
&__msg {
font-size: $font-size-xs;
color: var(--clr-text-secondary);
font-style: italic;
}
&__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: $spacing-2; }
&__item {
background: var(--clr-surface-raised, #{$gray-50});
border-radius: $radius-base;
padding: $spacing-3;
display: flex;
flex-direction: column;
gap: $spacing-1;
}
&__item-top {
display: flex;
align-items: center;
gap: $spacing-2;
flex-wrap: wrap;
}
&__item-user {
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: var(--clr-text);
flex: 1;
}
&__item-date {
font-size: 11px;
color: var(--clr-text-muted);
}
&__item-comment {
font-size: $font-size-xs;
color: var(--clr-text-secondary);
margin: 0;
line-height: 1.5;
}
&__form {
display: flex;
flex-direction: column;
gap: $spacing-2;
padding: $spacing-3;
background: $primary-50;
border: 1px solid $primary-100;
border-radius: $radius-base;
}
&__form-label {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: var(--clr-text);
margin: 0;
}
&__textarea {
width: 100%;
padding: $spacing-2 $spacing-3;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
font-size: $font-size-xs;
font-family: $font-family-base;
color: var(--clr-text);
background: var(--clr-surface);
resize: vertical;
outline: none;
&:focus { border-color: $primary-400; box-shadow: 0 0 0 2px rgba($primary-400, 0.15); }
}
&__submit {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-2 $spacing-3;
background: var(--clr-primary, #{$color-primary});
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
transition: opacity $transition-fast;
&:disabled { opacity: 0.55; cursor: not-allowed; }
&:hover:not(:disabled) { opacity: 0.9; }
}
&__thanks {
font-size: $font-size-xs;
color: $accent-600;
font-weight: $font-weight-medium;
margin: 0;
}
}
.stars {
display: inline-flex;
gap: 2px;
&__star {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: #d97706;
transition: transform $transition-fast;
&:hover { transform: scale(1.2); }
&:disabled { cursor: default; opacity: 0.8; }
&--on { color: #f59e0b; }
}
}

View File

@@ -0,0 +1,185 @@
// [REQ F3] Wyszukiwanie dostępności inteligentny ranking slotów (scoreSlot, findSlots)
// [REQ D7] React Query useQuery do pobierania wszystkich rezerwacji
// [REQ T6] Optymalizacja useMemo dla maxScore paska wynikowego
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Calendar, Clock, Zap, ChevronRight } from 'lucide-react';
import { useServices } from '../hooks/useServices';
import { API_URL } from '../config';
import styles from './SlotFinder.module.scss';
const todayISO = () => new Date().toISOString().split('T')[0];
const plusDays = (n) => new Date(Date.now() + n * 864e5).toISOString().split('T')[0];
const TIME_SLOTS = [
'09:00','09:30','10:00','10:30','11:00','11:30',
'12:00','12:30','13:00','13:30','14:00','14:30',
'15:00','15:30','16:00','16:30','17:00',
];
function scoreSlot(dateStr, time, preferredTime, daysFromNow) {
let score = 100;
const hour = parseInt(time);
if (preferredTime === 'morning' && hour >= 9 && hour < 12) score += 30;
if (preferredTime === 'afternoon' && hour >= 12 && hour < 15) score += 30;
if (preferredTime === 'late' && hour >= 15) score += 30;
score -= daysFromNow * 2;
const day = new Date(dateStr).getDay();
if (day === 0 || day === 6) score -= 20;
if (hour === 10 || hour === 14) score += 10;
return Math.max(score, 0);
}
function findSlots(allReservations, { serviceId, dateFrom, dateTo, preferredTime, maxResults }) {
const booked = new Set(
allReservations
.filter((r) => r.serviceId === serviceId && r.status !== 'cancelled')
.map((r) => `${r.date}|${r.time}`)
);
const results = [];
const today = new Date();
const cur = new Date(dateFrom);
const end = new Date(dateTo);
while (cur <= end) {
const dateStr = cur.toISOString().split('T')[0];
const daysFromNow = Math.round((cur - today) / 864e5);
const dow = cur.toLocaleDateString('en-US', { weekday: 'long' });
for (const time of TIME_SLOTS) {
if (!booked.has(`${dateStr}|${time}`)) {
const hour = parseInt(time);
const tag = hour < 12 ? 'Morning' : hour < 15 ? 'Afternoon' : 'Evening';
results.push({ date: dateStr, time, score: scoreSlot(dateStr, time, preferredTime, daysFromNow), dayOfWeek: dow, daysFromNow, tag });
}
}
cur.setDate(cur.getDate() + 1);
}
return results.sort((a, b) => b.score - a.score).slice(0, maxResults);
}
const SlotFinder = ({ onSelectSlot }) => {
const { data: services = [] } = useServices();
const { data: allReservations = [] } = useQuery({
queryKey: ['reservations'],
queryFn: () => fetch(`${API_URL}/reservations`).then((r) => r.json()),
staleTime: 30_000,
});
const [form, setForm] = useState({
serviceId: '',
dateFrom: todayISO(),
dateTo: plusDays(14),
preferredTime: 'any',
maxResults: '8',
});
const [results, setResults] = useState(null);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const handleSearch = () => {
if (!form.serviceId) return;
setResults(findSlots(allReservations, {
serviceId: form.serviceId,
dateFrom: form.dateFrom,
dateTo: form.dateTo,
preferredTime: form.preferredTime,
maxResults: parseInt(form.maxResults, 10),
}));
};
const maxScore = useMemo(
() => (results?.length ? Math.max(...results.map((r) => r.score)) : 1),
[results],
);
return (
<div className={styles['sf']}>
<div className={styles['sf__header']}>
<Zap size={18} className={styles['sf__header-icon']} />
<div>
<h2 className={styles['sf__title']}>Smart Slot Finder</h2>
<p className={styles['sf__subtitle']}>Ranks available slots across a date range by score</p>
</div>
</div>
<div className={styles['sf__form']}>
<div className={styles['sf__field']}>
<label className={styles['sf__label']}>Service</label>
<select className={styles['sf__select']} value={form.serviceId} onChange={(e) => set('serviceId', e.target.value)}>
<option value=""> Pick a service </option>
{services.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div className={styles['sf__row']}>
<div className={styles['sf__field']}>
<label className={styles['sf__label']}>From</label>
<input type="date" className={styles['sf__input']} value={form.dateFrom} min={todayISO()} onChange={(e) => set('dateFrom', e.target.value)} />
</div>
<div className={styles['sf__field']}>
<label className={styles['sf__label']}>To</label>
<input type="date" className={styles['sf__input']} value={form.dateTo} min={form.dateFrom} onChange={(e) => set('dateTo', e.target.value)} />
</div>
<div className={styles['sf__field']}>
<label className={styles['sf__label']}>Preference</label>
<select className={styles['sf__select']} value={form.preferredTime} onChange={(e) => set('preferredTime', e.target.value)}>
<option value="any">Any time</option>
<option value="morning">Morning (912)</option>
<option value="afternoon">Afternoon (1215)</option>
<option value="late">Late (1518)</option>
</select>
</div>
<div className={styles['sf__field']}>
<label className={styles['sf__label']}>Show top</label>
<select className={styles['sf__select']} value={form.maxResults} onChange={(e) => set('maxResults', e.target.value)}>
<option value="5">5 results</option>
<option value="8">8 results</option>
<option value="12">12 results</option>
</select>
</div>
</div>
<button className={styles['sf__btn']} onClick={handleSearch} disabled={!form.serviceId}>
<Search size={15} /> Find best slots
</button>
</div>
{results !== null && (
<div className={styles['sf__results']}>
{results.length === 0 ? (
<p className={styles['sf__empty']}>No available slots in this date range.</p>
) : (
<>
<p className={styles['sf__count']}>{results.length} slot{results.length !== 1 ? 's' : ''} ranked by availability score</p>
<div className={styles['sf__grid']}>
{results.map((slot, idx) => (
<div key={`${slot.date}-${slot.time}`} className={[styles['sf__card'], idx === 0 ? styles['sf__card--best'] : ''].join(' ')}>
<span className={styles['sf__tag']}>{slot.tag}</span>
<div className={styles['sf__card-date']}><Calendar size={12} />{slot.date}<span className={styles['sf__dow']}>{slot.dayOfWeek.slice(0, 3)}</span></div>
<div className={styles['sf__card-time']}><Clock size={12} />{slot.time}</div>
<p className={styles['sf__days']}>
{slot.daysFromNow === 0 ? 'Today' : slot.daysFromNow === 1 ? 'Tomorrow' : `In ${slot.daysFromNow} days`}
</p>
<div className={styles['sf__bar']}>
<div className={styles['sf__bar-fill']} style={{ width: `${(slot.score / maxScore) * 100}%` }} />
</div>
{onSelectSlot && (
<button className={styles['sf__book']} onClick={() => onSelectSlot({ serviceId: form.serviceId, date: slot.date, time: slot.time })}>
Book <ChevronRight size={13} />
</button>
)}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
};
export default SlotFinder;

View File

@@ -0,0 +1,265 @@
@use '../styles/variables' as *;
.sf {
background: var(--clr-surface, #{$color-surface});
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-xl;
padding: $spacing-6;
margin-bottom: $spacing-6;
&__header {
display: flex;
align-items: flex-start;
gap: $spacing-3;
margin-bottom: $spacing-5;
}
&__header-icon {
color: $primary-500;
flex-shrink: 0;
margin-top: 3px;
}
&__title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: var(--clr-text, #{$color-text-primary});
margin: 0 0 $spacing-1;
}
&__subtitle {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
margin: 0;
}
&__loading-badge {
display: inline-block;
background: $primary-100;
color: $primary-700;
border-radius: $radius-full;
padding: 0 $spacing-2;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
}
&__form {
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: $spacing-3;
@media (max-width: 900px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
&__field {
display: flex;
flex-direction: column;
gap: $spacing-1;
}
&__label {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: var(--clr-text-secondary, #{$color-text-secondary});
text-transform: uppercase;
letter-spacing: 0.04em;
}
&__input,
&__select {
width: 100%;
padding: $spacing-2 $spacing-3;
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-base;
font-size: $font-size-sm;
font-family: $font-family-base;
color: var(--clr-text, #{$color-text-primary});
background: var(--clr-surface, #{$color-surface});
outline: none;
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
border-color: $primary-400;
box-shadow: 0 0 0 3px rgba($primary-400, 0.15);
}
}
&__btn {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-5;
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;
transition: background $transition-fast, opacity $transition-fast;
&:hover:not(:disabled) {
background: var(--clr-primary-hover, #{$color-primary-hover});
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
&__wasm-error {
padding: $spacing-4;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: $radius-base;
color: #991b1b;
font-size: $font-size-sm;
}
// ── Results ───────────────────────────────────────────────────────────────
&__results {
margin-top: $spacing-5;
border-top: 1px solid var(--clr-border, #{$color-border});
padding-top: $spacing-5;
}
&__empty {
font-size: $font-size-sm;
color: var(--clr-text-secondary, #{$color-text-secondary});
text-align: center;
padding: $spacing-8 0;
}
&__count {
font-size: $font-size-xs;
color: var(--clr-text-secondary, #{$color-text-secondary});
margin-bottom: $spacing-3;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: $spacing-3;
}
// ── Slot card ─────────────────────────────────────────────────────────────
&__card {
background: var(--clr-surface-raised, #{$gray-50});
border: 1px solid var(--clr-border, #{$color-border});
border-radius: $radius-lg;
padding: $spacing-3;
display: flex;
flex-direction: column;
gap: $spacing-2;
transition: box-shadow $transition-fast;
&:hover {
box-shadow: $shadow-md;
}
&--best {
border-color: $primary-400;
background: $primary-50;
box-shadow: 0 0 0 2px rgba($primary-400, 0.25);
}
}
&__tag {
display: inline-block;
font-size: 10px;
font-weight: $font-weight-semibold;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $primary-600;
background: $primary-100;
border-radius: $radius-full;
padding: 2px 8px;
align-self: flex-start;
.sf__card--best & {
background: $primary-500;
color: #fff;
}
}
&__card-date {
display: flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-xs;
color: var(--clr-text, #{$color-text-primary});
font-weight: $font-weight-medium;
}
&__dow {
color: var(--clr-text-secondary, #{$color-text-secondary});
font-weight: $font-weight-normal;
margin-left: 2px;
}
&__card-time {
display: flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: var(--clr-text, #{$color-text-primary});
}
&__days {
font-size: $font-size-xs;
color: var(--clr-text-secondary, #{$color-text-secondary});
margin: 0;
}
&__bar {
height: 4px;
background: var(--clr-border, #{$color-border});
border-radius: $radius-full;
overflow: hidden;
}
&__bar-fill {
height: 100%;
background: linear-gradient(90deg, $primary-400, $primary-600);
border-radius: $radius-full;
transition: width 0.4s ease;
}
&__book {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: $spacing-1;
padding: $spacing-1 $spacing-2;
background: var(--clr-primary, #{$color-primary});
color: #fff;
border: none;
border-radius: $radius-base;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
font-family: $font-family-base;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: var(--clr-primary-hover, #{$color-primary-hover});
}
}
}

View File

@@ -0,0 +1,28 @@
// [REQ V4] Spójny design StatusBadge jako atom UI z kolorami dla każdego statusu rezerwacji
import styles from './StatusBadge.module.scss';
const LABELS = {
confirmed: 'Confirmed',
pending: 'Pending',
cancelled: 'Cancelled',
};
const StatusBadge = ({ status, size = 'md' }) => {
const label = LABELS[status] ?? status;
return (
<span
className={[
styles['status-badge'],
styles[`status-badge--${status}`],
styles[`status-badge--${size}`],
].join(' ')}
aria-label={`Status: ${label}`}
>
<span className={styles['status-badge__dot']} aria-hidden="true" />
<span className={styles['status-badge__label']}>{label}</span>
</span>
);
};
export default StatusBadge;

View File

@@ -0,0 +1,84 @@
@use '../styles/variables' as *;
// Block
.status-badge {
display: inline-flex;
align-items: center;
gap: $spacing-2;
padding: 2px $spacing-3;
border-radius: $radius-full;
font-family: $font-family-base;
font-weight: $font-weight-semibold;
letter-spacing: 0.03em;
text-transform: capitalize;
white-space: nowrap;
border: 1px solid transparent;
// ── Elements ─────────────────────────────────────────────────
&__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
&__label {
line-height: 1;
}
// ── Status modifiers ──────────────────────────────────────────
&--confirmed {
background: var(--badge-confirmed-bg, #{$accent-100});
color: var(--badge-confirmed-text, #{$accent-700});
border-color: var(--badge-confirmed-border, #{$accent-200});
.status-badge__dot {
background: $accent-500;
}
}
&--pending {
background: var(--badge-pending-bg, #fef3c7);
color: var(--badge-pending-text, #92400e);
border-color: var(--badge-pending-border, #fde68a);
.status-badge__dot {
background: #f59e0b;
}
}
&--cancelled {
background: var(--badge-cancelled-bg, #fef2f2);
color: var(--badge-cancelled-text, #991b1b);
border-color: var(--badge-cancelled-border, #fecaca);
.status-badge__dot {
background: $color-error;
}
}
// ── Size modifiers ────────────────────────────────────────────
&--sm {
font-size: $font-size-xs;
padding: 1px $spacing-2;
.status-badge__dot {
width: 5px;
height: 5px;
}
}
&--md {
font-size: $font-size-sm;
}
&--lg {
font-size: $font-size-base;
padding: $spacing-1 $spacing-4;
.status-badge__dot {
width: 9px;
height: 9px;
}
}
}

View File

@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import StatusBadge from './StatusBadge';
describe('StatusBadge', () => {
it('renders "Confirmed" label for confirmed status', () => {
render(<StatusBadge status="confirmed" />);
expect(screen.getByText('Confirmed')).toBeInTheDocument();
});
it('renders "Pending" label for pending status', () => {
render(<StatusBadge status="pending" />);
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('renders "Cancelled" label for cancelled status', () => {
render(<StatusBadge status="cancelled" />);
expect(screen.getByText('Cancelled')).toBeInTheDocument();
});
it('falls back to raw status string for unknown status', () => {
render(<StatusBadge status="unknown-state" />);
expect(screen.getByText('unknown-state')).toBeInTheDocument();
});
it('sets aria-label with status text', () => {
render(<StatusBadge status="confirmed" />);
expect(screen.getByRole('generic', { name: /status: confirmed/i })).toBeInTheDocument();
});
it('applies size modifier class', () => {
const { container } = render(<StatusBadge status="pending" size="sm" />);
const badge = container.firstChild;
expect(badge.className).toMatch(/sm/);
});
});

View File

@@ -0,0 +1,22 @@
// [REQ V4] Spójny design przełącznik motywu (light/dark) dostępny w nagłówku każdej strony
import { Sun, Moon } from 'lucide-react';
import { useTheme } from '../context/ThemeContext';
import styles from './ThemeToggle.module.scss';
const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
return (
<button
className={styles['theme-toggle']}
onClick={toggleTheme}
aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
title={`Switch to ${isDark ? 'light' : 'dark'} mode`}
>
{isDark ? <Sun size={17} /> : <Moon size={17} />}
</button>
);
};
export default ThemeToggle;

View File

@@ -0,0 +1,20 @@
@use '../styles/variables' as *;
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: $radius-base;
border: 1px solid var(--clr-border, #{$color-border});
background: var(--clr-surface, #{$color-surface});
color: var(--clr-text-secondary, #{$color-text-secondary});
cursor: pointer;
transition: background $transition-fast, color $transition-fast, border-color $transition-fast;
&:hover {
background: var(--clr-surface-raised, #{$gray-100});
color: var(--clr-text, #{$color-text-primary});
}
}

View File

@@ -0,0 +1,142 @@
// [REQ F8] Zarządzanie użytkownikami (admin) dezaktywacja kont, reset hasła
// [REQ D6] HOC eksportowany przez withRole(['admin'])(UserManagementInner)
// [REQ D7] React Query useQuery + useMutation z invalidateQueries
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { UserCheck, UserX, Shield, Mail, RefreshCw } from 'lucide-react';
import withRole from '../hocs/withRole';
import StatusBadge from './StatusBadge';
import { API_URL } from '../config';
import styles from './UserManagement.module.scss';
const fetchUsers = () =>
fetch(`${API_URL}/users`).then((r) => r.json());
const patchUser = ({ id, patch }) =>
fetch(`${API_URL}/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then((r) => r.json());
const ROLES = ['client', 'admin'];
const UserManagementInner = () => {
const qc = useQueryClient();
const [editingRole, setEditingRole] = useState(null);
const { data: users = [], isLoading, refetch } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
const { mutate: updateUser, isPending } = useMutation({
mutationFn: patchUser,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['users'] });
setEditingRole(null);
},
});
const toggleActive = (user) => {
updateUser({ id: user.id, patch: { active: !(user.active ?? true) } });
};
const changeRole = (user, role) => {
updateUser({ id: user.id, patch: { role } });
};
if (isLoading) return <p className={styles['um__msg']}>Loading users</p>;
return (
<div className={styles['um']}>
<div className={styles['um__toolbar']}>
<span className={styles['um__count']}>{users.length} users</span>
<button className={styles['um__refresh']} onClick={() => refetch()} disabled={isPending}>
<RefreshCw size={14} /> Refresh
</button>
</div>
<div className={styles['um__table-wrap']}>
<table className={styles['um__table']}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Verified</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isActive = u.active !== false;
return (
<tr key={u.id} className={isActive ? '' : styles['um__row--inactive']}>
<td>{u.name ?? '—'}</td>
<td className={styles['um__cell--email']}>
<Mail size={12} /> {u.email}
</td>
<td>
{editingRole === u.id ? (
<select
className={styles['um__role-select']}
defaultValue={u.role}
onChange={(e) => changeRole(u, e.target.value)}
onBlur={() => setEditingRole(null)}
autoFocus
>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<button
className={styles['um__role-btn']}
onClick={() => setEditingRole(u.id)}
title="Click to change role"
>
<Shield size={11} /> {u.role}
</button>
)}
</td>
<td>
<StatusBadge
status={u.verified !== false ? 'confirmed' : 'pending'}
size="sm"
/>
</td>
<td>
<span className={[
styles['um__status'],
isActive ? styles['um__status--active'] : styles['um__status--inactive'],
].join(' ')}>
{isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<button
className={[
styles['um__toggle'],
isActive ? styles['um__toggle--deactivate'] : styles['um__toggle--activate'],
].join(' ')}
onClick={() => toggleActive(u)}
disabled={isPending}
title={isActive ? 'Deactivate account' : 'Activate account'}
>
{isActive ? <UserX size={14} /> : <UserCheck size={14} />}
{isActive ? 'Deactivate' : 'Activate'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
// Wrap with HOC — only admins can see user management
const UserManagement = withRole(['admin'])(UserManagementInner);
export default UserManagement;

View File

@@ -0,0 +1,161 @@
@use '../styles/variables' as *;
.um {
display: flex;
flex-direction: column;
gap: $spacing-4;
&__msg {
padding: $spacing-8;
text-align: center;
color: var(--clr-text-secondary);
font-size: $font-size-sm;
}
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
&__count {
font-size: $font-size-sm;
color: var(--clr-text-secondary);
}
&__refresh {
display: inline-flex;
align-items: center;
gap: $spacing-1;
padding: $spacing-1 $spacing-3;
border: 1px solid var(--clr-border);
border-radius: $radius-base;
background: transparent;
font-size: $font-size-xs;
font-family: $font-family-base;
color: var(--clr-text-secondary);
cursor: pointer;
transition: background $transition-fast;
&:hover { background: var(--clr-surface-raised); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
&__table-wrap {
overflow-x: auto;
border: 1px solid var(--clr-border);
border-radius: $radius-lg;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: $font-size-sm;
th {
background: var(--clr-surface-raised, #{$gray-50});
padding: $spacing-3 $spacing-4;
text-align: left;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
color: var(--clr-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--clr-border);
}
td {
padding: $spacing-3 $spacing-4;
border-bottom: 1px solid var(--clr-border);
color: var(--clr-text);
vertical-align: middle;
&:last-child { border-bottom: none; }
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--clr-surface-raised, #{$gray-50}); }
}
&__row--inactive td {
opacity: 0.55;
}
&__cell--email {
display: flex;
align-items: center;
gap: $spacing-1;
font-size: $font-size-xs;
color: var(--clr-text-secondary);
}
&__role-btn {
display: inline-flex;
align-items: center;
gap: 4px;
background: $primary-50;
border: 1px solid $primary-200;
border-radius: $radius-full;
padding: 2px $spacing-2;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
color: $primary-700;
cursor: pointer;
transition: background $transition-fast;
&:hover { background: $primary-100; }
}
&__role-select {
padding: 2px $spacing-2;
border: 1px solid $primary-300;
border-radius: $radius-base;
font-size: $font-size-xs;
font-family: $font-family-base;
background: var(--clr-surface);
color: var(--clr-text);
outline: none;
}
&__status {
display: inline-block;
font-size: 11px;
font-weight: $font-weight-medium;
padding: 2px $spacing-2;
border-radius: $radius-full;
&--active { background: $accent-100; color: $accent-700; }
&--inactive { background: $gray-100; color: $gray-600; }
}
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: $spacing-1 $spacing-3;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
font-family: $font-family-base;
cursor: pointer;
border: 1px solid transparent;
transition: background $transition-fast, opacity $transition-fast;
&:disabled { opacity: 0.5; cursor: not-allowed; }
&--deactivate {
background: #fee2e2;
color: #dc2626;
border-color: #fecaca;
&:hover:not(:disabled) { background: #fecaca; }
}
&--activate {
background: $accent-100;
color: $accent-700;
border-color: $accent-200;
&:hover:not(:disabled) { background: $accent-200; }
}
}
}

5
src/config.js Normal file
View File

@@ -0,0 +1,5 @@
// [REQ T5] Obsługa zapytań API centralny adres backendu
// W trybie dev: http://localhost:3001 (json-server)
// W Dockerze: /api (nginx proxy → kontener api)
// Wartość pochodzi ze zmiennej środowiskowej Vite ustawionej w .env lub przy docker build
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';

152
src/context/AuthContext.jsx Normal file
View File

@@ -0,0 +1,152 @@
// createContext jest potrzbny do tworzenia kontekstu w React - przechowywanie stanu zalogowanego użytkownika
// useContext jest potrzebny do pobierania stanu zalogowanego użytkownika z kontekstu
// useState jest potrzebny do przechowywania stanu zalogowanego użytkownika w komponencie
// useEffect jest potrzebny do synchronizacji stanu zalogowanego użytkownika z localStorage
// useCallback jest potrzebny do tworzenia funkcji, które nie zmieniają się przy każdym renderze komponentu
// useRef jest potrzebny do przechowywania wartości, która nie powoduje renderowania komponentu przy zmianie
// [REQ F1] System uwierzytelniania login, 2FA, role, sesja
// [REQ T1] Podstawowe hooki useState, useEffect, useCallback, useRef
// [REQ T4] Context API globalny stan użytkownika dostępny przez useAuth()
// [REQ T6] Optymalizacja useCallback zapobiega tworzeniu nowych referencji funkcji
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
// axios jest potrzebny do wykonywania zapytań HTTP do API - odpowiednik fetch, ale z większą funkcjonalnością i prostszym API
import axios from 'axios';
import { API_URL } from '../config';
const AuthContext = createContext(null);
// klucz do localStorage do przechowywania stanu zalogowanego użytkownika
const STORAGE_KEY = 'rms_user';
export const AuthProvider = ({ children }) => {
// inicjalizacja stanu zalogowanego użytkownika z localStorage, jeśli istnieje
const [user, setUser] = useState(() => {
try {
// pobieranie stanu zalogowanego użytkownika z localStorage, jeśli istnieje
const stored = localStorage.getItem(STORAGE_KEY);
// jeśli uda się sparsować JSONa z localStorage, zwracamy obiekt użytkownika
return stored ? JSON.parse(stored) : null;
}
// jeśli nie uda się sparsować JSONa z localStorage, zwracamy null
catch {
return null;
}
});
// state o stanie ładowania, domyślnie false, ustawiany na true podczas logowania i wylogowywania
// bo po wejściu na stronę nic się jescze nie ładuje
const [loading, setLoading] = useState(false);
// state o błędzie logowania, domyślnie null, ustawiany na string z komunikatem o błędzie podczas logowania
const [error, setError] = useState(null);
// state o stanie oczekiwania na weryfikację 2FA, domyślnie null, ustawiany na obiekt użytkownika po poprawnym logowaniu
const [pendingUser, setPendingUser] = useState(null); // set after password OK, before 2FA
// state o kodzie 2FA, domyślnie null, ustawiany na string z kodem 2FA po poprawnym logowaniu
const [twoFACode, setTwoFACode] = useState(null); // the "sent" code
// useRef do przechowywania obiektu użytkownika podczas oczekiwania na weryfikację 2FA, nie powoduje renderowania komponentu przy zmianie
const twoFARef = useRef(null);
// useEffect do synchronizacji stanu zalogowanego użytkownika z localStorage, wywoływany przy zmianie stanu user
// useEffect reguje na efekty uboczne poza reactem, w tym przypadku gdy zmienni się stan user
// wtedy zapisujemy stan user do localStorage, jeśli user jest null to usuwamy z localStorage
// localStorage jest to funkcja przegladarki do przechowywania danych w przeglądarce
useEffect(() => {
if (user) {
// ustawiamy item po kluczu STORAGE_KEY w localStorage na string JSONa z obiektem user
// STORAGE_KEY jest to klucz do localStorage, w tym przypadku 'rms_user' ustawiony wyżej
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
// jeśli user jest null to usuwamy item po kluczu STORAGE_KEY z localStorage
localStorage.removeItem(STORAGE_KEY);
}
}, [user]);
// useCallback mówi reactowi żeby zapamiętał te funkcję w pamięci i nie tworzył jej przy każdym renderze komponentu
// async mówi reactowi że funkcja jest asynchroniczna i zwraca promise, dzięki temu możemy używać await w tej funkcji
// funkcja przyjmuje dwa parametry email i password, które są stringami, które użytkownik wpisuje w formularzu logowania
const login = useCallback(async (email, password) => {
setLoading(true);
setError(null);
try {
// pytam backend o listę użytkowników, filtruję po emailu ,a następnie po password
// jeśli znajdę użytkownika to zwracam obiekt użytkownika bez hasła, jeśli nie to rzucam błąd
// await - czekamy na odpowiedź
const { data } = await axios.get(`${API_URL}/users`, {
params: { email },
});
const match = data.find(
// (u) to funkcja strzałkowa, która przyjmuje obiekt użytkownika i zwraca true jeśli email i password się zgadzają
(u) => u.email === email && u.password === password
);
if (!match) {
throw new Error('Invalid email or password.');
}
if (match.verified === false) {
throw new Error('Please verify your email address before signing in.');
}
// Never store the password in state or localStorage
// destrukturzacjia z porzuceniem pola password z obiektu match, reszta pól idzie do safeUser
const { password: _omit, ...safeUser } = match;
// Generate fake 2FA code and wait for verification
const code = String(Math.floor(100000 + Math.random() * 900000));
twoFARef.current = safeUser;
setTwoFACode(code);
setPendingUser(safeUser);
return { twoFAPending: true, code }; // caller shows the 2FA step
} catch (err) {
const knownMessages = [
'Invalid email or password.',
'Please verify your email address before signing in.',
];
const message = knownMessages.includes(err.message)
? err.message
: 'Could not reach the server. Make sure the API is running.';
setError(message);
throw new Error(message);
} finally {
setLoading(false); // wyłącza kręciołek ładowania
}
}, []); // [] funkcja nie zależy od żadnych zmiennych z zewnątrz komponentu
const verify2FA = useCallback((inputCode) => {
if (inputCode === twoFACode) {
const safeUser = twoFARef.current;
setPendingUser(null);
setTwoFACode(null);
twoFARef.current = null;
setUser(safeUser);
return safeUser;
}
throw new Error('Incorrect code. Please try again.');
}, [twoFACode]);
const cancel2FA = useCallback(() => {
setPendingUser(null);
setTwoFACode(null);
twoFARef.current = null;
}, []);
const logout = useCallback(() => {
setUser(null);
setError(null);
}, []);
const clearError = useCallback(() => setError(null), []);
// zwrócenie komponentu AuthContext.Provider, który dostarcza wartości
// user, loading, error, login, logout, clearError, pendingUser, twoFACode,
// verify2FA i cancel2FA do całej aplikacji React, która jest opakowana w ten provider.
// Dzięki temu możemy korzystać z tych wartości w dowolnym miejscu w aplikacji React za pomocą hooka useAuth.
return (
<AuthContext.Provider value={{
user, loading, error, login, logout, clearError,
pendingUser, twoFACode, verify2FA, cancel2FA,
}}>
{children}
</AuthContext.Provider>
);
};
// export const useAuth to custom hook, który pozwala na pobieranie wartości z AuthContext w dowolnym miejscu w aplikacji React
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
};

View File

@@ -0,0 +1,46 @@
// [REQ T4] Context API ThemeContext przechowuje i udostępnia motyw (light/dark) całej aplikacji
// createContext to funkcja, która tworzy kontekst React, który pozwala na przekazywanie danych przez drzewo komponentów
// bez konieczności przekazywania propsów na każdym poziomie.
// useContext to hook, który pozwala na pobieranie wartości z kontekstu React w dowolnym miejscu w aplikacji React.
// useState to hook, który pozwala na przechowywanie stanu w funkcjonalnych komponentach React.
// useEffect to hook, który pozwala na wykonywanie efektów ubocznych w funkcjonalnych komponentach React.
import { createContext, useContext, useState, useEffect } from 'react';
// tworzymy kontekst ThemeContext, który będzie przechowywał informacje o aktualnym motywie (jasny/ciemny) i funkcję do jego przełączania
const ThemeContext = createContext(null);
// ThemeProvider to komponent, który dostarcza wartości z ThemeContext do całej aplikacji React, która jest opakowana w ten provider.
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(
() => localStorage.getItem('rms_theme') || 'light'
);
// useEffect jest używany do synchronizacji atrybutu data-theme w elemencie html z aktualnym motywem oraz
// do zapisywania motywu w localStorage, aby zapamiętać wybór użytkownika między sesjami.
// efekt który odpali się tylko wtedy gdy theme się zmieni
// document to globalny obiekt z js najwyższy możliwy poziom drzewa DOM
// document.documentElement to element html, który jest najwyższym elementem w drzewie DOM, reprezentuje cały dokument HTML
// setAttribute ustawia atrybut o nazwie data-theme na wartość theme,
// dzięki temu możemy stylować aplikację za pomocą CSS w zależności od wartości tego atrybutu
// localStorage.setItem zapisuje wartość theme pod kluczem 'rms_theme' w localStorage, aby zapamiętać wybór użytkownika między sesjami
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('rms_theme', theme);
}, [theme]);
// toggleTheme to funkcja, która przełącza motyw między jasnym a ciemnym, jest przekazywana do wartości ThemeContext,
// dzięki temu możemy ją wywołać w dowolnym miejscu w aplikacji React, aby zmienić motyw.
const toggleTheme = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
// zwrócenie komponentu ThemeContext.Provider, który dostarcza wartości theme i toggleTheme do całej aplikacji React, która jest opakowana w ten provider.
// Dzięki temu możemy korzystać z tych wartości w dowolnym miejscu w aplikacji React za pomocą hooka useTheme.
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children} // wszystko co jest opakowane w ThemeProvider będzie miało dostęp do wartości theme i toggleTheme z ThemeContext
// children to specjalna props, która reprezentuje wszystkie elementy, które są opakowane w ThemeProvider
// korzystane jak rosyjska matrioszka, ale wasal mojego wasala nie jest moim wasalem
, więc nie mogę przekazać propsów do dzieci, które opakowane w ThemeProvider, dlatego muszę użyć kontekstu React
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
};

26
src/hocs/withRole.jsx Normal file
View File

@@ -0,0 +1,26 @@
// [REQ D6] Higher-Order Component withRole(['admin'])(Component) opakowuje komponent w weryfikację roli
// [REQ D8] HOC pattern zwraca nowy komponent z dodaną logiką autoryzacji bez zmiany oryginału
// [REQ T7] Render props i HOC przykład HOC używanego w całej aplikacji do ochrony widoków
import { useAuth } from '../context/AuthContext';
/**
* HOC — renders WrappedComponent only when the logged-in user has one of
* the allowed roles. Renders `fallback` (default: null) otherwise.
*
* Usage:
* const AdminOnly = withRole(['admin'])(SomeComponent);
*/
const withRole = (allowedRoles, fallback = null) => (WrappedComponent) => {
const displayName = WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component';
const WithRole = (props) => {
const { user } = useAuth();
if (!user || !allowedRoles.includes(user.role)) return fallback;
return <WrappedComponent {...props} />;
};
WithRole.displayName = `withRole(${displayName})`;
return WithRole;
};
export default withRole;

View File

@@ -0,0 +1,17 @@
// [REQ T2] Custom hooks useCreateReservation enkapsuluje useMutation + invalidateQueries
// [REQ D7] React Query useMutation z onSuccess invaliduje cache /reservations
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createReservation } from '../api/reservations';
export const useCreateReservation = ({ onSuccess, onError } = {}) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createReservation,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['reservations'] });
onSuccess?.(data);
},
onError,
});
};

View File

@@ -0,0 +1,17 @@
// [REQ T2] Custom hooks useDeleteReservation enkapsuluje useMutation DELETE
// [REQ D7] React Query useMutation z invalidateQueries po sukcesie
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteReservation } from '../api/reservations';
export const useDeleteReservation = ({ onSuccess, onError } = {}) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteReservation,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['reservations'] });
onSuccess?.(data);
},
onError,
});
};

View File

@@ -0,0 +1,10 @@
// [REQ T2] Custom hooks useReservations enkapsuluje useQuery dla admina
// [REQ D7] React Query useQuery z kluczem ['reservations'] i automatycznym odświeżaniem
import { useQuery } from '@tanstack/react-query';
import { getAllReservations } from '../api/reservations';
export const useReservations = () =>
useQuery({
queryKey: ['reservations'],
queryFn: getAllReservations,
});

10
src/hooks/useServices.js Normal file
View File

@@ -0,0 +1,10 @@
// [REQ T2] Custom hooks useServices enkapsuluje useQuery dla listy usług
// [REQ D7] React Query useQuery z kluczem ['services'], staleTime 5 min
import { useQuery } from '@tanstack/react-query';
import { getServices } from '../api/services';
export const useServices = () =>
useQuery({
queryKey: ['services'],
queryFn: getServices,
});

View File

@@ -0,0 +1,11 @@
// [REQ T2] Custom hooks useSlotReservations pobiera rezerwacje dla danego slotu (serwis+data)
// [REQ D7] React Query useQuery z kluczem ['slot-reservations', serviceId, date]
import { useQuery } from '@tanstack/react-query';
import { getReservationsBySlot } from '../api/reservations';
export const useSlotReservations = (serviceId, date) =>
useQuery({
queryKey: ['reservations', 'slot', serviceId, date],
queryFn: () => getReservationsBySlot(serviceId, date),
enabled: !!serviceId && !!date,
});

View File

@@ -0,0 +1,16 @@
// [REQ T2] Custom hooks useUpdateReservation enkapsuluje useMutation PATCH
// [REQ D7] React Query useMutation do zmiany statusu i terminu rezerwacji
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateReservation } from '../api/reservations';
export const useUpdateReservation = (options = {}) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }) => updateReservation(id, patch),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reservations'] });
options.onSuccess?.();
},
onError: options.onError,
});
};

View File

@@ -0,0 +1,11 @@
// [REQ T2] Custom hooks useUserReservations filtruje rezerwacje po userId zalogowanego
// [REQ D7] React Query useQuery z kluczem ['reservations', userId]
import { useQuery } from '@tanstack/react-query';
import { getUserReservations } from '../api/reservations';
export const useUserReservations = (userId) =>
useQuery({
queryKey: ['reservations', 'user', userId],
queryFn: () => getUserReservations(userId),
enabled: !!userId,
});

0
src/index.css Normal file
View File

131
src/main.jsx Normal file
View File

@@ -0,0 +1,131 @@
// [REQ D3] GraphQL z Apollo Client ApolloClient + SchemaLink + makeExecutableSchema (in-memory)
// [REQ T5] Obsługa zapytań API QueryClient (React Query) + ApolloClient (GraphQL)
// [REQ T4] Context API ApolloProvider i QueryClientProvider opakowują całą aplikację
// StricMode jest potrzebny do wykrywania potencjalnych problemów przed zbudowaniem.
import { StrictMode } from 'react';
// createRoot odpowiada wczepienie w HTML
import { createRoot } from 'react-dom/client';
//QueryClient i QueryClientProvider są potrzebne do konfiguracji react-query - pamięc podręczna zapytań
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
//ApolloClient i InMemoryCache są potrzebne do konfiguracji Apollo Client - pamięc podręczna zapytań GraphQL
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
// ApolloProvider jest potrzebny do klienta Apollo w całej aplikacji React
import { ApolloProvider } from '@apollo/client/react';
// SchemaLink jest potrzbeny do tworzenia klienta Apollo z lokalnym schematem GraphQL wewnątrz kodu
import { SchemaLink } from '@apollo/client/link/schema';
// makeExecutableSchema jest potrzebny do tworzenia szablonów za pomocą opisu GraphQL w kodzie
import { makeExecutableSchema } from '@graphql-tools/schema';
// Importowanie głównego komponentu aplikacji
import App from './App.jsx';
// Instancja z konfiguracją react-query
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: 1 } },
});
// Definicja schematu GraphQL
const typeDefs = `
type UserProfile {
id: ID!
name: String!
email: String!
role: String!
joinedAt: String!
reservationsCount: Int!
avatarUrl: String!
}
type Review {
id: ID!
reservationId: String!
serviceId: String!
userId: String!
userName: String!
rating: Int!
comment: String
createdAt: String!
}
type Query {
userProfile(id: ID!): UserProfile
serviceReviews(serviceId: String!): [Review!]!
userReviews(userId: String!): [Review!]!
}
type Mutation {
submitReview(
reservationId: String!
serviceId: String!
userId: String!
userName: String!
rating: Int!
comment: String
): Review!
}
`;
// Przechowywanie recenzji w pamięci (symulacja bazy danych)
const reviewStore = [
{ id: 'rv1', reservationId: 'r3', serviceId: 's3', userId: 'u4', userName: 'Marek Nowak', rating: 5, comment: 'Excellent service!', createdAt: '2026-05-10' },
{ id: 'rv2', reservationId: 'r5', serviceId: 's3', userId: 'u3', userName: 'Anna Kowalski', rating: 4, comment: 'Good experience overall.', createdAt: '2026-06-02' },
{ id: 'rv3', reservationId: 'r6', serviceId: 's2', userId: 'u4', userName: 'Marek Nowak', rating: 3, comment: 'Average, could be better.', createdAt: '2026-06-05' },
];
// Zmienna do generowania unikalnych identyfikatorów recenzji
let nextReviewId = reviewStore.length + 1;
// Konfiguracja klienta Apollo z lokalnym schematem GraphQL
const apolloClient = new ApolloClient({
// zmiast linku z internetu tworzymy lokalny schemat
link: new SchemaLink({
// makeExecutableSchema tworzy schemat GraphQL z definicji typów i resolverów w kodzie
schema: makeExecutableSchema({
// typeDefs definiuje wyżej strukturę danych i zapytań GraphQL
typeDefs,
//inline resolvers definiuje funkcje obsługujące zapytania i mutacje GraphQL w kodzie
resolvers: {
// Logika resolverów dla zapytań i mutacji GraphQL
Query: {
// Jeśli fronted pyta o profil użytkownika, zwracamy dane zdefiniowane w kodzie oraz url awatara
userProfile: (_, { id }) => ({
id,
name: 'Anna Kowalski',
email: 'anna.kowalski@example.com',
role: 'client',
joinedAt: '2024-01-15',
reservationsCount: 3,
avatarUrl: `https://i.pravatar.cc/150?u=${id}`,
}),
// Jeśli fronted pyta o recenzje dla danego serwisu, filtrujemy recenzje w pamięci według serviceId
serviceReviews: (_, { serviceId }) => reviewStore.filter((r) => r.serviceId === serviceId),
// Jeśli fronted pyta o recenzje danego użytkownika, filtrujemy recenzje w pamięci według userId
userReviews: (_, { userId }) => reviewStore.filter((r) => r.userId === userId),
},
// Logika resolverów dla mutacji GraphQL
Mutation: {
// Jeśli fronted wysyła mutację submitReview, tworzymy nową recenzję i dodajemy ją do pamięci
submitReview: (_, args) => {
// Tworzymy nową recenzję z unikalnym id, danymi z args oraz aktualną datą
const review = { id: `rv${nextReviewId++}`, ...args, comment: args.comment ?? null, createdAt: new Date().toISOString().split('T')[0] };
reviewStore.push(review);
return review;
},
},
},
}),
}),
// Włączenie pamięci podręcznej w Apollo Client, aby przechowywać wyniki zapytań GraphQL
cache: new InMemoryCache(),
});
// Renderowanie aplikacji React w elemencie root
createRoot(document.getElementById('root')).render(
// StrictMode jest używany do wykrywania potencjalnych problemów w aplikacji React
<StrictMode>
{/* // ApolloProvider dostarcza klienta Apollo do całej aplikacji React, umożliwiając korzystanie z GraphQL */}
<ApolloProvider client={apolloClient}>
{/* // QueryClientProvider dostarcza klienta react-query do całej aplikacji */}
<QueryClientProvider client={queryClient}>
{/* // Renderowanie głównego komponentu aplikacji który otrzymuje wszystkie Contexty z "góry" */}
<App />
</QueryClientProvider>
</ApolloProvider>
</StrictMode>,
);

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);
}
}

1
src/setupTests.js Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import Modal from '../components/Modal';
export default {
title: 'Components/Modal',
component: Modal,
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
const ModalWrapper = ({ danger = false, title, message }) => {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} style={{ padding: '8px 16px', cursor: 'pointer' }}>
Open Modal
</button>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
title={title}
message={message}
confirmLabel="Confirm"
danger={danger}
/>
</>
);
};
export const Default = {
name: 'Default (informational)',
render: () => (
<ModalWrapper
title="Confirm action"
message="Are you sure you want to proceed with this action?"
/>
),
};
export const Danger = {
name: 'Danger (destructive)',
render: () => (
<ModalWrapper
danger
title="Delete reservation"
message='Are you sure you want to permanently delete reservation "RES-001"? This action cannot be undone.'
/>
),
};

View File

@@ -0,0 +1,63 @@
import StatusBadge from '../components/StatusBadge';
export default {
title: 'Components/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
status: {
description: 'Reservation status value',
control: { type: 'select' },
options: ['confirmed', 'pending', 'cancelled'],
},
size: {
description: 'Badge size variant',
control: { type: 'select' },
options: ['sm', 'md', 'lg'],
},
},
};
export const Confirmed = {
args: { status: 'confirmed', size: 'md' },
};
export const Pending = {
args: { status: 'pending', size: 'md' },
};
export const Cancelled = {
args: { status: 'cancelled', size: 'md' },
};
export const SizeSmall = {
args: { status: 'confirmed', size: 'sm' },
name: 'Size — Small',
};
export const SizeLarge = {
args: { status: 'pending', size: 'lg' },
name: 'Size — Large',
};
export const AllStatuses = {
name: 'All statuses',
render: () => (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status="confirmed" />
<StatusBadge status="pending" />
<StatusBadge status="cancelled" />
</div>
),
};
export const AllSizes = {
name: 'All sizes',
render: () => (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status="confirmed" size="sm" />
<StatusBadge status="confirmed" size="md" />
<StatusBadge status="confirmed" size="lg" />
</div>
),
};

View File

@@ -0,0 +1,18 @@
import ThemeToggle from '../components/ThemeToggle';
export default {
title: 'Components/ThemeToggle',
component: ThemeToggle,
tags: ['autodocs'],
parameters: { layout: 'centered' },
};
export const Default = {
name: 'Theme Toggle',
render: () => (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span style={{ fontSize: '14px', color: '#6b7280' }}>Toggle dark/light mode:</span>
<ThemeToggle />
</div>
),
};

125
src/styles/_variables.scss Normal file
View File

@@ -0,0 +1,125 @@
// [REQ V1] System projektowania tokeny kolorów, typografii i spacingu jako zmienne SCSS
// [REQ V2] Responsywność breakpointy $bp-sm / $bp-md / $bp-lg używane w całej aplikacji
// [REQ V5] Motyw ciemny zmienne --color-bg / --color-text nadpisywane przez [data-theme="dark"]
// Color Palette
$primary-900: #0a2540;
$primary-800: #0d3360;
$primary-700: #1a4a7a;
$primary-600: #1e5f9a;
$primary-500: #2575b7;
$primary-400: #4a90d4;
$primary-300: #7ab3e0;
$primary-200: #b3d4f0;
$primary-100: #dceeff;
$primary-50: #f0f7ff;
$accent-900: #064e3b;
$accent-800: #065f46;
$accent-700: #047857;
$accent-600: #059669;
$accent-500: #10b981;
$accent-400: #34d399;
$accent-300: #6ee7b7;
$accent-200: #a7f3d0;
$accent-100: #d1fae5;
$accent-50: #ecfdf5;
// Neutrals
$gray-900: #111827;
$gray-800: #1f2937;
$gray-700: #374151;
$gray-600: #4b5563;
$gray-500: #6b7280;
$gray-400: #9ca3af;
$gray-300: #d1d5db;
$gray-200: #e5e7eb;
$gray-100: #f3f4f6;
$gray-50: #f9fafb;
// Semantic
$color-background: $gray-50;
$color-surface: #ffffff;
$color-border: $gray-200;
$color-text-primary: $gray-900;
$color-text-secondary: $gray-600;
$color-text-muted: $gray-400;
$color-primary: $primary-600;
$color-primary-hover: $primary-700;
$color-accent: $accent-500;
$color-accent-hover: $accent-600;
$color-success: $accent-500;
$color-warning: #f59e0b;
$color-error: #ef4444;
$color-info: $primary-400;
// Typography
$font-family-base: 'Inter', system-ui, -apple-system, sans-serif;
$font-family-mono: 'JetBrains Mono', 'Fira Code', monospace;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 1.875rem;
$font-size-4xl: 2.25rem;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
$line-height-tight: 1.25;
$line-height-snug: 1.375;
$line-height-normal: 1.5;
$line-height-relaxed: 1.625;
// Spacing
$spacing-1: 0.25rem;
$spacing-2: 0.5rem;
$spacing-3: 0.75rem;
$spacing-4: 1rem;
$spacing-5: 1.25rem;
$spacing-6: 1.5rem;
$spacing-8: 2rem;
$spacing-10: 2.5rem;
$spacing-12: 3rem;
$spacing-16: 4rem;
$spacing-20: 5rem;
$spacing-24: 6rem;
// Border radius
$radius-sm: 0.25rem;
$radius-base: 0.375rem;
$radius-md: 0.5rem;
$radius-lg: 0.75rem;
$radius-xl: 1rem;
$radius-full: 9999px;
// Shadows
$shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
$shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
$shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
$shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
$shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
// Transitions
$transition-fast: 150ms ease;
$transition-base: 250ms ease;
$transition-slow: 350ms ease;
// Breakpoints
$bp-sm: 640px;
$bp-md: 768px;
$bp-lg: 1024px;
$bp-xl: 1280px;
$bp-2xl: 1536px;
// Layout
$sidebar-width: 260px;
$header-height: 64px;
$container-max-width: 1280px;

118
src/styles/main.scss Normal file
View File

@@ -0,0 +1,118 @@
@use 'variables' as *;
// ─── CSS Custom Properties (runtime theming) ──────────────────────────────────
:root {
--clr-bg: #{$gray-50};
--clr-surface: #ffffff;
--clr-surface-raised: #{$gray-100};
--clr-border: #{$gray-200};
--clr-text: #{$gray-900};
--clr-text-secondary: #{$gray-600};
--clr-text-muted: #{$gray-400};
--clr-primary: #{$primary-600};
--clr-primary-hover: #{$primary-700};
--clr-accent: #{$accent-500};
}
[data-theme="dark"] {
--clr-bg: #{$gray-900};
--clr-surface: #{$gray-800};
--clr-surface-raised: #{$gray-700};
--clr-border: #{$gray-700};
--clr-text: #{$gray-100};
--clr-text-secondary: #{$gray-400};
--clr-text-muted: #{$gray-600};
--clr-primary: #{$primary-400};
--clr-primary-hover: #{$primary-300};
--clr-accent: #{$accent-400};
}
// ─── Reset ────────────────────────────────────────────────────────────────────
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: $font-family-base;
font-size: $font-size-base;
font-weight: $font-weight-normal;
line-height: $line-height-normal;
color: var(--clr-text);
background-color: var(--clr-bg);
transition: background-color $transition-base, color $transition-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--clr-primary);
text-decoration: none;
&:hover {
color: var(--clr-primary-hover);
text-decoration: underline;
}
}
img,
svg {
display: block;
max-width: 100%;
}
button {
cursor: pointer;
font-family: inherit;
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-weight: $font-weight-semibold;
line-height: $line-height-tight;
color: var(--clr-text);
}
h1 { font-size: $font-size-4xl; }
h2 { font-size: $font-size-3xl; }
h3 { font-size: $font-size-2xl; }
h4 { font-size: $font-size-xl; }
h5 { font-size: $font-size-lg; }
h6 { font-size: $font-size-base;}
// ─── Utilities ────────────────────────────────────────────────────────────────
.container {
width: 100%;
max-width: $container-max-width;
margin-inline: auto;
padding-inline: $spacing-6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,67 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import BookingForm from '../components/BookingForm';
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({ user: { id: 'u1', email: 'test@test.com' } }),
}));
vi.mock('../hooks/useServices', () => ({
useServices: () => ({
data: [{ id: 's1', name: 'Consultation', price: 50, duration: 30 }],
isLoading: false,
isError: false,
}),
}));
const mockMutate = vi.fn();
vi.mock('../hooks/useCreateReservation', () => ({
useCreateReservation: () => ({ mutate: mockMutate, isPending: false }),
}));
vi.mock('../hooks/useSlotReservations', () => ({
useSlotReservations: () => ({ data: [], isFetching: false }),
}));
describe('BookingForm', () => {
const renderForm = () => render(<BookingForm />);
it('renders all four form fields', () => {
renderForm();
expect(screen.getByLabelText(/service/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^time/i)).toBeInTheDocument();
expect(screen.getByLabelText(/special requirements/i)).toBeInTheDocument();
});
it('renders the submit button', () => {
renderForm();
expect(screen.getByRole('button', { name: /request booking/i })).toBeInTheDocument();
});
it('shows a validation error when a past date is submitted', async () => {
renderForm();
fireEvent.change(screen.getByLabelText(/^date/i), { target: { value: '2020-01-01' } });
fireEvent.change(screen.getByLabelText(/^time/i), { target: { value: '10:00' } });
fireEvent.change(screen.getByLabelText(/service/i), { target: { value: 's1' } });
fireEvent.click(screen.getByRole('button', { name: /request booking/i }));
await waitFor(() => {
expect(screen.getByText(/date cannot be in the past/i)).toBeInTheDocument();
});
});
it('shows a validation error when no service is selected', async () => {
renderForm();
fireEvent.click(screen.getByRole('button', { name: /request booking/i }));
await waitFor(() => {
expect(screen.getByText(/please select a service/i)).toBeInTheDocument();
});
});
it('does not call mutate when the form is invalid', async () => {
renderForm();
fireEvent.click(screen.getByRole('button', { name: /request booking/i }));
await waitFor(() => screen.getByText(/please select a service/i));
expect(mockMutate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,46 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import LoginPage from '../pages/LoginPage';
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({
login: vi.fn(),
user: null,
loading: false,
error: null,
clearError: vi.fn(),
}),
}));
const renderLogin = () =>
render(
<MemoryRouter>
<LoginPage />
</MemoryRouter>
);
describe('LoginPage', () => {
it('renders the Sign in button', () => {
renderLogin();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('Sign in button is enabled when not loading', () => {
renderLogin();
expect(screen.getByRole('button', { name: /sign in/i })).not.toBeDisabled();
});
it('clicking Sign in without input triggers HTML5 validation (button stays mounted)', async () => {
renderLogin();
const btn = screen.getByRole('button', { name: /sign in/i });
await act(async () => { fireEvent.click(btn); });
expect(btn).toBeInTheDocument();
});
it('renders email and password inputs', () => {
renderLogin();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import ProtectedRoute from '../components/ProtectedRoute';
const mockUseAuth = vi.fn();
vi.mock('../context/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
const renderProtected = (user, allowedRoles, initialPath = '/protected') =>
render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/login" element={<div>Login Page</div>} />
<Route path="/dashboard" element={<div>Dashboard</div>} />
<Route path="/admin" element={<div>Admin Page</div>} />
<Route element={<ProtectedRoute allowedRoles={allowedRoles} />}>
<Route path="/protected" element={<div>Protected Content</div>} />
</Route>
</Routes>
</MemoryRouter>
);
describe('ProtectedRoute', () => {
it('redirects unauthenticated users to /login', () => {
mockUseAuth.mockReturnValue({ user: null });
renderProtected(null, ['client']);
expect(screen.getByText('Login Page')).toBeInTheDocument();
});
it('renders the outlet for a user with the correct role', () => {
mockUseAuth.mockReturnValue({ user: { role: 'client' } });
renderProtected({ role: 'client' }, ['client']);
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
it('redirects a client away from an admin-only route to /dashboard', () => {
mockUseAuth.mockReturnValue({ user: { role: 'client' } });
renderProtected({ role: 'client' }, ['admin']);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
it('redirects an admin away from a client-only route to /admin', () => {
mockUseAuth.mockReturnValue({ user: { role: 'admin' } });
renderProtected({ role: 'admin' }, ['client']);
expect(screen.getByText('Admin Page')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import StatusBadge from '../components/StatusBadge';
describe('StatusBadge', () => {
it.each([
['confirmed', 'Confirmed'],
['pending', 'Pending'],
['cancelled', 'Cancelled'],
])('renders "%s" with correct visible label', (status, label) => {
render(<StatusBadge status={status} />);
expect(screen.getByText(label)).toBeInTheDocument();
});
it('sets an aria-label that includes the status', () => {
const { container } = render(<StatusBadge status="confirmed" />);
expect(container.querySelector('[aria-label="Status: Confirmed"]')).toBeInTheDocument();
});
it('falls back to the raw status string when status is unknown', () => {
render(<StatusBadge status="unknown" />);
expect(screen.getByText('unknown')).toBeInTheDocument();
});
it('renders a dot element for visual indicator', () => {
const { container } = render(<StatusBadge status="pending" />);
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
});
});