Updated PLANS.md, AGENTS.md, and arabic-localization.md to reflect the “foundations now, full translations later” approach and marked progress accordingly.

Implemented localization foundations across backend and frontend (locale settings/middleware, preferred language, i18n wiring, RTL support, minimal Arabic UI strings, Accept-Language).
Added targeted backend and frontend tests plus a risks note for pending full translation coverage.
This commit is contained in:
2026-02-28 11:48:58 +03:00
parent fd90af33b3
commit d40bb10876
27 changed files with 407 additions and 68 deletions
+31 -12
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiGet } from "./api/client";
import { setLocale } from "./i18n";
export default function App() {
const [salons, setSalons] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("idle");
const { t, i18n } = useTranslation();
useEffect(() => {
let ignore = false;
@@ -33,15 +36,31 @@ export default function App() {
return (
<div className="page">
<header className="hero">
<p className="eyebrow">Salon Booking Platform</p>
<h1>Find, compare, and book top salons near you.</h1>
<p className="subtitle">
Search by city or service, compare pricing, and lock in your slot in seconds.
</p>
<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="Search by salon or service"
placeholder={t("hero.searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
@@ -49,12 +68,12 @@ export default function App() {
</header>
<section className="results">
<h2>Salons</h2>
{status === "loading" && <p>Loading salons...</p>}
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && (
<p className="error">Unable to load salons. Start the backend API to see results.</p>
<p className="error">{t("results.error")}</p>
)}
{status === "ready" && salons.length === 0 && <p>No salons found.</p>}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<article className="card" key={salon.id}>
@@ -62,10 +81,10 @@ export default function App() {
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || "No description yet."}</p>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || "Phone unavailable"}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
</article>
))}
+14 -3
View File
@@ -1,12 +1,23 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import App from "./App.jsx";
import i18n from "./i18n";
describe("App", () => {
it("renders the hero copy", () => {
it("renders the hero copy", async () => {
await i18n.changeLanguage("en");
render(<App />);
expect(
screen.getByText("Find, compare, and book top salons near you.")
).toBeInTheDocument();
});
it("switches to Arabic and sets RTL direction", async () => {
await i18n.changeLanguage("en");
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
await waitFor(() => {
expect(document.documentElement.dir).toBe("rtl");
});
expect(screen.getByText("الصالونات")).toBeInTheDocument();
});
});
+7 -1
View File
@@ -1,3 +1,5 @@
import { getActiveLocale } from "../i18n";
const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function handleResponse(response) {
@@ -9,6 +11,10 @@ async function handleResponse(response) {
}
export async function apiGet(path) {
const response = await fetch(`${API_BASE}${path}`);
const response = await fetch(`${API_BASE}${path}`, {
headers: {
"Accept-Language": getActiveLocale(),
},
});
return handleResponse(response);
}
+23
View File
@@ -0,0 +1,23 @@
{
"hero": {
"eyebrow": "منصة حجز الصالونات",
"title": "اعثري على أفضل الصالونات القريبة منك وقارني بينها واحجزي بسهولة.",
"subtitle": "ابحثي حسب المدينة أو الخدمة، قارني الأسعار، واحجزي موعدك خلال ثوانٍ.",
"searchPlaceholder": "ابحثي عن صالون أو خدمة"
},
"results": {
"title": "الصالونات",
"loading": "جارٍ تحميل الصالونات...",
"error": "تعذر تحميل الصالونات. شغّلي واجهة الخلفية لرؤية النتائج.",
"empty": "لا توجد صالونات."
},
"card": {
"noDescription": "لا يوجد وصف بعد.",
"phoneUnavailable": "الهاتف غير متوفر"
},
"locale": {
"label": "اللغة",
"arabic": "العربية",
"english": "الإنجليزية"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"hero": {
"eyebrow": "Salon Booking Platform",
"title": "Find, compare, and book top salons near you.",
"subtitle": "Search by city or service, compare pricing, and lock in your slot in seconds.",
"searchPlaceholder": "Search by salon or service"
},
"results": {
"title": "Salons",
"loading": "Loading salons...",
"error": "Unable to load salons. Start the backend API to see results.",
"empty": "No salons found."
},
"card": {
"noDescription": "No description yet.",
"phoneUnavailable": "Phone unavailable"
},
"locale": {
"label": "Language",
"arabic": "العربية",
"english": "English"
}
}
+91
View File
@@ -0,0 +1,91 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./en.json";
import arSa from "./ar-sa.json";
const STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ar-sa";
const SUPPORTED_LOCALES = ["ar-sa", "en"];
function normalizeLocale(value) {
if (!value) {
return DEFAULT_LOCALE;
}
const lowered = value.toLowerCase();
if (lowered.startsWith("ar")) {
return "ar-sa";
}
return "en";
}
function readStoredLocale() {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch (error) {
return null;
}
}
function writeStoredLocale(locale) {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, locale);
} catch (error) {
// Ignore storage errors (private mode, blocked storage, etc.).
}
}
function getInitialLocale() {
const stored = readStoredLocale();
if (stored) {
return normalizeLocale(stored);
}
if (typeof navigator !== "undefined") {
return normalizeLocale(navigator.language);
}
return DEFAULT_LOCALE;
}
function isRtl(locale) {
return normalizeLocale(locale) === "ar-sa";
}
function applyDocumentLocale(locale) {
if (typeof document === "undefined") {
return;
}
const normalized = normalizeLocale(locale);
document.documentElement.lang = normalized;
document.documentElement.dir = isRtl(normalized) ? "rtl" : "ltr";
}
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
"ar-sa": { translation: arSa },
},
lng: getInitialLocale(),
fallbackLng: "en",
interpolation: { escapeValue: false },
});
applyDocumentLocale(i18n.language);
i18n.on("languageChanged", applyDocumentLocale);
export function setLocale(locale) {
const normalized = normalizeLocale(locale);
writeStoredLocale(normalized);
return i18n.changeLanguage(normalized);
}
export function getActiveLocale() {
return i18n.language || DEFAULT_LOCALE;
}
export { DEFAULT_LOCALE, SUPPORTED_LOCALES, STORAGE_KEY };
export default i18n;
+1
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./i18n";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
+48 -2
View File
@@ -1,9 +1,9 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=Noto+Sans+Arabic:wght@400;600;700&display=swap");
:root {
color: #1c1b1f;
background: linear-gradient(160deg, #fdf1e5 0%, #f7f2ec 40%, #eef1ff 100%);
font-family: "Space Grotesk", "Trebuchet MS", sans-serif;
font-family: "Space Grotesk", "Noto Sans Arabic", "Trebuchet MS", sans-serif;
}
* {
@@ -15,6 +15,10 @@ body {
min-height: 100vh;
}
:dir(rtl) {
font-family: "Noto Sans Arabic", "Space Grotesk", "Trebuchet MS", sans-serif;
}
.page {
max-width: 1100px;
margin: 0 auto;
@@ -31,6 +35,14 @@ body {
box-shadow: 0 20px 40px rgba(26, 26, 26, 0.08);
}
.hero-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.eyebrow {
letter-spacing: 0.2em;
text-transform: uppercase;
@@ -58,6 +70,40 @@ h1 {
font-size: 16px;
}
:dir(rtl) .eyebrow {
letter-spacing: 0.08em;
text-transform: none;
}
:dir(rtl) .search input {
text-align: right;
}
.locale-switch {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.7);
border-radius: 999px;
padding: 6px;
border: 1px solid #eadfd2;
}
.locale-switch button {
border: none;
background: transparent;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
color: #3c3a3f;
}
.locale-switch button.active {
background: #1c1b1f;
color: white;
}
.results {
margin-top: 48px;
}