Client engagement · Sole Developer · Jul 2026 — Jul 2026
ЯB Wrap
Persian RTL site, built to be found from inside Iran
Shipped & running
What mattered
One services constant renders both the page copy and the JSON-LD, so the two cannot disagree.
System map
Context
A one-page site for a paint-protection and vinyl-wrap studio in Isfahan.
Next.js 14 App Router with output: "export" — a static bundle, no server,
no runtime to keep alive for a shop that does not need one.
The constraint that shaped it is not visual: the client cannot claim their Google Business listing, because sanctions block the verification step. Every local-SEO playbook starts there, so the site has to assert what a claimed listing normally would.
One source of truth for content and structured data
Hand-written JSON-LD in the head and hand-written copy on the page drift immediately: someone adds a service and forgets the schema.
Both are generated from the same constant. SERVICES in src/lib/constants.ts
renders the visible cards, and the AutoRepair node's offer catalogue is a
map over the same array:
hasOfferCatalog: {
"@type": "OfferCatalog",
itemListElement: SERVICES.map((s) => ({
"@type": "Offer",
itemOffered: { "@type": "Service", name: s.name, description: s.longDesc },
})),
}
A service cannot exist on the page and be missing from the machine-readable record. Adding one is a single object in one file.
The same SITE constant supplies the address, geo coordinates and both contact
points to the schema, the footer and the map link — so the name/address/phone
triple that local search cares about is byte-identical everywhere by
construction, not by discipline.
Crawler surface as a deliberate output
robots.txt names and allows GPTBot, OAI-SearchBot, ChatGPT-User,
ClaudeBot and PerplexityBot alongside the search engines, and the site ships
an llms.txt — a bilingual FA/EN summary written for model consumption rather
than for a reader. If the answer to "best PPF shop in Isfahan" is increasingly
assembled by an assistant, the site should be readable by one.
RTL, and animation that does not fight it
Persian, dir="rtl", Vazirmatn. Scroll reveals go through one hook —
useScrollReveal — that wraps GSAP in a gsap.context() and returns
ctx.revert() on unmount, so timelines and ScrollTriggers are torn down rather
than accumulating across React's re-mounts. Direction-aware offsets are
parameters (x, y) rather than hardcoded, because a slide-in from the left is
backwards in RTL. The hero is one GSAP timeline with negative offsets to overlap
its steps, instead of nested delays that have to be recomputed whenever a step
changes.
Deleting a feature
Two commits on the same day: replace the gallery grid with an Instagram link, then delete the images and the PHP lister behind it.
The shop posts finished cars to Instagram daily and was never going to maintain
a second copy. Gallery.tsx is now 39 lines — a heading and one outbound link.
Less code, no asset pipeline, and nothing that goes stale on its own.
Result
Shipped and running. The ranking work went live in July 2026, which is too recent to quote numbers for — there is no before/after here yet.
Screens



