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:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
8
.storybook/main.js
Normal file
8
.storybook/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: ['@storybook/addon-essentials'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
11
.storybook/preview.js
Normal file
11
.storybook/preview.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import '../src/styles/main.scss';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# ── Stage 1: build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
|
||||
# W produkcji zapytania API idą przez nginx proxy /api
|
||||
ARG VITE_API_URL=/api
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: serve ──────────────────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
11
Dockerfile.api
Normal file
11
Dockerfile.api
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g json-server
|
||||
|
||||
COPY db.json ./db.json
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["json-server", "--watch", "db.json", "--host", "0.0.0.0", "--port", "3001"]
|
||||
100
Justfile
Normal file
100
Justfile
Normal file
@@ -0,0 +1,100 @@
|
||||
# Reservation Management System — Justfile
|
||||
# Wymaga: just, node 20+, npm, docker, docker compose
|
||||
# Instalacja just: https://github.com/casey/just#installation
|
||||
|
||||
# ── Zmienne ─────────────────────────────────────────────────────────────────
|
||||
|
||||
API_PORT := "3001"
|
||||
DEV_PORT := "5173"
|
||||
|
||||
# ── Domyślna komenda (lista wszystkich) ──────────────────────────────────────
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# ── Tryb deweloperski ─────────────────────────────────────────────────────────
|
||||
|
||||
# Zainstaluj zależności npm
|
||||
install:
|
||||
npm ci
|
||||
|
||||
# Uruchom json-server (mock API) w tle na porcie 3001
|
||||
api:
|
||||
npx json-server --watch db.json --host 0.0.0.0 --port {{API_PORT}}
|
||||
|
||||
# Uruchom serwer deweloperski Vite
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
# Uruchom API i Vite równolegle (wymaga GNU parallel lub mprocs)
|
||||
start: install
|
||||
#!/usr/bin/env bash
|
||||
trap 'kill 0' EXIT
|
||||
npx json-server --watch db.json --host 0.0.0.0 --port {{API_PORT}} &
|
||||
npm run dev
|
||||
|
||||
# ── Testy ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Uruchom testy jednostkowe (Vitest)
|
||||
test:
|
||||
npm run test
|
||||
|
||||
# Uruchom testy e2e Playwright (wymaga działającego API i dev serwera)
|
||||
e2e:
|
||||
node test_rms.mjs
|
||||
|
||||
# Uruchom Storybook
|
||||
storybook:
|
||||
npm run storybook
|
||||
|
||||
# ── Budowanie ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Zbuduj aplikację produkcyjną (VITE_API_URL=/api dla Dockera)
|
||||
build:
|
||||
VITE_API_URL=/api npm run build
|
||||
|
||||
# Zbuduj i sprawdź lokalnie przez preview Vite (wymaga osobnego API)
|
||||
preview: build
|
||||
npm run preview
|
||||
|
||||
# ── Docker ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Zbuduj obrazy Docker (frontend + api)
|
||||
docker-build:
|
||||
docker compose build
|
||||
|
||||
# Uruchom aplikację w Dockerze (http://localhost)
|
||||
docker-up:
|
||||
docker compose up -d
|
||||
@echo ""
|
||||
@echo "Aplikacja dostępna na: http://localhost"
|
||||
@echo "API (json-server): http://localhost:{{API_PORT}}"
|
||||
@echo ""
|
||||
@echo "Zatrzymaj: just docker-down"
|
||||
|
||||
# Uruchom w trybie interaktywnym z logami
|
||||
docker-up-logs:
|
||||
docker compose up
|
||||
|
||||
# Zatrzymaj kontenery
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
# Zatrzymaj i usuń wolumeny (resetuje db.json do stanu z obrazu)
|
||||
docker-clean:
|
||||
docker compose down -v
|
||||
|
||||
# Przebuduj i uruchom od zera
|
||||
docker-rebuild:
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
@echo "Aplikacja dostępna na: http://localhost"
|
||||
|
||||
# Pokaż logi kontenerów
|
||||
docker-logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Status kontenerów
|
||||
docker-ps:
|
||||
docker compose ps
|
||||
247
README.md
Normal file
247
README.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Reservation Management System
|
||||
|
||||
Aplikacja SPA do zarządzania rezerwacjami zbudowana w React 19 + Vite 8. Umożliwia klientom przeglądanie dostępnych terminów, dokonywanie rezerwacji z płatnością depozytu, zarządzanie historią rezerwacji oraz wystawianie opinii. Administratorzy mają dostęp do panelu zarządzania, kalendarza rezerwacji, analityki i eksportu danych.
|
||||
|
||||
---
|
||||
|
||||
## Uruchomienie
|
||||
|
||||
```bash
|
||||
# Terminal 1 – REST API (json-server na porcie 3001)
|
||||
npm run api
|
||||
|
||||
# Terminal 2 – Vite dev server (port 5173)
|
||||
npm run dev
|
||||
|
||||
# Testy jednostkowe (Vitest + RTL)
|
||||
npm test
|
||||
|
||||
# Testy e2e (Playwright)
|
||||
node test_rms.mjs
|
||||
|
||||
# Storybook (port 6006)
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
> **Uwaga:** przed uruchomieniem `test_rms.mjs` oba serwery muszą być aktywne. Test automatycznie przywraca dane seed (r1, r2) do stanu `pending` przed każdym przebiegiem.
|
||||
|
||||
---
|
||||
|
||||
## Dane testowe
|
||||
|
||||
| Rola | E-mail | Hasło |
|
||||
|------|--------|-------|
|
||||
| Admin | `admin@reservations.dev` | `Admin1234!` |
|
||||
| Klient | `anna.kowalski@example.com` | `Client1234!` |
|
||||
|
||||
Logowanie zawsze uruchamia **weryfikację 2FA** – kod jest wyświetlany bezpośrednio w interfejsie (symulowany e-mail).
|
||||
|
||||
---
|
||||
|
||||
## Stos technologiczny
|
||||
|
||||
| Warstwa | Technologia |
|
||||
|---------|-------------|
|
||||
| UI | React 19, Vite 8, SCSS Modules |
|
||||
| Routing | React Router v7 |
|
||||
| Zapytania REST | TanStack React Query v5 + Axios |
|
||||
| Zapytania GraphQL | Apollo Client v4 + SchemaLink (in-memory) |
|
||||
| Formularze | Formik + Zod (`zod-formik-adapter`) |
|
||||
| Ikony | Lucide React |
|
||||
| REST mock | json-server (db.json) |
|
||||
| Testy jednostkowe | Vitest + React Testing Library |
|
||||
| Testy e2e | Playwright |
|
||||
| Dokumentacja | Storybook 8 |
|
||||
|
||||
---
|
||||
|
||||
## Struktura projektu
|
||||
|
||||
```
|
||||
web_projekt/
|
||||
├── db.json # Baza danych json-server (users, services, reservations)
|
||||
├── test_rms.mjs # Testy e2e Playwright (32 testy)
|
||||
├── src/
|
||||
│ ├── main.jsx # Punkt wejścia: Apollo Client, QueryClient, renderowanie
|
||||
│ ├── App.jsx # Router, lazy loading, ErrorBoundary, Suspense, ProtectedRoute
|
||||
│ ├── index.css # Reset + CSS custom properties (tryb ciemny/jasny)
|
||||
│ ├── styles/
|
||||
│ │ ├── _variables.scss # Tokeny projektu: kolory, spacing, typografia, cienie, media queries
|
||||
│ │ └── main.scss # Globalne style bazowe
|
||||
│ ├── api/
|
||||
│ │ ├── reservations.js # CRUD rezerwacji przez Axios (json-server :3001)
|
||||
│ │ ├── services.js # Pobieranie listy usług
|
||||
│ │ └── users.js # Pobieranie i aktualizacja użytkowników
|
||||
│ ├── context/
|
||||
│ │ ├── AuthContext.jsx # Stan uwierzytelnienia, 2FA, role, sesja w localStorage
|
||||
│ │ └── ThemeContext.jsx # Przełącznik jasny/ciemny motyw (data-theme na <html>)
|
||||
│ ├── hocs/
|
||||
│ │ └── withRole.jsx # HOC: renderuje komponent tylko dla dozwolonych ról
|
||||
│ ├── hooks/
|
||||
│ │ ├── useCreateReservation.js # useMutation: POST /reservations
|
||||
│ │ ├── useDeleteReservation.js # useMutation: DELETE /reservations/:id
|
||||
│ │ ├── useReservations.js # useQuery: GET /reservations (wszystkie)
|
||||
│ │ ├── useServices.js # useQuery: GET /services
|
||||
│ │ ├── useSlotReservations.js # useQuery: GET /reservations?serviceId&date
|
||||
│ │ ├── useUpdateReservation.js # useMutation: PATCH /reservations/:id
|
||||
│ │ └── useUserReservations.js # useQuery: GET /reservations?userId
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.jsx # Formularz logowania (Formik+Zod) + ekran 2FA z podglądem kodu
|
||||
│ │ ├── LoginPage.module.scss
|
||||
│ │ ├── RegisterPage.jsx # Rejestracja z walidacją Zod i weryfikacją e-mail
|
||||
│ │ ├── RegisterPage.module.scss
|
||||
│ │ ├── DashboardPage.jsx # Panel klienta: AvailabilitySearch, SlotFinder, BookingWizard, MyReservations, ProfileEditModal
|
||||
│ │ ├── DashboardPage.module.scss
|
||||
│ │ ├── ReservationsPage.jsx # Historia rezerwacji z filtrami, anulowaniem, RescheduleModal, ReviewsSection, Google Calendar, re-book
|
||||
│ │ ├── ReservationsPage.module.scss
|
||||
│ │ ├── AdminPage.jsx # Panel admina: tabela rezerwacji, UserManagement, AnalyticsDashboard, AdminCalendar, blokowanie terminów (useTransition)
|
||||
│ │ └── AdminPage.module.scss
|
||||
│ ├── components/
|
||||
│ │ ├── AvailabilitySearch.jsx # Wyszukiwanie wolnych slotów z synchronizacją URL (useSearchParams)
|
||||
│ │ ├── AvailabilitySearch.module.scss
|
||||
│ │ ├── BookingWizard.jsx # 4-krokowy kreator rezerwacji (Usługa → Data → Wymagania → Podsumowanie)
|
||||
│ │ ├── BookingWizard.module.scss
|
||||
│ │ ├── BookingForm.jsx # Uproszczony formularz rezerwacji (react-hook-form)
|
||||
│ │ ├── BookingForm.module.scss
|
||||
│ │ ├── SlotFinder.jsx # Inteligentny finder slotów z punktacją (scoreSlot, findSlots)
|
||||
│ │ ├── SlotFinder.module.scss
|
||||
│ │ ├── PaymentModal.jsx # Modal płatności: Karta / Google Pay / Przelew bankowy (Portal)
|
||||
│ │ ├── PaymentModal.module.scss
|
||||
│ │ ├── RescheduleModal.jsx # Modal zmiany terminu rezerwacji (Portal)
|
||||
│ │ ├── RescheduleModal.module.scss
|
||||
│ │ ├── ProfileEditModal.jsx # Modal edycji profilu i preferencji powiadomień (Portal, Formik+Zod)
|
||||
│ │ ├── ProfileEditModal.module.scss
|
||||
│ │ ├── ReviewsSection.jsx # Sekcja opinii (GraphQL: useQuery + useMutation przez Apollo)
|
||||
│ │ ├── ReviewsSection.module.scss
|
||||
│ │ ├── ProfileView.jsx # Widok profilu (GraphQL: userProfile query)
|
||||
│ │ ├── ProfileView.module.scss
|
||||
│ │ ├── MyReservations.jsx # Sidebar z aktywnymi rezerwacjami użytkownika
|
||||
│ │ ├── MyReservations.module.scss
|
||||
│ │ ├── AdminCalendar.jsx # Widok kalendarza dla admina (miesiąc, kliknięcie dnia, panel boczny)
|
||||
│ │ ├── AdminCalendar.module.scss
|
||||
│ │ ├── AnalyticsDashboard.jsx # Statystyki rezerwacji z eksportem CSV (DataRenderer, useMemo)
|
||||
│ │ ├── AnalyticsDashboard.module.scss
|
||||
│ │ ├── UserManagement.jsx # Zarządzanie użytkownikami przez admina (tabela, dezaktywacja, reset hasła)
|
||||
│ │ ├── UserManagement.module.scss
|
||||
│ │ ├── Modal.jsx # Bazowy komponent modala (Portal, useCallback, Escape key, aria)
|
||||
│ │ ├── Modal.module.scss
|
||||
│ │ ├── StatusBadge.jsx # Odznaka statusu rezerwacji (warianty kolorów, rozmiary, aria-label)
|
||||
│ │ ├── StatusBadge.module.scss
|
||||
│ │ ├── StatusBadge.test.jsx # Testy RTL dla StatusBadge
|
||||
│ │ ├── ThemeToggle.jsx # Przycisk przełączania motywu (jasny/ciemny)
|
||||
│ │ ├── ThemeToggle.module.scss
|
||||
│ │ ├── DataRenderer.jsx # Render-prop: opakowuje useQuery, przekazuje dane przez children()
|
||||
│ │ ├── ErrorBoundary.jsx # Class component: łapie błędy runtime, wyświetla fallback UI
|
||||
│ │ ├── ErrorBoundary.module.scss
|
||||
│ │ ├── LoadingSpinner.jsx # Animowany spinner ładowania
|
||||
│ │ ├── LoadingSpinner.module.scss
|
||||
│ │ └── ProtectedRoute.jsx # Guard routingu: sprawdza rolę i przekierowuje
|
||||
│ ├── stories/
|
||||
│ │ ├── StatusBadge.stories.jsx # Storybook: wszystkie warianty odznaki statusu
|
||||
│ │ ├── Modal.stories.jsx # Storybook: modal informacyjny i destrukcyjny
|
||||
│ │ └── ThemeToggle.stories.jsx # Storybook: przełącznik motywu
|
||||
│ └── tests/
|
||||
│ ├── LoginPage.test.jsx # RTL: formularz logowania, błędy walidacji
|
||||
│ ├── BookingForm.test.jsx # RTL: formularz rezerwacji, react-hook-form
|
||||
│ ├── ProtectedRoute.test.jsx # RTL: ochrona tras, przekierowania
|
||||
│ └── StatusBadge.test.jsx # RTL: warianty statusu
|
||||
└── public/
|
||||
└── index.html # Zawiera <div id="root"> i <div id="portal-root">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektura danych
|
||||
|
||||
### REST (json-server :3001)
|
||||
|
||||
- `GET/POST /reservations` – rezerwacje
|
||||
- `GET /services` – usługi
|
||||
- `GET/PATCH /users` – użytkownicy
|
||||
- Filtrowanie przez query params: `?userId=`, `?serviceId=`, `?date=`
|
||||
|
||||
### GraphQL (Apollo Client – in-memory, bez zewnętrznego serwera)
|
||||
|
||||
Schema i resolvery zdefiniowane w `main.jsx`, wykonywane lokalnie przez `SchemaLink`:
|
||||
|
||||
```graphql
|
||||
type Query {
|
||||
userProfile(id: ID!): UserProfile
|
||||
serviceReviews(serviceId: String!): [Review!]!
|
||||
userReviews(userId: String!): [Review!]!
|
||||
}
|
||||
type Mutation {
|
||||
submitReview(reservationId: String!, serviceId: String!, ...): Review!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lista wymagań i ich realizacja
|
||||
|
||||
### Wymagania funkcjonalne (11)
|
||||
|
||||
| # | Kryterium | Realizacja | Status |
|
||||
|---|-----------|-----------|--------|
|
||||
| F1 | **System uwierzytelniania i autoryzacji** | `AuthContext.jsx` – logowanie e-mail + 2FA (kod wyświetlany w UI); role `client`/`admin`; sesja w `localStorage`; rejestracja z weryfikacją e-mail (`RegisterPage.jsx`); `ProtectedRoute` blokuje dostęp wg roli | [OK] |
|
||||
| F2 | **Zarządzanie profilami użytkowników** | `ProfileEditModal.jsx` – edycja danych (Formik+Zod, PATCH `/users`), 5 preferencji powiadomień (toggle switches: potwierdzenie, anulowanie, przypomnienie 24h, zmiana terminu, promocje); `ProfileView.jsx` – dane z GraphQL; historia w `ReservationsPage.jsx` | [OK] |
|
||||
| F3 | **Wyszukiwanie i przeglądanie dostępności** | `AvailabilitySearch.jsx` – filtracja po dacie i usłudze, siatka slotów, URL sync (`useSearchParams`); `SlotFinder.jsx` – ranking slotów w zakresie dat z preferencjami (rano/południe/wieczór), pasek score | [OK] |
|
||||
| F4 | **Proces rezerwacji** | `BookingWizard.jsx` – 4 kroki: wybór usługi, data+czas (blokada zajętych slotów), wymagania specjalne, podsumowanie z ceną; `PaymentModal.jsx` – 3 metody płatności, potwierdzenie z paragonem | [OK] |
|
||||
| F5 | **System płatności online** | `PaymentModal.jsx` – karta kredytowa (Formik, walidacja), Google Pay (symulacja), przelew bankowy (IBAN/BIC, numer referencyjny); depozyt 50% ceny; `transactionId` zapisywany w rezerwacji | [OK] |
|
||||
| F6 | **Zarządzanie rezerwacjami przez użytkowników** | `ReservationsPage.jsx` – filtry (wszystkie/nadchodzące/minione/anulowane), anulowanie z potwierdzeniem, `RescheduleModal.jsx` (zmiana terminu), "Book again" (re-book), Google Calendar link | [OK] |
|
||||
| F7 | **Panel zarządzania rezerwacjami (admin)** | `AdminPage.jsx` – tabela rezerwacji (Confirm/Cancel/Delete), `AdminCalendar.jsx` (miesiąc, panel dnia), blokowanie i zwalnianie terminów z datepickerem i listą | [OK] |
|
||||
| F8 | **Zarządzanie użytkownikami (admin)** | `UserManagement.jsx` – tabela użytkowników, dezaktywacja konta (toggle), symulacja resetu hasła; dostęp chroniony przez HOC `withRole(['admin'])` | [OK] |
|
||||
| F9 | **Analityka i raportowanie** | `AnalyticsDashboard.jsx` – statystyki (łączna liczba, potwierdzone, anulowane, przychód z depozytów, średnia ocena); wykresy słupkowe CSS; eksport CSV z nagłówkami | [OK] |
|
||||
| F10 | **Integracja z zewnętrznymi systemami** | Google Calendar: link `eventedit` generowany dynamicznie z datą/godziną/tytułem dla każdej nadchodzącej rezerwacji; pełne REST API dostępne dla zewnętrznych integracji | [OK] |
|
||||
| F11 | **System opinii i ocen** | `ReviewsSection.jsx` – ocena gwiazdkowa (1–5) + komentarz, zapis przez GraphQL mutation `submitReview`; pobieranie przez `serviceReviews` query; widoczne po rozwinięciu minionej rezerwacji | [OK] |
|
||||
|
||||
### Wymagania wizualne BIU ZAO (6)
|
||||
|
||||
| # | Kryterium | Realizacja | Status |
|
||||
|---|-----------|-----------|--------|
|
||||
| V1 | **Spójny i nowoczesny interfejs** | Jeden design system: tokeny w `_variables.scss` (paleta primary/accent/gray, spacing 4px scale, typografia, cienie), spójne BEM, Lucide icons, karty z `border-radius`, modale z overlay | [OK] |
|
||||
| V2 | **Pełna responsywność** | 12 breakpointów w SCSS (`$bp-xs` 480px–`$bp-2xl` 1536px); grid w DashboardPage stackuje kolumny, tabela admina scrolluje poziomo, wizard adaptuje układ pól | [OK] |
|
||||
| V3 | **Animacje i efekty przejścia** | CSS `transition` na przyciskach/kartach/modalach; `@keyframes` pulse na wyróżnionej karcie rezerwacji; spinner ładowania CSS; hover lift (`box-shadow` + `transform`) | [OK] |
|
||||
| V4 | **Wizualny feedback dla interakcji** | `StatusBadge` kolorystycznie odróżnia statusy; `:disabled`/`:hover`/`:focus-visible` na wszystkich elementach interaktywnych; błędy inline w formularzach; spinner na przyciskach pending | [OK] |
|
||||
| V5 | **Estetyczne wykorzystanie SCSS** | SCSS Modules z BEM, `@use '../styles/variables' as *`, zagnieżdżone reguły `&__`, `&--`, CSS custom properties dla motywu (`--clr-bg`, `--clr-text` etc.), media queries przez zmienne | [OK] |
|
||||
| V6 | **Wykorzystanie wbudowanych komponentów** | Natywne `<input type="date">`, `<input type="time">`, `<select>`, `<textarea>`, `<dialog role="dialog">`; `<dl>/<dt>/<dd>` dla danych rezerwacji; semantyczny HTML5 (`<header>`, `<main>`, `<aside>`) | [OK] |
|
||||
|
||||
### Wymagania techniczne BIU ZAO (10)
|
||||
|
||||
| # | Kryterium | Realizacja | Status |
|
||||
|---|-----------|-----------|--------|
|
||||
| T1 | **Podstawowe hooki React** | `useState` – stan formularzy, UI, modali; `useEffect` – inicjalizacja auth z localStorage, scroll do karty przez `useRef`; `useContext` – `useAuth()`, `useTheme()` w całej aplikacji | [OK] |
|
||||
| T2 | **Custom hooki** | 7 hooków w `src/hooks/`: `useServices`, `useUserReservations`, `useSlotReservations`, `useReservations`, `useCreateReservation`, `useUpdateReservation`, `useDeleteReservation` | [OK] |
|
||||
| T3 | **React Router** | React Router v7: `BrowserRouter`, `Routes`, `Route`, `Navigate`, `Outlet`, `Link`, `useNavigate`, `useLocation`, `useSearchParams`; chronione trasy przez `ProtectedRoute` z `allowedRoles` | [OK] |
|
||||
| T4 | **Zarządzanie stanem (Context API)** | `AuthContext` – użytkownik, 2FA (`pendingUser`/`twoFACode`/`verify2FA`), role, sesja; `ThemeContext` – motyw jasny/ciemny z `localStorage` i `data-theme` na `<html>` | [OK] |
|
||||
| T5 | **Obsługa zapytań API** | Axios w `src/api/` dla REST (json-server); Apollo Client `SchemaLink` dla GraphQL (in-memory schema z resolverami w `main.jsx`) | [OK] |
|
||||
| T6 | **Techniki optymalizacyjne** | `React.lazy` + `Suspense` (DashboardPage, AdminPage); `useMemo` (statystyki w AnalyticsDashboard, maxScore w SlotFinder); `useCallback` (auth functions, Escape handler w Modal) | [OK] |
|
||||
| T7 | **Wzorce kompozycji komponentów** | HOC `withRole(['admin'])(Component)` z `displayName`; Render props `DataRenderer` – delegacja renderowania przez `children` jako funkcję; oba używane w komponentach produkcyjnych | [OK] |
|
||||
| T8 | **Obsługa formularzy i walidacja** | Formik + Zod (`toFormikValidationSchema`): LoginPage, RegisterPage, ProfileEditModal; `react-hook-form` + `zodResolver`: BookingForm; walidacja krokowa w BookingWizard | [OK] |
|
||||
| T9 | **Konsekwentny styl kodu** | SCSS BEM, camelCase hooks/functions, PascalCase komponenty, `styles['block__element--modifier']` we wszystkich SCSS Modules, ESLint, jednolite formatowanie | [OK] |
|
||||
| T10 | **Nowoczesny JavaScript** | Destrukturyzacja: `const { user, logout } = useAuth()`, `const { password: _omit, ...safeUser } = match`; spread: `{ ...prev, [key]: value }`; optional chaining `?.`; nullish `??`; template literals | [OK] |
|
||||
|
||||
### Wymagania dodatkowe BIU ZAO (11)
|
||||
|
||||
| # | Kryterium | Realizacja | Status |
|
||||
|---|-----------|-----------|--------|
|
||||
| D1 | **React Suspense i Error Boundaries** | `ErrorBoundary.jsx` (class component z `componentDidCatch`, fallback UI + przycisk reload); `<Suspense fallback={<LoadingSpinner/>}>` w `App.jsx` opakowuje lazy-loaded strony | [OK] |
|
||||
| D2 | **Zaawansowana obsługa formularzy** | Formik + `toFormikValidationSchema(zodSchema)`: LoginPage, RegisterPage, ProfileEditModal; `react-hook-form` + `zodResolver`: BookingForm; oba podejścia w jednym projekcie | [OK] |
|
||||
| D3 | **GraphQL z Apollo Client** | Apollo Client v4 + `makeExecutableSchema` + `SchemaLink`; in-memory resolvery dla `userProfile`, `serviceReviews`, `userReviews`, `submitReview` (store w pamięci); `useQuery`/`useMutation` z `@apollo/client/react` | [OK] |
|
||||
| D4 | **React Portals** | 4 komponenty mountowane do `#portal-root` przez `createPortal()`: `Modal.jsx`, `PaymentModal.jsx`, `ProfileEditModal.jsx`, `RescheduleModal.jsx` | [OK] |
|
||||
| D5 | **React.lazy i dynamic imports** | `lazy(() => import('./pages/DashboardPage'))` i `lazy(() => import('./pages/AdminPage'))` w `App.jsx`; ładowanie on-demand przy pierwszym wejściu na trasę | [OK] |
|
||||
| D6 | **Render props i HOC** | `DataRenderer.jsx` – render prop (`children` jako funkcja), używany w `AnalyticsDashboard`; `withRole.jsx` – HOC z `displayName`, używany w `AdminPage`; oba wzorce produkcyjne | [OK] |
|
||||
| D7 | **React Query** | TanStack React Query v5: 7 custom hooków, `staleTime` 5 min, `invalidateQueries` po mutacjach (lista rezerwacji odświeżana po każdej zmianie), `isPending` dla UX | [OK] |
|
||||
| D8 | **React DevTools** | `WithRole.displayName` dla czytelności drzewa; QueryClient kompatybilny z React Query Devtools; Apollo `InMemoryCache` widoczna w Apollo DevTools | [OK] |
|
||||
| D9 | **Testy z React Testing Library** | 5 plików testowych, 25+ testów (Vitest): LoginPage (formularz, błędy, submit), BookingForm (walidacja RHF), ProtectedRoute (przekierowania, role), StatusBadge (warianty x2) | [OK] |
|
||||
| D10 | **Storybook** | 3 pliki stories w `src/stories/`: StatusBadge (wszystkie statusy + rozmiary), Modal (Default + Danger z useState wrapper), ThemeToggle (z etykietą); Storybook 8 + `@storybook/react-vite` | [OK] |
|
||||
| D11 | **React Concurrent Mode** | `useTransition` w `AdminPage.jsx`: przełączanie zakładek (Tabela/Kalendarz/Analityka/Użytkownicy) przez `startTransition(() => setMainTab(key))` – nie blokuje UI przy ciężkich re-renderach | [OK] |
|
||||
|
||||
---
|
||||
|
||||
## Wyniki testów
|
||||
|
||||
```
|
||||
Vitest (jednostkowe): 25 passed, 0 failed
|
||||
Playwright (e2e): 32 passed, 0 failed, 2 warnings
|
||||
```
|
||||
124
db.json
Normal file
124
db.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "u1",
|
||||
"email": "admin@reservations.dev",
|
||||
"password": "Admin1234!",
|
||||
"role": "admin",
|
||||
"avatarUrl": "https://i.pravatar.cc/150?u=admin"
|
||||
},
|
||||
{
|
||||
"id": "u2",
|
||||
"email": "anna.kowalski@example.com",
|
||||
"password": "Client1234!",
|
||||
"role": "client",
|
||||
"avatarUrl": "https://i.pravatar.cc/150?u=anna"
|
||||
},
|
||||
{
|
||||
"id": "u3",
|
||||
"email": "marek.nowak@example.com",
|
||||
"password": "Client1234!",
|
||||
"role": "client",
|
||||
"avatarUrl": "https://i.pravatar.cc/150?u=marek"
|
||||
},
|
||||
{
|
||||
"id": "u4",
|
||||
"email": "julia.wisniewska@example.com",
|
||||
"password": "Client1234!",
|
||||
"role": "client",
|
||||
"avatarUrl": "https://i.pravatar.cc/150?u=julia"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"id": "s1",
|
||||
"name": "Initial Consultation",
|
||||
"description": "A 30-minute introductory session to discuss your needs and goals.",
|
||||
"price": 50,
|
||||
"duration": 30
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"name": "Standard Session",
|
||||
"description": "A full 60-minute service session tailored to your requirements.",
|
||||
"price": 120,
|
||||
"duration": 60
|
||||
},
|
||||
{
|
||||
"id": "s3",
|
||||
"name": "Premium Package",
|
||||
"description": "An extended 90-minute premium session with a detailed follow-up report.",
|
||||
"price": 200,
|
||||
"duration": 90
|
||||
},
|
||||
{
|
||||
"id": "s4",
|
||||
"name": "Express Appointment",
|
||||
"description": "A focused 15-minute quick-turnaround appointment for urgent matters.",
|
||||
"price": 30,
|
||||
"duration": 15
|
||||
}
|
||||
],
|
||||
"reservations": [
|
||||
{
|
||||
"id": "r1",
|
||||
"userId": "u2",
|
||||
"serviceId": "s1",
|
||||
"date": "2026-06-25",
|
||||
"time": "09:00",
|
||||
"status": "cancelled"
|
||||
},
|
||||
{
|
||||
"id": "r2",
|
||||
"userId": "u3",
|
||||
"serviceId": "s2",
|
||||
"date": "2026-06-26",
|
||||
"time": "11:30",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"id": "r3",
|
||||
"userId": "u4",
|
||||
"serviceId": "s3",
|
||||
"date": "2026-06-27",
|
||||
"time": "14:00",
|
||||
"status": "confirmed"
|
||||
},
|
||||
{
|
||||
"id": "r4",
|
||||
"userId": "u2",
|
||||
"serviceId": "s4",
|
||||
"date": "2026-06-20",
|
||||
"time": "10:00",
|
||||
"status": "cancelled"
|
||||
},
|
||||
{
|
||||
"id": "r5",
|
||||
"userId": "u3",
|
||||
"serviceId": "s3",
|
||||
"date": "2026-07-01",
|
||||
"time": "13:00",
|
||||
"status": "confirmed"
|
||||
},
|
||||
{
|
||||
"id": "r6",
|
||||
"userId": "u4",
|
||||
"serviceId": "s2",
|
||||
"date": "2026-07-03",
|
||||
"time": "16:00",
|
||||
"status": "confirmed"
|
||||
},
|
||||
{
|
||||
"userId": "u2",
|
||||
"serviceId": "s1",
|
||||
"date": "2026-09-01",
|
||||
"time": "14:30",
|
||||
"specialRequirements": null,
|
||||
"status": "pending",
|
||||
"depositPaid": 25,
|
||||
"transactionId": "TXN-USDR1S5D",
|
||||
"id": "-4aG8VQUVhM"
|
||||
}
|
||||
],
|
||||
"$schema": "./node_modules/json-server/schema.json"
|
||||
}
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
volumes:
|
||||
# db.json jest montowany, więc zmiany danych przeżywają restart kontenera
|
||||
- ./db.json:/app/db.json
|
||||
ports:
|
||||
- "3001:3001"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: /api
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- api
|
||||
restart: unless-stopped
|
||||
21
eslint.config.js
Normal file
21
eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web_projekt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="portal-root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
nginx.conf
Normal file
21
nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA routing — wszystkie ścieżki serwowane przez index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API → kontener json-server
|
||||
location /api/ {
|
||||
proxy_pass http://api:3001/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
7537
package-lock.json
generated
Normal file
7537
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "web_projekt",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"api": "json-server --watch db.json --port 3001",
|
||||
"test": "vitest",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.2.3",
|
||||
"@graphql-tools/mock": "^9.1.7",
|
||||
"@graphql-tools/schema": "^10.0.33",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"axios": "^1.18.0",
|
||||
"formik": "^2.4.9",
|
||||
"graphql": "^16.14.2",
|
||||
"lucide-react": "^1.20.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.101.0",
|
||||
"zod": "^4.4.3",
|
||||
"zod-formik-adapter": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@storybook/addon-essentials": "^8.6.14",
|
||||
"@storybook/react-vite": "^8.6.18",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"json-server": "^1.0.0-beta.15",
|
||||
"playwright": "^1.61.0",
|
||||
"storybook": "^8.6.18",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/slot_engine.wasm
Executable file
BIN
public/slot_engine.wasm
Executable file
Binary file not shown.
575
public/wasm_exec.js
Normal file
575
public/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
91
src/App.jsx
Normal file
91
src/App.jsx
Normal 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
22
src/api/reservations.js
Normal 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
7
src/api/services.js
Normal 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
37
src/api/users.js
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
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 |
155
src/components/AdminCalendar.jsx
Normal file
155
src/components/AdminCalendar.jsx
Normal 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;
|
||||
207
src/components/AdminCalendar.module.scss
Normal file
207
src/components/AdminCalendar.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
168
src/components/AnalyticsDashboard.jsx
Normal file
168
src/components/AnalyticsDashboard.jsx
Normal 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;
|
||||
162
src/components/AnalyticsDashboard.module.scss
Normal file
162
src/components/AnalyticsDashboard.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
121
src/components/AvailabilitySearch.jsx
Normal file
121
src/components/AvailabilitySearch.jsx
Normal 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;
|
||||
174
src/components/AvailabilitySearch.module.scss
Normal file
174
src/components/AvailabilitySearch.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
243
src/components/BookingForm.jsx
Normal file
243
src/components/BookingForm.jsx
Normal 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;
|
||||
246
src/components/BookingForm.module.scss
Normal file
246
src/components/BookingForm.module.scss
Normal 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); }
|
||||
}
|
||||
344
src/components/BookingWizard.jsx
Normal file
344
src/components/BookingWizard.jsx
Normal 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;
|
||||
436
src/components/BookingWizard.module.scss
Normal file
436
src/components/BookingWizard.module.scss
Normal 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); } }
|
||||
19
src/components/DataRenderer.jsx
Normal file
19
src/components/DataRenderer.jsx
Normal 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;
|
||||
39
src/components/ErrorBoundary.jsx
Normal file
39
src/components/ErrorBoundary.jsx
Normal 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;
|
||||
55
src/components/ErrorBoundary.module.scss
Normal file
55
src/components/ErrorBoundary.module.scss
Normal 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}); }
|
||||
}
|
||||
}
|
||||
10
src/components/LoadingSpinner.jsx
Normal file
10
src/components/LoadingSpinner.jsx
Normal 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;
|
||||
31
src/components/LoadingSpinner.module.scss
Normal file
31
src/components/LoadingSpinner.module.scss
Normal 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
78
src/components/Modal.jsx
Normal 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;
|
||||
142
src/components/Modal.module.scss
Normal file
142
src/components/Modal.module.scss
Normal 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; }
|
||||
}
|
||||
85
src/components/MyReservations.jsx
Normal file
85
src/components/MyReservations.jsx
Normal 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;
|
||||
141
src/components/MyReservations.module.scss
Normal file
141
src/components/MyReservations.module.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
285
src/components/PaymentModal.jsx
Normal file
285
src/components/PaymentModal.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
// [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 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 3–4 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 }) => (
|
||||
<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']}>krzysztofcieslik875@gmail.com</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 [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} />
|
||||
)}
|
||||
{method === 'bank' && (
|
||||
<BankTransferForm phase={phase} onPay={handlePay} deposit={deposit} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentModal;
|
||||
315
src/components/PaymentModal.module.scss
Normal file
315
src/components/PaymentModal.module.scss
Normal 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); } }
|
||||
215
src/components/ProfileEditModal.jsx
Normal file
215
src/components/ProfileEditModal.jsx
Normal 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;
|
||||
260
src/components/ProfileEditModal.module.scss
Normal file
260
src/components/ProfileEditModal.module.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
78
src/components/ProfileView.jsx
Normal file
78
src/components/ProfileView.jsx
Normal 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;
|
||||
99
src/components/ProfileView.module.scss
Normal file
99
src/components/ProfileView.module.scss
Normal file
@@ -0,0 +1,99 @@
|
||||
@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});
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&__item-value {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
}
|
||||
|
||||
&__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; }
|
||||
}
|
||||
}
|
||||
22
src/components/ProtectedRoute.jsx
Normal file
22
src/components/ProtectedRoute.jsx
Normal 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;
|
||||
134
src/components/RescheduleModal.jsx
Normal file
134
src/components/RescheduleModal.jsx
Normal 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} — #{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;
|
||||
207
src/components/RescheduleModal.module.scss
Normal file
207
src/components/RescheduleModal.module.scss
Normal 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); } }
|
||||
152
src/components/ReviewsSection.jsx
Normal file
152
src/components/ReviewsSection.jsx
Normal 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;
|
||||
153
src/components/ReviewsSection.module.scss
Normal file
153
src/components/ReviewsSection.module.scss
Normal 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; }
|
||||
}
|
||||
}
|
||||
185
src/components/SlotFinder.jsx
Normal file
185
src/components/SlotFinder.jsx
Normal 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 (9–12)</option>
|
||||
<option value="afternoon">Afternoon (12–15)</option>
|
||||
<option value="late">Late (15–18)</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;
|
||||
265
src/components/SlotFinder.module.scss
Normal file
265
src/components/SlotFinder.module.scss
Normal 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/components/StatusBadge.jsx
Normal file
28
src/components/StatusBadge.jsx
Normal 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;
|
||||
84
src/components/StatusBadge.module.scss
Normal file
84
src/components/StatusBadge.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/components/StatusBadge.test.jsx
Normal file
36
src/components/StatusBadge.test.jsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
22
src/components/ThemeToggle.jsx
Normal file
22
src/components/ThemeToggle.jsx
Normal 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;
|
||||
20
src/components/ThemeToggle.module.scss
Normal file
20
src/components/ThemeToggle.module.scss
Normal 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});
|
||||
}
|
||||
}
|
||||
142
src/components/UserManagement.jsx
Normal file
142
src/components/UserManagement.jsx
Normal 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;
|
||||
161
src/components/UserManagement.module.scss
Normal file
161
src/components/UserManagement.module.scss
Normal 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
5
src/config.js
Normal 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
152
src/context/AuthContext.jsx
Normal 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;
|
||||
};
|
||||
46
src/context/ThemeContext.jsx
Normal file
46
src/context/ThemeContext.jsx
Normal 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 są 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
26
src/hocs/withRole.jsx
Normal 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;
|
||||
17
src/hooks/useCreateReservation.js
Normal file
17
src/hooks/useCreateReservation.js
Normal 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,
|
||||
});
|
||||
};
|
||||
17
src/hooks/useDeleteReservation.js
Normal file
17
src/hooks/useDeleteReservation.js
Normal 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,
|
||||
});
|
||||
};
|
||||
10
src/hooks/useReservations.js
Normal file
10
src/hooks/useReservations.js
Normal 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
10
src/hooks/useServices.js
Normal 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,
|
||||
});
|
||||
11
src/hooks/useSlotReservations.js
Normal file
11
src/hooks/useSlotReservations.js
Normal 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,
|
||||
});
|
||||
16
src/hooks/useUpdateReservation.js
Normal file
16
src/hooks/useUpdateReservation.js
Normal 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,
|
||||
});
|
||||
};
|
||||
11
src/hooks/useUserReservations.js
Normal file
11
src/hooks/useUserReservations.js
Normal 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
0
src/index.css
Normal file
131
src/main.jsx
Normal file
131
src/main.jsx
Normal 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
313
src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
// [REQ F7] Panel admina – tabela rezerwacji, kalendarz, blokowanie terminów
|
||||
// [REQ D11] React Concurrent Mode – useTransition przy przełączaniu zakładek (nie blokuje UI)
|
||||
// [REQ T1] Podstawowe hooki – useState + useTransition
|
||||
// useTransition to nowoczesny hook, który pozwala na oznaczenie pewnych stanów jako
|
||||
// tymczasowe, co pozwala na lepsze zarządzanie wydajnością i płynnością interfejsu użytkownika
|
||||
import { useState, useTransition } from 'react';
|
||||
// gotowe ikony z lucide-react
|
||||
import { Trash2, RefreshCw, CheckCircle2, XCircle, LayoutList, CalendarDays, BarChart2, Users, Lock, Unlock } from 'lucide-react';
|
||||
import AdminCalendar from '../components/AdminCalendar';
|
||||
import AnalyticsDashboard from '../components/AnalyticsDashboard';
|
||||
import UserManagement from '../components/UserManagement';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useReservations } from '../hooks/useReservations';
|
||||
import { useDeleteReservation } from '../hooks/useDeleteReservation';
|
||||
import { useUpdateReservation } from '../hooks/useUpdateReservation';
|
||||
import Modal from '../components/Modal';
|
||||
import ProfileView from '../components/ProfileView';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
import styles from './AdminPage.module.scss';
|
||||
|
||||
const AdminPage = () => {
|
||||
// wyciągniecie usera z kontekstu
|
||||
const { user, logout } = useAuth();
|
||||
// wyciągniecie danych rezerwacji z hooka useReservations, który pobiera dane z backendu
|
||||
const { data: reservations = [], isLoading, isError, refetch } = useReservations();
|
||||
// useState do przechowywania stanu aktywnej zakładki (table / calendar)
|
||||
const [activeTab, setActiveTab] = useState('table');
|
||||
// useState do przechowywania stanu głównej zakładki (reservations / analytics / users)
|
||||
const [mainTab, setMainTab] = useState('reservations');
|
||||
// useState do przechowywania stanu id rezerwacji, która ma być usunięta (do modala)
|
||||
const [targetId, setTargetId] = useState(null);
|
||||
// useState do przechowywania stanu zablokowanych dat w kalendarzu
|
||||
const [blockedDates, setBlockedDates] = useState([]);
|
||||
// useState do przechowywania stanu inputa do blokowania daty
|
||||
const [blockInput, setBlockInput] = useState('');
|
||||
// useTransition do oznaczenia stanu mainTab jako tymczasowego
|
||||
// powoduje wykonanie tego w tle, a nie blokowanie interfejsu użytkownika podczas przełączania zakładek
|
||||
// useTransition() zwraca tablicę dwóch elementów - bolean isPending, który mówi czy aktualnie trwa przejście
|
||||
// , oraz funkcję startTransition, która służy do oznaczenia stanu jako tymczasowego
|
||||
// w tym przypadku pomijamy pierwszy element nie jest potrzbny
|
||||
const [, startTransition] = useTransition();
|
||||
// const na każdy render
|
||||
const isModalOpen = targetId !== null;
|
||||
// tworzenie funkcji handleMainTab przy pomocy funkcji strzałkowej przymającej key
|
||||
// następnie wywołanie setMainTab z key w funkcji startTransition, która oznacza stan jako tymczasowy
|
||||
// co ustala że priorytet renderowania tego jest niższy niż innych stanów
|
||||
const handleMainTab = (key) => startTransition(() => setMainTab(key));
|
||||
|
||||
const handleBlockDate = () => {
|
||||
// jeśli nie ma daty lub jest już zablokowana igorujemy
|
||||
if (!blockInput || blockedDates.includes(blockInput)) return;
|
||||
// dodanie daty do zablokowanych dat i posortowanie ich rosnąco
|
||||
setBlockedDates((d) => [...d, blockInput].sort());
|
||||
// wyczyszczenie inputa po dodaniu daty do zablokowanych
|
||||
setBlockInput('');
|
||||
};
|
||||
// funkcja do odblokowywania daty, która usuwa datę z tablicy zablokowanych dat
|
||||
// bierze tablice zablokowanych dat, filtruję ją tworząc nową tablicę bez wybranej daty i ustawia tę nową tablicę jako stan zablokowanych dat
|
||||
const handleUnblockDate = (date) => setBlockedDates((d) => d.filter((x) => x !== date));
|
||||
// użycie custom hooka useDeleteReservation, który zwraca funkcję mutate do usuwania rezerwacji oraz isPending do sprawdzania czy aktualnie trwa usuwanie
|
||||
// zapis isPending jako isDeleting, aby nie mylić z innymi stanami ładowania
|
||||
const { mutate: remove, isPending: isDeleting } = useDeleteReservation({
|
||||
// jeśli uda się usnąć zamykamy modal ustawiając targetId na null
|
||||
onSuccess: () => setTargetId(null),
|
||||
});
|
||||
// użycie custom hooka useUpdateReservation, który zwraca funkcję mutate do aktualizowania rezerwacji oraz isPending do sprawdzania czy aktualnie trwa aktualizacja
|
||||
// zapis isPending jako isUpdating, aby nie mylić z innymi stanami ładowania
|
||||
const { mutate: updateStatus, isPending: isUpdating } = useUpdateReservation();
|
||||
// funkcje do obsługi modala - ustawienie targetId na id rezerwacji, która ma być usunięta
|
||||
const handleDeleteClick = (id) => setTargetId(id);
|
||||
// funkcje do obsługi modala - potwierdzenie usunięcia rezerwacji wywołuje funkcję remove z id rezerwacji
|
||||
const handleConfirm = () => remove(targetId);
|
||||
// funkcje do obsługi modala - anulowanie usunięcia rezerwacji ustawia targetId na null - zamyka modal
|
||||
const handleCancel = () => setTargetId(null);
|
||||
// funkcje do obsługi modala - potwierdzenie zmiany statusu rezerwacji wywołuje funkcję updateStatus z id rezerwacji i nowym statusem
|
||||
const handleConfirmRes = (id) => updateStatus({ id, patch: { status: 'confirmed' } });
|
||||
// funkcje do obsługi modala - anulowanie zmiany statusu rezerwacji wywołuje funkcję updateStatus z id rezerwacji i statusem 'cancelled'
|
||||
const handleCancelStatus = (id) => updateStatus({ id, patch: { status: 'cancelled' } });
|
||||
// renderowanie komponentu AdminPage
|
||||
// styles zawiera klasy CSS z pliku AdminPage.module.scss, które są używane do stylowania komponentu
|
||||
|
||||
return (
|
||||
<div className={styles['admin']}>
|
||||
<header className={styles['admin__header']}>
|
||||
<div className={styles['admin__header-inner']}>
|
||||
<div>
|
||||
<h1 className={styles['admin__heading']}>Admin Panel</h1>
|
||||
<p className={styles['admin__subtitle']}>{user?.email}</p>
|
||||
</div>
|
||||
<div className={styles['admin__header-actions']}>
|
||||
<ThemeToggle />
|
||||
<button className={styles['admin__logout-btn']} onClick={logout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles['admin__main']}>
|
||||
{/* Main navigation tabs */}
|
||||
<div className={styles['admin__main-tabs']}>
|
||||
{[
|
||||
{ key: 'reservations', label: 'Reservations', icon: LayoutList },
|
||||
{ key: 'analytics', label: 'Analytics', icon: BarChart2 },
|
||||
{ key: 'users', label: 'Users', icon: Users },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={[styles['admin__main-tab'], mainTab === key ? styles['admin__main-tab--active'] : ''].join(' ')}
|
||||
onClick={() => handleMainTab(key)}
|
||||
>
|
||||
<Icon size={15} /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mainTab === 'analytics' && (
|
||||
<section className={styles['admin__section']}>
|
||||
<div className={styles['admin__section-header']}>
|
||||
<h2 className={styles['admin__section-title']}>Analytics & Reporting</h2>
|
||||
</div>
|
||||
<AnalyticsDashboard reservations={reservations} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{mainTab === 'users' && (
|
||||
<section className={styles['admin__section']}>
|
||||
<div className={styles['admin__section-header']}>
|
||||
<h2 className={styles['admin__section-title']}>User Management</h2>
|
||||
</div>
|
||||
<UserManagement />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{mainTab === 'reservations' && <div className={styles['admin__grid']}>
|
||||
{/* Reservations table / calendar */}
|
||||
<section className={styles['admin__section']}>
|
||||
<div className={styles['admin__section-header']}>
|
||||
<h2 className={styles['admin__section-title']}>All Reservations</h2>
|
||||
<div className={styles['admin__tabs']}>
|
||||
<button
|
||||
className={[styles['admin__tab'], activeTab === 'table' ? styles['admin__tab--active'] : ''].join(' ')}
|
||||
onClick={() => setActiveTab('table')}
|
||||
>
|
||||
<LayoutList size={14} /> Table
|
||||
</button>
|
||||
<button
|
||||
className={[styles['admin__tab'], activeTab === 'calendar' ? styles['admin__tab--active'] : ''].join(' ')}
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
>
|
||||
<CalendarDays size={14} /> Calendar
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={styles['admin__refresh-btn']}
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className={styles['admin__msg--error']}>
|
||||
Could not load reservations. Make sure the API is running on port 3001.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activeTab === 'calendar' && !isLoading && (
|
||||
<div className={styles['admin__calendar-wrap']}>
|
||||
<AdminCalendar reservations={reservations} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'table' && isLoading ? (
|
||||
<p className={styles['admin__msg--loading']}>Loading reservations…</p>
|
||||
) : activeTab === 'table' && reservations.length === 0 ? (
|
||||
<p className={styles['admin__msg--empty']}>No reservations found.</p>
|
||||
) : activeTab === 'table' && (
|
||||
<div className={styles['admin__table-wrapper']}>
|
||||
<table className={styles['admin__table']}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User ID</th>
|
||||
<th>Service ID</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reservations.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className={styles['admin__cell--mono']}>{r.id}</td>
|
||||
<td className={styles['admin__cell--mono']}>{r.userId}</td>
|
||||
<td className={styles['admin__cell--mono']}>{r.serviceId}</td>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.time}</td>
|
||||
<td><StatusBadge status={r.status} size="sm" /></td>
|
||||
<td>
|
||||
<div className={styles['admin__actions']}>
|
||||
{r.status === 'pending' && (
|
||||
<button
|
||||
className={styles['admin__confirm-btn']}
|
||||
onClick={() => handleConfirmRes(r.id)}
|
||||
disabled={isUpdating}
|
||||
aria-label={`Confirm reservation ${r.id}`}
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
{(r.status === 'pending' || r.status === 'confirmed') && (
|
||||
<button
|
||||
className={styles['admin__cancel-status-btn']}
|
||||
onClick={() => handleCancelStatus(r.id)}
|
||||
disabled={isUpdating}
|
||||
aria-label={`Cancel reservation ${r.id}`}
|
||||
>
|
||||
<XCircle size={15} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={styles['admin__delete-btn']}
|
||||
onClick={() => handleDeleteClick(r.id)}
|
||||
disabled={isDeleting || isUpdating}
|
||||
aria-label={`Delete reservation ${r.id}`}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Blocked dates + GraphQL User Profile */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<section className={styles['admin__section']}>
|
||||
<div className={styles['admin__section-header']}>
|
||||
<h2 className={styles['admin__section-title']}>Block / Release Dates</h2>
|
||||
</div>
|
||||
<div className={styles['admin__block-body']}>
|
||||
<div className={styles['admin__block-row']}>
|
||||
<input
|
||||
type="date"
|
||||
className={styles['admin__block-input']}
|
||||
value={blockInput}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
onChange={(e) => setBlockInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={styles['admin__block-btn']}
|
||||
onClick={handleBlockDate}
|
||||
disabled={!blockInput}
|
||||
>
|
||||
<Lock size={14} /> Block date
|
||||
</button>
|
||||
</div>
|
||||
{blockedDates.length === 0 ? (
|
||||
<p className={styles['admin__msg--empty']} style={{ padding: '1rem' }}>
|
||||
No dates blocked.
|
||||
</p>
|
||||
) : (
|
||||
<ul className={styles['admin__block-list']}>
|
||||
{blockedDates.map((d) => (
|
||||
<li key={d} className={styles['admin__block-item']}>
|
||||
<span>{d}</span>
|
||||
<button
|
||||
className={styles['admin__unblock-btn']}
|
||||
onClick={() => handleUnblockDate(d)}
|
||||
>
|
||||
<Unlock size={13} /> Release
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles['admin__section']}>
|
||||
<div className={styles['admin__section-header']}>
|
||||
<h2 className={styles['admin__section-title']}>User Profile (GraphQL)</h2>
|
||||
</div>
|
||||
<ErrorBoundary>
|
||||
<ProfileView userId={user?.id} />
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
</div>}
|
||||
</main>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
title="Delete Reservation"
|
||||
message={`Are you sure you want to permanently delete reservation "${targetId}"? This action cannot be undone.`}
|
||||
confirmLabel={isDeleting ? 'Deleting…' : 'Delete'}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
371
src/pages/AdminPage.module.scss
Normal file
371
src/pages/AdminPage.module.scss
Normal file
@@ -0,0 +1,371 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
// ── Block ─────────────────────────────────────────────────────────────────────
|
||||
.admin {
|
||||
min-height: 100vh;
|
||||
background: var(--clr-bg);
|
||||
|
||||
// ── Elements ────────────────────────────────────────────────────────────────
|
||||
&__header {
|
||||
background: var(--clr-surface);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__header-inner {
|
||||
max-width: $container-max-width;
|
||||
margin-inline: auto;
|
||||
padding: $spacing-4 $spacing-6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__heading {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--clr-text);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary);
|
||||
margin-top: $spacing-1;
|
||||
}
|
||||
|
||||
&__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
|
||||
&__logout-btn {
|
||||
padding: $spacing-2 $spacing-4;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
background: transparent;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
|
||||
}
|
||||
|
||||
&__main {
|
||||
max-width: $container-max-width;
|
||||
margin-inline: auto;
|
||||
padding: $spacing-6 $spacing-6 $spacing-8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-6;
|
||||
}
|
||||
|
||||
&__main-tabs {
|
||||
display: flex;
|
||||
gap: $spacing-2;
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&__main-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-5;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color $transition-fast, border-color $transition-fast;
|
||||
|
||||
&:hover { color: var(--clr-text); }
|
||||
|
||||
&--active {
|
||||
color: var(--clr-primary, #{$color-primary});
|
||||
border-bottom-color: var(--clr-primary, #{$color-primary});
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: $spacing-6;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: $bp-lg) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
&__section {
|
||||
background: var(--clr-surface);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-xl;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-5 $spacing-6;
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--clr-text);
|
||||
}
|
||||
|
||||
&__refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--clr-surface-raised); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__table-wrapper { overflow-x: auto; }
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
th {
|
||||
padding: $spacing-3 $spacing-4;
|
||||
text-align: left;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--clr-text-muted);
|
||||
background: var(--clr-surface-raised);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $spacing-3 $spacing-4;
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
color: var(--clr-text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--clr-surface-raised); }
|
||||
}
|
||||
|
||||
&__cell--mono {
|
||||
font-family: $font-family-mono;
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: $spacing-1;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast, border-color $transition-fast;
|
||||
|
||||
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
|
||||
|
||||
&--active {
|
||||
background: var(--clr-primary);
|
||||
border-color: var(--clr-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__calendar-wrap {
|
||||
padding: $spacing-5;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
}
|
||||
|
||||
&__confirm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-2;
|
||||
background: transparent;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: $radius-base;
|
||||
color: #15803d;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: #f0fdf4; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__cancel-status-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-2;
|
||||
background: transparent;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: $radius-base;
|
||||
color: #c2410c;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: #fff7ed; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-2;
|
||||
background: transparent;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: $radius-base;
|
||||
color: $color-error;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: #fef2f2; }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__msg--loading,
|
||||
&__msg--empty {
|
||||
padding: $spacing-8 $spacing-6;
|
||||
text-align: center;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
|
||||
&__msg--error {
|
||||
padding: $spacing-5 $spacing-6;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-error;
|
||||
background: #fef2f2;
|
||||
border-bottom: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
// Block / release dates
|
||||
&__block-body {
|
||||
padding: $spacing-4 $spacing-5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
|
||||
&__block-row {
|
||||
display: flex;
|
||||
gap: $spacing-3;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__block-input {
|
||||
padding: $spacing-2 $spacing-3;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-sm;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text);
|
||||
background: var(--clr-surface);
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: $primary-400;
|
||||
box-shadow: 0 0 0 2px rgba($primary-400, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
&__block-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-4;
|
||||
background: $primary-600;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, opacity $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: $primary-700; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__block-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-2;
|
||||
}
|
||||
|
||||
&__block-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text);
|
||||
}
|
||||
|
||||
&__unblock-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: $spacing-1 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-xs;
|
||||
font-family: $font-family-base;
|
||||
color: #15803d;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: #f0fdf4; }
|
||||
}
|
||||
}
|
||||
82
src/pages/DashboardPage.jsx
Normal file
82
src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
// Link jest potrzebny do nawigacji w aplikacji React - zmiana widoku na inną ścieżkę
|
||||
import { Link } from 'react-router-dom';
|
||||
// Ikony z lucide-react do wyświetlania ikon w aplikacji React
|
||||
import { BookOpen, UserCog } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useUserReservations } from '../hooks/useUserReservations';
|
||||
import SlotFinder from '../components/SlotFinder';
|
||||
import AvailabilitySearch from '../components/AvailabilitySearch';
|
||||
import BookingWizard from '../components/BookingWizard';
|
||||
import MyReservations from '../components/MyReservations';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
import ProfileEditModal from '../components/ProfileEditModal';
|
||||
import styles from './DashboardPage.module.scss';
|
||||
// funkcja DashboardPage to komponent strony głównej dla zalogowanego użytkownika, który wyświetla jego rezerwacje, pozwala na wyszukiwanie dostępnych slotów i zarządzanie profilem
|
||||
const DashboardPage = () => {
|
||||
// desrukturyzacja user i logout z useAuth - pobieramy zalogowanego użytkownika i funkcję wylogowania z kontekstu
|
||||
const { user, logout } = useAuth();
|
||||
// useState do przechowywania stanu prefillSlot, który jest używany do wstępnego wypełnienia formularza rezerwacji wybranym slotem
|
||||
const [prefillSlot, setPrefillSlot] = useState(null);
|
||||
// useState do przechowywania stanu profileOpen, który jest używany do otwierania i zamykania modala edycji profilu
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
// custom hook useUserReservations pobiera rezerwacje zalogowanego użytkownika z API i zwraca je w data
|
||||
const { data: reservations = [] } = useUserReservations(user?.id);
|
||||
// activeCount to zmienna, która przechowuje liczbę aktywnych rezerwacji (pending lub confirmed) dla zalogowanego użytkownika
|
||||
const activeCount = reservations.filter(
|
||||
(r) => r.status === 'pending' || r.status === 'confirmed'
|
||||
).length;
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles['dashboard']}>
|
||||
<header className={styles['dashboard__header']}>
|
||||
<div className={styles['dashboard__header-inner']}>
|
||||
<div>
|
||||
<h1 className={styles['dashboard__title']}>Welcome back</h1>
|
||||
<p className={styles['dashboard__subtitle']}>{user?.email}</p>
|
||||
</div>
|
||||
<div className={styles['dashboard__header-actions']}>
|
||||
<Link to="/reservations" className={styles['dashboard__res-link']}>
|
||||
<BookOpen size={15} />
|
||||
My Reservations
|
||||
{activeCount > 0 && (
|
||||
<span className={styles['dashboard__res-badge']}>{activeCount}</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
className={styles['dashboard__logout-btn']}
|
||||
onClick={() => setProfileOpen(true)}
|
||||
>
|
||||
<UserCog size={15} /> Profile
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<button className={styles['dashboard__logout-btn']} onClick={logout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles['dashboard__main']}>
|
||||
<AvailabilitySearch onSelectSlot={setPrefillSlot} />
|
||||
|
||||
<SlotFinder onSelectSlot={setPrefillSlot} />
|
||||
|
||||
<div className={styles['dashboard__grid']}>
|
||||
<section className={styles['dashboard__booking']}>
|
||||
<BookingWizard
|
||||
prefillSlot={prefillSlot}
|
||||
onPrefillUsed={() => setPrefillSlot(null)}
|
||||
/>
|
||||
</section>
|
||||
<aside className={styles['dashboard__aside']}>
|
||||
<MyReservations />
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ProfileEditModal isOpen={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
118
src/pages/DashboardPage.module.scss
Normal file
118
src/pages/DashboardPage.module.scss
Normal file
@@ -0,0 +1,118 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
// ── Block ─────────────────────────────────────────────────────────────────────
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
background: var(--clr-bg);
|
||||
|
||||
// ── Elements ────────────────────────────────────────────────────────────────
|
||||
&__header {
|
||||
background: var(--clr-surface);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__header-inner {
|
||||
max-width: $container-max-width;
|
||||
margin-inline: auto;
|
||||
padding: $spacing-4 $spacing-6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--clr-text);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary);
|
||||
margin-top: $spacing-1;
|
||||
}
|
||||
|
||||
&__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
|
||||
&__res-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-4;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
background: transparent;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--clr-surface-raised);
|
||||
color: var(--clr-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__res-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: $primary-500;
|
||||
color: #fff;
|
||||
border-radius: $radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
&__logout-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-4;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
background: transparent;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--clr-surface-raised);
|
||||
color: var(--clr-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
max-width: $container-max-width;
|
||||
margin-inline: auto;
|
||||
padding: $spacing-8 $spacing-6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-6;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: $spacing-6;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: $bp-lg) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
&__booking { min-width: 0; }
|
||||
|
||||
&__aside { }
|
||||
}
|
||||
222
src/pages/LoginPage.jsx
Normal file
222
src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
// [REQ F1] System uwierzytelniania – formularz logowania + ekran weryfikacji 2FA
|
||||
// [REQ D2] Zaawansowane formularze – Formik + Zod (toFormikValidationSchema)
|
||||
// [REQ T8] Obsługa formularzy i walidacja – walidacja e-mail i hasła, błędy inline
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import { ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { useFormik } from 'formik';
|
||||
import { z } from 'zod';
|
||||
import { toFormikValidationSchema } from 'zod-formik-adapter';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import styles from './LoginPage.module.scss';
|
||||
// kkonfiguracaj schematu walidacji formularza logowania dla Zod
|
||||
const schema = z.object({
|
||||
email: z.string().email('Enter a valid email address.'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters.'),
|
||||
});
|
||||
// komponent LoginPage to strona logowania, która obsługuje zarówno formularz logowania jak i ekran weryfikacji 2FA
|
||||
const LoginPage = () => {
|
||||
// wyciągnięcie funkcji i stanów z useAuth - login do logowania,
|
||||
// user do sprawdzania czy użytkownik jest zalogowany, error do wyświetlania błędów logowania,
|
||||
// clearError do czyszczenia błędów, pendingUser do sprawdzania czy jesteśmy w trakcie weryfikacji
|
||||
// 2FA, twoFACode do przechowywania kodu 2FA, verify2FA do weryfikacji kodu 2FA, cancel2FA do anulowania procesu 2FA
|
||||
const { login, user, error, clearError, pendingUser, twoFACode, verify2FA, cancel2FA } = useAuth();
|
||||
// useState do przechowywania stanu inputa 2FA oraz błędu 2FA, które są używane tylko w ekranie weryfikacji 2FA
|
||||
const [twoFAInput, setTwoFAInput] = useState('');
|
||||
const [twoFAError, setTwoFAError] = useState('');
|
||||
// useNavigate i useLocation do nawigacji i sprawdzania aktualnej lokalizacji w aplikacji React Router
|
||||
const navigate = useNavigate();
|
||||
// useLocation zwraca obiekt reprezentujący aktualną lokalizację, który zawiera informacje o ścieżce, stanie i innych parametrach URL.
|
||||
const location = useLocation();
|
||||
// useEffect do przekierowywania zalogowanego użytkownika na odpowiednią stronę po zalogowaniu, wywoływany przy zmianie stanu user
|
||||
useEffect(() => {
|
||||
if (user) navigate(user.role === 'admin' ? '/admin' : '/dashboard', { replace: true });
|
||||
}, [user, navigate]);
|
||||
// konfiguracja hooka useFormik do obsługi formularza logowania, który przyjmuje initialValues, validationSchema i onSubmit
|
||||
const formik = useFormik({
|
||||
initialValues: { email: '', password: '' },
|
||||
validationSchema: toFormikValidationSchema(schema),
|
||||
onSubmit: async ({ email, password }) => {
|
||||
clearError();
|
||||
try {
|
||||
await login(email, password);
|
||||
// login returns { twoFAPending: true } — LoginPage will show 2FA step
|
||||
} catch {
|
||||
// error state is managed by AuthContext
|
||||
}
|
||||
},
|
||||
});
|
||||
// funkcja do obsługi weryfikacji 2FA, która jest wywoływana po submitowaniu formularza weryfikacji 2FA
|
||||
const handle2FA = (e) => {
|
||||
//e.preventDefault() zapobiega domyślnej akcji submitowania formularza, która powodowałaby przeładowanie strony, dzięki temu możemy obsłużyć weryfikację 2FA bez przeładowania strony
|
||||
e.preventDefault();
|
||||
// wyczyszczenie błędu 2FA przed próbą weryfikacji
|
||||
setTwoFAError('');
|
||||
try {
|
||||
// loggedIn to wynik funkcji verify2FA, która sprawdza czy wprowadzony kod 2FA jest poprawny, jeśli jest poprawny zwraca obiekt zalogowanego użytkownika, jeśli nie jest poprawny rzuca błąd
|
||||
const loggedIn = verify2FA(twoFAInput.trim());
|
||||
// uzyskanie ścieżki, z której użytkownik został przekierowany na stronę logowania, jeśli istnieje, lub ustawienie domyślnej ścieżki w zależności od roli użytkownika (admin -> /admin, client -> /dashboard)
|
||||
const from = location.state?.from?.pathname;
|
||||
navigate(from || (loggedIn.role === 'admin' ? '/admin' : '/dashboard'), { replace: true });
|
||||
} catch (err) {
|
||||
setTwoFAError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 2FA screen
|
||||
// pendingUser to stan z AuthContext, który jest ustawiany na obiekt użytkownika po poprawnym logowaniu hasła
|
||||
// , ale przed weryfikacją 2FA, jeśli pendingUser jest ustawiony to wyświetlamy ekran weryfikacji 2FA zamiast formularza logowania
|
||||
// jest to fake email
|
||||
if (pendingUser) {
|
||||
return (
|
||||
<div className={styles['login']}>
|
||||
<div className={styles['login__card']}>
|
||||
<div className={styles['login__header']}>
|
||||
<ShieldCheck size={36} className={styles['login__twofa-icon']} />
|
||||
<h1 className={styles['login__title']}>Two-Factor Auth</h1>
|
||||
<p className={styles['login__subtitle']}>
|
||||
A 6-digit code was sent to <strong>{pendingUser.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fake email preview showing the code */}
|
||||
<div className={styles['login__code-preview']}>
|
||||
<div className={styles['login__code-bar']}>
|
||||
<span className={styles['login__code-dot']} />
|
||||
<span className={styles['login__code-dot']} />
|
||||
<span className={styles['login__code-dot']} />
|
||||
<span className={styles['login__code-bar-label']}>Security email</span>
|
||||
</div>
|
||||
<div className={styles['login__code-body']}>
|
||||
<p>Hello {pendingUser.name ?? pendingUser.email},</p>
|
||||
<p>Your verification code is:</p>
|
||||
<p className={styles['login__code-number']}>{twoFACode}</p>
|
||||
<p className={styles['login__code-note']}>This code expires in 5 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handle2FA} className={styles['login__form']}>
|
||||
{twoFAError && (
|
||||
<div className={styles['login__alert']} role="alert">
|
||||
<span>{twoFAError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles['login__field']}>
|
||||
<label htmlFor="code" className={styles['login__label']}>Enter code</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
className={styles['login__input']}
|
||||
placeholder="000000"
|
||||
value={twoFAInput}
|
||||
onChange={(e) => setTwoFAInput(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className={styles['login__submit']}>
|
||||
Verify & Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['login__register-link']}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||
onClick={() => { cancel2FA(); setTwoFAInput(''); setTwoFAError(''); }}
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// renderujemy normalny ekran logowania jeśli nie jesteśmy w trakcie weryfikacji 2FA (pendingUser jest null)
|
||||
return (
|
||||
<div className={styles['login']}>
|
||||
<div className={styles['login__card']}>
|
||||
<div className={styles['login__header']}>
|
||||
<h1 className={styles['login__title']}>Reservation System</h1>
|
||||
<p className={styles['login__subtitle']}>Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form className={styles['login__form']} onSubmit={formik.handleSubmit} noValidate>
|
||||
{location.state?.registered && (
|
||||
<div className={styles['login__success']} role="status">
|
||||
Account verified! You can now sign in.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={styles['login__alert']} role="alert">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['login__alert-close']}
|
||||
onClick={clearError}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles['login__field']}>
|
||||
<label htmlFor="email" className={styles['login__label']}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className={[
|
||||
styles['login__input'],
|
||||
formik.touched.email && formik.errors.email ? styles['login__input--error'] : '',
|
||||
].join(' ')}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
{...formik.getFieldProps('email')}
|
||||
/>
|
||||
{formik.touched.email && formik.errors.email && (
|
||||
<p className={styles['login__field-error']}>{formik.errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles['login__field']}>
|
||||
<label htmlFor="password" className={styles['login__label']}>Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className={[
|
||||
styles['login__input'],
|
||||
formik.touched.password && formik.errors.password ? styles['login__input--error'] : '',
|
||||
].join(' ')}
|
||||
placeholder="password"
|
||||
autoComplete="current-password"
|
||||
{...formik.getFieldProps('password')}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<p className={styles['login__field-error']}>{formik.errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['login__submit']}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles['login__register-link']}>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className={styles['login__link']}>Sign up</Link>
|
||||
</p>
|
||||
|
||||
<div className={styles['login__hint']}>
|
||||
<p><strong>Admin:</strong> admin@reservations.dev / Admin1234!</p>
|
||||
<p><strong>Client:</strong> anna.kowalski@example.com / Client1234!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// eksportowanie komponentu LoginPage jako domyślnego eksportu z tego pliku, dzięki czemu można go importować w innych częściach aplikacji
|
||||
export default LoginPage;
|
||||
227
src/pages/LoginPage.module.scss
Normal file
227
src/pages/LoginPage.module.scss
Normal file
@@ -0,0 +1,227 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
// ── Block ─────────────────────────────────────────────────────────────────────
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, $primary-800 0%, $primary-600 50%, $accent-600 100%);
|
||||
padding: $spacing-4;
|
||||
|
||||
// ── Elements ────────────────────────────────────────────────────────────────
|
||||
&__card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--clr-surface, #{$color-surface});
|
||||
border-radius: $radius-xl;
|
||||
box-shadow: $shadow-xl;
|
||||
padding: $spacing-10 $spacing-8;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-8;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $primary-700;
|
||||
margin-bottom: $spacing-1;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-5;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: $radius-base;
|
||||
color: #991b1b;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&__alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: $font-size-lg;
|
||||
line-height: 1;
|
||||
color: #991b1b;
|
||||
padding: 0 $spacing-1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { opacity: 0.7; }
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
border: 1px solid var(--clr-border, #{$color-border});
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-base;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
background: var(--clr-surface, #{$color-surface});
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
outline: none;
|
||||
|
||||
&::placeholder { color: var(--clr-text-muted, #{$color-text-muted}); }
|
||||
|
||||
&:focus {
|
||||
border-color: $primary-400;
|
||||
box-shadow: 0 0 0 3px rgba($primary-400, 0.2);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: $color-error;
|
||||
|
||||
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.2); }
|
||||
}
|
||||
}
|
||||
|
||||
&__field-error {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-error;
|
||||
margin-top: $spacing-1;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
margin-top: $spacing-2;
|
||||
width: 100%;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: var(--clr-primary, #{$color-primary});
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, opacity $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--clr-primary-hover, #{$color-primary-hover}); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__success {
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: $radius-base;
|
||||
color: #15803d;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
&__register-link {
|
||||
margin-top: $spacing-5;
|
||||
text-align: center;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--clr-primary, #{$color-primary});
|
||||
font-weight: $font-weight-medium;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-top: $spacing-6;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: var(--clr-surface-raised, #{$gray-50});
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
line-height: $line-height-relaxed;
|
||||
|
||||
strong { color: var(--clr-text, #{$color-text-primary}); }
|
||||
}
|
||||
|
||||
// ── 2FA elements ─────────────────────────────────────────────────────────────
|
||||
&__twofa-icon {
|
||||
color: $primary-600;
|
||||
margin-bottom: $spacing-3;
|
||||
}
|
||||
|
||||
&__code-preview {
|
||||
margin-bottom: $spacing-6;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&__code-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-4;
|
||||
background: var(--clr-surface-raised, #{$gray-50});
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
}
|
||||
|
||||
&__code-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: $radius-full;
|
||||
background: var(--clr-border);
|
||||
}
|
||||
|
||||
&__code-bar-label {
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__code-body {
|
||||
padding: $spacing-4;
|
||||
color: var(--clr-text-secondary);
|
||||
line-height: $line-height-relaxed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-2;
|
||||
|
||||
p { margin: 0; }
|
||||
}
|
||||
|
||||
&__code-number {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-bold;
|
||||
letter-spacing: 0.2em;
|
||||
color: $primary-600;
|
||||
text-align: center;
|
||||
padding: $spacing-3 0;
|
||||
}
|
||||
|
||||
&__code-note {
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-muted);
|
||||
}
|
||||
}
|
||||
269
src/pages/RegisterPage.jsx
Normal file
269
src/pages/RegisterPage.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
// [REQ F1] System uwierzytelniania – formularz rejestracji z weryfikacją e-mail
|
||||
// [REQ T8] Obsługa formularzy i walidacja – Formik + Zod, błędy inline przy każdym polu
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useFormik } from 'formik';
|
||||
import { z } from 'zod';
|
||||
import { toFormikValidationSchema } from 'zod-formik-adapter';
|
||||
import { Mail, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { registerUser, verifyUser } from '../api/users';
|
||||
import styles from './RegisterPage.module.scss';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters.'),
|
||||
email: z.string().email('Enter a valid email address.'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters.')
|
||||
.regex(/[A-Z]/, 'Must contain at least one uppercase letter.')
|
||||
.regex(/\d/, 'Must contain at least one digit.'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
// superRefine pozwala na dodanie niestandardowej walidacji, która porównuje dwa pola password i confirmPassword, jeśli nie są takie same to dodaje błąd do confirmPassword
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Passwords do not match.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [stage, setStage] = useState('form'); // 'form' | 'sent' | 'done'
|
||||
const [pending, setPending] = useState(null);
|
||||
const [serverError, setServerError] = useState('');
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: { name: '', email: '', password: '', confirmPassword: '' },
|
||||
validationSchema: toFormikValidationSchema(schema),
|
||||
onSubmit: async ({ name, email, password }) => {
|
||||
setServerError('');
|
||||
try {
|
||||
const result = await registerUser({ name, email, password });
|
||||
setPending(result);
|
||||
setStage('sent');
|
||||
} catch (err) {
|
||||
setServerError(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleVerify = async () => {
|
||||
setVerifying(true);
|
||||
try {
|
||||
await verifyUser(pending.token);
|
||||
setStage('done');
|
||||
setTimeout(() => navigate('/login', { state: { registered: true } }), 2500);
|
||||
} catch (err) {
|
||||
setServerError(err.message);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
// jeśli stage jest 'done' to wyświetlamy ekran sukcesu z informacją o weryfikacji konta i przekierowaniu do logowania
|
||||
if (stage === 'done') {
|
||||
return (
|
||||
<div className={styles['reg']}>
|
||||
<div className={styles['reg__card']}>
|
||||
<div className={styles['reg__success']}>
|
||||
<CheckCircle2 size={52} className={styles['reg__success-icon']} />
|
||||
<h2 className={styles['reg__success-title']}>Account verified!</h2>
|
||||
<p className={styles['reg__success-sub']}>Redirecting you to sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// jeśli stage jest 'sent' to wyświetlamy ekran z informacją o wysłaniu maila weryfikacyjnego oraz podglądem tego maila,
|
||||
// z którego można kliknąć przycisk weryfikacji, który wywołuje funkcję handleVerify
|
||||
if (stage === 'sent') {
|
||||
return (
|
||||
<div className={styles['reg']}>
|
||||
<div className={[styles['reg__card'], styles['reg__card--wide']].join(' ')}>
|
||||
<div className={styles['reg__header']}>
|
||||
<Mail size={32} className={styles['reg__mail-icon']} />
|
||||
<h1 className={styles['reg__title']}>Check your inbox</h1>
|
||||
<p className={styles['reg__subtitle']}>
|
||||
We sent a verification link to <strong>{pending.user.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles['reg__email-preview']}>
|
||||
<div className={styles['reg__email-bar']}>
|
||||
<span className={styles['reg__email-dot']} />
|
||||
<span className={styles['reg__email-dot']} />
|
||||
<span className={styles['reg__email-dot']} />
|
||||
<span className={styles['reg__email-bar-label']}>Email preview</span>
|
||||
</div>
|
||||
<div className={styles['reg__email-meta']}>
|
||||
<div className={styles['reg__email-row']}>
|
||||
<span className={styles['reg__email-key']}>From</span>
|
||||
<span className={styles['reg__email-val']}>noreply@reservations.dev</span>
|
||||
</div>
|
||||
<div className={styles['reg__email-row']}>
|
||||
<span className={styles['reg__email-key']}>To</span>
|
||||
<span className={styles['reg__email-val']}>{pending.user.email}</span>
|
||||
</div>
|
||||
<div className={styles['reg__email-row']}>
|
||||
<span className={styles['reg__email-key']}>Subject</span>
|
||||
<span className={styles['reg__email-val']}>Verify your Reservation System account</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['reg__email-body']}>
|
||||
<p className={styles['reg__email-greeting']}>Hello {pending.user.name},</p>
|
||||
<p className={styles['reg__email-text']}>
|
||||
Thanks for signing up! Click the button below to verify your email address
|
||||
and activate your account.
|
||||
</p>
|
||||
<button
|
||||
className={styles['reg__email-cta']}
|
||||
onClick={handleVerify}
|
||||
disabled={verifying}
|
||||
>
|
||||
{verifying
|
||||
? <><Loader2 size={16} className={styles['reg__spinner']} /> Verifying...</>
|
||||
: 'Verify my account'
|
||||
}
|
||||
</button>
|
||||
<p className={styles['reg__email-footer']}>
|
||||
This link expires in 24 hours. If you did not create an account, ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<p className={styles['reg__server-error']}>{serverError}</p>
|
||||
)}
|
||||
|
||||
<p className={styles['reg__resend']}>
|
||||
Wrong email?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles['reg__link-btn']}
|
||||
onClick={() => { setStage('form'); setPending(null); }}
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// jeśli stage jest 'form' to wyświetlamy normalny formularz rejestracji, który jest obsługiwany przez hook useFormik
|
||||
// , a błędy walidacji są wyświetlane inline przy każdym polu, a błąd serwera jest wyświetlany na górze formularza
|
||||
return (
|
||||
<div className={styles['reg']}>
|
||||
<div className={styles['reg__card']}>
|
||||
<div className={styles['reg__header']}>
|
||||
<h1 className={styles['reg__title']}>Create account</h1>
|
||||
<p className={styles['reg__subtitle']}>Join the Reservation System</p>
|
||||
</div>
|
||||
|
||||
<form className={styles['reg__form']} onSubmit={formik.handleSubmit} noValidate>
|
||||
{serverError && (
|
||||
<div className={styles['reg__alert']} role="alert">{serverError}</div>
|
||||
)}
|
||||
|
||||
<div className={styles['reg__field']}>
|
||||
<label htmlFor="name" className={styles['reg__label']}>Full name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
className={[
|
||||
styles['reg__input'],
|
||||
formik.touched.name && formik.errors.name ? styles['reg__input--error'] : '',
|
||||
].join(' ')}
|
||||
placeholder="Jan Kowalski"
|
||||
autoComplete="name"
|
||||
{...formik.getFieldProps('name')}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<p className={styles['reg__field-error']}>{formik.errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles['reg__field']}>
|
||||
<label htmlFor="email" className={styles['reg__label']}>Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className={[
|
||||
styles['reg__input'],
|
||||
formik.touched.email && formik.errors.email ? styles['reg__input--error'] : '',
|
||||
].join(' ')}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
{...formik.getFieldProps('email')}
|
||||
/>
|
||||
{formik.touched.email && formik.errors.email && (
|
||||
<p className={styles['reg__field-error']}>{formik.errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles['reg__field']}>
|
||||
<label htmlFor="password" className={styles['reg__label']}>Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className={[
|
||||
styles['reg__input'],
|
||||
formik.touched.password && formik.errors.password ? styles['reg__input--error'] : '',
|
||||
].join(' ')}
|
||||
placeholder="min. 8 characters"
|
||||
autoComplete="new-password"
|
||||
{...formik.getFieldProps('password')}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
<p className={styles['reg__field-error']}>{formik.errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles['reg__field']}>
|
||||
<label htmlFor="confirmPassword" className={styles['reg__label']}>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
className={[
|
||||
styles['reg__input'],
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? styles['reg__input--error']
|
||||
: '',
|
||||
].join(' ')}
|
||||
placeholder="repeat password"
|
||||
autoComplete="new-password"
|
||||
{...formik.getFieldProps('confirmPassword')}
|
||||
/>
|
||||
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||
<p className={styles['reg__field-error']}>{formik.errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['reg__submit']}
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting
|
||||
? <><Loader2 size={16} className={styles['reg__spinner']} /> Creating account...</>
|
||||
: 'Create account'
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles['reg__signin-link']}>
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className={styles['reg__link']}>Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
305
src/pages/RegisterPage.module.scss
Normal file
305
src/pages/RegisterPage.module.scss
Normal file
@@ -0,0 +1,305 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.reg {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, $primary-800 0%, $primary-600 50%, $accent-600 100%);
|
||||
padding: $spacing-4;
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--clr-surface, #{$color-surface});
|
||||
border-radius: $radius-xl;
|
||||
box-shadow: $shadow-xl;
|
||||
padding: $spacing-10 $spacing-8;
|
||||
|
||||
&--wide { max-width: 560px; }
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-8;
|
||||
}
|
||||
|
||||
&__mail-icon {
|
||||
color: $primary-600;
|
||||
margin-bottom: $spacing-3;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $primary-700;
|
||||
margin-bottom: $spacing-1;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
|
||||
strong { color: var(--clr-text, #{$color-text-primary}); }
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-5;
|
||||
}
|
||||
|
||||
&__alert {
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: $radius-base;
|
||||
color: #991b1b;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100%;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
border: 1px solid var(--clr-border, #{$color-border});
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-base;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
background: var(--clr-surface, #{$color-surface});
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
outline: none;
|
||||
|
||||
&::placeholder { color: var(--clr-text-muted, #{$color-text-muted}); }
|
||||
|
||||
&:focus {
|
||||
border-color: $primary-400;
|
||||
box-shadow: 0 0 0 3px rgba($primary-400, 0.2);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: $color-error;
|
||||
&:focus { border-color: $color-error; box-shadow: 0 0 0 3px rgba($color-error, 0.2); }
|
||||
}
|
||||
}
|
||||
|
||||
&__field-error {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
margin-top: $spacing-2;
|
||||
width: 100%;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: var(--clr-primary, #{$color-primary});
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-2;
|
||||
transition: background $transition-fast, opacity $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--clr-primary-hover, #{$color-primary-hover}); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
animation: reg-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
&__signin-link {
|
||||
margin-top: $spacing-6;
|
||||
text-align: center;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
}
|
||||
|
||||
&__link {
|
||||
color: var(--clr-primary, #{$color-primary});
|
||||
font-weight: $font-weight-medium;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
&__link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--clr-primary, #{$color-primary});
|
||||
font-weight: $font-weight-medium;
|
||||
font-size: $font-size-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
// ── Email preview ─────────────────────────────────────────────────────────────
|
||||
|
||||
&__email-preview {
|
||||
border: 1px solid var(--clr-border, #{$color-border});
|
||||
border-radius: $radius-xl;
|
||||
overflow: hidden;
|
||||
margin-bottom: $spacing-6;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__email-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-3 $spacing-4;
|
||||
background: var(--clr-surface-raised, #{$gray-100});
|
||||
border-bottom: 1px solid var(--clr-border, #{$color-border});
|
||||
}
|
||||
|
||||
&__email-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: $gray-300;
|
||||
|
||||
&:nth-child(1) { background: #fc5f5a; }
|
||||
&:nth-child(2) { background: #fdbe2c; }
|
||||
&:nth-child(3) { background: #27c840; }
|
||||
}
|
||||
|
||||
&__email-bar-label {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-muted, #{$color-text-muted});
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__email-meta {
|
||||
padding: $spacing-3 $spacing-5;
|
||||
background: var(--clr-surface-raised, #{$gray-50});
|
||||
border-bottom: 1px solid var(--clr-border, #{$color-border});
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-1;
|
||||
}
|
||||
|
||||
&__email-row {
|
||||
display: flex;
|
||||
gap: $spacing-3;
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
&__email-key {
|
||||
color: var(--clr-text-muted, #{$color-text-muted});
|
||||
font-weight: $font-weight-semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__email-val {
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
}
|
||||
|
||||
&__email-body {
|
||||
padding: $spacing-6 $spacing-6 $spacing-5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-4;
|
||||
}
|
||||
|
||||
&__email-greeting {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
}
|
||||
|
||||
&__email-text {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
line-height: $line-height-relaxed;
|
||||
}
|
||||
|
||||
&__email-cta {
|
||||
align-self: center;
|
||||
padding: $spacing-3 $spacing-8;
|
||||
background: var(--clr-primary, #{$color-primary});
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--clr-primary-hover, #{$color-primary-hover}); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__email-footer {
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-muted, #{$color-text-muted});
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--clr-border, #{$color-border});
|
||||
padding-top: $spacing-4;
|
||||
margin-top: $spacing-2;
|
||||
}
|
||||
|
||||
&__server-error {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-error;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__resend {
|
||||
text-align: center;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
}
|
||||
|
||||
// ── Success state ─────────────────────────────────────────────────────────────
|
||||
|
||||
&__success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-4;
|
||||
padding: $spacing-6 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__success-icon { color: #16a34a; }
|
||||
|
||||
&__success-title {
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-bold;
|
||||
color: var(--clr-text, #{$color-text-primary});
|
||||
}
|
||||
|
||||
&__success-sub {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary, #{$color-text-secondary});
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reg-spin { to { transform: rotate(360deg); } }
|
||||
237
src/pages/ReservationsPage.jsx
Normal file
237
src/pages/ReservationsPage.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// [REQ F6] Zarządzanie rezerwacjami – lista rezerwacji użytkownika, zmiana terminu, anulowanie
|
||||
// [REQ F10] Powiadomienia – toast na potwierdzenie akcji (zmiana, anulowanie)
|
||||
// [REQ T1] Podstawowe hooki – useState, useEffect, useRef
|
||||
// [REQ T3] React Router – useSearchParams do filtrowania listy po statusie z URL
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
BookOpen, ChevronLeft, ChevronDown, ChevronUp,
|
||||
XCircle, AlertTriangle, CalendarDays, Clock,
|
||||
CreditCard, Hash, FileText, Tag, RefreshCw, ExternalLink, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useUserReservations } from '../hooks/useUserReservations';
|
||||
import { useUpdateReservation } from '../hooks/useUpdateReservation';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ReviewsSection from '../components/ReviewsSection';
|
||||
import RescheduleModal from '../components/RescheduleModal';
|
||||
import styles from './ReservationsPage.module.scss';
|
||||
|
||||
const todayISO = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const FILTERS = ['all', 'upcoming', 'past', 'cancelled'];
|
||||
const FILTER_LABELS = { all: 'All', upcoming: 'Upcoming', past: 'Past', cancelled: 'Cancelled' };
|
||||
|
||||
const ReservationCard = ({ reservation: r, service, onCancel, cancelPending, highlighted, user, onRebook }) => {
|
||||
const [open, setOpen] = useState(highlighted);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
const cardRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlighted && cardRef.current) {
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [highlighted]);
|
||||
|
||||
const isUpcoming = r.date >= todayISO() && r.status !== 'cancelled';
|
||||
const isPast = r.date < todayISO() && r.status !== 'cancelled';
|
||||
const canCancel = r.status === 'pending' || r.status === 'confirmed';
|
||||
// gcUrl to zmienna, która przechowuje link do dodania rezerwacji do Google Calendar, jeśli rezerwacja jest nadchodząca, jeśli rezerwacja jest przeszła to gcUrl jest null
|
||||
const gcUrl = (() => {
|
||||
if (!isUpcoming) return null;
|
||||
const [year, month, day] = r.date.split('-');
|
||||
const [h, m] = (r.time ?? '09:00').split(':');
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const start = `${year}${month}${day}T${pad(h)}${pad(m)}00`;
|
||||
const end = `${year}${month}${day}T${pad((+h + 1) % 24)}${pad(m)}00`;
|
||||
return `https://calendar.google.com/calendar/r/eventedit?text=${encodeURIComponent(service?.name ?? 'Reservation')}&dates=${start}/${end}&details=${encodeURIComponent(`Reservation #${r.id}`)}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={[
|
||||
styles['res-card'],
|
||||
r.status === 'cancelled' ? styles['res-card--cancelled'] : '',
|
||||
isUpcoming ? styles['res-card--upcoming'] : '',
|
||||
highlighted ? styles['res-card--highlighted'] : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
className={styles['res-card__toggle']}
|
||||
onClick={() => { setOpen((v) => !v); setConfirmCancel(false); }}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className={styles['res-card__summary']}>
|
||||
<span className={styles['res-card__name']}>{service?.name ?? r.serviceId}</span>
|
||||
<div className={styles['res-card__meta']}>
|
||||
<span className={styles['res-card__meta-item']}><CalendarDays size={12} /> {r.date}</span>
|
||||
<span className={styles['res-card__meta-item']}><Clock size={12} /> {r.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['res-card__right']}>
|
||||
<StatusBadge status={r.status} size="sm" />
|
||||
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={styles['res-card__body']}>
|
||||
<dl className={styles['res-card__dl']}>
|
||||
<div className={styles['res-card__dl-row']}><dt><Hash size={12} /> Reference</dt><dd>#{r.id}</dd></div>
|
||||
<div className={styles['res-card__dl-row']}><dt><Tag size={12} /> Service</dt><dd>{service?.name ?? r.serviceId}</dd></div>
|
||||
{service && (
|
||||
<div className={styles['res-card__dl-row']}><dt><CreditCard size={12} /> Price</dt><dd>${service.price} ({service.duration} min)</dd></div>
|
||||
)}
|
||||
<div className={styles['res-card__dl-row']}><dt><CalendarDays size={12} /> Date</dt><dd>{r.date} · {r.time}</dd></div>
|
||||
{r.depositPaid != null && (
|
||||
<div className={styles['res-card__dl-row']}><dt><CreditCard size={12} /> Deposit paid</dt><dd>${Number(r.depositPaid).toFixed(2)}</dd></div>
|
||||
)}
|
||||
{r.transactionId && (
|
||||
<div className={styles['res-card__dl-row']}><dt><Hash size={12} /> Transaction</dt><dd className={styles['res-card__mono']}>{r.transactionId}</dd></div>
|
||||
)}
|
||||
{r.specialRequirements && (
|
||||
<div className={styles['res-card__dl-row']}><dt><FileText size={12} /> Notes</dt><dd>{r.specialRequirements}</dd></div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className={styles['res-card__actions']}>
|
||||
{gcUrl && (
|
||||
<a href={gcUrl} target="_blank" rel="noopener noreferrer" className={styles['res-card__cal-btn']}>
|
||||
<ExternalLink size={13} /> Add to Google Calendar
|
||||
</a>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button className={styles['res-card__reschedule-btn']} onClick={() => setRescheduleOpen(true)}>
|
||||
<RefreshCw size={14} /> Reschedule
|
||||
</button>
|
||||
)}
|
||||
{(isPast || r.status === 'cancelled') && (
|
||||
<button className={styles['res-card__rebook-btn']} onClick={() => onRebook(r)}>
|
||||
<RotateCcw size={14} /> Book again
|
||||
</button>
|
||||
)}
|
||||
{canCancel && !confirmCancel && (
|
||||
<button className={styles['res-card__cancel-trigger']} onClick={() => setConfirmCancel(true)}>
|
||||
<XCircle size={14} /> Cancel reservation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmCancel && (
|
||||
<div className={styles['res-card__confirm']}>
|
||||
<AlertTriangle size={16} className={styles['res-card__confirm-icon']} />
|
||||
<p className={styles['res-card__confirm-text']}>Are you sure you want to cancel this reservation?</p>
|
||||
<div className={styles['res-card__confirm-btns']}>
|
||||
<button className={styles['res-card__confirm-yes']} onClick={() => { onCancel(r.id); setConfirmCancel(false); }} disabled={cancelPending}>
|
||||
{cancelPending ? 'Cancelling…' : 'Yes, cancel it'}
|
||||
</button>
|
||||
<button className={styles['res-card__confirm-no']} onClick={() => setConfirmCancel(false)}>Keep it</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPast && r.serviceId && (
|
||||
<div className={styles['res-card__reviews']}>
|
||||
<ReviewsSection serviceId={r.serviceId} reservationId={r.id} userId={user?.id ?? ''} userName={user?.name ?? user?.email ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RescheduleModal isOpen={rescheduleOpen} onClose={() => setRescheduleOpen(false)} reservation={r} serviceName={service?.name} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ReservationsPage = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const highlightId = searchParams.get('id');
|
||||
const { data: reservations = [], isLoading } = useUserReservations(user?.id);
|
||||
const { data: services = [] } = useServices();
|
||||
const { mutate: updateStatus, isPending: cancelPending } = useUpdateReservation();
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
const getService = (id) => services.find((s) => s.id === id);
|
||||
|
||||
const filtered = reservations.filter((r) => {
|
||||
if (filter === 'upcoming') return r.date >= todayISO() && r.status !== 'cancelled';
|
||||
if (filter === 'past') return r.date < todayISO() && r.status !== 'cancelled';
|
||||
if (filter === 'cancelled') return r.status === 'cancelled';
|
||||
return true;
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) =>
|
||||
filter === 'past'
|
||||
? (b.date + b.time).localeCompare(a.date + a.time)
|
||||
: (a.date + a.time).localeCompare(b.date + b.time)
|
||||
);
|
||||
|
||||
const counts = {
|
||||
all: reservations.length,
|
||||
upcoming: reservations.filter((r) => r.date >= todayISO() && r.status !== 'cancelled').length,
|
||||
past: reservations.filter((r) => r.date < todayISO() && r.status !== 'cancelled').length,
|
||||
cancelled: reservations.filter((r) => r.status === 'cancelled').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles['rp']}>
|
||||
<header className={styles['rp__header']}>
|
||||
<div className={styles['rp__header-inner']}>
|
||||
<div className={styles['rp__header-left']}>
|
||||
<Link to="/dashboard" className={styles['rp__back']}><ChevronLeft size={16} /> Dashboard</Link>
|
||||
<div>
|
||||
<h1 className={styles['rp__title']}><BookOpen size={20} /> My Reservations</h1>
|
||||
<p className={styles['rp__subtitle']}>{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['rp__header-actions']}>
|
||||
<ThemeToggle />
|
||||
<button className={styles['rp__logout']} onClick={logout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles['rp__main']}>
|
||||
<div className={styles['rp__tabs']}>
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f} className={[styles['rp__tab'], filter === f ? styles['rp__tab--active'] : ''].join(' ')} onClick={() => setFilter(f)}>
|
||||
{FILTER_LABELS[f]}
|
||||
{counts[f] > 0 && <span className={styles['rp__tab-count']}>{counts[f]}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles['rp__msg']}>Loading reservations…</p>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className={styles['rp__msg']}>
|
||||
{filter === 'all' ? 'You have no reservations yet.' : `No ${FILTER_LABELS[filter].toLowerCase()} reservations.`}
|
||||
</p>
|
||||
) : (
|
||||
<div className={styles['rp__list']}>
|
||||
{sorted.map((r) => (
|
||||
<ReservationCard
|
||||
key={r.id}
|
||||
reservation={r}
|
||||
service={getService(r.serviceId)}
|
||||
onCancel={(id) => updateStatus({ id, patch: { status: 'cancelled' } })}
|
||||
cancelPending={cancelPending}
|
||||
highlighted={r.id === highlightId}
|
||||
user={user}
|
||||
onRebook={(r) => navigate('/dashboard', { state: { rebookServiceId: r.serviceId } })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReservationsPage;
|
||||
477
src/pages/ReservationsPage.module.scss
Normal file
477
src/pages/ReservationsPage.module.scss
Normal file
@@ -0,0 +1,477 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.rp {
|
||||
min-height: 100vh;
|
||||
background: var(--clr-bg);
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
|
||||
&__header {
|
||||
background: var(--clr-surface);
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
&__header-inner {
|
||||
max-width: $container-max-width;
|
||||
margin-inline: auto;
|
||||
padding: $spacing-4 $spacing-6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-4;
|
||||
}
|
||||
|
||||
&__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-5;
|
||||
}
|
||||
|
||||
&__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text-secondary);
|
||||
text-decoration: none;
|
||||
padding: $spacing-1 $spacing-2;
|
||||
border-radius: $radius-base;
|
||||
transition: color $transition-fast, background $transition-fast;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--clr-text);
|
||||
background: var(--clr-surface-raised);
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--clr-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary);
|
||||
margin: $spacing-1 0 0;
|
||||
}
|
||||
|
||||
&__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
|
||||
&__logout {
|
||||
padding: $spacing-2 $spacing-4;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
background: transparent;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover { background: var(--clr-surface-raised); color: var(--clr-text); }
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
&__main {
|
||||
max-width: 780px;
|
||||
margin-inline: auto;
|
||||
padding: $spacing-8 $spacing-6;
|
||||
}
|
||||
|
||||
// ── Filter tabs ───────────────────────────────────────────────────────────
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: $spacing-2;
|
||||
margin-bottom: $spacing-6;
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-2;
|
||||
padding: $spacing-2 $spacing-4;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color $transition-fast, border-color $transition-fast;
|
||||
|
||||
&:hover { color: var(--clr-text); }
|
||||
|
||||
&--active {
|
||||
color: var(--clr-primary, #{$color-primary});
|
||||
border-bottom-color: var(--clr-primary, #{$color-primary});
|
||||
}
|
||||
}
|
||||
|
||||
&__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--clr-surface-raised, #{$gray-100});
|
||||
border-radius: $radius-full;
|
||||
font-size: 11px;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--clr-text-secondary);
|
||||
|
||||
.rp__tab--active & {
|
||||
background: rgba($primary-500, 0.12);
|
||||
color: $primary-600;
|
||||
}
|
||||
}
|
||||
|
||||
&__msg {
|
||||
text-align: center;
|
||||
padding: $spacing-16 0;
|
||||
font-size: $font-size-sm;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reservation card ─────────────────────────────────────────────────────────
|
||||
|
||||
.res-card {
|
||||
background: var(--clr-surface);
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-xl;
|
||||
overflow: hidden;
|
||||
transition: box-shadow $transition-fast;
|
||||
|
||||
&:hover { box-shadow: $shadow-md; }
|
||||
|
||||
&--cancelled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&--upcoming {
|
||||
border-left: 3px solid $primary-400;
|
||||
}
|
||||
|
||||
&--highlighted {
|
||||
box-shadow: 0 0 0 2px $primary-400, $shadow-md;
|
||||
animation: res-card-pulse 1.2s ease-out;
|
||||
}
|
||||
// @keyframes z wieloma klatkami
|
||||
// które zmieniają box-shadow z 0 0 0 4px rgba($primary-400, 0.5),
|
||||
// $shadow-md na 0 0 0 2px $primary-400, $shadow-md
|
||||
// animacja trwa 1.2s i jest ease-out
|
||||
// jest to animacja pulsowania karty rezerwacji, która jest wyróżniona
|
||||
@keyframes res-card-pulse {
|
||||
0% { box-shadow: 0 0 0 4px rgba($primary-400, 0.5), $shadow-md; }
|
||||
100% { box-shadow: 0 0 0 2px $primary-400, $shadow-md; }
|
||||
}
|
||||
|
||||
// ── Toggle button (collapsed header) ──────────────────────────────────────
|
||||
|
||||
&__toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-4;
|
||||
padding: $spacing-4 $spacing-5;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--clr-text);
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: var(--clr-surface-raised, #{$gray-50}); }
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: var(--clr-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-3;
|
||||
flex-shrink: 0;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
// ── Expanded body ─────────────────────────────────────────────────────────
|
||||
|
||||
&__body {
|
||||
border-top: 1px solid var(--clr-border);
|
||||
padding: $spacing-5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-4;
|
||||
}
|
||||
|
||||
&__dl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&__dl-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: $spacing-3;
|
||||
padding: $spacing-2 0;
|
||||
border-bottom: 1px solid var(--clr-border);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
dt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
color: var(--clr-text-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
font-size: $font-size-xs;
|
||||
color: var(--clr-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
&__mono {
|
||||
font-family: $font-family-mono;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// ── Actions row ───────────────────────────────────────────────────────────
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__pdf-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--clr-surface-raised);
|
||||
color: var(--clr-text);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Google Calendar link ──────────────────────────────────────────────────
|
||||
|
||||
&__cal-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid $accent-200;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $accent-700;
|
||||
text-decoration: none;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: $accent-50; }
|
||||
}
|
||||
|
||||
// ── Re-book trigger ───────────────────────────────────────────────────────
|
||||
|
||||
&__rebook-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid $accent-200;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: $accent-700;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: $accent-50; }
|
||||
}
|
||||
|
||||
// ── Reschedule trigger ────────────────────────────────────────────────────
|
||||
|
||||
&__reschedule-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid $primary-200;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: $primary-600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: $primary-50; }
|
||||
}
|
||||
|
||||
// ── Cancel trigger ────────────────────────────────────────────────────────
|
||||
|
||||
&__cancel-trigger {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
background: transparent;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: $color-error;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: #fef2f2; }
|
||||
}
|
||||
|
||||
// ── Cancel confirmation ───────────────────────────────────────────────────
|
||||
|
||||
&__confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-3;
|
||||
padding: $spacing-4;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: $radius-lg;
|
||||
}
|
||||
|
||||
&__confirm-icon {
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__confirm-text {
|
||||
font-size: $font-size-sm;
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__confirm-btns {
|
||||
display: flex;
|
||||
gap: $spacing-3;
|
||||
}
|
||||
|
||||
&__confirm-yes {
|
||||
padding: $spacing-2 $spacing-4;
|
||||
background: $color-error;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-family: $font-family-base;
|
||||
cursor: pointer;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) { opacity: 0.9; }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
&__confirm-no {
|
||||
padding: $spacing-2 $spacing-4;
|
||||
background: transparent;
|
||||
border: 1px solid var(--clr-border);
|
||||
border-radius: $radius-base;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-family: $font-family-base;
|
||||
color: var(--clr-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:hover { background: var(--clr-surface-raised); }
|
||||
}
|
||||
|
||||
&__reviews {
|
||||
margin-top: $spacing-4;
|
||||
padding-top: $spacing-4;
|
||||
border-top: 1px solid var(--clr-border);
|
||||
}
|
||||
}
|
||||
1
src/setupTests.js
Normal file
1
src/setupTests.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
50
src/stories/Modal.stories.jsx
Normal file
50
src/stories/Modal.stories.jsx
Normal 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.'
|
||||
/>
|
||||
),
|
||||
};
|
||||
63
src/stories/StatusBadge.stories.jsx
Normal file
63
src/stories/StatusBadge.stories.jsx
Normal 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>
|
||||
),
|
||||
};
|
||||
18
src/stories/ThemeToggle.stories.jsx
Normal file
18
src/stories/ThemeToggle.stories.jsx
Normal 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
125
src/styles/_variables.scss
Normal 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
118
src/styles/main.scss
Normal 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;
|
||||
}
|
||||
67
src/tests/BookingForm.test.jsx
Normal file
67
src/tests/BookingForm.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
46
src/tests/LoginPage.test.jsx
Normal file
46
src/tests/LoginPage.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
50
src/tests/ProtectedRoute.test.jsx
Normal file
50
src/tests/ProtectedRoute.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
29
src/tests/StatusBadge.test.jsx
Normal file
29
src/tests/StatusBadge.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
335
test_rms.mjs
Normal file
335
test_rms.mjs
Normal file
@@ -0,0 +1,335 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = 'http://localhost:5173';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// After a successful password submit, the app shows a 2FA screen with the code
|
||||
// displayed in the UI. This helper waits for it, reads the code, and submits.
|
||||
async function complete2FA(page) {
|
||||
const codeEl = page.locator('[class*="code-number"]').first();
|
||||
try {
|
||||
await codeEl.waitFor({ state: 'visible', timeout: 4000 });
|
||||
} catch {
|
||||
return; // 2FA screen did not appear — user likely already navigated
|
||||
}
|
||||
const code = await codeEl.innerText().catch(() => '');
|
||||
if (!code) return;
|
||||
await page.locator('input#code').fill(code.trim());
|
||||
await page.locator('button[type="submit"]').click();
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Reset seed reservations so the test is repeatable across runs.
|
||||
async function resetDb() {
|
||||
const api = 'http://localhost:3001';
|
||||
const patch = (id, body) =>
|
||||
fetch(`${api}/reservations/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Ensure r1 and r2 are pending so admin-confirm test always finds a button.
|
||||
await patch('r1', { status: 'pending' });
|
||||
await patch('r2', { status: 'pending' });
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await resetDb();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// Capture browser console errors
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') pageErrors.push(msg.text());
|
||||
});
|
||||
|
||||
const results = [];
|
||||
const ok = (l, n='') => results.push(`PASS ${l}${n ? ' — '+n : ''}`);
|
||||
const fail = (l, n='') => results.push(`FAIL ${l}${n ? ' — '+n : ''}`);
|
||||
const warn = (l, n='') => results.push(`WARN ${l}${n ? ' — '+n : ''}`);
|
||||
|
||||
// ── 1. Login page loads ────────────────────────────────────────────────────
|
||||
await page.goto(BASE + '/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const hasEmailInput = await page.locator('input[type="email"]').isVisible().catch(() => false);
|
||||
hasEmailInput ? ok('Login page loads (email field visible)') : fail('Login page missing email field');
|
||||
|
||||
// ── 2. Wrong credentials show error ───────────────────────────────────────
|
||||
await page.fill('input[type="email"]', 'wrong@example.com');
|
||||
await page.fill('input[type="password"]', 'wrongpass');
|
||||
await page.click('button[type="submit"]');
|
||||
await sleep(1000);
|
||||
const errorVisible = await page.locator('text=/invalid|incorrect|wrong/i').isVisible().catch(() => false);
|
||||
errorVisible ? ok('Wrong credentials → error message') : fail('Wrong credentials — no error shown');
|
||||
|
||||
// ── 3. Admin login ────────────────────────────────────────────────────────
|
||||
await page.fill('input[type="email"]', 'admin@reservations.dev');
|
||||
await page.fill('input[type="password"]', 'Admin1234!');
|
||||
await page.click('button[type="submit"]');
|
||||
await complete2FA(page);
|
||||
await page.waitForURL('**/admin', { timeout: 5000 }).catch(() => {});
|
||||
page.url().includes('/admin')
|
||||
? ok('Admin login → /admin')
|
||||
: fail('Admin redirect failed', page.url());
|
||||
|
||||
// ── 4. Admin table rows ───────────────────────────────────────────────────
|
||||
await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => {});
|
||||
const rowCount = await page.locator('tbody tr').count().catch(() => 0);
|
||||
rowCount > 0 ? ok('Admin table', `${rowCount} rows`) : fail('Admin table empty');
|
||||
|
||||
// ── 5. Confirm button on pending row ─────────────────────────────────────
|
||||
const confirmBtn = page.locator('[aria-label^="Confirm reservation"]').first();
|
||||
const hasConfirm = await confirmBtn.isVisible().catch(() => false);
|
||||
hasConfirm ? ok('Confirm button visible') : fail('Confirm button missing');
|
||||
|
||||
if (hasConfirm) {
|
||||
await confirmBtn.click();
|
||||
await sleep(1200);
|
||||
const confirmedBadge = await page.locator('text=Confirmed').count();
|
||||
confirmedBadge >= 1
|
||||
? ok('Confirm click updates row to Confirmed')
|
||||
: warn('Confirmed badge not found after click');
|
||||
}
|
||||
|
||||
// ── 6. Delete modal ───────────────────────────────────────────────────────
|
||||
// Wait for table to re-render after previous action
|
||||
await page.waitForSelector('table tbody tr', { timeout: 4000 }).catch(() => {});
|
||||
await sleep(500);
|
||||
const deleteBtn = page.locator('[aria-label^="Delete reservation"]').first();
|
||||
await deleteBtn.click().catch(() => {});
|
||||
await sleep(600);
|
||||
const modalVisible = await page.locator('[role="dialog"]').isVisible().catch(() => false);
|
||||
modalVisible ? ok('Delete confirmation modal opens') : fail('Delete modal did not open');
|
||||
// Dismiss via Cancel button
|
||||
const cancelModalBtn = page.locator('[class*="modal__btn--cancel"]').first();
|
||||
await cancelModalBtn.click().catch(() => {
|
||||
page.keyboard.press('Escape').catch(() => {});
|
||||
});
|
||||
await sleep(300);
|
||||
|
||||
// ── 7. Calendar tab ───────────────────────────────────────────────────────
|
||||
const calTab = page.locator('button', { hasText: /Calendar/i });
|
||||
const hasCalTab = await calTab.isVisible().catch(() => false);
|
||||
hasCalTab ? ok('Calendar tab button visible') : fail('Calendar tab missing');
|
||||
|
||||
if (hasCalTab) {
|
||||
await calTab.click();
|
||||
await sleep(600);
|
||||
const monthTitle = await page.locator('text=/January|February|March|April|May|June|July|August|September|October|November|December/').first().innerText().catch(() => '');
|
||||
monthTitle ? ok('Calendar view renders month', monthTitle) : fail('Calendar month title missing');
|
||||
const monCell = await page.locator('text=Mon').first().isVisible().catch(() => false);
|
||||
monCell ? ok('Calendar weekday headers render') : fail('Calendar weekday headers missing');
|
||||
|
||||
// Click a day cell that has bookings
|
||||
const busyCell = page.locator('[class*="cell--busy"]').first();
|
||||
const hasBusy = await busyCell.isVisible().catch(() => false);
|
||||
if (hasBusy) {
|
||||
await busyCell.click();
|
||||
await sleep(400);
|
||||
const panelVisible = await page.locator('[class*="cal__panel"]').isVisible().catch(() => false);
|
||||
panelVisible ? ok('Clicking busy day opens side panel') : fail('Calendar side panel did not open');
|
||||
} else {
|
||||
warn('No busy day cells visible in current month view');
|
||||
}
|
||||
|
||||
// Navigate to next month
|
||||
await page.locator('[aria-label="Next month"]').click().catch(() => {});
|
||||
await sleep(400);
|
||||
const newMonth = await page.locator('text=/January|February|March|April|May|June|July|August|September|October|November|December/').first().innerText().catch(() => '');
|
||||
newMonth !== monthTitle
|
||||
? ok('Calendar next-month navigation', newMonth)
|
||||
: fail('Calendar did not advance to next month');
|
||||
}
|
||||
|
||||
// ── 8. Admin logout → client login ───────────────────────────────────────
|
||||
await page.locator('button:has-text("Table")').click().catch(() => {});
|
||||
await sleep(200);
|
||||
await page.click('button:has-text("Sign out")').catch(() => {});
|
||||
await page.waitForURL('**/login', { timeout: 3000 }).catch(() => {});
|
||||
page.url().includes('/login') ? ok('Admin logout → /login') : warn('Logout redirect', page.url());
|
||||
|
||||
await page.fill('input[type="email"]', 'anna.kowalski@example.com');
|
||||
await page.fill('input[type="password"]', 'Client1234!');
|
||||
await page.click('button[type="submit"]');
|
||||
await complete2FA(page);
|
||||
await page.waitForURL('**/dashboard', { timeout: 5000 }).catch(() => {});
|
||||
page.url().includes('/dashboard') ? ok('Client login → /dashboard') : fail('Dashboard redirect', page.url());
|
||||
|
||||
// ── 9. AvailabilitySearch ─────────────────────────────────────────────────
|
||||
await page.waitForLoadState('networkidle');
|
||||
await sleep(500);
|
||||
const availTitle = await page.locator('text=Check Availability').isVisible().catch(() => false);
|
||||
availTitle ? ok('AvailabilitySearch section renders') : fail('AvailabilitySearch missing');
|
||||
|
||||
// ── 10. URL sync ──────────────────────────────────────────────────────────
|
||||
await page.locator('input[type="date"]').first().fill('2026-07-15');
|
||||
await page.locator('select').first().selectOption({ index: 1 });
|
||||
await sleep(1200);
|
||||
const urlAfterFilter = page.url();
|
||||
urlAfterFilter.includes('date=2026-07-15') ? ok('Date syncs to URL') : fail('Date param missing', urlAfterFilter);
|
||||
urlAfterFilter.includes('service=') ? ok('Service syncs to URL') : fail('Service param missing', urlAfterFilter);
|
||||
|
||||
// ── 11. Time slot grid ────────────────────────────────────────────────────
|
||||
const slotGrid = await page.locator('button:has-text("09:00")').isVisible().catch(() => false);
|
||||
slotGrid ? ok('Time slot grid renders (09:00 visible)') : fail('Time slot grid missing');
|
||||
|
||||
// ── 12. Slot click pre-fills wizard ──────────────────────────────────────
|
||||
const freeSlot = page.locator('[class*="slot--free"]').first();
|
||||
const hasFreeSlot = await freeSlot.isVisible().catch(() => false);
|
||||
if (hasFreeSlot) {
|
||||
const slotText = await freeSlot.innerText().catch(() => '?');
|
||||
await freeSlot.click();
|
||||
await sleep(600);
|
||||
const step3Active = await page.locator('text=/Special Requirements/i').isVisible().catch(() => false);
|
||||
step3Active ? ok(`Slot click (${slotText}) jumps wizard to step 3`) : fail('Slot click did not advance wizard');
|
||||
} else {
|
||||
warn('No free slots for selected date/service');
|
||||
}
|
||||
|
||||
// ── 13. BookingWizard step navigation ────────────────────────────────────
|
||||
await page.goto(BASE + '/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForSelector('[class*="wizard__step-title"]', { timeout: 5000 }).catch(() => {});
|
||||
const wizardTitle = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||||
wizardTitle.includes('Select a Service')
|
||||
? ok('Wizard renders at step 1 after reload')
|
||||
: fail('Wizard step 1 title missing', `got: "${wizardTitle}"`);
|
||||
|
||||
// Pick a service card
|
||||
const serviceCard = page.locator('[class*="service-card"]').first();
|
||||
const hasCards = await serviceCard.isVisible().catch(() => false);
|
||||
hasCards ? ok('Service cards render') : fail('Service cards missing');
|
||||
if (hasCards) await serviceCard.click();
|
||||
|
||||
// Next → step 2
|
||||
await page.locator('button:has-text("Next")').first().click().catch(() => {});
|
||||
await sleep(400);
|
||||
const step2Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||||
step2Title.includes('Date') ? ok('Wizard step 2 (Date & Time)') : fail('Step 2 not reached', step2Title);
|
||||
|
||||
// Fill date + time for a date with no conflicts
|
||||
await page.locator('input[type="date"]').last().fill('2026-09-01');
|
||||
await page.locator('input[type="time"]').last().fill('14:30');
|
||||
await page.locator('button:has-text("Next")').first().click().catch(() => {});
|
||||
await sleep(400);
|
||||
|
||||
const step3Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||||
step3Title.includes('Requirements') ? ok('Wizard step 3 (Requirements)') : fail('Step 3 not reached', step3Title);
|
||||
|
||||
await page.locator('button:has-text("Next")').first().click().catch(() => {});
|
||||
await sleep(400);
|
||||
|
||||
const step4Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||||
step4Title.includes('Summary') ? ok('Wizard step 4 (Summary)') : fail('Step 4 not reached', step4Title);
|
||||
|
||||
// ── 14. Payment modal full flow ───────────────────────────────────────────
|
||||
const payBtn = page.locator('button:has-text("Confirm & Pay")');
|
||||
const hasPayBtn = await payBtn.isVisible().catch(() => false);
|
||||
hasPayBtn ? ok('Confirm & Pay button in step 4') : fail('Confirm & Pay button missing');
|
||||
|
||||
if (hasPayBtn) {
|
||||
await payBtn.click();
|
||||
await sleep(500);
|
||||
const payModalVisible = await page.locator('text=/Secure Payment/i').isVisible().catch(() => false);
|
||||
payModalVisible ? ok('Payment modal opens') : fail('Payment modal did not open');
|
||||
|
||||
if (payModalVisible) {
|
||||
const depositAmt = await page.locator('[class*="deposit-amt"]').innerText().catch(() => '');
|
||||
depositAmt ? ok('Deposit amount displayed', depositAmt) : fail('Deposit amount missing');
|
||||
|
||||
// Fill card form using placeholder selectors
|
||||
await page.locator('input[placeholder*="1234"]').fill('4111111111111111');
|
||||
await page.locator('input[placeholder*="MM"]').fill('12/28');
|
||||
await page.locator('input[type="password"]').fill('123');
|
||||
await page.locator('input[placeholder*="John"]').fill('Jan Kowalski');
|
||||
await sleep(300);
|
||||
|
||||
// Check no validation errors yet
|
||||
const payDepositBtn = page.locator('[class*="pay__pay-btn"]');
|
||||
await payDepositBtn.click().catch(() => {});
|
||||
|
||||
// Wait for PaymentModal to reach 'done' phase (~1.5s) then createReservation POST (~200ms)
|
||||
// then setPaymentOpen(false) causing overlay to disappear
|
||||
await page.locator('[class*="pay__overlay"]').waitFor({ state: 'hidden', timeout: 8000 }).catch(() => {});
|
||||
await sleep(400);
|
||||
|
||||
const bookingConfirmed = await page.locator('text=/Booking Confirmed/i').isVisible().catch(() => false);
|
||||
bookingConfirmed
|
||||
? ok('Full booking flow — Booking Confirmed shown')
|
||||
: fail('Booking Confirmed not shown after payment');
|
||||
|
||||
// Check receipt fields appear
|
||||
const receiptVisible = await page.locator('[class*="wizard__receipt"]').isVisible().catch(() => false);
|
||||
receiptVisible ? ok('Receipt block rendered') : warn('Receipt block not visible');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 15. My Reservations sidebar ──────────────────────────────────────────
|
||||
// Navigate fresh to see sidebar (not in done state)
|
||||
await page.goto(BASE + '/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await sleep(600);
|
||||
const myResVisible = await page.locator('text=/My Reservations/i').first().isVisible().catch(() => false);
|
||||
myResVisible ? ok('My Reservations sidebar renders') : fail('My Reservations missing');
|
||||
|
||||
// Check it shows at least one reservation for this user (u2 has multiple)
|
||||
const resItems = await page.locator('[class*="my-res__item"]').count().catch(() => 0);
|
||||
resItems > 0 ? ok('My Reservations lists items', `${resItems} items`) : fail('No reservation items found');
|
||||
|
||||
// ── 16. Cancel from My Reservations ──────────────────────────────────────
|
||||
const cancelResBtn = page.locator('[class*="my-res__cancel-btn"]').first();
|
||||
const hasCancelRes = await cancelResBtn.isVisible().catch(() => false);
|
||||
if (hasCancelRes) {
|
||||
const countBefore = await page.locator('[class*="my-res__item"]').count();
|
||||
await cancelResBtn.click();
|
||||
await sleep(1000);
|
||||
const cancelledBadge = await page.locator('text=Cancelled').count();
|
||||
cancelledBadge >= 1 ? ok('Cancel from My Reservations updates status') : warn('Cancelled badge not found after cancel');
|
||||
} else {
|
||||
warn('No cancellable reservations visible in sidebar');
|
||||
}
|
||||
|
||||
// ── 17. Dark mode toggle ─────────────────────────────────────────────────
|
||||
const themeBtn = page.locator('[aria-label*="dark"],[aria-label*="light"],[aria-label*="theme"]').first();
|
||||
const hasTheme = await themeBtn.isVisible().catch(() => false);
|
||||
if (hasTheme) {
|
||||
await themeBtn.click();
|
||||
await sleep(300);
|
||||
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||
theme === 'dark' ? ok('Dark mode toggle works') : warn('data-theme after toggle', theme ?? 'null');
|
||||
} else {
|
||||
warn('Theme toggle not found');
|
||||
}
|
||||
|
||||
// ── 18. Protected route (client blocked from /admin) ─────────────────────
|
||||
await page.goto(BASE + '/admin');
|
||||
await sleep(600);
|
||||
page.url().includes('/dashboard')
|
||||
? ok('Client blocked from /admin → redirected to /dashboard')
|
||||
: fail('Client could access /admin', page.url());
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────
|
||||
if (pageErrors.length > 0) {
|
||||
warn('Browser console errors during test', pageErrors.slice(0, 3).join(' | '));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n' + '═'.repeat(62));
|
||||
console.log(' MANUAL TEST RESULTS — ' + new Date().toISOString().slice(0,19));
|
||||
console.log('═'.repeat(62));
|
||||
results.forEach((r) => console.log(' ' + r));
|
||||
const passed = results.filter((r) => r.startsWith('PASS')).length;
|
||||
const failed = results.filter((r) => r.startsWith('FAIL')).length;
|
||||
const warned = results.filter((r) => r.startsWith('WARN')).length;
|
||||
console.log('═'.repeat(62));
|
||||
console.log(` ${passed} passed · ${warned} warnings · ${failed} failed`);
|
||||
console.log('═'.repeat(62));
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
run().catch((err) => { console.error(err); process.exit(1); });
|
||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './src/setupTests.js',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user