Next.js 15 site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from majdtrading.com. Persian, English and Arabic; RTL and LTR. - Pages: home, about, contact, six product categories, blog index and detail - i18n: prose in per-locale JSON, structure in TypeScript, drift checked by `npm run i18n:check`. Blog stays single-language by design - Meshkee backend: blog content, tenant resolution, session via the customer portal, and /meshkee/sitemap-config.json - Live market data from livedata.ir, cached server-side once a minute - Self-hosted fonts: IRANYekan (fa), IBM Plex Sans Arabic (ar), Space Grotesk and BBH Bartle (Latin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
527 lines
25 KiB
Markdown
527 lines
25 KiB
Markdown
# Majd Trading — ARIO MAJD ASIA
|
|
|
|
Next.js site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from
|
|
[majdtrading.com](https://www.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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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-sans` **and** set `font-family`.
|
|
Redefining the variable alone is not enough — `body` declares
|
|
`font-family: var(--font-sans)` itself, so a rule on `<html>` never reaches it.
|
|
Setting `font-family` alone 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 carry `lang` (the `<html>` element and a blog article), so everything
|
|
else inherits and Tailwind's `font-display` utility 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_COOKIE` in `src/lib/auth.ts` is the single place to correct.
|
|
- **The `/auth/me` shape.** 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 `Location` header 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 `h2`s, 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.tsx` validates 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 `title` attribute is just the URL and every `alt` is the company name).
|
|
The logo-to-link mapping in `src/lib/site.ts` is 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/blogs` returns `total: 0`), so the homepage carousel
|
|
is hidden and `/{locale}/blog` shows 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.
|