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:
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ar">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^23.11.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
|
||||
+31
-12
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"hero": {
|
||||
"eyebrow": "منصة حجز الصالونات",
|
||||
"title": "اعثري على أفضل الصالونات القريبة منك وقارني بينها واحجزي بسهولة.",
|
||||
"subtitle": "ابحثي حسب المدينة أو الخدمة، قارني الأسعار، واحجزي موعدك خلال ثوانٍ.",
|
||||
"searchPlaceholder": "ابحثي عن صالون أو خدمة"
|
||||
},
|
||||
"results": {
|
||||
"title": "الصالونات",
|
||||
"loading": "جارٍ تحميل الصالونات...",
|
||||
"error": "تعذر تحميل الصالونات. شغّلي واجهة الخلفية لرؤية النتائج.",
|
||||
"empty": "لا توجد صالونات."
|
||||
},
|
||||
"card": {
|
||||
"noDescription": "لا يوجد وصف بعد.",
|
||||
"phoneUnavailable": "الهاتف غير متوفر"
|
||||
},
|
||||
"locale": {
|
||||
"label": "اللغة",
|
||||
"arabic": "العربية",
|
||||
"english": "الإنجليزية"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user