Match ehsan.sa look: font, hero, and new Riyal symbol

Follow-up tweaks so the EHSAN POC matches the official ehsan.sa site.

- Font: switched from Tajawal to IBM Plex Sans Arabic (index.html +
  index.css). ehsan.sa's exact webfont couldn't be auto-detected (site
  blocks scraping; no Wayback snapshot), so picked the closest official
  match.
- Home hero: replaced the gray search-box hero with a full-bleed green
  branded banner (badge, title, subtitle, two CTAs, decorative leaf SVGs),
  matching ehsan.sa. Moved the search bar above the featured opportunities
  grid (with an sr-only label for accessibility).
- Currency: replaced the legacy "﷼" glyph everywhere with the new official
  Saudi Riyal symbol via a reusable <Riyal /> component that masks a
  processed PNG (src/assets/riyal.png) colored with currentColor; marked
  aria-hidden since the adjacent number conveys the value. Applied across
  home stats, OpportunityCard, donate, track, admin, request.
- Added AR+EN translation keys heroBadge/heroBrowse.

Verified: tsc clean, no console errors, screenshots confirm hero, font, and
riyal symbol render correctly. Code review fixes applied (search label,
decorative riyal aria, removed unused key).
This commit is contained in:
Replit Agent
2026-06-05 18:05:28 +00:00
parent 5d40b0d3c2
commit 8519202949
17 changed files with 241 additions and 134 deletions
+1
View File
@@ -1,2 +1,3 @@
- [Donate semantics](donate-semantics.md) — donations accumulate + clamp to target; a case enters the closed-loop pipeline only when fully funded. - [Donate semantics](donate-semantics.md) — donations accumulate + clamp to target; a case enters the closed-loop pipeline only when fully funded.
- [api-server data](api-server-data.md) — mockDb is in-memory and mutated by POST calls; restart the workflow to reset to clean seed for demos. - [api-server data](api-server-data.md) — mockDb is in-memory and mutated by POST calls; restart the workflow to reset to clean seed for demos.
- [EHSAN branding](ehsan-branding.md) — ehsan.sa blocks scraping; font is IBM Plex Sans Arabic (best match), currency uses new Saudi Riyal symbol via <Riyal/> mask component.
+13
View File
@@ -0,0 +1,13 @@
---
name: EHSAN branding (ehsan.sa)
description: Font, currency symbol, and hero conventions for matching the official ehsan.sa look.
---
# Matching ehsan.sa
- **Font:** the POC uses `IBM Plex Sans Arabic` (Google Fonts) as the closest official-looking match. ehsan.sa's exact webfont could NOT be auto-detected — the live site blocks server-side access (Firecrawl returns ERR_TUNNEL_CONNECTION_FAILED, curl with a browser UA returns nothing, and the Wayback Machine has no snapshot). If the font ever needs to be exact, ask the user to read it from browser DevTools (Computed > font-family).
- **Currency = new Saudi Riyal symbol**, not the legacy `﷼` glyph and not the word "ريال". It is rendered by the `<Riyal />` component (`src/components/Riyal.tsx`) which masks a processed PNG (`src/assets/riyal.png`) with `mask-image` + `background-color: currentColor`, so it inherits the surrounding text color and font size. The symbol is decorative (`aria-hidden`) since the adjacent number conveys the value.
**Why:** the user explicitly flagged the wrong font, the wrong currency symbol, and a non-official hero. The riyal mask approach was chosen because font/Unicode support for the new symbol is unreliable; masking the user-provided glyph guarantees it matches their reference and recolors anywhere.
**How to apply:** put the amount number first, then `<Riyal />` (e.g. `{amount.toLocaleString()} <Riyal />`). The home hero should be a full-bleed green (`bg-primary`) banner with a badge, title/subtitle, and CTAs — not a gray search box.
+1 -1
View File
@@ -1,4 +1,4 @@
modules = ["nodejs-24"] modules = ["nodejs-24", "python-3.11"]
[deployment] [deployment]
router = "application" router = "application"
+1 -1
View File
@@ -15,7 +15,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Share2, ShoppingCart } from "lucide-react"; import { Share2, ShoppingCart } from "lucide-react";
import { getNeedImage } from "../lib/needImages"; import { getNeedImage } from "../lib/needImages";
import { Riyal } from "@/components/Riyal";
interface OpportunityCardProps { interface OpportunityCardProps {
request: { request: {
@@ -17,8 +18,6 @@ interface OpportunityCardProps {
}; };
} }
const RIYAL = "﷼";
export function OpportunityCard({ request }: OpportunityCardProps) { export function OpportunityCard({ request }: OpportunityCardProps) {
const { t } = useLanguage(); const { t } = useLanguage();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
@@ -78,14 +77,14 @@ export function OpportunityCard({ request }: OpportunityCardProps) {
<div className="grid grid-cols-2 gap-2 bg-muted/40 rounded-xl p-4 mb-4 mt-auto"> <div className="grid grid-cols-2 gap-2 bg-muted/40 rounded-xl p-4 mb-4 mt-auto">
<div className="text-center border-s border-border"> <div className="text-center border-s border-border">
<p className="text-sm text-primary mb-1">{t.opportunities.remainingShort}</p> <p className="text-sm text-primary mb-1">{t.opportunities.remainingShort}</p>
<p className="font-bold text-foreground"> <p className="font-bold text-foreground inline-flex items-center gap-1">
{RIYAL} {remaining.toLocaleString()} {remaining.toLocaleString()} <Riyal />
</p> </p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-sm text-primary mb-1">{t.opportunities.collectedShort}</p> <p className="text-sm text-primary mb-1">{t.opportunities.collectedShort}</p>
<p className="font-bold text-foreground"> <p className="font-bold text-foreground inline-flex items-center gap-1">
{RIYAL} {request.collectedAmount.toLocaleString()} {request.collectedAmount.toLocaleString()} <Riyal />
</p> </p>
</div> </div>
</div> </div>
@@ -113,7 +112,7 @@ export function OpportunityCard({ request }: OpportunityCardProps) {
</Button> </Button>
<div className="relative flex-1"> <div className="relative flex-1">
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none"> <span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none">
{RIYAL} <Riyal />
</span> </span>
<Input <Input
type="number" type="number"
@@ -0,0 +1,32 @@
import riyalUrl from "@/assets/riyal.png";
interface RiyalProps {
className?: string;
size?: string;
}
/**
* Official new Saudi Riyal currency symbol.
* Rendered as a CSS mask so it inherits the current text color (currentColor).
*/
export function Riyal({ className = "", size = "0.95em" }: RiyalProps) {
return (
<span
aria-hidden="true"
className={`inline-block shrink-0 align-[-0.12em] ${className}`}
style={{
width: size,
height: size,
backgroundColor: "currentColor",
WebkitMaskImage: `url(${riyalUrl})`,
maskImage: `url(${riyalUrl})`,
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
WebkitMaskPosition: "center",
maskPosition: "center",
WebkitMaskSize: "contain",
maskSize: "contain",
}}
/>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Tajawal:wght@300;400;500;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@@ -126,7 +126,7 @@
--chart-4: 143 20% 80%; --chart-4: 143 20% 80%;
--chart-5: 143 10% 90%; --chart-5: 143 10% 90%;
--app-font-sans: 'Tajawal', sans-serif; --app-font-sans: 'IBM Plex Sans Arabic', sans-serif;
--app-font-serif: Georgia, serif; --app-font-serif: Georgia, serif;
--app-font-mono: Menlo, monospace; --app-font-mono: Menlo, monospace;
--radius: 0.5rem; --radius: 0.5rem;
@@ -62,6 +62,8 @@ export const en = {
searchOpportunities: "Search Donation Opportunities", searchOpportunities: "Search Donation Opportunities",
searchLabel: "Find a cause to support", searchLabel: "Find a cause to support",
searchButton: "Search", searchButton: "Search",
heroBadge: "National Platform for Charitable Work",
heroBrowse: "Browse Opportunities",
featuredTitle: "Featured Opportunities", featuredTitle: "Featured Opportunities",
noResults: "No opportunities match your search.", noResults: "No opportunities match your search.",
}, },
@@ -270,6 +272,8 @@ export const ar = {
searchOpportunities: "ابحث في فرص التبرع", searchOpportunities: "ابحث في فرص التبرع",
searchLabel: "ابحث عن قضية لدعمها", searchLabel: "ابحث عن قضية لدعمها",
searchButton: "بحث", searchButton: "بحث",
heroBadge: "المنصة الوطنية للعمل الخيري",
heroBrowse: "تصفّح الفرص",
featuredTitle: "الفرص المميزة", featuredTitle: "الفرص المميزة",
noResults: "لا توجد فرص تطابق بحثك.", noResults: "لا توجد فرص تطابق بحثك.",
}, },
+2 -1
View File
@@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Link } from "wouter"; import { Link } from "wouter";
import { Riyal } from "@/components/Riyal";
export default function Admin() { export default function Admin() {
const { t } = useLanguage(); const { t } = useLanguage();
@@ -70,7 +71,7 @@ export default function Admin() {
<TableCell> <TableCell>
{t.needTypes[req.needType as keyof typeof t.needTypes] || req.needType} {t.needTypes[req.needType as keyof typeof t.needTypes] || req.needType}
</TableCell> </TableCell>
<TableCell>{req.requestedAmount.toLocaleString()} </TableCell> <TableCell><span className="inline-flex items-center gap-1">{req.requestedAmount.toLocaleString()} <Riyal /></span></TableCell>
<TableCell> <TableCell>
<Badge variant="secondary" className="font-normal"> <Badge variant="secondary" className="font-normal">
{t.statuses[req.status as keyof typeof t.statuses] || req.status} {t.statuses[req.status as keyof typeof t.statuses] || req.status}
+6 -6
View File
@@ -17,8 +17,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { CheckCircle, Heart, Gift, ShoppingCart, Check } from "lucide-react"; import { CheckCircle, Heart, Gift, ShoppingCart, Check } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { getNeedImage } from "../lib/needImages"; import { getNeedImage } from "../lib/needImages";
import { Riyal } from "@/components/Riyal";
const RIYAL = "﷼";
const PRESETS = [100, 50, 10]; const PRESETS = [100, 50, 10];
const schema = z.object({ const schema = z.object({
@@ -203,11 +203,11 @@ export default function Donate() {
<div className="grid grid-cols-2 gap-2 bg-muted/40 rounded-xl p-4"> <div className="grid grid-cols-2 gap-2 bg-muted/40 rounded-xl p-4">
<div className="text-center border-s border-border"> <div className="text-center border-s border-border">
<p className="text-sm text-primary mb-1">{t.opportunities.remainingShort}</p> <p className="text-sm text-primary mb-1">{t.opportunities.remainingShort}</p>
<p className="font-bold text-foreground">{RIYAL} {remaining.toLocaleString()}</p> <p className="font-bold text-foreground inline-flex items-center gap-1">{remaining.toLocaleString()} <Riyal /></p>
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-sm text-primary mb-1">{t.opportunities.collectedShort}</p> <p className="text-sm text-primary mb-1">{t.opportunities.collectedShort}</p>
<p className="font-bold text-foreground">{RIYAL} {request.collectedAmount.toLocaleString()}</p> <p className="font-bold text-foreground inline-flex items-center gap-1">{request.collectedAmount.toLocaleString()} <Riyal /></p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -244,7 +244,7 @@ export default function Donate() {
}`} }`}
data-testid={`preset-${p}`} data-testid={`preset-${p}`}
> >
{RIYAL} {p} <span className="inline-flex items-center justify-center gap-1">{p} <Riyal /></span>
</button> </button>
))} ))}
</div> </div>
@@ -252,7 +252,7 @@ export default function Donate() {
{/* Custom amount */} {/* Custom amount */}
<div className="relative"> <div className="relative">
<span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none"> <span className="absolute start-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm pointer-events-none">
{RIYAL} <Riyal />
</span> </span>
<Input <Input
type="number" type="number"
@@ -313,7 +313,7 @@ export default function Donate() {
<CardContent className="pt-5"> <CardContent className="pt-5">
<div className="flex items-center justify-between bg-primary/5 rounded-lg px-4 py-3 mb-5"> <div className="flex items-center justify-between bg-primary/5 rounded-lg px-4 py-3 mb-5">
<span className="text-sm text-muted-foreground">{t.donate.amount}</span> <span className="text-sm text-muted-foreground">{t.donate.amount}</span>
<span className="text-lg font-bold text-primary">{RIYAL} {Number(amount).toLocaleString()}</span> <span className="text-lg font-bold text-primary inline-flex items-center gap-1">{Number(amount).toLocaleString()} <Riyal /></span>
</div> </div>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
+79 -24
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { OpportunityCard } from "../components/OpportunityCard"; import { OpportunityCard } from "../components/OpportunityCard";
import { Riyal } from "@/components/Riyal";
import { Link } from "wouter"; import { Link } from "wouter";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
@@ -26,37 +27,71 @@ export default function Home() {
}); });
return ( return (
<div className="container mx-auto px-4 py-12"> <>
{/* Hero */} {/* Hero — official EHSAN green banner */}
<section className="text-center py-16 bg-primary/5 rounded-3xl mb-12 border border-primary/10 px-6"> <section className="relative overflow-hidden bg-primary text-primary-foreground">
<h1 className="text-4xl md:text-5xl font-bold text-primary mb-6"> <div
className="absolute inset-0 opacity-25"
style={{
background:
"radial-gradient(circle at 85% 20%, rgba(255,255,255,0.18) 0, transparent 45%), radial-gradient(circle at 10% 90%, rgba(0,0,0,0.18) 0, transparent 50%)",
}}
/>
{/* decorative leaves (visual only) */}
<svg
className="pointer-events-none absolute -bottom-10 start-0 h-56 w-56 text-white/10"
viewBox="0 0 200 200"
fill="currentColor"
aria-hidden="true"
>
<path d="M100 10C60 40 40 90 60 140c30-20 60-50 80-100C120 60 100 90 90 120c-5-40 0-80 10-110z" />
</svg>
<svg
className="pointer-events-none absolute -top-12 end-8 h-72 w-72 text-white/10 rotate-180"
viewBox="0 0 200 200"
fill="currentColor"
aria-hidden="true"
>
<path d="M100 10C60 40 40 90 60 140c30-20 60-50 80-100C120 60 100 90 90 120c-5-40 0-80 10-110z" />
</svg>
<div className="container mx-auto px-4 py-20 md:py-24 relative">
<div className="max-w-2xl">
<span className="inline-block rounded-full bg-white/15 px-4 py-1 text-sm font-medium mb-5">
{t.home.heroBadge}
</span>
<h1 className="text-3xl md:text-5xl font-bold leading-tight mb-5">
{t.home.heroTitle} {t.home.heroTitle}
</h1> </h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8"> <p className="text-lg md:text-xl text-primary-foreground/90 mb-8 leading-relaxed">
{t.home.heroSubtitle} {t.home.heroSubtitle}
</p> </p>
<div className="flex flex-wrap gap-3">
{/* Search Bar */} <Link href="/opportunities">
<div className="max-w-xl mx-auto"> <Button
<label className="block text-sm font-medium text-foreground mb-2"> size="lg"
{t.home.searchLabel} className="bg-white text-primary hover:bg-white/90 font-bold px-7"
</label> data-testid="button-heroBrowse"
<div className="flex gap-2"> >
<div className="relative flex-1"> {t.home.heroBrowse}
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> </Button>
<Input </Link>
className="ps-9" <Link href="/request">
placeholder={t.common.searchPlaceholder} <Button
value={query} size="lg"
onChange={(e) => setQuery(e.target.value)} variant="outline"
data-testid="input-search" className="border-white/70 bg-transparent text-white hover:bg-white/10 font-bold px-7"
/> data-testid="button-heroRequest"
>
{t.nav.requestSupport}
</Button>
</Link>
</div> </div>
<Button data-testid="button-search">{t.home.searchButton}</Button>
</div> </div>
</div> </div>
</section> </section>
<div className="container mx-auto px-4 py-12">
{/* Stats */} {/* Stats */}
{statsLoading ? ( {statsLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
@@ -85,8 +120,8 @@ export default function Home() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-4xl font-bold text-primary"> <div className="text-4xl font-bold text-primary inline-flex items-center gap-2">
{stats?.totalCollected?.toLocaleString() || 0} {stats?.totalCollected?.toLocaleString() || 0} <Riyal />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -114,6 +149,25 @@ export default function Home() {
</Link> </Link>
</div> </div>
{/* Search */}
<div className="mb-8 max-w-xl">
<label htmlFor="home-search" className="sr-only">{t.home.searchLabel}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
id="home-search"
className="ps-9"
placeholder={t.common.searchPlaceholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="input-search"
/>
</div>
<Button data-testid="button-search">{t.home.searchButton}</Button>
</div>
</div>
{pubLoading ? ( {pubLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-96 w-full" />)} {[1, 2, 3].map((i) => <Skeleton key={i} className="h-96 w-full" />)}
@@ -150,5 +204,6 @@ export default function Home() {
</div> </div>
</section> </section>
</div> </div>
</>
); );
} }
+2 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { CheckCircle, XCircle, Clock } from "lucide-react"; import { CheckCircle, XCircle, Clock } from "lucide-react";
import { Riyal } from "@/components/Riyal";
const schema = z.object({ const schema = z.object({
beneficiaryName: z.string().min(2), beneficiaryName: z.string().min(2),
@@ -227,7 +228,7 @@ export default function RequestSupport() {
name="requestedAmount" name="requestedAmount"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t.request.amount} ()</FormLabel> <FormLabel className="inline-flex items-center gap-1">{t.request.amount} (<Riyal />)</FormLabel>
<FormControl> <FormControl>
<Input data-testid="input-amount" type="number" min={1} {...field} /> <Input data-testid="input-amount" type="number" min={1} {...field} />
</FormControl> </FormControl>
+3 -2
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Check, Clock } from "lucide-react"; import { Check, Clock } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { Riyal } from "@/components/Riyal";
const STEPS = [ const STEPS = [
"step1", "step2", "step3", "step4", "step5", "step1", "step2", "step3", "step4", "step5",
@@ -100,13 +101,13 @@ export default function Track() {
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1"> <p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
{t.request.amount} {t.request.amount}
</p> </p>
<p className="font-semibold">{request.requestedAmount.toLocaleString()} </p> <p className="font-semibold inline-flex items-center gap-1">{request.requestedAmount.toLocaleString()} <Riyal /></p>
</div> </div>
<div> <div>
<p className="text-muted-foreground text-xs uppercase tracking-wide mb-1"> <p className="text-muted-foreground text-xs uppercase tracking-wide mb-1">
{t.opportunities.collected} {t.opportunities.collected}
</p> </p>
<p className="font-semibold text-primary">{request.collectedAmount.toLocaleString()} </p> <p className="font-semibold text-primary inline-flex items-center gap-1">{request.collectedAmount.toLocaleString()} <Riyal /></p>
</div> </div>
{request.donorName && ( {request.donorName && (
<div> <div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB