diff --git a/.gitignore b/.gitignore index 3d70248ba2..edadc3b71f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,43 @@ -node_modules -.DS_Store +# --- Node --- +node_modules/ +**/node_modules/ + +# --- Build artifacts --- +dist/ +build/ +client/dist/ +server/dist/ +**/dist/ +*.log + +# --- Env (secrets) --- .env +*.env +**/.env +**/*.env .env.local -.env.development.local -.env.test.local -.env.production.local +client/.env +server/.env + +# --- Package locks --- +package-lock.json +yarn.lock +pnpm-lock.yaml + +# --- OS cruft --- +.DS_Store +Thumbs.db + +# --- Editor --- +.vscode/ +.idea/ -build +# --- Vite cache --- +.vite/ +**/.vite/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* +# --- Test coverage --- +coverage/ -package-lock.json \ No newline at end of file +# --- Misc --- +*.local diff --git a/Procfile b/Procfile deleted file mode 100644 index dc14c0b63a..0000000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: npm start --prefix backend \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 31466b54c2..0000000000 --- a/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Final Project - -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. - -## The problem - -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? - -## View it live - -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file diff --git a/README.txt b/README.txt new file mode 100644 index 0000000000..1ae8b56fa6 --- /dev/null +++ b/README.txt @@ -0,0 +1,129 @@ +# 💈 Barber Booking – Final Project + +A modern full-stack barber appointment app with real-time slot availability, email confirmations (ICS calendar invite), and a protected admin dashboard. +Built with a focus on clean code, accessibility, and responsive design. + +**Live site:** [https://barber-rico.netlify.app](https://barber-rico.netlify.app) +**API (Render):** [https://project-final-it0v.onrender.com](https://project-final-it0v.onrender.com) + +--- + +## 🆕 Project Update (Refactor Summary) + +This version includes a full refactor and code cleanup based on feedback: + +- Converted all components to **functional React** with hooks +- Simplified and reorganized file structure for readability +- Improved responsive design and dark/light theme consistency +- Ensured `.env` and `node_modules` are properly ignored in Git +- Verified all Technigo requirements are met + +--- + +## ✹ Features + +- Fast frontend (Vite + React + Tailwind) +- Dark/Light theme toggle (saved in localStorage) +- Calendar with selectable time slots (30-min increments) +- Smooth booking flow with confirmation screen +- Email confirmation + downloadable `.ics` calendar invite +- Admin dashboard (JWT-protected): list, mark as done, delete bookings +- Security middleware: rate limiting, Helmet, CORS +- SEO & PWA optimized (favicons, manifest, Open Graph tags) + +--- + +## đŸ§± Tech Stack + +**Frontend** +- React (functional components) +- React Router +- Tailwind CSS +- Vite + +**Backend** +- Node.js + Express +- MongoDB (Mongoose) +- Nodemailer (Mailtrap for dev) +- JWT authentication + +**Hosting** +- Frontend → **Netlify** +- Backend → **Render** + +--- + +## 📁 Project Structure + +```bash +project-final/ +├─ client/ # React app (Vite) +│ ├─ public/ # Favicon, manifest, logo, SEO assets +│ ├─ src/ +│ │ ├─ components/ # Header, ThemeToggle, ProtectedRoute, etc. +│ │ ├─ pages/ # SelectTime, DetailsPage, ConfirmPage, Admin +│ │ ├─ store/ # Contexts (Auth, Booking) +│ │ ├─ App.jsx, main.jsx, index.css, ... +│ └─ index.html +└─ server/ # Express API + ├─ index.js # Routes, email, DB connection + ├─ package.json + └─ .env # 🔒 private config (not committed) +``` + +**Booking schema:** +```js +// Unique index prevents double-booking +bookingSchema.index({ date: 1, time: 1 }, { unique: true }); +``` + +--- + +## 🔒 Security + +- Admin JWT stored in **sessionStorage** (auto-clears when tab closes) +- Uses `helmet`, `cors`, and rate limiting +- MongoDB unique index on date+time +- `.env` is excluded from Git (with `.env.example` provided) + +--- + +## 🌐 SEO & UX + +- Semantic, accessible structure (WCAG friendly) +- `` + meta descriptions and Open Graph tags +- Light/dark contrast verified +- Responsive across all breakpoints (320px → 1920px) +- Smooth transitions and animations for better UX + +--- + +## 🛠 Troubleshooting + +- **Admin login not working** + - Ensure `JWT_SECRET` exists in `.env` and restart server. + +- **CORS error** + - Check that `CLIENT_URL` in `.env` matches your frontend URL. + +- **Email not received** + - Use Mailtrap sandbox credentials in dev; check spam folder. + +- **Duplicate booking** + - MongoDB unique index on date+time prevents double-booking (returns 409). + +--- + +## ✅ Status + +✔ Functional React Components +✔ Responsive and accessible UI +✔ Code cleaned and organized after feedback +✔ Meets all Technigo project requirements + +--- + +## đŸ§© Author + +Built by **DevByRico** đŸŽšđŸ’» +_Refactored & improved based on Technigo feedback 2025._ \ No newline at end of file diff --git a/backend/.babelrc b/backend/.babelrc deleted file mode 100644 index cedf24f1a5..0000000000 --- a/backend/.babelrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "presets": [ - "@babel/preset-env" - ] -} \ No newline at end of file diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index d1438c9108..0000000000 --- a/backend/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Backend part of Final Project - -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. - -## Getting Started - -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index 08f29f2448..0000000000 --- a/backend/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "project-final-backend", - "version": "1.0.0", - "description": "Server part of final project", - "scripts": { - "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" - }, - "author": "", - "license": "ISC", - "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", - "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" - } -} \ No newline at end of file diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index 070c875189..0000000000 --- a/backend/server.js +++ /dev/null @@ -1,22 +0,0 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; - -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; - -const port = process.env.PORT || 8080; -const app = express(); - -app.use(cors()); -app.use(express.json()); - -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); - -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000000..78d395e04e --- /dev/null +++ b/client/index.html @@ -0,0 +1,38 @@ +<!-- client/index.html --> +<!doctype html> +<html lang="sv"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + + <title>BĂ€sta barbern – Boka + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000000..31acd7f942 --- /dev/null +++ b/client/package.json @@ -0,0 +1,28 @@ +{ + "name": "booking-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "dayjs": "^1.11.18", + "framer-motion": "^11.0.8", + "lucide-react": "^0.546.0", + "react": "^18.3.1", + "react-calendar": "^6.0.0", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "vite": "^5.2.0" + }, + "proxy": "http://localhost:5000" +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/public/_redirects b/client/public/_redirects new file mode 100644 index 0000000000..ad37e2c2c9 --- /dev/null +++ b/client/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/client/public/apple-touch-icon.png b/client/public/apple-touch-icon.png new file mode 100644 index 0000000000..3862b3f967 Binary files /dev/null and b/client/public/apple-touch-icon.png differ diff --git a/client/public/favicon-96x96.png b/client/public/favicon-96x96.png new file mode 100644 index 0000000000..6d1b352455 Binary files /dev/null and b/client/public/favicon-96x96.png differ diff --git a/client/public/favicon.ico b/client/public/favicon.ico new file mode 100644 index 0000000000..9aa6216ea6 Binary files /dev/null and b/client/public/favicon.ico differ diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000000..518967b706 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/client/public/icon-192.png b/client/public/icon-192.png new file mode 100644 index 0000000000..9ef2cd7c87 Binary files /dev/null and b/client/public/icon-192.png differ diff --git a/client/public/icon-512.png b/client/public/icon-512.png new file mode 100644 index 0000000000..6398e9549a Binary files /dev/null and b/client/public/icon-512.png differ diff --git a/client/public/logo.png b/client/public/logo.png new file mode 100644 index 0000000000..bff32ef4c3 Binary files /dev/null and b/client/public/logo.png differ diff --git a/client/public/robots.txt b/client/public/robots.txt new file mode 100644 index 0000000000..0faed1cdc9 --- /dev/null +++ b/client/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: http://localhost:5000/sitemap.xml diff --git a/client/public/site.webmanifest b/client/public/site.webmanifest new file mode 100644 index 0000000000..99ba14dd12 --- /dev/null +++ b/client/public/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "Barber Shop", + "short_name": "Barber", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } + ], + "theme_color": "#0ea5e9", + "background_color": "#0ea5e9", + "display": "standalone" +} diff --git a/client/public/sitemap.xml b/client/public/sitemap.xml new file mode 100644 index 0000000000..32941dc825 --- /dev/null +++ b/client/public/sitemap.xml @@ -0,0 +1,5 @@ + + + http://localhost:5000/ + http://localhost:5000/admin/login + diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000000..fc72774821 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import Header from "./components/Header"; +import SelectTime from "./pages/SelectTime"; +import DetailsPage from "./pages/DetailsPage"; +import Confirmation from "./pages/ConfirmationPage"; +import AdminDashboard from "./pages/AdminDashboard"; +import LoginPage from "./pages/LoginPage"; +import ProtectedRoute from "./components/ProtectedRoute"; + +export default function App() { + return ( + +
+ + } /> + } /> + } /> + } /> + + + + } + /> + Not found.

} /> +
+ + ); +} diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx new file mode 100644 index 0000000000..a8706289e2 --- /dev/null +++ b/client/src/components/Header.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import ThemeToggle from "./ThemeToggle"; +import { useAuth } from "../store/auth"; + +export default function Header() { + const { token, logout } = useAuth(); + + return ( +
+ + Best Barber Logo + Best Barber Booking + + +
+ + {token ? ( + <> + + Admin Panel + + + + ) : ( + + Admin + + )} +
+
+ ); +} diff --git a/client/src/components/ProtectedRoute.jsx b/client/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000000..1fcfc61630 --- /dev/null +++ b/client/src/components/ProtectedRoute.jsx @@ -0,0 +1,42 @@ +// src/components/ProtectedRoute.jsx +import React, { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuth } from "../store/auth"; + +export default function ProtectedRoute({ children }) { + const { token, logout } = useAuth(); + const [isValid, setIsValid] = useState(null); + + useEffect(() => { + if (!token) { + setIsValid(false); + return; + } + + const checkToken = async () => { + try { + const res = await fetch("/api/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("Unauthorized"); + setIsValid(true); + } catch { + logout(); + setIsValid(false); + } + }; + + checkToken(); + }, [token, logout]); + + if (isValid === null) + return ( +
+ Verifierar inloggning... +
+ ); + + if (!isValid) return ; + + return children; +} diff --git a/client/src/components/ThemeToggle.jsx b/client/src/components/ThemeToggle.jsx new file mode 100644 index 0000000000..8cb5c34ecf --- /dev/null +++ b/client/src/components/ThemeToggle.jsx @@ -0,0 +1,29 @@ +// src/components/ThemeToggle.jsx +import React, { useEffect, useState } from "react"; + +export default function ThemeToggle() { + const [isDark, setIsDark] = useState(() => { + return localStorage.getItem("theme") === "dark"; + }); + + useEffect(() => { + const root = window.document.documentElement; + if (isDark) { + root.classList.add("dark"); + localStorage.setItem("theme", "dark"); + } else { + root.classList.remove("dark"); + localStorage.setItem("theme", "light"); + } + }, [isDark]); + + return ( + + ); +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000000..6a665845de --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,215 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* === Base Layout === */ +html, body, #root { + height: 100%; +} +body { + @apply bg-gray-50 text-gray-900 dark:bg-slate-900 dark:text-slate-100 transition-colors duration-300; +} + +/* === Fade-in Animation for Header === */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-fadeIn { + animation: fadeIn 0.4s ease-in-out; +} + +/* === Utilities === */ +.card { + @apply bg-white dark:bg-slate-800 rounded-2xl border border-gray-200 dark:border-slate-700 shadow p-4; +} +.input { + @apply px-3 py-2 rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 outline-none; +} +.btn { + @apply inline-flex items-center justify-center px-4 py-2 rounded-xl bg-blue-600 text-white font-medium hover:bg-blue-700 transition; +} +.muted { + @apply text-gray-600 dark:text-slate-400; +} +textarea { + @apply w-full p-4 min-h-[160px] border border-gray-300 rounded-md bg-white text-black resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600; +} + +/* === React Calendar === */ +.react-calendar { + @apply w-full max-w-lg mx-auto bg-white dark:bg-slate-800 rounded-2xl p-4 + text-gray-900 dark:text-slate-100 shadow-md border border-gray-200 dark:border-slate-700 transition-all; +} + +/* Responsiv fix */ +@media (max-width: 640px) { + .react-calendar { + @apply max-w-full text-sm p-3; + } +} + +/* === Navigation === */ +.react-calendar__navigation { + @apply flex justify-between items-center mb-4; +} +.react-calendar__navigation button { + @apply text-gray-700 dark:text-gray-200 text-sm font-medium bg-transparent rounded-md px-3 py-1 transition; + outline: none !important; + box-shadow: none !important; +} +.react-calendar__navigation button:hover { + @apply text-blue-500 dark:text-blue-400; + filter: drop-shadow(0 0 4px rgba(59,130,246,0.4)); +} +.react-calendar__navigation button:disabled { + @apply opacity-40 cursor-not-allowed text-gray-400 dark:text-slate-500; +} + +/* === Weekdays === */ +.react-calendar__month-view__weekdays { + @apply text-xs text-gray-500 dark:text-slate-400 uppercase font-semibold mb-1 text-center; +} + +/* === Dates === */ +.react-calendar__tile { + @apply text-sm rounded-lg p-2 text-center transition-all bg-gray-50 dark:bg-slate-700 text-gray-700 dark:text-slate-100 hover:bg-blue-100 dark:hover:bg-slate-600; +} +.react-calendar__tile--now { + @apply bg-blue-100 dark:bg-slate-600 font-semibold; +} +.react-calendar__tile--active { + @apply bg-blue-600 text-white hover:bg-blue-700; +} +.react-calendar__tile:focus { + outline: none; + @apply ring-2 ring-blue-400 dark:ring-blue-500; +} +.react-calendar__month-view__days__day--neighboringMonth { + @apply text-gray-400 dark:text-slate-500; +} + +/* === Month & Year view tiles === */ +.react-calendar__year-view__months__month, +.react-calendar__decade-view__years__year, +.react-calendar__century-view__decades__decade { + @apply text-sm sm:text-base rounded-lg py-3 px-2 flex items-center justify-center transition-all bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-100 hover:bg-blue-100 dark:hover:bg-slate-600; +} +.react-calendar__year-view__months, +.react-calendar__decade-view__years, +.react-calendar__century-view__decades { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + padding-top: 0.25rem; +} +.react-calendar__tile--active:enabled, +.react-calendar__year-view__months__month--active { + @apply bg-blue-600 text-white font-semibold hover:bg-blue-700; +} + +/* === Time Slot Buttons === */ +.time-slot-grid { + @apply grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4; +} +.time-slot-btn { + @apply border border-gray-400 dark:border-slate-600 rounded-lg py-2 text-sm font-medium text-gray-800 dark:text-slate-100 hover:bg-blue-600 hover:text-white dark:hover:bg-blue-500 transition; +} + +/* === FINAL FIX for Arrow Highlight Bug === */ +.react-calendar__navigation button:enabled:hover, +.react-calendar__navigation button:enabled:focus, +.react-calendar__navigation button:enabled:active { + background: none !important; + color: #60a5fa !important; + filter: drop-shadow(0 0 4px rgba(96,165,250,0.4)); + outline: none !important; + box-shadow: none !important; + transform: scale(0.96); +} +.react-calendar__navigation button:enabled { + background-color: transparent !important; +} + +/* === FINAL RESPONSIVE FIX for Month/Year Layout === */ +.react-calendar__viewContainer { + display: flex; + justify-content: center; +} +.react-calendar__year-view__months, +.react-calendar__decade-view__years, +.react-calendar__century-view__decades { + display: grid; + grid-template-columns: repeat(3, minmax(100px, 1fr)); + gap: 0.75rem; + justify-content: center; + align-items: center; + margin: 0 auto; +} + +/* === MOBILE CALENDAR FIX === */ +@media (max-width: 480px) { + .react-calendar { + @apply w-full max-w-[95%] mx-auto text-sm p-3 rounded-xl bg-white dark:bg-slate-800 shadow-md; + } + + .react-calendar__navigation { + @apply text-sm mb-2 justify-between; + } + + .react-calendar__navigation button { + @apply px-2 py-1 text-xs; + } + + .react-calendar__month-view__weekdays { + @apply text-[10px] font-medium tracking-tight mb-1; + } + + .react-calendar__tile { + @apply text-xs p-2 rounded-md; + } + + .react-calendar__tile--active { + @apply bg-blue-600 text-white font-semibold; + } + + .react-calendar__month-view__days { + @apply grid gap-[3px]; + } + + .time-slot-grid { + @apply grid grid-cols-2 gap-2 mt-3; + } + + .time-slot-btn { + @apply text-xs py-2; + } +} + +/* === MOBILE HEADER FIX === */ +@media (max-width: 480px) { + header { + @apply flex-col items-center text-center gap-2; + } + + header a { + @apply text-base; + } + + header div { + @apply flex justify-center gap-3; + } + + header img { + @apply mx-auto; + } + + header span { + @apply text-sm; + } + + /* Förhindra radbrytning i loggan */ + header a span { + white-space: nowrap; + } +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000000..e89f37c4e2 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; +import { AuthProvider } from "./store/auth"; +import { BookingProvider } from "./store/booking"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + + + +); diff --git a/client/src/pages/AdminDashboard.jsx b/client/src/pages/AdminDashboard.jsx new file mode 100644 index 0000000000..587d17ec03 --- /dev/null +++ b/client/src/pages/AdminDashboard.jsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from "react"; +import { api } from "../store/lib"; +import { useAuth } from "../store/auth"; +import { CheckCircle, Clock, Trash2, User, Search } from "lucide-react"; + +export default function AdminDashboard() { + const { token } = useAuth(); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [filter, setFilter] = useState("all"); + const [search, setSearch] = useState(""); + + useEffect(() => { + loadBookings(); + }, []); + + async function loadBookings() { + try { + const data = await api("/api/bookings", { token }); + setBookings(data); + } catch (err) { + console.error(err); + setError(err.message || "Failed to load bookings."); + } finally { + setLoading(false); + } + } + + async function toggleStatus(id) { + await api(`/api/bookings/${id}/toggle`, { method: "PATCH", token }); + loadBookings(); + } + + async function deleteBooking(id) { + if (!confirm("Delete this booking?")) return; + await api(`/api/bookings/${id}`, { method: "DELETE", token }); + loadBookings(); + } + + const filtered = bookings + .filter((b) => (filter === "all" ? true : b.status === filter)) + .filter((b) => { + const q = search.toLowerCase(); + return ( + b.name.toLowerCase().includes(q) || + b.email.toLowerCase().includes(q) || + b.phone.toLowerCase().includes(q) || + b.service.toLowerCase().includes(q) || + b.date.toLowerCase().includes(q) + ); + }); + + if (loading) + return

