Expand each category with body copy, highlights, supply steps, and FAQ in fa/en/ar, and surface livedata oil benchmarks on the oil page. Co-authored-by: Cursor <cursoragent@cursor.com>
Majd Trading — ARIO MAJD ASIA
Next.js site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from majdtrading.com. Phase one ships three pages in Persian (RTL); the routing and content layers are already locale-aware so English (LTR) and Arabic can be added without restructuring.
Running it
npm run dev
Then open http://localhost:3001 — / redirects to /fa. (Port 3001 rather than
3000, which the MeshkeeApp Backend occupies on this machine; change it in
.claude/launch.json and the dev script if that is not true for you.)
npm run build produces the production build; npm run start serves it.
Pages
| Route | Page |
|---|---|
/fa |
صفحه اصلی |
/fa/about |
درباره ی ما |
/fa/contact |
تماس با ما |
/fa/products/[slug] |
one page per product category (7) |
Category slugs: oil, mineral, food, chemical, livestock, appliance,
construction. Each is prerendered from products.items in the dictionary, and
the same slug selects the page, its icon, and its header-menu entry.
Structure
src/
app/[locale]/ # locale segment owns <html lang> and <html dir>
layout.tsx # header + footer shell, metadata
page.tsx # homepage: slider, services, intro, products, partners, CTA
about/page.tsx # about, vision, mission, values
contact/page.tsx # contact cards, form, map
components/ # Header, Slider, Footer, PageHero, Section, ContactForm
i18n/
config.ts # locales, direction map, labels
dictionaries.ts # dictionary loader
dictionaries/fa.ts # all Persian copy
lib/site.ts # address, phones, emails, partners — shared across locales
middleware.ts # redirects un-prefixed paths to the default locale
public/
images/ # slider, logo, service icons, partner logos
fonts/ # IRANYekan (self-hosted)
Design tokens
The palette is taken from the live site's --wb-* custom properties and is
defined once in src/app/globals.css under @theme:
| Token | Value | Use |
|---|---|---|
--color-brand |
#1A3851 |
headings, header, dark bands |
--color-accent |
#FF214F |
CTAs, active state, rules |
--color-muted |
#838385 |
secondary text |
--color-brand-500 |
#3E6684 |
Latin title lines on light bands |
--color-brand-300 |
#A8BECD |
Latin lines and rules on dark bands |
--color-surface |
#F6F7F9 |
alternating section bands |
--color-accent |
#FF214F |
form validation only — see below |
Red is not a brand colour here. It appears in exactly three places, all inside
the contact form: the * on required fields, the inline error messages, and the
border of a field in error. Everything that used to be red — buttons, active nav,
rules, captions, hover states — is now navy, steel blue, or an invert to white on
dark bands. grep -rn accent src should only ever return ContactForm.tsx.
Type
Three faces, all self-hosted in public/fonts/ — no CDN call at runtime, which
matters for visitors on networks that cannot reach Google Fonts.
| Face | Where | Class |
|---|---|---|
| IRANYekan | all Persian text | body default |
| BBH Bartle | Latin heading lines only — section headings, page heroes, the slider | .latin-title / font-display |
| Space Grotesk | Latin labels and figures — card captions, the brand line, list numbers | .latin |
| Space Grotesk | phone numbers, emails, the Instagram handle | .latin-ui |
BBH Bartle is caps-only and ships a single weight, so .latin-title sets
font-weight: 400 and never takes a bold utility — asking for one would give a
synthesised faux bold. It is also why emails do not use it: ceo.majd@hotmail.com
would render as CEO.MAJD@HOTMAIL.COM. That is what .latin-ui is for.
Only .latin-ui sets direction: ltr. .latin and .latin-title isolate their
run but inherit the page direction, so text-align: start still resolves to the
right edge under RTL. Adding direction: ltr to them silently left-aligns every
caption while the Persian heading beside it stays right-aligned — the same trap
applies to start-* / end-* inset utilities on such an element.
Section headings
SectionHeading and PageHero both set the Latin line behind the Persian
title as a faint backdrop, echoing the hero slider. It is decorative repetition
of the heading, so it is aria-hidden. An optional Persian subtitle sits
between the title and the rule.
The backdrop is centred on the whole lockup, not on the title alone, and capped
at clamp(1.75rem, 5.5vw, 3rem) — sized to overhang the Persian title without
running past the section's own width. Raising that ceiling is what makes it
spill off both edges of the viewport.
balanceLines in src/lib/text.ts breaks the Latin string across at most two
rows, split where the halves come closest to equal length, so the backdrop reads
as a block behind the lockup rather than one very wide line. Strings of ten
characters or fewer stay on one row — breaking ABOUT US would strand US on a
line of its own.
It carries the font-display utility rather than .latin-title, because any
direction: ltr on a positioned element makes inset-inline and
text-align: start resolve against that element instead of the page — which
left-aligns the backdrop inside an RTL layout.
Flat by design: no shadows, no rounded corners. Gradients appear only as
readability scrims over photography. Section separation comes from 1px hairline
grids (gap-px over a bg-brand-100 parent) and alternating background bands.
Photography is used three ways, each with a scrim so type stays legible over any image. The hero slider and the service tiles share one treatment: the scrim rises from the bottom edge and clears by mid-frame, so the top of every photo stays open and the type sits in the dark lower half. The full-bleed products band uses a vertical vignette instead — dark where the heading and chips sit, open through the middle.
Multi-language
Three languages for the static pages (fa, en, ar); the blog stays
single-language.
Where things live
src/content/index.ts ids, slugs, image paths, ordering ← developer
src/i18n/locales/fa.json prose only ← translator
src/i18n/locales/en.json
src/i18n/locales/ar.json
src/i18n/types.ts the Dictionary shape
src/i18n/compose.ts joins structure to copy on the id
The split is by who edits it. Image paths and slugs are identical in every
language, so they live once in TypeScript — where slugs also keep their literal
types, which is what lets ProductIcon name={product.slug} fail the build if a
category has no icon behind it. Translators only ever open a JSON file of
sentences.
src/i18n/locales/<code>.ts is a three-line wrapper that assigns the JSON to
Dictionary, so a missing key fails the build.
The fa → en/ar workflow
npm run i18n:check
Run it after editing fa.json. The type annotation already catches missing keys;
this catches the two things it cannot see — stale keys left behind after a
rename, and arrays whose lengths have drifted (a mission list of 14 items in
one language and 13 in another renders perfectly and is wrong).
Partner organisation names live in the locale files too, keyed by the ids in
src/lib/site.ts — these bodies have official names in all three languages, so
leaving them in Persian on the English page was wrong. Only the logo and link
stay structural.
The blog is outside all of this
Posts live in src/lib/posts.ts with their own lang and dir. One rule:
page chrome follows the visitor's locale, the article follows the post. So
/en/blog/cargo-insurance serves an English header and footer, in Space Grotesk,
around a Persian article marked lang="fa" dir="rtl" and set in IRANYekan.
The homepage carousel is rendered only where the visitor's locale matches the
language of the posts (post.lang === locale), so /en and /ar omit the
section rather than showing Persian cards. Adding an English post later makes it
appear on /en with no code change.
Because one article is reachable from three locale paths, each post sets
alternates.canonical to its default-locale URL. Static pages instead declare
hreflang alternates, since those genuinely are translations of one another.
Fonts per language
| Locale | Body face |
|---|---|
fa |
IRANYekan |
ar |
IBM Plex Sans Arabic |
en |
Space Grotesk |
IBM Plex Sans Arabic was chosen over IRANYekan's Arabic coverage: IRANYekan is a Persian typeface and its Arabic shaping reads foreign. Plex is a corporate face with a real weight range that sits comfortably beside Space Grotesk. Only the Arabic subset is downloaded (~34KB per weight).
Two things make the switching work, and both are easy to break:
- The
[lang="…"]rules redefine--font-sansand setfont-family. Redefining the variable alone is not enough —bodydeclaresfont-family: var(--font-sans)itself, so a rule on<html>never reaches it. Settingfont-familyalone is not enough either — the nested blog article needs the variable rebound for its own subtree. - They are attribute selectors, not
:lang(). They match only elements that actually carrylang(the<html>element and a blog article), so everything else inherits and Tailwind'sfont-displayutility still wins on the Latin backdrops.
ss01 (Persian digit forms) is keyed on [lang="fa"], not on direction —
Arabic is also RTL but uses different digit shapes.
English has no Latin gloss
Every *Latin field is an empty string in en.json, and the components skip the
backdrop when it is empty. The device exists to pair a non-Latin heading with an
English one; in English it would print the heading twice. Filling those fields in
turns the backdrops back on.
Market data
A framed panel on the homepage, in all three languages. Server-side, entirely in
this repo (src/lib/market/).
| File | What it is |
|---|---|
displayed.ts |
the one list to edit — which figures appear, in order |
instruments.ts |
catalogue of every id the upstream publishes (39 identified) |
livedata.ts |
the source adapter — three parsers, see below |
index.ts |
caching, failure handling |
types.ts |
the MarketSource interface |
The frame has three panes: the id-based figures, the gold table, and world exchange status. Labels are ours, translated in each locale file — only the numbers come from upstream.
The gold table's last row is the world ounce, which is not part of that table
upstream — it is read from its own id (300106) and appended. That id is
deliberately absent from displayed.ts; listing it there too would print the
same figure twice in one frame. Its Toman cell shows an em dash, because
upstream publishes no Toman price for the ounce and deriving one from the gram
rate would be inventing a figure.
Three parsers, three levels of fragility
- Figures (gold ounce, Brent, USDT, exchange USD/EUR) are anchored on stable
numeric ids (
s_200101). The page is ad-heavy and its layout shifts, but an id has a fixed meaning. Most robust. - Gold by purity has no ids — it is anchored on the section heading and each row's fixed Persian label. If upstream renames a row, that row drops out and the others survive.
- Exchange sessions are anchored on
class="nma"plus the status icon class.
Session state is taken from upstream rather than computed from opening hours,
because upstream accounts for public holidays — it reports the actual
reopening date, which a hours-only calculation would get wrong every holiday.
Those dates are reformatted into each reader's own calendar: Aug 31 in English,
شهریور ۹ in Persian, أغسطس ٣١ in Arabic.
Why server-side, and why 60s
livedata.ir sends no Access-Control-Allow-Origin (verified with an
Origin: https://majdtrading.com request), so a browser fetch is blocked
outright. Rendering on the server also means the upstream is hit once per
minute for the whole site, not once per visitor.
60s and not 30s because the upstream regenerates on the minute — measured at
04:29:02, 04:30:02, 04:30:02 across samples 25s apart. Polling faster
doubles their load for no extra freshness.
Presentation notes
Table headers and their values both align to the page's start edge, and
dir="ltr" sits on an inner span around each number rather than on the cell.
Putting it on the cell changes what text-end resolves to, which left every
header sitting on the opposite side of its own column from the values beneath it.
Upstream groups some figures (29,136,700) and not others (199000). The
component groups the ungrouped ones so the frame is internally consistent; the
digits themselves are never altered.
Swapping the source
index.ts has one line — const source: MarketSource = livedataSource. When the
company backend exists, add backend.ts implementing MarketSource and change
that line. The component, the caching and the selection list stay as they are.
getMarketSnapshot() never throws: an unreachable upstream logs and returns
null, so a build never depends on a third party being up and a visitor never
sees a broken widget.
Before this goes live
The figures are compiled by livedata.ir, whose site is funded by advertising. The frame carries visible attribution and a link back, but permission is still worth getting in writing — they sell ad space and have a contact page, so a licence or attribution agreement is a realistic ask.
Meshkee backend
Contract: https://api.meshkee.com/docs/website · base https://api.meshkee.com/api/v1
src/lib/api.ts holds the base URL, the tenant domain and a apiGet helper that
returns null instead of throwing — a marketing page must still render when
the API is down, so every caller degrades to hiding its section.
GET /tenants/majdtrading.com confirms the tenant: business id 25,
primaryColor: "dark-blue", defaultLocale: "fa", modules
products, blog, finance. Logo and favicon are still null upstream, so the site
keeps using the local logo files.
Blog
Now served from the backend (src/lib/blog.ts), replacing the placeholder posts.
The two endpoints disagree in shape and both are normalised behind one type:
GET /tenants/{domain}/blogs |
{ items: [...] } |
GET /tenants/{domain}/blogs/{slug} |
{ blog: {...} } |
Bodies arrive as HTML from the Meshkee editor and are styled by .prose-brand
in globals.css rather than a typography plugin. The editor emits inline
float/margin on images, which that rule overrides so images stay in the flow.
Media comes from *.parspack.net, allowlisted in next.config.ts —
next/image renders an empty box for any host not listed there.
Accounts
Sign-in happens on customer.{domain}; this site never sees a password and never
calls POST /auth/login. It reads a session token from a cookie, asks
GET /auth/me who it belongs to, and renders the name with the honorific plus a
dropdown (profile / sign out, and manage website for an administrator).
Two things here are unverified and are the first place to look if it misbehaves:
- The cookie. The API authenticates with a Bearer JWT, which portals usually
keep in localStorage — and localStorage is not shared across subdomains. For
this to work, the portal must write the token to a cookie scoped to the apex
(
.majdtrading.com). Whether it does, and under what name, is not documented.SESSION_COOKIEinsrc/lib/auth.tsis the single place to correct. - The
/auth/meshape. It has no response schema in the OpenAPI document and needs a token we do not hold, so it could not be sampled. The parser is deliberately shape-tolerant across the plausible spellings of name and role.
Until the cookie name matches, getSession() returns null and the header shows
the sign-in link — the correct fallback, just not the whole feature.
auth.ts is server-only; the client-safe links live in portal.ts. Importing
the former from a client component pulls next/headers into the browser bundle
and fails the build.
The cost of a personalised header
Reading cookies in the locale layout opts every page into on-demand
rendering. The build table still prints ● against most routes, but that reflects
generateStaticParams, not prerendering: prerender-manifest.json lists exactly
one route (/_not-found). Fetch-level caching is unaffected — the market data is
still fetched once a minute for the whole site — but HTML is no longer prebuilt.
The alternative is to fetch the session client-side and keep the pages static, at the cost of a brief signed-out flash. Worth revisiting once the cookie mechanism is confirmed.
SEO and sitemap
Per the Meshkee SEO brief.
No public/sitemap.xml or public/robots.txt — nginx proxies both to the
Meshkee API. Adding local copies would shadow the real ones.
What this site publishes instead is GET /meshkee/sitemap-config.json
(src/app/meshkee/sitemap-config.json/route.ts): the static pages the API cannot
enumerate. It is generated from the same locales and productCategories
constants the app renders from, so it cannot drift from what exists. CMS content
reaches the sitemap through the API's own child sitemaps.
After deploying, run: business dashboard → Website → Settings → Sync sitemap config.
Canonical blog URLs
/{locale}/blog/{id}/{titleSlug}, resolved through GET .../blogs/by-id/{id}.
The slug segment is decorative — the id resolves the page — and is built with
slugify() from the title. The API's own slug field is deliberately not
used: on real data it holds legacy values like item-old-2945, which the brief
rules out of public URLs. A request whose slug has drifted 307s to the canonical
form.
Two encoding traps live in that redirect, both of which surface as a 500 rather than anything obvious:
- Next hands the route param percent-encoded, so it must be decoded before comparing against a Persian slug — otherwise every request looks like a drift.
- A
Locationheader may not carry raw non-ASCII, so the redirect target must be re-encoded on the way out.
On-page
Every page carries a unique title and meta description, and exactly one <h1>
— audited across all locales. The homepage needed one added: its hero is a
rotating slider whose captions are h2s, so the document had no h1 at all. It
ships sr-only, keeping the outline correct without inventing a visible title
the design does not have.
Grids that follow their data
lgColumns() in src/lib/grid.ts maps an item count onto a static
lg:grid-cols-N class. Tailwind extracts class names as literal strings, so
lg:grid-cols-${n} never reaches the stylesheet — hence the lookup rather than
interpolation.
Used by the homepage product row and the "other categories" row on each product page. Both had drifted: removing the home appliances category left six items in a seven-column grid, and five "others" in a six-column one, each with a dead column at the end. Deriving the count means that cannot recur.
Known gaps
- The contact form has no backend.
ContactForm.tsxvalidates on the client and then shows the success message without sending anything. Point it at a server action or mail endpoint before launch — the spot is marked with a comment. - Content is homepage/about/contact only. Products, services, news and catalog exist on the live site as separate pages; they appear here as summary sections, not as their own routes.
- The map on the contact page is an OpenStreetMap embed centred on Sepah Square, Qom, with a link out beneath it.
- Partner names are not sourced from majdtrading.com — that site carries none
(every
titleattribute is just the URL and everyaltis the company name). The logo-to-link mapping insrc/lib/site.tsis taken from its markup and is reliable. The names are the organisations' own: ICCIMA and ISIPO were confirmed from their sites' own titles, IRICA is legible in its logo. Qom Chamber, Iran Fair and the ministry entry (dotic.ir) could not be reached from here and are named from their logos and naming convention — worth a check before launch. - The backend has no published blog posts for majdtrading.com
(
/tenants/majdtrading.com/blogsreturnstotal: 0), so the homepage carousel is hidden and/{locale}/blogshows its empty state. Both fill in on their own once posts are published — no code change. - Home appliances was removed as a product category. The phrase still appears in the About copy, because that is the company's own description of what it trades, taken verbatim from majdtrading.com.
- The product category copy is mine, not the client's. The intro paragraph and goods list on each of the seven pages were written for this build. They are plausible for an Iranian trading company but are not sourced from majdtrading.com — the client must confirm what they actually trade before launch. Everything else on the site is their own copy.
- The homepage contact frame repeats the address, phones and email that the footer shows directly beneath it. Fine if the frame is meant to be the closing call to action, but it is duplication worth a decision.
Assets
Logo, slider photography, service icons and partner logos were downloaded from
majdtrading.com into public/images/. IRANYekan — the same family the live site
loads from a CDN — is self-hosted in public/fonts/, alongside Space Grotesk.
public/images/sample/ holds the photos from the Image Sample/ folder,
re-encoded to sRGB JPEG at web sizes. Which slot each one fills is set in
src/i18n/dictionaries/fa.ts (services.items[].image and
products.background) — swapping one is a one-line change.
Landscape frames go to the full-width slots, portrait frames to the tiles, so nothing is cropped against its grain.
| File | Slot | Source |
|---|---|---|
deck-containers.jpg |
slide 1 + products band | premium_photo-1749672969689… |
aerial-vessel.jpg |
slide 2 + service tile — IMPORT AND EXPORT | premium_photo-1661880889658… |
container-canyon.jpg |
slide 3 + service tile — TECHNICAL SERVICES | caleb-… |
port-vessel.jpg |
service tile — TRANSPORT | delfina-iacub-… |
container-stack.jpg |
service tile — DESIRED PRODUCTS | aron-yigin-… |
The header carries a products menu whose dropdown lists the seven categories. It
opens on hover and on keyboard focus, which is why the panel is hidden with
opacity/visibility rather than display: none — a panel that is not rendered
cannot take focus, so group-focus-within would never fire. Each entry targets
its own card (#product-oil, #product-mineral, …); the cards carry
scroll-mt-28 so the sticky header does not cover the one you land on.
There are no per-category pages yet, so those links jump within the homepage. If
categories become their own routes later, the hrefs in Header.tsx are the only
thing to change.
The seven product categories render as a grid of cards, each with a minimal line
icon above its label. The icons live in src/components/ProductIcon.tsx and are
selected by the icon key on each entry in products.items; they draw in
currentColor, so a card recolours its icon along with its text on hover. Adding
a category means adding a shape to that file and naming it in the dictionary —
the union type will flag a name with no shape behind it.
The slider is three slides, set in slider in the same dictionary file. Each
slide carries a Persian description shown under its title, and each service
item carries one that is hidden until the tile is hovered — revealed by animating
a wrapper grid row from 0fr to 1fr (so no fixed height is baked in) while the
paragraph fades and slides up. Each
slide's subtitle is split on spaces and set one word per line as an oversized,
10%-opacity backdrop behind the Persian headline — so "ARIO MAJD ASIA" stacks
as ARIO / MAJD / ASIA. It is aria-hidden, being decorative repetition of the
heading. Note that the positioning wrapper stays in the RTL flow and only the
words carry font-display: putting .latin-title (which sets direction: ltr)
on the positioned element makes start-0 resolve against its own direction and
anchor to the wrong edge.
Licensing — action needed before launch
aerial-vessel.jpg and deck-containers.jpg come from the two .avif files,
which are watermarked Unsplash+ preview downloads. Both now carry slides in
the hero, where the tiled Unsplash+ watermark is plainly visible at full width
— it is the most prominent thing on the page. These must be replaced with
licensed downloads before launch. Same filenames, no code change.
Screenshot 2026-08-25 at 23.04.50.png was left unused: it is a screen capture
of an Unsplash page, complete with browser UI and a tooltip, and is watermarked
as well.
container-stack.jpg is only 640px wide — the largest the source file offers.
It is slightly soft on a high-DPI screen in its tile; a larger re-download would
fix it.