A fully static, right-to-left Persian electronics storefront, built with Vite + React and served from GitHub Pages.
33 products · 8 categories · client-side search, filters and cart · no backend, no database, no tracking.
- ✨ Features
⚠️ What "no backend" means here- 🛠️ Tech stack
- 🚀 Installation and usage
- 📂 Code structure
- 🖼️ Adding or editing products
- ⚡ Performance notes
- 🛡️ Security notes
- 🎨 Customization
- 📄 License
🔎 A real catalogue, searched in the browser. 33 products across 8 categories, with full-text search, brand/price/stock/discount/rating filters, sorting and pagination — all running on static JSON, no API call anywhere. Every one of those choices lives in the query string, so a filtered view is a URL you can bookmark, share, refresh, or step back out of.
🧭 Right-to-left by design. Persian throughout, laid out with CSS logical
properties (start/end, not left/right), Persian digit formatting for
every price and count, and the self-hosted Vazirmatn typeface.
🛒 A cart and wishlist that survive a refresh. Both live in localStorage
via Zustand's persist, and both treat what they read back as untrusted input
— corrupt or hand-edited entries are dropped or clamped rather than rendered.
📱 Responsive from 320px up. Mobile-first layout, a slide-in menu, a scrollable category rail, and no horizontal overflow at any breakpoint.
💬 Demo support chat. A floating widget with a typing indicator and
keyword-matched canned replies. It says up front that nobody is on the other
end — see src/lib/chatReplies.ts.
♿ Accessible. Skip-to-content link, one <h1> per page, labelled form
fields and icon buttons, visible :focus-visible rings, drawers that really
trap focus instead of only claiming aria-modal, and a
prefers-reduced-motion block that turns the animations off.
🔒 Hardened by default. A restrictive Content-Security-Policy meta tag
ships in <head> — and the published build drops 'unsafe-inline' from
script-src entirely. Forms are validated with Zod, and no component anywhere
uses dangerouslySetInnerHTML.
🔗 Every URL is a real page. All 44 indexable routes are written out as
their own HTML file at build time, each with its own title, description,
canonical tag, social tags and JSON-LD (Product, BreadcrumbList,
Organization), plus a matching sitemap.xml and a 1200×630 social card
drawn from the site's own logo and typeface.
GitHub Pages serves static files only — no Node process, no database. The original project had an Express + MySQL backend; this build does not, and it never pretends otherwise.
| Area | Notes |
|---|---|
| Browsing and search | Runs on static JSON, in the browser |
| Filtering and sorting | Brand, max price, in-stock, discounted, rating |
| Pagination | Fully client-side |
| Product detail pages | Image gallery, spec table, related products |
| Cart | Saved in the visitor's localStorage |
| Wishlist | Saved in the visitor's localStorage |
These forms have complete UIs and real validation, but on submit they show a plain notice that this build has no backend, instead of faking success:
- Login and register (
/login,/register) - Placing an order (
/checkout) - Newsletter signup
The admin dashboard was dropped entirely — without a server there is nothing for it to administer. No visitor data is ever sent anywhere.
- Vite 8 — bundler and dev server.
- React 18 + React Router 7 for client-side routing.
- TypeScript, strict mode.
- Tailwind CSS 3 with custom design tokens (
tailwind.config.ts). - Zustand for cart and wishlist state, persisted to
localStorage. - Zod for form validation.
- Vazirmatn via
@fontsource-variable/vazirmatn— self-hosted, so no request ever leaves for Google's servers. - sharp, opentype.js and resvg in build scripts only, for thumbnails, the favicon and the social card.
Earlier versions of this project were built with Next.js (App Router). Since the site has no backend and is published statically to GitHub Pages, the server-side half of Next.js — SSR, API routes, runtime image optimisation — was never being used. Vite produces the same final artifact with a simpler config and a much faster build.
What that gave up, and how it's paid back: Next.js pre-rendered every page
at build time, so each product had its own HTML file. A plain Vite SPA has
one index.html and lets React Router sort out the rest — which means every
URL but the home page was served through the 404 fallback, answering with an
HTTP 404 and the site-wide title. scripts/prerender.mjs
puts that back: after the bundle is built it writes a real
dist/<route>/index.html for all 44 indexable URLs, each carrying its own
head. The body is still filled in by React — this is metadata prerendering,
not server rendering — but the pages now return 200 with their own titles,
which is what indexing and link previews depend on.
npm install
npm run devThen open http://localhost:3000.
npm run buildThe static site lands in dist/. To preview exactly what will be published:
npm start| Command | What it does |
|---|---|
npm run dev |
Regenerates assets, then starts Vite on port 3000 |
npm run build |
Regenerates assets, typechecks, builds into dist/, prerenders every route |
npm start |
Serves the built dist/ locally |
npm run lint |
ESLint, including the a11y rules |
npm run typecheck |
tsc --noEmit |
npm run build:images |
Rebuilds the 400px *-thumb.webp product thumbnails |
npm run build:data |
Rebuilds src/data/summaries.json from products.json |
npm run build:sitemap |
Rebuilds public/sitemap.xml |
npm run build:favicon |
Redraws favicon.svg, favicon.ico, apple-touch-icon.png |
npm run build:og |
Redraws public/og-image.png, the 1200×630 social card |
npm run build:hero |
Recomposes public/hero.webp from the featured product photos |
The image, data and sitemap steps run automatically before
devandbuild.summaries.jsonandsitemap.xmlare generated — don't hand-edit them. The favicon, social card and hero are design assets: they are committed, and only rebuilt when you change the brand or the products they feature.
index.html Vite entry point; site-wide <head> tags live here
vite.config.ts Base path, aliases, and the Pages output files
scripts/
build-thumbnails.mjs 400px WebP thumbnails for catalogue images
build-summaries.mjs Generates the light product records
build-sitemap.mjs Generates public/sitemap.xml
prerender.mjs Writes one HTML file per indexable route
build-favicon.mjs Draws the icons from the font's own "ت" glyph
build-og-image.mjs Draws the 1200×630 social card
build-hero.mjs Composes the home page's hero image
lib/routes.mjs The indexable routes + their metadata and JSON-LD
lib/woff.mjs Unpacks a WOFF to a TTF the drawing tools can read
src/
main.tsx Root render + BrowserRouter
App.tsx Layout shell and route table
routes/ One component per page
components/
Link.tsx Router link with a next/link-shaped API
PageMeta.tsx Per-route title, description, canonical, og:url
ErrorBoundary.tsx App-wide error boundary
layout/ home/ product/ shop/ cart/ checkout/ auth/ ui/
hooks/
useRouter.ts Small navigation helper
useHydrated.ts True once localStorage state has loaded
store/ Zustand cart and wishlist stores
data/
products.json Full catalogue
summaries.json Generated light records for cards
categories.json Categories
lib/
products.ts Reads over the full record (product page only)
summaries.ts Filter, sort and search over light records
format.ts Persian number, price and date formatting
validation.ts Zod schemas
email.ts The email rule, dependency-free (see below)
chatReplies.ts Canned support-chat replies
site.ts Site name, URL, repo, and the demo notice
| File | Contents | Used by |
|---|---|---|
products.json |
Full records, including descriptions and spec tables | lib/products.ts only (product detail page) |
summaries.json |
Just the fields a product card needs | Everything else — listings, filters, search, cart |
Descriptions and spec tables are roughly 70% of the catalogue's bytes and
never appear in a listing. If a component used on a listing imported
lib/products.ts, all of that would land in that route's bundle — so those
components always use lib/summaries.ts.
- Add the record to
src/data/products.json. - Drop the images into
public/as WebP. - Run
npm run build:imagesto generate the 400px thumbnails.
summaries.json and sitemap.xml regenerate on the next dev or build.
- Route-level code splitting. Every route except the home page is behind
React.lazy, so a visitor who never leaves the landing page never downloads the checkout schemas or the product gallery. The home page is eager on purpose — splitting it would only add a request that every first visit needs. - Zod is not in the first download. It used to be, because the footer's
newsletter box imported the shared schemas and the footer is on every page —
14 KB gzipped to validate one text field. That form now uses
src/lib/email.ts, whichvalidation.tsalso builds its email schema on, so the rule stays in one place and Zod loads only with the forms that need it. - Two sizes per image. Cards render at roughly 180–280 CSS px, so
build-thumbnails.mjsemits a 400px WebP next to each full-size image; the large file is only fetched on a product page. - A hero built at the size it renders.
hero.webpis composed at 1160×870, exactly twice its layout box, so it is sharp on a 2× screen and never upscaled or cropped on a 1× one. - Lazy images below the fold. Everything except the hero is
loading="lazy", and every image sits in a fixed-aspect-ratio box, so loading one never shifts the layout. - Self-hosted fonts, subset by script. The Arabic and Latin subsets load separately, so a page with no Latin text doesn't pay for Latin glyphs.
A cold load of the home page is about 165 KB over 22 requests: one ~89 KB gzipped JS bundle, 8 KB of CSS, the two font subsets, and the hero image.
The published output is entirely static, so the whole server-side class of vulnerabilities — SQL injection, auth bypass, file upload — cannot exist here; there is no server-side code to attack. What is in place:
- Content-Security-Policy as a
<meta>tag, withobject-src 'none',base-uri 'self',form-action 'none',worker-src 'none'andframe-src 'none'. The published build goes further than the source: the file in the repo allows inline script because Vite's dev server injects its HMR client that way, andcspForProduction()invite.config.tsstrips'unsafe-inline'out ofscript-srcwhen it writesdist/index.html— the production bundle has no inline script to allow. - No
dangerouslySetInnerHTMLanywhere — every string goes through React's escaping. localStorageis treated as untrusted. It is fully user-writable, so every persisted cart and wishlist line is re-validated on load: negative prices and quantities, non-numeric values, duplicate ids and image paths that are not really paths (javascript:,//another-host/x.png,..) are dropped or clamped.- The query string is treated the same way. The shop reads its filters, sort and page number out of the URL, so every one of them is validated against the real brands, sort keys and ratings before it is used.
- Zod validation on every form, with
rel="noopener noreferrer"on every external link. - No secrets in the repository — the project has no secret to keep.
npm auditreports 0 vulnerabilities.
Real HTTP security headers (X-Frame-Options, or frame-ancestors against
clickjacking) can't be set on GitHub Pages: the service doesn't allow custom
response headers, and browsers ignore frame-ancestors when it arrives via
<meta>. Hosting that supports headers — Netlify, Vercel, Cloudflare Pages —
is the fix if those matter.
Client-side validation is not a security boundary. Here it exists purely to give the visitor honest feedback, because there is no server that could be tricked into trusting the input.
Colours, spacing and shadows are design tokens in
tailwind.config.ts. The brand ramp drives the header,
buttons, links and the generated icons:
brand: {
50: "#eef4ff",
600: "#1d45f5", // primary actions, logo badge, favicon
700: "#1633e1", // hover state
// ...
}After changing brand-600, rerun npm run build:favicon,
npm run build:og and npm run build:hero so the icons, the social card and
the hero all match.
Store name, tagline, URL and contact details live in
src/lib/site.ts. The phone, email and address are
deliberate placeholders — replace them before using this for a real shop.
site.url is read by the app, the sitemap and the prerenderer alike.
Publishing under a different path or domain takes two edits: PROJECT_BASE
in vite.config.ts (or VITE_BASE_PATH at build time) for
the URL prefix, and site.url above for the canonical/OG/sitemap URLs. A
custom domain served from the root wants the base set to /.
Released under the MIT License. This started life as a fork of an
open-source storefront template by Aleksandar Kuzmanović; that copyright line
stays in LICENSE alongside the one for this rewrite.