Loading bookings...

; + if (error) + return

{error}

; + + return ( +
+

Admin Dashboard

+ + {/* Search bar */} +
+ + setSearch(e.target.value)} + placeholder="Search by name, email, date or service..." + className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Filter buttons */} +
+ {["all", "done"].map((type) => ( + + ))} +
+ + {filtered.length === 0 ? ( +

No bookings found.

+ ) : ( +
+ {filtered.map((b) => ( +
+
+
+ + {b.name} +
+

+ {b.service} — {b.date} at {b.time} +

+

+ {b.email} ‱ {b.phone} +

+
+ +
+ + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/pages/ConfirmationPage.jsx b/client/src/pages/ConfirmationPage.jsx new file mode 100644 index 0000000000..ea0b194d00 --- /dev/null +++ b/client/src/pages/ConfirmationPage.jsx @@ -0,0 +1,40 @@ +import React from "react"; +import { useLocation, Link } from "react-router-dom"; + +export default function ConfirmationPage() { + const location = useLocation(); + const { name, date, time, service } = location.state || {}; + + return ( +
+
+

Booking Confirmed 🎉

+ + {name ? ( + <> +

Thank you, {name}!

+

+ Your {service} appointment is confirmed for{" "} + {date} at {time}. +

+

+ A confirmation email has been sent to you. +

+ + Back to Home + + + ) : ( + <> +

+ Your booking was successful. Thank you! +

+ + Back to Home + + + )} +
+
+ ); +} diff --git a/client/src/pages/DetailsPage.jsx b/client/src/pages/DetailsPage.jsx new file mode 100644 index 0000000000..de6e6d03a9 --- /dev/null +++ b/client/src/pages/DetailsPage.jsx @@ -0,0 +1,147 @@ +import React, { useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { api } from "../store/lib"; + +export default function DetailsPage() { + const navigate = useNavigate(); + const location = useLocation(); + const selected = location.state; + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [service, setService] = useState(selected?.service || "Fade"); + const [otherService, setOtherService] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + if (!selected?.date || !selected?.time) { + return ( +
+

Please select a date and time first.

+
+ ); + } + + async function handleSubmit(e) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const chosenService = + service === "Other" && otherService.trim() !== "" + ? otherService + : service; + + await api("/api/bookings", { + method: "POST", + body: { + name, + email, + phone, + date: selected.date, + time: selected.time, + service: chosenService, + }, + }); + + navigate("/confirmation", { + state: { name, date: selected.date, time: selected.time, service: chosenService }, + }); + } catch (err) { + console.error(err); + setError(err.message || "Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Booking Details

+

+ You’re booking {service} on{" "} + {selected.date} at {selected.time}. +

+ +
+
+ + setName(e.target.value)} + className="w-full rounded-lg bg-slate-900 text-gray-100 border border-slate-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 px-3 py-2 outline-none transition" + placeholder="Your full name" + required + /> +
+ +
+ + setEmail(e.target.value)} + className="w-full rounded-lg bg-slate-900 text-gray-100 border border-slate-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 px-3 py-2 outline-none transition" + placeholder="you@example.com" + required + /> +
+ +
+ + setPhone(e.target.value)} + className="w-full rounded-lg bg-slate-900 text-gray-100 border border-slate-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 px-3 py-2 outline-none transition" + placeholder="+46 70 123 45 67" + required + /> +
+ +
+ + +
+ + {service === "Other" && ( +
+ +