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