Enhance documentation, implement Twilio OTP delivery, and update payment gateway methods. Updated AGENTS.md and README.md for clarity on ExecPlans and architecture. Added Twilio as a dependency and implemented capture/refund methods in MoyasarGateway. Improved frontend routing with react-router-dom and added authentication context. Updated styles and localization files for better user experience.

This commit is contained in:
2026-02-28 15:33:50 +03:00
parent 86fd07c778
commit a1da918f95
37 changed files with 1645 additions and 277 deletions
+24 -240
View File
@@ -1,245 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiGet, apiPost } from "./api/client";
import { setLocale } from "./i18n";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import MainLayout from "./layouts/MainLayout";
import HomePage from "./pages/HomePage";
import BookPage from "./pages/BookPage";
import PaymentPage from "./pages/PaymentPage";
import ProfilePage from "./pages/ProfilePage";
import BookingsPage from "./pages/BookingsPage";
import LoginPage from "./pages/LoginPage";
import SalonDetailPage from "./pages/SalonDetailPage";
import PaymentReturnPage from "./pages/PaymentReturnPage";
export default function App() {
const [salons, setSalons] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("idle");
const [paymentBookingId, setPaymentBookingId] = useState("");
const [paymentToken, setPaymentToken] = useState(() => localStorage.getItem("auth_token") || "");
const [paymentSourceType, setPaymentSourceType] = useState("stcpay");
const [paymentSourceValue, setPaymentSourceValue] = useState("");
const [paymentCallbackUrl, setPaymentCallbackUrl] = useState("");
const [paymentStatus, setPaymentStatus] = useState("idle");
const [paymentResult, setPaymentResult] = useState(null);
const [paymentError, setPaymentError] = useState("");
const { t, i18n } = useTranslation();
const idempotencyKey = useMemo(() => {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}, []);
useEffect(() => {
localStorage.setItem("auth_token", paymentToken);
}, [paymentToken]);
useEffect(() => {
let ignore = false;
async function load() {
setStatus("loading");
try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch (error) {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
async function handlePaymentSubmit(event) {
event.preventDefault();
setPaymentStatus("loading");
setPaymentError("");
setPaymentResult(null);
if (!paymentBookingId) {
setPaymentStatus("error");
setPaymentError(t("payment.errors.bookingRequired"));
return;
}
const source = { type: paymentSourceType };
if (paymentSourceType === "stcpay") {
if (!paymentSourceValue) {
setPaymentStatus("error");
setPaymentError(t("payment.errors.mobileRequired"));
return;
}
source.mobile = paymentSourceValue;
}
if (paymentSourceType === "token") {
if (!paymentSourceValue) {
setPaymentStatus("error");
setPaymentError(t("payment.errors.tokenRequired"));
return;
}
source.token = paymentSourceValue;
}
const payload = {
booking_id: Number(paymentBookingId),
provider: "moyasar",
idempotency_key: idempotencyKey,
source,
};
if (paymentCallbackUrl) {
payload.callback_url = paymentCallbackUrl;
}
try {
const data = await apiPost("/payments/", payload, paymentToken);
setPaymentResult(data);
setPaymentStatus("ready");
if (data?.redirect_url) {
window.location.assign(data.redirect_url);
}
} catch (error) {
setPaymentStatus("error");
setPaymentError(error.message || t("payment.errors.generic"));
}
}
return (
<div className="page">
<header className="hero">
<div className="hero-top">
<p className="eyebrow">{t("hero.eyebrow")}</p>
<div className="locale-switch" aria-label={t("locale.label")}>
<button
type="button"
className={i18n.language === "ar-sa" ? "active" : ""}
onClick={() => setLocale("ar-sa")}
>
{t("locale.arabic")}
</button>
<button
type="button"
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
</div>
<h1>{t("hero.title")}</h1>
<p className="subtitle">{t("hero.subtitle")}</p>
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</header>
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && (
<p className="error">{t("results.error")}</p>
)}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<article className="card" key={salon.id}>
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
</article>
))}
</div>
</section>
<section className="payments">
<div className="payments-header">
<div>
<h2>{t("payment.title")}</h2>
<p className="payments-subtitle">{t("payment.subtitle")}</p>
</div>
<span className="payments-badge">{t("payment.badge")}</span>
</div>
<form className="payments-form" onSubmit={handlePaymentSubmit}>
<label className="field">
<span>{t("payment.bookingId")}</span>
<input
type="number"
min="1"
value={paymentBookingId}
onChange={(event) => setPaymentBookingId(event.target.value)}
placeholder="123"
required
/>
</label>
<label className="field">
<span>{t("payment.accessToken")}</span>
<input
type="password"
value={paymentToken}
onChange={(event) => setPaymentToken(event.target.value)}
placeholder={t("payment.accessTokenPlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.sourceType")}</span>
<select
value={paymentSourceType}
onChange={(event) => setPaymentSourceType(event.target.value)}
>
<option value="stcpay">{t("payment.sources.stcpay")}</option>
<option value="token">{t("payment.sources.token")}</option>
<option value="applepay">{t("payment.sources.applepay")}</option>
</select>
</label>
<label className="field">
<span>{t("payment.sourceValue")}</span>
<input
type="text"
value={paymentSourceValue}
onChange={(event) => setPaymentSourceValue(event.target.value)}
placeholder={t("payment.sourceValuePlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.callbackUrl")}</span>
<input
type="url"
value={paymentCallbackUrl}
onChange={(event) => setPaymentCallbackUrl(event.target.value)}
placeholder="https://example.com/payments/return"
/>
</label>
<div className="payments-actions">
<button type="submit" disabled={paymentStatus === "loading"}>
{paymentStatus === "loading" ? t("payment.processing") : t("payment.payNow")}
</button>
<p className="helper">
{t("payment.idempotency")}: {idempotencyKey}
</p>
</div>
</form>
{paymentStatus === "error" && paymentError && (
<p className="error">{paymentError}</p>
)}
{paymentStatus === "ready" && paymentResult && (
<pre className="payment-result">{JSON.stringify(paymentResult, null, 2)}</pre>
)}
</section>
</div>
<BrowserRouter>
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="salon/:id" element={<SalonDetailPage />} />
<Route path="book" element={<BookPage />} />
<Route path="pay" element={<PaymentPage />} />
<Route path="pay/return" element={<PaymentReturnPage />} />
<Route path="bookings" element={<BookingsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="login" element={<LoginPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
+7 -2
View File
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import i18n from "./i18n";
vi.mock("./api/client", () => ({
@@ -8,10 +9,14 @@ vi.mock("./api/client", () => ({
apiPost: vi.fn()
}));
function TestWrapper({ children }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("App", () => {
it("renders the hero copy", async () => {
await i18n.changeLanguage("en");
render(<App />);
render(<App />, { wrapper: TestWrapper });
expect(
await screen.findByText("Find, compare, and book top salons near you.")
).toBeInTheDocument();
@@ -19,7 +24,7 @@ describe("App", () => {
it("switches to Arabic and sets RTL direction", async () => {
await i18n.changeLanguage("en");
render(<App />);
render(<App />, { wrapper: TestWrapper });
const arabicButton = screen.getByRole("button", { name: "العربية" });
fireEvent.click(arabicButton);
await waitFor(() => {
+55 -13
View File
@@ -2,26 +2,52 @@ import { getActiveLocale } from "../i18n";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function handleResponse(response) {
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Request failed: ${response.status}`);
export class ApiError extends Error {
constructor(message, { status, body } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
return response.json();
}
export async function apiGet(path) {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
"Accept-Language": getActiveLocale(),
},
});
async function handleResponse(response) {
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
// Ignore
}
if (!response.ok) {
const message =
(body?.detail && typeof body.detail === "string" ? body.detail : null) ||
text ||
`Request failed: ${response.status}`;
throw new ApiError(message, { status: response.status, body });
}
return body;
}
function baseHeaders() {
return {
"Accept-Language": getActiveLocale(),
};
}
export async function apiGet(path, token) {
const headers = { ...baseHeaders() };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, { headers });
return handleResponse(response);
}
export async function apiPost(path, body, token) {
const headers = {
"Accept-Language": getActiveLocale(),
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
@@ -31,7 +57,23 @@ export async function apiPost(path, body, token) {
const response = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers,
body: JSON.stringify(body),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse(response);
}
export async function apiPatch(path, body, token) {
const headers = {
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, {
method: "PATCH",
headers,
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse(response);
}
+24
View File
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { setLocale } from "../i18n";
export default function LocaleSwitch() {
const { t, i18n } = useTranslation();
return (
<div className="locale-switch" aria-label={t("locale.label")}>
<button
type="button"
className={i18n.language === "ar-sa" ? "active" : ""}
onClick={() => setLocale("ar-sa")}
>
{t("locale.arabic")}
</button>
<button
type="button"
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useTranslation } from "react-i18next";
import { usePaymentForm } from "../hooks/usePaymentForm";
export default function PaymentForm({ bookingId = "", token = "" }) {
const { t } = useTranslation();
const form = usePaymentForm(bookingId, token);
return (
<section className="payments">
<div className="payments-header">
<div>
<h2>{t("payment.title")}</h2>
<p className="payments-subtitle">{t("payment.subtitle")}</p>
</div>
<span className="payments-badge">{t("payment.badge")}</span>
</div>
<form
className="payments-form"
onSubmit={(e) => {
e.preventDefault();
form.submit();
}}
>
<label className="field">
<span>{t("payment.bookingId")}</span>
<input
type="number"
min="1"
value={form.bookingIdInput}
onChange={(e) => form.setBookingIdInput(e.target.value)}
placeholder="123"
required
/>
</label>
<label className="field">
<span>{t("payment.accessToken")}</span>
<input
type="password"
value={form.tokenInput}
onChange={(e) => form.setTokenInput(e.target.value)}
placeholder={t("payment.accessTokenPlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.sourceType")}</span>
<select
value={form.sourceType}
onChange={(e) => form.setSourceType(e.target.value)}
>
<option value="stcpay">{t("payment.sources.stcpay")}</option>
<option value="token">{t("payment.sources.token")}</option>
<option value="applepay">{t("payment.sources.applepay")}</option>
</select>
</label>
<label className="field">
<span>{t("payment.sourceValue")}</span>
<input
type="text"
value={form.sourceValue}
onChange={(e) => form.setSourceValue(e.target.value)}
placeholder={t("payment.sourceValuePlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.callbackUrl")}</span>
<input
type="url"
value={form.callbackUrl}
onChange={(e) => form.setCallbackUrl(e.target.value)}
placeholder="https://example.com/payments/return"
/>
</label>
<div className="payments-actions">
<button type="submit" disabled={form.status === "loading"}>
{form.status === "loading"
? t("payment.processing")
: t("payment.payNow")}
</button>
<p className="helper">
{t("payment.idempotency")}: {form.idempotencyKey}
</p>
</div>
</form>
{form.status === "error" && form.error && (
<p className="error">{form.error}</p>
)}
{form.status === "ready" && form.result && (
<pre className="payment-result">{JSON.stringify(form.result, null, 2)}</pre>
)}
</section>
);
}
@@ -0,0 +1,21 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
export default function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
+22
View File
@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function SalonCard({ salon }) {
const { t } = useTranslation();
return (
<article className="card" data-testid="salon-card">
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
<Link to={`/salon/${salon.id}`} className="card-link">
{t("card.viewDetails")}
</Link>
</article>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { useSalonSearch } from "../hooks/useSalonSearch";
import SalonCard from "./SalonCard";
export function SearchInput({ value, onChange }) {
const { t } = useTranslation();
return (
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={t("hero.searchPlaceholder")}
/>
</div>
);
}
export default function SalonSearch({ query }) {
const { t } = useTranslation();
const { salons, status } = useSalonSearch(query);
return (
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && <p className="error">{t("results.error")}</p>}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<SalonCard key={salon.id} salon={salon} />
))}
</div>
</section>
);
}
@@ -0,0 +1,42 @@
import { render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { BrowserRouter } from "react-router-dom";
import SalonSearch from "./SalonSearch";
import i18n from "../i18n";
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
}));
const { apiGet } = await import("../api/client");
function renderWithRouter(ui) {
return render(<BrowserRouter>{ui}</BrowserRouter>);
}
describe("SalonSearch", () => {
beforeEach(async () => {
vi.clearAllMocks();
apiGet.mockResolvedValue([]);
await i18n.changeLanguage("en");
});
it("shows loading then empty when no results", async () => {
renderWithRouter(<SalonSearch query="test" />);
await waitFor(() => {
expect(apiGet).toHaveBeenCalledWith("/salons/?q=test");
});
expect(screen.getByText(/no salons found/i)).toBeInTheDocument();
});
it("shows salon cards when results returned", async () => {
apiGet.mockResolvedValue([
{ id: 1, name: "Salon A", city: "Riyadh", rating_avg: 4.5, phone_number: "+966500000000" },
]);
renderWithRouter(<SalonSearch query="salon" />);
await waitFor(() => {
expect(screen.getByText("Salon A")).toBeInTheDocument();
});
expect(screen.getByText("Riyadh")).toBeInTheDocument();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { apiGet, apiPost } from "../api/client";
const STORAGE_ACCESS = "auth_access";
const STORAGE_REFRESH = "auth_refresh";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(() => {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_ACCESS);
});
const [refreshToken, setRefreshToken] = useState(() => {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_REFRESH);
});
const [loading, setLoading] = useState(true);
const persistTokens = useCallback((access, refresh) => {
setAccessToken(access);
setRefreshToken(refresh);
if (typeof window !== "undefined") {
if (access) localStorage.setItem(STORAGE_ACCESS, access);
else localStorage.removeItem(STORAGE_ACCESS);
if (refresh) localStorage.setItem(STORAGE_REFRESH, refresh);
else localStorage.removeItem(STORAGE_REFRESH);
}
}, []);
const logout = useCallback(() => {
setUser(null);
persistTokens(null, null);
}, [persistTokens]);
const login = useCallback((access, refresh, userData) => {
persistTokens(access, refresh);
setUser(userData);
}, [persistTokens]);
// Restore user from token on mount
useEffect(() => {
if (!accessToken) {
setLoading(false);
return;
}
apiGet("/auth/me/", accessToken)
.then((data) => {
setUser(data);
setLoading(false);
})
.catch(() => {
// Token invalid, try refresh
if (!refreshToken) {
logout();
setLoading(false);
return;
}
apiPost("/auth/token/refresh/", { refresh: refreshToken })
.then(({ access }) => {
persistTokens(access, refreshToken);
return apiGet("/auth/me/", access);
})
.then((data) => {
setUser(data);
})
.catch(() => {
logout();
})
.finally(() => {
setLoading(false);
});
});
}, [accessToken, refreshToken, logout, persistTokens]);
const value = {
user,
accessToken,
loading,
login,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}
+123
View File
@@ -0,0 +1,123 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiPost } from "../api/client";
function generateIdempotencyKey() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
// Must match AuthContext's STORAGE_ACCESS so fallback finds the same token
const AUTH_TOKEN_KEY = "auth_access";
export function usePaymentForm(bookingId = "", token = "") {
// token: optional auth token from AuthContext; tokenInput: manual override from form
const { t } = useTranslation();
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
const [tokenInput, setTokenInput] = useState(() => {
if (token) return token;
if (typeof window !== "undefined") {
return localStorage.getItem(AUTH_TOKEN_KEY) || "";
}
return "";
});
const [sourceType, setSourceType] = useState("stcpay");
const [sourceValue, setSourceValue] = useState("");
const [callbackUrl, setCallbackUrl] = useState(() => {
if (typeof window !== "undefined") {
return `${window.location.origin}/pay/return`;
}
return "";
});
const [status, setStatus] = useState("idle");
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const idempotencyKey = useMemo(generateIdempotencyKey, []);
// Persist token to localStorage when it changes
const setTokenInputAndPersist = (value) => {
setTokenInput(value);
if (typeof window !== "undefined") {
if (value) {
localStorage.setItem(AUTH_TOKEN_KEY, value);
} else {
localStorage.removeItem(AUTH_TOKEN_KEY);
}
}
};
async function submit() {
setStatus("loading");
setError("");
setResult(null);
if (!bookingIdInput) {
setStatus("error");
setError(t("payment.errors.bookingRequired"));
return;
}
const source = { type: sourceType };
if (sourceType === "stcpay") {
if (!sourceValue) {
setStatus("error");
setError(t("payment.errors.mobileRequired"));
return;
}
source.mobile = sourceValue;
}
if (sourceType === "token") {
if (!sourceValue) {
setStatus("error");
setError(t("payment.errors.tokenRequired"));
return;
}
source.token = sourceValue;
}
const payload = {
booking_id: Number(bookingIdInput),
provider: "moyasar",
idempotency_key: idempotencyKey,
source,
};
if (callbackUrl) {
payload.callback_url = callbackUrl;
}
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
const authToken = tokenInput;
try {
const data = await apiPost("/payments/", payload, authToken || undefined);
setResult(data);
setStatus("ready");
if (data?.redirect_url) {
window.location.assign(data.redirect_url);
}
} catch (err) {
setStatus("error");
setError(err.message || t("payment.errors.generic"));
}
}
return {
bookingIdInput,
setBookingIdInput,
tokenInput,
setTokenInput: setTokenInputAndPersist,
sourceType,
setSourceType,
sourceValue,
setSourceValue,
callbackUrl,
setCallbackUrl,
idempotencyKey,
status,
result,
error,
submit,
};
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { apiGet } from "../api/client";
export function useSalonSearch(query) {
const [salons, setSalons] = useState([]);
const [status, setStatus] = useState("idle");
useEffect(() => {
let ignore = false;
async function load() {
setStatus("loading");
try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
return { salons, status };
}
+3 -2
View File
@@ -13,9 +13,10 @@
},
"card": {
"noDescription": "لا يوجد وصف بعد.",
"phoneUnavailable": "الهاتف غير متوفر"
"phoneUnavailable": "الهاتف غير متوفر",
"viewDetails": "عرض التفاصيل والحجز"
},
"locale": {
"nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
"label": "اللغة",
"arabic": "العربية",
"english": "الإنجليزية"
+3 -2
View File
@@ -13,9 +13,10 @@
},
"card": {
"noDescription": "No description yet.",
"phoneUnavailable": "Phone unavailable"
"phoneUnavailable": "Phone unavailable",
"viewDetails": "View details & book"
},
"locale": {
"nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
"label": "Language",
"arabic": "العربية",
"english": "English"
+45
View File
@@ -0,0 +1,45 @@
import { Outlet, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocaleSwitch from "../components/LocaleSwitch";
import { useAuth } from "../contexts/AuthContext";
export default function MainLayout() {
const { t } = useTranslation();
const { isAuthenticated, logout } = useAuth();
return (
<div className="page">
<header className="main-header">
<nav className="main-nav">
<Link to="/" className="nav-brand">
{t("nav.home")}
</Link>
<Link to="/book" className="nav-link">
{t("nav.book")}
</Link>
<Link to="/pay" className="nav-link">
{t("nav.pay")}
</Link>
<Link to="/profile" className="nav-link">
{t("nav.profile")}
</Link>
<Link to="/bookings" className="nav-link">
{t("nav.bookings")}
</Link>
{isAuthenticated ? (
<button type="button" className="nav-link nav-logout" onClick={logout}>
{t("nav.logout")}
</button>
) : (
<Link to="/login" className="nav-link">
{t("nav.login")}
</Link>
)}
</nav>
<LocaleSwitch />
</header>
<main>
<Outlet />
</main>
</div>
);
}
+4 -1
View File
@@ -1,11 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import "./i18n";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>
);
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet, apiPost } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
export default function BookPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { accessToken } = useAuth();
const salonId = searchParams.get("salon");
const [salon, setSalon] = useState(null);
const [serviceId, setServiceId] = useState("");
const [staffId, setStaffId] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [notes, setNotes] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!salonId) return;
apiGet(`/salons/${salonId}/`)
.then(setSalon)
.catch(() => setSalon(null));
}, [salonId]);
if (!salonId) {
return (
<section className="book-page">
<h1>{t("book.title")}</h1>
<p>{t("book.selectSalon")}</p>
</section>
);
}
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
const duration = selectedService?.duration_minutes || 0;
function computeEndTime(startISO) {
if (!startISO || !duration) return null;
const start = new Date(startISO);
const end = new Date(start.getTime() + duration * 60 * 1000);
return end.toISOString();
}
async function handleSubmit(e) {
e.preventDefault();
setError("");
if (!serviceId || !staffId || !date || !time) {
setError(t("book.errors.fillAll"));
return;
}
// Use Asia/Riyadh offset for backend (KSA)
const startISO = `${date}T${time}:00+03:00`;
const endISO = computeEndTime(startISO);
if (!endISO) {
setError(t("book.errors.invalidTime"));
return;
}
setLoading(true);
try {
const booking = await apiPost(
"/bookings/",
{
service: Number(serviceId),
staff: Number(staffId),
start_time: startISO,
end_time: endISO,
notes,
},
accessToken
);
navigate(`/pay?booking=${booking.id}`);
} catch (err) {
setError(err.message || t("book.errors.generic"));
} finally {
setLoading(false);
}
}
const content = (
<section className="book-page">
<h1>{t("book.title")}</h1>
{salon && <p className="book-salon">{salon.name}</p>}
{!salon ? (
<p>{t("results.loading")}</p>
) : (
<form onSubmit={handleSubmit} className="book-form">
<label className="field">
<span>{t("book.service")}</span>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
required
>
<option value="">{t("book.selectService")}</option>
{salon.services?.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.duration_minutes} min, {s.price_amount} {s.currency}
</option>
))}
</select>
</label>
<label className="field">
<span>{t("book.staff")}</span>
<select
value={staffId}
onChange={(e) => setStaffId(e.target.value)}
required
>
<option value="">{t("book.selectStaff")}</option>
{salon.staff?.map((s) => (
<option key={s.id} value={s.id}>
{s.name || s.title || `Staff ${s.id}`}
</option>
))}
</select>
</label>
<label className="field">
<span>{t("book.date")}</span>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</label>
<label className="field">
<span>{t("book.time")}</span>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</label>
<label className="field">
<span>{t("book.notes")}</span>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("book.notesPlaceholder")}
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? t("book.submitting") : t("book.submit")}
</button>
</form>
)}
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
function formatDateTime(iso) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export default function BookingsPage() {
const { t } = useTranslation();
const { accessToken } = useAuth();
const [bookings, setBookings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!accessToken) return;
apiGet("/bookings/", accessToken)
.then(setBookings)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [accessToken]);
const content = (
<section className="bookings-page">
<h1>{t("bookings.title")}</h1>
<p className="bookings-subtitle">{t("bookings.subtitle")}</p>
{loading && <p>{t("results.loading")}</p>}
{error && <p className="error">{error}</p>}
{!loading && !error && bookings.length === 0 && (
<p>{t("bookings.empty")}</p>
)}
{!loading && !error && bookings.length > 0 && (
<ul className="bookings-list">
{bookings.map((b) => (
<li key={b.id} className="booking-card">
<div className="booking-header">
<span className="booking-status">{b.status}</span>
<span className="booking-salon">{b.salon_name}</span>
</div>
<p className="booking-service">{b.service_name}</p>
<p className="booking-time">
{formatDateTime(b.start_time)} {formatDateTime(b.end_time)}
</p>
<p className="booking-price">
{b.price_amount} {b.currency}
</p>
<Link to={`/pay?booking=${b.id}`} className="booking-pay-link">
{t("bookings.pay")}
</Link>
</li>
))}
</ul>
)}
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+21
View File
@@ -0,0 +1,21 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SearchInput, default as SalonSearch } from "../components/SalonSearch";
export default function HomePage() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
return (
<>
<header className="hero">
<div className="hero-top">
<p className="eyebrow">{t("hero.eyebrow")}</p>
</div>
<h1>{t("hero.title")}</h1>
<p className="subtitle">{t("hero.subtitle")}</p>
<SearchInput value={query} onChange={setQuery} />
</header>
<SalonSearch query={query} />
</>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiPost, ApiError } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
export default function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [step, setStep] = useState("phone");
const [phone, setPhone] = useState("");
const [channel, setChannel] = useState("sms");
const [requestId, setRequestId] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const from = location.state?.from?.pathname || "/";
async function handleRequestOtp(e) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiPost("/auth/phone/request/", {
phone_number: phone,
channel,
});
setRequestId(res.request_id);
setStep("verify");
} catch (err) {
const body = err instanceof ApiError ? err.body : null;
if (body?.retry_after_seconds) {
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
} else {
setError(err.message || t("auth.errors.generic"));
}
} finally {
setLoading(false);
}
}
async function handleVerify(e) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiPost("/auth/phone/verify/", {
request_id: requestId,
code,
});
login(res.access, res.refresh, res.user);
navigate(from, { replace: true });
} catch (err) {
const body = err instanceof ApiError ? err.body : null;
if (body?.retry_after_seconds) {
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
} else {
setError(err.message || t("auth.errors.generic"));
}
} finally {
setLoading(false);
}
}
if (step === "phone") {
return (
<section className="auth-page">
<h1>{t("auth.title")}</h1>
<p className="auth-subtitle">{t("auth.subtitle")}</p>
<form onSubmit={handleRequestOtp} className="auth-form">
<label className="field">
<span>{t("auth.phone")}</span>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+966512345678"
required
/>
</label>
<label className="field">
<span>{t("auth.channel")}</span>
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
<option value="sms">{t("auth.sms")}</option>
<option value="whatsapp">{t("auth.whatsapp")}</option>
</select>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? t("auth.sending") : t("auth.sendCode")}
</button>
</form>
</section>
);
}
return (
<section className="auth-page">
<h1>{t("auth.verifyTitle")}</h1>
<p className="auth-subtitle">{t("auth.verifySubtitle", { phone })}</p>
<form onSubmit={handleVerify} className="auth-form">
<label className="field">
<span>{t("auth.code")}</span>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
placeholder="123456"
required
/>
</label>
{error && <p className="error">{error}</p>}
<div className="auth-actions">
<button type="submit" disabled={loading || code.length < 6}>
{loading ? t("auth.verifying") : t("auth.verify")}
</button>
<button
type="button"
className="auth-back"
onClick={() => {
setStep("phone");
setCode("");
setError("");
}}
>
{t("auth.back")}
</button>
</div>
</form>
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { BrowserRouter } from "react-router-dom";
import LoginPage from "./LoginPage";
import { AuthProvider } from "../contexts/AuthContext";
import i18n from "../i18n";
vi.mock("../api/client", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, apiPost: vi.fn() };
});
const { apiPost } = await import("../api/client");
function renderLogin() {
return render(
<AuthProvider>
<BrowserRouter>
<LoginPage />
</BrowserRouter>
</AuthProvider>
);
}
describe("LoginPage", () => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage("en");
});
it("renders phone input and send code button", () => {
renderLogin();
expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send code" })).toBeInTheDocument();
});
it("shows verify step after successful OTP request", async () => {
apiPost.mockResolvedValueOnce({ request_id: "abc-123", expires_at: "2025-01-01T12:00:00Z" });
renderLogin();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await waitFor(() => {
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
});
});
it("shows error when OTP request fails", async () => {
apiPost.mockRejectedValueOnce(new Error("Rate limited"));
renderLogin();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await waitFor(() => {
expect(screen.getByText("Rate limited")).toBeInTheDocument();
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import { useSearchParams } from "react-router-dom";
import PaymentForm from "../components/PaymentForm";
import { useAuth } from "../contexts/AuthContext";
export default function PaymentPage() {
const [searchParams] = useSearchParams();
const bookingIdFromUrl = searchParams.get("booking") || "";
const { accessToken } = useAuth();
return <PaymentForm bookingId={bookingIdFromUrl} token={accessToken || ""} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { useSearchParams, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function PaymentReturnPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const status = searchParams.get("status") || "";
const id = searchParams.get("id") || "";
const isSuccess = ["paid", "captured", "authorized"].includes(status.toLowerCase());
return (
<section className="payment-return">
<h1>{isSuccess ? t("paymentReturn.success") : t("paymentReturn.title")}</h1>
<p>
{isSuccess
? t("paymentReturn.successMessage")
: t("paymentReturn.checkStatus")}
</p>
{id && <p className="payment-return-id">{t("paymentReturn.reference", { id })}</p>}
<Link to="/profile" className="book-cta">
{t("paymentReturn.viewBookings")}
</Link>
</section>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
export default function ProfilePage() {
const { t } = useTranslation();
const { user } = useAuth();
const content = (
<section className="profile-page">
<h1>{t("profile.title")}</h1>
{user && (
<p className="profile-phone">
{user.phone_number || user.email || t("profile.noContact")}
</p>
)}
<Link to="/bookings" className="book-cta">
{t("profile.myBookings")}
</Link>
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
export default function SalonDetailPage() {
const { t } = useTranslation();
const { id } = useParams();
const [salon, setSalon] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
apiGet(`/salons/${id}/`)
.then(setSalon)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <p>{t("results.loading")}</p>;
if (error) return <p className="error">{error}</p>;
if (!salon) return null;
return (
<section className="salon-detail">
<h1>{salon.name}</h1>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
<h2>{t("salon.services")}</h2>
<ul className="service-list">
{salon.services?.map((s) => (
<li key={s.id}>
{s.name} {s.duration_minutes} min, {s.price_amount} {s.currency}
</li>
))}
</ul>
<h2>{t("salon.staff")}</h2>
<ul className="staff-list">
{salon.staff?.map((s) => (
<li key={s.id}>{s.name || s.title || `Staff ${s.id}`}</li>
))}
</ul>
<Link to={`/book?salon=${salon.id}`} className="book-cta">
{t("book.cta")}
</Link>
</section>
);
}
+181
View File
@@ -25,6 +25,42 @@ body {
padding: 48px 24px 80px;
}
.main-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px solid #eadfd2;
}
.main-nav {
display: flex;
gap: 16px;
align-items: center;
}
.nav-brand,
.nav-link {
color: #1c1b1f;
text-decoration: none;
font-weight: 600;
}
.nav-brand:hover,
.nav-link:hover {
text-decoration: underline;
}
.nav-logout {
background: none;
border: none;
cursor: pointer;
font: inherit;
}
.hero {
display: flex;
flex-direction: column;
@@ -237,6 +273,151 @@ h1 {
font-size: 14px;
}
.card-link {
display: inline-block;
margin-top: 8px;
color: #1c1b1f;
font-weight: 600;
text-decoration: none;
}
.card-link:hover {
text-decoration: underline;
}
.auth-page {
max-width: 400px;
margin: 0 auto;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
}
.auth-subtitle {
color: #5c5a5f;
margin: 8px 0 0;
}
.auth-actions {
display: flex;
gap: 12px;
}
.auth-back {
background: transparent;
border: 1px solid #dad3ca;
color: #3c3a3f;
}
.auth-loading {
text-align: center;
padding: 48px;
}
.salon-detail {
margin-bottom: 32px;
}
.service-list,
.staff-list {
list-style: none;
padding: 0;
margin: 12px 0;
}
.service-list li,
.staff-list li {
padding: 8px 0;
border-bottom: 1px solid #eadfd2;
}
.book-cta {
display: inline-block;
margin-top: 24px;
padding: 12px 24px;
background: #1c1b1f;
color: white;
font-weight: 600;
text-decoration: none;
border-radius: 999px;
}
.book-cta:hover {
opacity: 0.9;
}
.book-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
margin-top: 24px;
}
.book-salon {
color: #5c5a5f;
margin: 4px 0 0;
}
.bookings-list {
list-style: none;
padding: 0;
margin: 24px 0 0;
}
.booking-card {
background: white;
padding: 20px;
border-radius: 16px;
box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08);
margin-bottom: 16px;
}
.booking-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.booking-status {
text-transform: capitalize;
font-weight: 600;
}
.booking-service,
.booking-time,
.booking-price {
margin: 8px 0 0;
color: #5c5a5f;
}
.booking-pay-link {
display: inline-block;
margin-top: 12px;
font-weight: 600;
color: #1c1b1f;
}
.payment-return {
max-width: 480px;
margin: 0 auto;
text-align: center;
}
.payment-return-id {
font-size: 14px;
color: #5c5a5f;
}
.profile-phone {
margin: 8px 0 16px;
color: #5c5a5f;
}
.error {
color: #b00020;
}