Initial import: Majd Trading website
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>
@@ -0,0 +1,11 @@
|
||||
|
||||
# Local dev preview config
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.claude/
|
||||
.env*.local
|
||||
.next/
|
||||
next-env.d.ts
|
||||
node_modules/
|
||||
out/
|
||||
tsconfig.tsbuildinfo
|
||||
|
After Width: | Height: | Size: 8.3 MiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 423 KiB |
|
After Width: | Height: | Size: 690 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 698 KiB |
@@ -0,0 +1,526 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
// Blog artwork is served from the Meshkee media host, so next/image needs
|
||||
// it allowlisted. Anything not listed here renders as an empty box.
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "*.parspack.net" },
|
||||
{ protocol: "https", hostname: "cdn.meshkee.com" },
|
||||
{ protocol: "https", hostname: "*.meshkee.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "majd-trading",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"i18n:check": "node scripts/check-i18n.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"server-only": "^0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 715 KiB |
|
After Width: | Height: | Size: 629 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 581 KiB |
|
After Width: | Height: | Size: 899 KiB |
|
After Width: | Height: | Size: 371 KiB |
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Compares every locale file against the reference locale (fa).
|
||||
*
|
||||
* The `Dictionary` annotation on each wrapper already fails the build on a
|
||||
* MISSING key. This catches the other two cases it cannot see: keys left behind
|
||||
* after a rename, and arrays whose length drifted apart (a mission list of 14
|
||||
* items in one language and 13 in another renders fine and is wrong).
|
||||
*
|
||||
* Run after editing fa.json, before touching en/ar: npm run i18n:check
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dir = join(here, "..", "src", "i18n", "locales");
|
||||
const REFERENCE = "fa";
|
||||
const OTHERS = ["en", "ar"];
|
||||
|
||||
const load = (l) => JSON.parse(readFileSync(join(dir, `${l}.json`), "utf8"));
|
||||
|
||||
/** Flattens to `a.b.c` paths; arrays report as one path plus their length. */
|
||||
function paths(value, prefix = "", out = new Map()) {
|
||||
if (Array.isArray(value)) {
|
||||
out.set(prefix, `array:${value.length}`);
|
||||
return out;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
paths(v, prefix ? `${prefix}.${k}` : k, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.set(prefix, typeof value);
|
||||
return out;
|
||||
}
|
||||
|
||||
const reference = paths(load(REFERENCE));
|
||||
let problems = 0;
|
||||
|
||||
for (const locale of OTHERS) {
|
||||
const current = paths(load(locale));
|
||||
const missing = [...reference.keys()].filter((k) => !current.has(k));
|
||||
const stale = [...current.keys()].filter((k) => !reference.has(k));
|
||||
const lengths = [...reference.entries()].filter(
|
||||
([k, v]) => current.has(k) && v !== current.get(k) && v.startsWith("array")
|
||||
);
|
||||
|
||||
if (!missing.length && !stale.length && !lengths.length) {
|
||||
console.log(` ${locale}: in sync with ${REFERENCE} (${reference.size} keys)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
problems += missing.length + stale.length + lengths.length;
|
||||
console.log(` ${locale}:`);
|
||||
for (const k of missing) console.log(` missing ${k}`);
|
||||
for (const k of stale) console.log(` stale ${k} (not in ${REFERENCE})`);
|
||||
for (const [k, v] of lengths)
|
||||
console.log(` length ${k} ${REFERENCE}=${v} ${locale}=${current.get(k)}`);
|
||||
}
|
||||
|
||||
if (problems) {
|
||||
console.error(`\ni18n check failed: ${problems} problem(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\ni18n check passed.");
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { pageImages } from "@/content";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = await getDictionary(locale);
|
||||
return { title: t.about.heading, description: t.about.paragraphs[0] };
|
||||
}
|
||||
|
||||
export default async function AboutPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const t = await getDictionary(locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
title={t.about.heading}
|
||||
latin={t.about.headingLatin}
|
||||
image={pageImages.about}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<div className="grid gap-12 lg:grid-cols-[3fr_2fr]">
|
||||
<div className="space-y-6">
|
||||
{t.about.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="text-justify text-base leading-9 text-brand/80">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative aspect-3/4 w-full self-start">
|
||||
<Image
|
||||
src={pageImages.aboutPortrait}
|
||||
alt={t.meta.company}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-surface">
|
||||
<SectionHeading
|
||||
title={t.about.vision.heading}
|
||||
latin={t.about.vision.headingLatin}
|
||||
subtitle={t.about.vision.subtitle}
|
||||
align="start"
|
||||
/>
|
||||
<div className="mt-8 space-y-6">
|
||||
{t.about.vision.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph} className="text-justify text-base leading-9 text-brand/80">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHeading
|
||||
title={t.about.mission.heading}
|
||||
latin={t.about.mission.headingLatin}
|
||||
subtitle={t.about.mission.subtitle}
|
||||
align="start"
|
||||
/>
|
||||
<ol className="mt-10 grid gap-px bg-brand-100 md:grid-cols-2">
|
||||
{t.about.mission.items.map((item, i) => (
|
||||
<li key={item} className="flex gap-4 bg-white p-6">
|
||||
<span className="latin shrink-0 text-2xl font-bold text-brand-100">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<p className="text-sm leading-8 text-brand/80">{item}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-brand">
|
||||
<SectionHeading
|
||||
title={t.about.values.heading}
|
||||
latin={t.about.values.headingLatin}
|
||||
subtitle={t.about.values.subtitle}
|
||||
tone="light"
|
||||
/>
|
||||
<ul className="mt-12 flex flex-wrap justify-center gap-3">
|
||||
{t.about.values.items.map((value) => (
|
||||
<li
|
||||
key={value}
|
||||
className="border border-white/20 px-6 py-3 text-sm font-medium text-white/85 transition-colors hover:border-white hover:bg-white hover:text-brand"
|
||||
>
|
||||
{value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { Section } from "@/components/Section";
|
||||
import { isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { pageImages } from "@/content";
|
||||
import { getPostById } from "@/lib/blog";
|
||||
|
||||
type Params = { locale: string; id: string; slug: string };
|
||||
|
||||
/** A malformed escape sequence must not take the page down. */
|
||||
function safeDecode(value: string) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale, id } = await params;
|
||||
const post = await getPostById(Number(id));
|
||||
if (!post) return {};
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.abstract ?? undefined,
|
||||
alternates: { canonical: `/${locale}/blog/${post.id}/${encodeURIComponent(post.slug)}` },
|
||||
openGraph: post.imageUrl ? { images: [post.imageUrl], type: "article" } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: Promise<Params> }) {
|
||||
const { locale, id, slug } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId)) notFound();
|
||||
|
||||
const post = await getPostById(numericId);
|
||||
if (!post) notFound();
|
||||
|
||||
// The slug segment is SEO-only. If it drifts from the canonical form (an old
|
||||
// link, or the title was edited), send the reader to the canonical URL.
|
||||
//
|
||||
// Two encoding traps here, both of which produce a 500 rather than a redirect:
|
||||
// the incoming param arrives percent-ENCODED, so it must be decoded before
|
||||
// comparing against a Persian slug; and a Location header may not contain raw
|
||||
// non-ASCII, so the target must be encoded on the way back out.
|
||||
const decodedSlug = safeDecode(slug);
|
||||
if (decodedSlug !== post.slug) {
|
||||
redirect(`/${locale}/blog/${post.id}/${encodeURIComponent(post.slug)}`);
|
||||
}
|
||||
|
||||
const t = await getDictionary(locale);
|
||||
|
||||
const publishedAt = post.publishedAt
|
||||
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
|
||||
new Date(post.publishedAt)
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
title={post.title}
|
||||
latin={t.blog.headingLatin}
|
||||
image={post.imageUrl ?? pageImages.about}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<article className="mx-auto max-w-3xl">
|
||||
<Reveal>
|
||||
{publishedAt && (
|
||||
<p className="text-xs text-muted">
|
||||
{t.blog.publishedOn}{" "}
|
||||
<time dateTime={post.publishedAt ?? undefined} className="text-brand/70">
|
||||
{publishedAt}
|
||||
</time>
|
||||
{post.author && <span className="text-brand/70"> · {post.author}</span>}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-5 block h-1 w-16 bg-brand" />
|
||||
|
||||
{post.abstract && (
|
||||
<p className="mt-8 text-justify text-base font-medium leading-9 text-brand">
|
||||
{post.abstract}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{post.bodyHtml && (
|
||||
/* Body arrives as HTML from the Meshkee editor — authored in the
|
||||
client's own dashboard, trusted the way a CMS body is.
|
||||
`.prose-brand` in globals.css styles it without a plugin. */
|
||||
<div
|
||||
className="prose-brand mt-8"
|
||||
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
||||
/>
|
||||
)}
|
||||
</Reveal>
|
||||
</article>
|
||||
|
||||
<div className="mx-auto mt-12 max-w-3xl border-t border-brand-100 pt-8">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="text-sm font-bold text-brand transition-colors hover:text-brand-500"
|
||||
>
|
||||
{t.blog.backToList}
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { Section } from "@/components/Section";
|
||||
import { isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { pageImages } from "@/content";
|
||||
import { getPosts } from "@/lib/blog";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = await getDictionary(locale);
|
||||
return { title: t.blog.heading, description: t.blog.subtitle };
|
||||
}
|
||||
|
||||
export default async function BlogIndexPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
|
||||
const t = await getDictionary(locale);
|
||||
const posts = await getPosts(24);
|
||||
|
||||
const formatDate = (value: string | null) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
|
||||
new Date(value)
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title={t.blog.heading} latin={t.blog.headingLatin} image={pageImages.about} />
|
||||
|
||||
<Section>
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-center text-sm leading-8 text-brand/60">{t.blog.empty}</p>
|
||||
) : (
|
||||
<div className="grid gap-px bg-brand-100 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post, i) => (
|
||||
<Reveal key={post.id} delay={i * 70} className="bg-white">
|
||||
<Link href={`/${locale}/blog/${post.id}/${post.slug}`} className="group flex h-full flex-col">
|
||||
<div className="relative aspect-16/10 w-full overflow-hidden bg-brand-900">
|
||||
{post.imageUrl && (
|
||||
<Image
|
||||
src={post.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
{post.publishedAt && (
|
||||
<time dateTime={post.publishedAt} className="text-xs text-muted">
|
||||
{formatDate(post.publishedAt)}
|
||||
</time>
|
||||
)}
|
||||
<h2 className="mt-3 text-base font-bold leading-8 text-brand">{post.title}</h2>
|
||||
{post.abstract && (
|
||||
<p className="mt-3 line-clamp-3 flex-1 text-sm leading-7 text-brand/65">
|
||||
{post.abstract}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { pageImages } from "@/content";
|
||||
import { site } from "@/lib/site";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = await getDictionary(locale);
|
||||
return { title: t.contact.heading, description: site.address };
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
title,
|
||||
latin,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
latin: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="bg-white p-8">
|
||||
<div className="grid h-14 w-14 place-items-center bg-brand-100 text-brand">{icon}</div>
|
||||
<h2 className="mt-6 text-base font-bold text-brand">{title}</h2>
|
||||
<span className="latin mt-1 block text-[11px] text-muted">{latin}</span>
|
||||
<div className="mt-4 space-y-1.5 text-sm leading-8 text-brand/75">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ContactPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const t = await getDictionary(locale);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
title={t.contact.heading}
|
||||
latin={t.contact.headingLatin}
|
||||
image={pageImages.contact}
|
||||
/>
|
||||
|
||||
<Section className="bg-surface">
|
||||
<div className="grid gap-px bg-brand-100 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard
|
||||
title={t.contact.addressHeading}
|
||||
latin={t.contact.addressLatin}
|
||||
icon={
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 21s7-5.6 7-11a7 7 0 10-14 0c0 5.4 7 11 7 11z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="2.6" stroke="currentColor" strokeWidth="1.7" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<p>{site.address}</p>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard
|
||||
title={t.contact.emailHeading}
|
||||
latin={t.contact.emailLatin}
|
||||
icon={
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="1.7" />
|
||||
<path d="M3.5 6.5L12 13l8.5-6.5" stroke="currentColor" strokeWidth="1.7" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{site.emails.map((email) => (
|
||||
<a
|
||||
key={email}
|
||||
href={`mailto:${email}`}
|
||||
dir="ltr"
|
||||
className="latin-ui block text-start transition-colors hover:text-brand"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
))}
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard
|
||||
title={t.contact.phoneHeading}
|
||||
latin={t.contact.phoneLatin}
|
||||
icon={
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6.2 3.5h3l1.5 4-2 1.4a12.5 12.5 0 006.4 6.4l1.4-2 4 1.5v3c0 1-.8 1.8-1.8 1.7C10.6 19.9 4.1 13.4 3.5 5.3c-.1-1 .7-1.8 1.7-1.8z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
{site.phones.map((phone, i) => (
|
||||
<a
|
||||
key={phone}
|
||||
href={`tel:${phone}`}
|
||||
dir="ltr"
|
||||
className="latin-ui block text-start transition-colors hover:text-brand"
|
||||
>
|
||||
{site.phonesDisplay[i]}
|
||||
</a>
|
||||
))}
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard
|
||||
title={t.contact.socialHeading}
|
||||
latin={t.contact.socialLatin}
|
||||
icon={
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="5" stroke="currentColor" strokeWidth="1.7" />
|
||||
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.7" />
|
||||
<circle cx="17.6" cy="6.4" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={site.instagram.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="latin-ui block text-start transition-colors hover:text-brand"
|
||||
>
|
||||
{site.instagram.handle}
|
||||
</a>
|
||||
</InfoCard>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<div className="grid gap-12 lg:grid-cols-[3fr_2fr]">
|
||||
<div>
|
||||
<SectionHeading
|
||||
title={t.contact.lead}
|
||||
latin={t.contact.leadLatin}
|
||||
subtitle={t.contact.leadSubtitle}
|
||||
align="start"
|
||||
/>
|
||||
<div className="mt-10">
|
||||
<ContactForm t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-start">
|
||||
<iframe
|
||||
title={site.address}
|
||||
src={site.map.embed}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="h-96 w-full border border-brand-100 bg-surface"
|
||||
/>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm leading-8 text-brand/75">{site.address}</p>
|
||||
<a
|
||||
href={site.map.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-bold text-brand transition-colors hover:text-brand-500"
|
||||
>
|
||||
{t.contact.viewOnMap}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import Footer from "@/components/Footer";
|
||||
import { locales, localeDirection, defaultLocale, isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { site } from "@/lib/site";
|
||||
import "@/app/globals.css";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) return {};
|
||||
const t = await getDictionary(locale);
|
||||
|
||||
return {
|
||||
metadataBase: new URL(site.url),
|
||||
title: {
|
||||
default: `${t.meta.company} | ${t.meta.companyLatin}`,
|
||||
template: `%s | ${t.meta.companyShort}`,
|
||||
},
|
||||
description: t.meta.description,
|
||||
icons: { icon: "/images/brand/logo.png" },
|
||||
// Static pages exist in all three languages; tell search engines they are
|
||||
// translations of one another rather than duplicates. (Blog posts override
|
||||
// this with a single canonical — they are not translated.)
|
||||
alternates: {
|
||||
languages: Object.fromEntries([
|
||||
...locales.map((code) => [code, `/${code}`]),
|
||||
["x-default", `/${defaultLocale}`],
|
||||
]),
|
||||
},
|
||||
openGraph: {
|
||||
title: t.meta.company,
|
||||
description: t.meta.description,
|
||||
locale,
|
||||
type: "website",
|
||||
images: ["/images/slider/slider1.jpg"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
|
||||
const t = await getDictionary(locale);
|
||||
const dir = localeDirection[locale];
|
||||
const session = await getSession();
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir}>
|
||||
<head>
|
||||
{/* Scroll-reveal starts hidden; without JS it must never stay that way. */}
|
||||
<noscript>
|
||||
<style>{`[data-reveal]{opacity:1 !important;transform:none !important}`}</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:m-3 focus:rounded focus:bg-brand focus:px-4 focus:py-2 focus:text-white"
|
||||
>
|
||||
{t.common.skipToContent}
|
||||
</a>
|
||||
<Header locale={locale} t={t} session={session} />
|
||||
<main id="main" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer locale={locale} t={t} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import Slider from "@/components/Slider";
|
||||
import BlogCarousel from "@/components/BlogCarousel";
|
||||
import MarketFrame from "@/components/MarketFrame";
|
||||
import ProductIcon from "@/components/ProductIcon";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { composeCategories, composeServices, composeSlides } from "@/i18n/compose";
|
||||
import { pageImages, productsBackground } from "@/content";
|
||||
import { lgColumns } from "@/lib/grid";
|
||||
import { getMarketSnapshot } from "@/lib/market";
|
||||
import { getPosts } from "@/lib/blog";
|
||||
import { site } from "@/lib/site";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!isLocale(locale)) notFound();
|
||||
const t = await getDictionary(locale);
|
||||
|
||||
// Posts come from the Meshkee backend and are not translated — the section is
|
||||
// simply absent when nothing is published.
|
||||
const posts = await getPosts(9);
|
||||
|
||||
// null when nothing is selected in market/displayed.ts, or the upstream is
|
||||
// unreachable — the strip is then simply absent.
|
||||
const market = await getMarketSnapshot();
|
||||
|
||||
const slides = composeSlides(t);
|
||||
const serviceTiles = composeServices(t);
|
||||
const categories = composeCategories(t);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The hero is a rotating slider whose captions are h2s, so the page had
|
||||
no h1 at all. One shipped here, off-screen, keeps the document outline
|
||||
correct without inventing a visible title the design does not have. */}
|
||||
<h1 className="sr-only">
|
||||
{t.meta.company} — {t.meta.tagline}
|
||||
</h1>
|
||||
|
||||
<Slider slides={slides} t={t} />
|
||||
|
||||
{market && (
|
||||
<Section className="bg-surface">
|
||||
<SectionHeading
|
||||
title={t.market.heading}
|
||||
latin={t.market.headingLatin}
|
||||
subtitle={t.market.subtitle}
|
||||
/>
|
||||
<div className="mt-12">
|
||||
<MarketFrame snapshot={market} t={t} locale={locale} />
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Services — photo tiles under a navy scrim */}
|
||||
<Section>
|
||||
<SectionHeading
|
||||
title={t.services.heading}
|
||||
latin={t.services.headingLatin}
|
||||
subtitle={t.services.subtitle}
|
||||
/>
|
||||
<div className="mt-12 grid gap-px bg-brand-100 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{serviceTiles.map((item, i) => (
|
||||
<Reveal
|
||||
as="article"
|
||||
key={item.id}
|
||||
delay={i * 110}
|
||||
className="group relative aspect-4/5 overflow-hidden bg-brand-900"
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
{/* Scrim rises from the bottom edge, matching the hero. */}
|
||||
<div className="absolute inset-0 bg-linear-to-t from-brand-900/95 from-0% via-brand-900/60 via-30% to-brand-900/15" />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-7">
|
||||
<span className="latin block text-[11px] font-semibold text-brand-300">
|
||||
{item.latin}
|
||||
</span>
|
||||
<h3 className="mt-2 text-lg font-bold leading-8 text-white">{item.title}</h3>
|
||||
|
||||
{/* Revealed on hover. The 0fr -> 1fr grid row animates the
|
||||
height without hard-coding one, so any length of copy works. */}
|
||||
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-500 ease-out group-hover:grid-rows-[1fr]">
|
||||
<div className="overflow-hidden">
|
||||
<p className="mt-3 translate-y-3 text-sm leading-7 text-white/75 opacity-0 transition-[opacity,transform] duration-500 ease-out group-hover:translate-y-0 group-hover:opacity-100">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="mt-4 block h-0.5 w-10 bg-brand-300 transition-all duration-500 group-hover:w-20 group-hover:bg-white" />
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Intro */}
|
||||
<Section className="bg-surface">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
title={t.home.introHeading}
|
||||
latin={t.home.introLatin}
|
||||
align="start"
|
||||
/>
|
||||
<p className="mt-8 text-justify text-base leading-9 text-brand/80">
|
||||
{t.home.introBody}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/about`}
|
||||
className="mt-8 inline-block bg-brand px-7 py-3 text-sm font-bold text-white transition-colors hover:bg-brand-700"
|
||||
>
|
||||
{t.home.introCta}
|
||||
</Link>
|
||||
</Reveal>
|
||||
<Reveal delay={180} className="relative">
|
||||
{/* Flat block peeking out from behind the photo. */}
|
||||
<span className="absolute -bottom-4 -start-4 hidden h-32 w-32 bg-brand lg:block" />
|
||||
<div className="relative aspect-4/3 w-full">
|
||||
<Image
|
||||
src={pageImages.intro}
|
||||
alt={t.meta.company}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Products — full-bleed photo band under a navy scrim */}
|
||||
<section id="products" className="relative overflow-hidden bg-brand-900">
|
||||
<Image
|
||||
src={productsBackground}
|
||||
alt=""
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* Vignette: dark at the edges where the type sits, open through the middle. */}
|
||||
<div className="absolute inset-0 bg-linear-to-b from-brand-900/92 via-brand-900/55 to-brand-900/92" />
|
||||
|
||||
<div className="relative mx-auto max-w-7xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
|
||||
<SectionHeading
|
||||
title={t.products.heading}
|
||||
latin={t.products.headingLatin}
|
||||
subtitle={t.products.lead}
|
||||
tone="light"
|
||||
/>
|
||||
{/* Column count follows the data, so the row always fills the width. */}
|
||||
<ul
|
||||
className={`mt-12 grid grid-cols-2 gap-3 sm:grid-cols-3 ${lgColumns(
|
||||
categories.length
|
||||
)}`}
|
||||
>
|
||||
{categories.map((product, i) => (
|
||||
<Reveal as="li" key={product.slug} delay={i * 70}>
|
||||
<Link
|
||||
href={`/${locale}/products/${product.slug}`}
|
||||
className="flex h-full flex-col items-center gap-4 border border-white/25 px-3 py-7 text-center text-white/85 transition-colors hover:border-white hover:bg-white hover:text-brand"
|
||||
>
|
||||
{/* Inherits currentColor, so it flips to navy with the card. */}
|
||||
<ProductIcon name={product.slug} />
|
||||
<span className="text-xs font-medium leading-6 sm:text-sm">
|
||||
{product.title}
|
||||
</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Partners */}
|
||||
<Section className="bg-surface">
|
||||
<SectionHeading
|
||||
title={t.home.partnersHeading}
|
||||
latin={t.home.partnersLatin}
|
||||
subtitle={t.home.partnersSubtitle}
|
||||
/>
|
||||
<div className="mt-12 grid grid-cols-2 gap-px bg-brand-100 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{site.partners.map((partner, i) => {
|
||||
const name = t.partners[partner.id];
|
||||
return (
|
||||
<Reveal key={partner.id} delay={i * 90} className="group bg-white">
|
||||
<a
|
||||
href={partner.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-full flex-col items-center justify-start gap-4 p-6 text-center"
|
||||
>
|
||||
<Image
|
||||
src={partner.logo}
|
||||
alt={name}
|
||||
width={110}
|
||||
height={60}
|
||||
className="max-h-14 w-auto object-contain grayscale transition duration-500 group-hover:grayscale-0"
|
||||
/>
|
||||
<span className="text-xs leading-6 text-brand/65 transition-colors group-hover:text-brand">
|
||||
{name}
|
||||
</span>
|
||||
</a>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Blog — only in the language the posts are written in. */}
|
||||
{posts.length > 0 && (
|
||||
<Section>
|
||||
<SectionHeading
|
||||
title={t.blog.heading}
|
||||
latin={t.blog.headingLatin}
|
||||
subtitle={t.blog.subtitle}
|
||||
/>
|
||||
<div className="mt-12">
|
||||
<BlogCarousel posts={posts} locale={locale} t={t} />
|
||||
</div>
|
||||
<div className="mt-10 text-center">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-block border border-brand px-7 py-3 text-sm font-bold text-brand transition-colors hover:bg-brand hover:text-white"
|
||||
>
|
||||
{t.blog.viewAll}
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Contact frame */}
|
||||
<section className="bg-brand px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
|
||||
<Reveal className="mx-auto max-w-7xl border border-white/25 p-8 sm:p-12">
|
||||
<div className="grid gap-10 lg:grid-cols-[3fr_2fr] lg:items-center lg:gap-14">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
{t.home.ctaHeading}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-sm leading-8 text-white/70">
|
||||
{t.home.ctaBody}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="mt-8 inline-block bg-white px-8 py-4 text-sm font-bold text-brand transition-colors hover:bg-brand-100"
|
||||
>
|
||||
{t.home.ctaButton}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-6 border-t border-white/15 pt-8 text-sm lg:border-s lg:border-t-0 lg:pt-0 lg:ps-14">
|
||||
<li className="flex gap-4">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mt-1 shrink-0 text-brand-300"
|
||||
>
|
||||
<path
|
||||
d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="2.6" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="block text-xs text-brand-300">
|
||||
{t.footer.addressHeading}
|
||||
</span>
|
||||
<span className="mt-1 block leading-7 text-white/80">{site.address}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-4">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mt-1 shrink-0 text-brand-300"
|
||||
>
|
||||
<path
|
||||
d="M6.2 3.5h3l1.5 4-2 1.4a12.5 12.5 0 0 0 6.4 6.4l1.4-2 4 1.5v3c0 1-.8 1.8-1.8 1.7C10.6 19.9 4.1 13.4 3.5 5.3c-.1-1 .7-1.8 1.7-1.8z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<span className="block text-xs text-brand-300">
|
||||
{t.footer.phoneHeading}
|
||||
</span>
|
||||
{site.phones.map((phone, i) => (
|
||||
<a
|
||||
key={phone}
|
||||
href={`tel:${phone}`}
|
||||
dir="ltr"
|
||||
className="latin-ui mt-1 block text-start text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
{site.phonesDisplay[i]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-4">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="mt-1 shrink-0 text-brand-300"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M3.5 6.5 12 13l8.5-6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="block text-xs text-brand-300">
|
||||
{t.footer.emailHeading}
|
||||
</span>
|
||||
<a
|
||||
href={`mailto:${site.emails[0]}`}
|
||||
dir="ltr"
|
||||
className="latin-ui mt-1 block text-start text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
{site.emails[0]}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import ProductIcon from "@/components/ProductIcon";
|
||||
import Reveal from "@/components/Reveal";
|
||||
import { Section, SectionHeading } from "@/components/Section";
|
||||
import { locales, isLocale } from "@/i18n/config";
|
||||
import { getDictionary } from "@/i18n/dictionaries";
|
||||
import { composeCategories, composeCategory } from "@/i18n/compose";
|
||||
import { productCategories } from "@/content";
|
||||
import { lgColumns } from "@/lib/grid";
|
||||
|
||||
type Params = { locale: string; slug: string };
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.flatMap((locale) =>
|
||||
productCategories.map((item) => ({ locale, slug: item.slug }))
|
||||
);
|
||||
}
|
||||
|
||||
async function findProduct(locale: string, slug: string) {
|
||||
if (!isLocale(locale)) return null;
|
||||
const t = await getDictionary(locale);
|
||||
const product = composeCategory(t, slug);
|
||||
return product ? { t, product } : null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const found = await findProduct(locale, slug);
|
||||
if (!found) return {};
|
||||
return { title: found.product.title, description: found.product.intro };
|
||||
}
|
||||
|
||||
export default async function ProductCategoryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
const found = await findProduct(locale, slug);
|
||||
if (!found) notFound();
|
||||
|
||||
const { t, product } = found;
|
||||
const others = composeCategories(t).filter((item) => item.slug !== product.slug);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title={product.title} latin={product.latin} image={product.image} />
|
||||
|
||||
<Section>
|
||||
<div className="grid gap-12 lg:grid-cols-[3fr_2fr]">
|
||||
<Reveal>
|
||||
<ProductIcon name={product.slug} className="h-12 w-12 text-brand" />
|
||||
<p className="mt-6 text-justify text-base leading-9 text-brand/80">
|
||||
{product.intro}
|
||||
</p>
|
||||
<p className="mt-6 border-s-2 border-brand-100 ps-5 text-sm leading-8 text-brand/60">
|
||||
{t.products.servicesNote}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={150}>
|
||||
<SectionHeading
|
||||
title={t.products.goodsHeading}
|
||||
latin={t.products.goodsLatin}
|
||||
align="start"
|
||||
/>
|
||||
<ul className="mt-8 grid grid-cols-2 gap-px bg-brand-100">
|
||||
{product.goods.map((item) => (
|
||||
<li key={item} className="bg-white px-4 py-3.5 text-sm text-brand/80">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Other categories */}
|
||||
<Section className="bg-surface">
|
||||
<SectionHeading title={t.products.otherHeading} latin={t.products.otherLatin} />
|
||||
{/* One fewer than the full set — the current category is excluded. */}
|
||||
<div
|
||||
className={`mt-12 grid grid-cols-2 gap-px bg-brand-100 sm:grid-cols-3 ${lgColumns(
|
||||
others.length
|
||||
)}`}
|
||||
>
|
||||
{others.map((item, i) => (
|
||||
<Reveal key={item.slug} delay={i * 80} className="bg-white">
|
||||
<Link
|
||||
href={`/${locale}/products/${item.slug}`}
|
||||
className="group flex h-full flex-col items-center gap-3 p-6 text-center"
|
||||
>
|
||||
<ProductIcon
|
||||
name={item.slug}
|
||||
className="h-9 w-9 text-brand/70 transition-colors group-hover:text-brand"
|
||||
/>
|
||||
<span className="text-xs leading-6 text-brand/70 transition-colors group-hover:text-brand">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Inquiry */}
|
||||
<section className="bg-brand px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
|
||||
<Reveal className="mx-auto flex max-w-7xl flex-col items-center gap-6 border border-white/25 p-8 text-center sm:p-12 lg:flex-row lg:justify-between lg:text-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
{t.products.inquiryHeading}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xl text-sm leading-8 text-white/70">
|
||||
{t.products.inquiryBody}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/contact`}
|
||||
className="shrink-0 bg-white px-8 py-4 text-sm font-bold text-brand transition-colors hover:bg-brand-100"
|
||||
>
|
||||
{t.products.inquiryButton}
|
||||
</Link>
|
||||
</Reveal>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ---- IRANYekan, self-hosted (same family the current majdtrading.com uses) ---- */
|
||||
@font-face {
|
||||
font-family: "IRANYekan";
|
||||
src: url("/fonts/iran-yekan-light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANYekan";
|
||||
src: url("/fonts/iran-yekan-regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANYekan";
|
||||
src: url("/fonts/iran-yekan-medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANYekan";
|
||||
src: url("/fonts/iran-yekan-bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANYekan";
|
||||
src: url("/fonts/iran-yekan-extrabold.ttf") format("truetype");
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IRANYekan";
|
||||
src: url("/fonts/iran-yekan-black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ---- IBM Plex Sans Arabic: Arabic body face. IRANYekan covers Arabic script
|
||||
but is a Persian typeface and reads foreign in Arabic. ---- */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans Arabic";
|
||||
src: url("/fonts/plex-arabic-400.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans Arabic";
|
||||
src: url("/fonts/plex-arabic-500.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans Arabic";
|
||||
src: url("/fonts/plex-arabic-600.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans Arabic";
|
||||
src: url("/fonts/plex-arabic-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ---- BBH Bartle: display face for the Latin captions. Caps-only, one weight,
|
||||
so never ask it for bold — it would be synthesised. ---- */
|
||||
@font-face {
|
||||
font-family: "BBH Bartle";
|
||||
src: url("/fonts/bbh-bartle-latin.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ---- Space Grotesk, self-hosted (one variable file covers 400-700) ---- */
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("/fonts/space-grotesk-latin.woff2") format("woff2");
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Palette lifted from majdtrading.com's --wb-* custom properties */
|
||||
--color-brand: #1a3851;
|
||||
--color-brand-700: #163045;
|
||||
--color-brand-900: #0f2233;
|
||||
--color-brand-100: #e8edf2;
|
||||
/* Steel blues carry the accents that used to be red. */
|
||||
--color-brand-300: #a8becd;
|
||||
--color-brand-500: #3e6684;
|
||||
|
||||
/* Red is reserved for form validation only — required marks, errors. */
|
||||
--color-accent: #ff214f;
|
||||
|
||||
/* Market movement. Semantic, like validation red — not brand colour. */
|
||||
--color-up: #1f7a4d;
|
||||
--color-down: #c0392b;
|
||||
--color-muted: #838385;
|
||||
--color-surface: #f6f7f9;
|
||||
|
||||
--font-sans: "IRANYekan", "Segoe UI", Tahoma, system-ui, sans-serif;
|
||||
--font-fa: "IRANYekan", "Segoe UI", Tahoma, system-ui, sans-serif;
|
||||
--font-ar: "IBM Plex Sans Arabic", "IRANYekan", Tahoma, system-ui, sans-serif;
|
||||
--font-en: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
--font-latin: "Space Grotesk", "Segoe UI", Tahoma, system-ui, sans-serif;
|
||||
--font-display: "BBH Bartle", "Space Grotesk", "Segoe UI", Tahoma, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: var(--color-brand);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Body face follows the language of the subtree, not the page.
|
||||
These selectors match only elements that actually carry `lang` — <html>, and
|
||||
a blog article whose language differs from the page around it. Everything
|
||||
else inherits, which leaves Tailwind's font utilities free to win where they
|
||||
are applied (the Latin backdrops use `font-display`).
|
||||
|
||||
They redefine --font-sans rather than only setting font-family: `body` sets
|
||||
`font-family: var(--font-sans)` itself, so a rule on <html> alone would never
|
||||
reach it. */
|
||||
[lang="fa"] {
|
||||
--font-sans: var(--font-fa);
|
||||
font-family: var(--font-sans);
|
||||
/* Persian digits read better than Latin ones in Farsi copy. Keyed on language,
|
||||
not direction — Arabic uses different digit forms. */
|
||||
font-feature-settings: "ss01";
|
||||
}
|
||||
|
||||
[lang="ar"] {
|
||||
--font-sans: var(--font-ar);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
[lang="en"] {
|
||||
--font-sans: var(--font-en);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Latin text stays LTR even inside an RTL document.
|
||||
|
||||
.latin-title — BBH Bartle. Reserved for heading lockups only.
|
||||
.latin — Space Grotesk, uppercase. Card labels, figures, brand line.
|
||||
.latin-ui — Space Grotesk, natural case. Phones, emails, handle. */
|
||||
/* Only `.latin-ui` forces LTR — its strings carry neutrals (+, (), @, .) that
|
||||
need it. `.latin` and `.latin-title` isolate the run but keep the inherited
|
||||
direction, so `text-align: start` still resolves to the right edge in RTL.
|
||||
Setting `direction: ltr` here would silently left-align every caption. */
|
||||
.latin-title,
|
||||
.latin {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.latin-ui {
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
/* Titles only — the English line that sits with a section or page heading. */
|
||||
.latin-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
/* Every other English label: card captions, figures, the brand line. */
|
||||
.latin {
|
||||
font-family: var(--font-latin);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
/* Strings that must keep their case and stay legible: phones, emails, handle. */
|
||||
.latin-ui {
|
||||
font-family: var(--font-latin);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Blog bodies arrive as HTML from the Meshkee editor. Styled here rather than
|
||||
with a typography plugin, so the output matches the rest of the site. */
|
||||
.prose-brand {
|
||||
color: color-mix(in srgb, var(--color-brand) 80%, transparent);
|
||||
line-height: 2.25;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.prose-brand > * + * {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.prose-brand h2,
|
||||
.prose-brand h3,
|
||||
.prose-brand h4 {
|
||||
color: var(--color-brand);
|
||||
font-weight: 700;
|
||||
line-height: 1.8;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.prose-brand h2 { font-size: 1.25rem; }
|
||||
.prose-brand h3 { font-size: 1.1rem; }
|
||||
|
||||
.prose-brand a {
|
||||
color: var(--color-brand);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.prose-brand ul,
|
||||
.prose-brand ol {
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
.prose-brand ul { list-style: disc; }
|
||||
.prose-brand ol { list-style: decimal; }
|
||||
.prose-brand li + li { margin-top: 0.5rem; }
|
||||
|
||||
.prose-brand img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
/* The editor emits inline float/margin styles; keep images in the flow. */
|
||||
display: block !important;
|
||||
float: none !important;
|
||||
margin-inline: auto !important;
|
||||
}
|
||||
|
||||
.prose-brand table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prose-brand blockquote {
|
||||
border-inline-start: 2px solid var(--color-brand-100);
|
||||
padding-inline-start: 1.25rem;
|
||||
color: color-mix(in srgb, var(--color-brand) 60%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { locales } from "@/i18n/config";
|
||||
import { productCategories } from "@/content";
|
||||
import { TENANT_DOMAIN } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Static-page manifest for the Meshkee sitemap builder.
|
||||
*
|
||||
* `/sitemap.xml` and `/robots.txt` are NOT served from here — nginx proxies both
|
||||
* to the Meshkee API, which is why no copies exist in `public/`. This route
|
||||
* publishes only the static pages the API cannot know about; CMS content (blog
|
||||
* posts, products) reaches the sitemap through the API's own child sitemaps.
|
||||
*
|
||||
* Derived from the same route constants the app renders from, so it cannot fall
|
||||
* out of step with what actually exists. Nothing auth-related is listed — sign
|
||||
* in lives on the customer portal, not on this domain.
|
||||
*
|
||||
* After deploying: business dashboard → Website → Settings → Sync sitemap config.
|
||||
*/
|
||||
export const dynamic = "force-static";
|
||||
|
||||
type StaticPage = { path: string; changefreq?: string; priority?: number };
|
||||
|
||||
export function GET() {
|
||||
const staticPages: StaticPage[] = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
staticPages.push({ path: `/${locale}`, changefreq: "daily", priority: 1.0 });
|
||||
staticPages.push({ path: `/${locale}/about`, changefreq: "monthly", priority: 0.6 });
|
||||
staticPages.push({ path: `/${locale}/contact`, changefreq: "monthly", priority: 0.5 });
|
||||
staticPages.push({ path: `/${locale}/blog`, changefreq: "daily", priority: 0.7 });
|
||||
|
||||
// Category pages are ours, not CMS products — the API cannot enumerate them.
|
||||
for (const category of productCategories) {
|
||||
staticPages.push({
|
||||
path: `/${locale}/products/${category.slug}`,
|
||||
changefreq: "monthly",
|
||||
priority: 0.8,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
baseUrl: `https://${TENANT_DOMAIN}`,
|
||||
staticPages,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link";
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
import { portalLinks, type Session } from "@/lib/portal";
|
||||
|
||||
/**
|
||||
* Signed out: a link to the customer portal. Signed in: the person's name with
|
||||
* the honorific, and a dropdown.
|
||||
*
|
||||
* Sign-in and sign-out both happen on the portal — this site never handles a
|
||||
* password. Opens on hover and on keyboard focus, so the panel is hidden with
|
||||
* opacity/visibility rather than `display: none` (an unrendered panel cannot
|
||||
* take focus, and `group-focus-within` would never fire).
|
||||
*/
|
||||
export default function AccountMenu({
|
||||
session,
|
||||
t,
|
||||
}: {
|
||||
session: Session | null;
|
||||
t: Dictionary;
|
||||
}) {
|
||||
// Signed out: icon only. The label lives in aria-label/title so it is still
|
||||
// announced and still shows on hover.
|
||||
if (!session) {
|
||||
return (
|
||||
<a
|
||||
href={portalLinks.login}
|
||||
aria-label={t.account.login}
|
||||
title={t.account.login}
|
||||
className="grid h-9 w-9 place-items-center text-brand/65 transition-colors hover:text-brand"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ href: portalLinks.profile, label: t.account.profile },
|
||||
// Only an administrator sees this one.
|
||||
...(session.isAdmin ? [{ href: portalLinks.admin, label: t.account.manageWebsite }] : []),
|
||||
{ href: portalLinks.logout, label: t.account.logout },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t.account.menu}
|
||||
className="flex items-center gap-1.5 px-2 py-2 text-[13px] font-medium text-brand transition-colors group-hover:text-brand-500 group-focus-within:text-brand-500"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
{/* "علی عزیز" — the honorific is empty in English, so no stray space. */}
|
||||
<span>
|
||||
{session.displayName}
|
||||
{t.account.honorific ? ` ${t.account.honorific}` : ""}
|
||||
</span>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-200 group-hover:rotate-180 group-focus-within:rotate-180"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="invisible absolute end-0 top-full z-50 min-w-44 -translate-y-1 border border-brand-100 bg-white py-2 opacity-0 transition duration-200 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block px-5 py-2.5 text-[13px] text-brand/70 transition-colors hover:bg-surface hover:text-brand"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
import type { BlogPost } from "@/lib/blog";
|
||||
|
||||
/**
|
||||
* Horizontal post carousel.
|
||||
*
|
||||
* Scrolling is native (scroll-snap) rather than a transformed track, so it works
|
||||
* with a trackpad, a touch swipe and the keyboard for free. The arrows just call
|
||||
* `scrollBy`, flipped for RTL where scrollLeft runs negative.
|
||||
*/
|
||||
export default function BlogCarousel({
|
||||
posts,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
posts: BlogPost[];
|
||||
locale: Locale;
|
||||
t: Dictionary;
|
||||
}) {
|
||||
const trackRef = useRef<HTMLUListElement>(null);
|
||||
const [atStart, setAtStart] = useState(true);
|
||||
const [atEnd, setAtEnd] = useState(false);
|
||||
|
||||
const sync = useCallback(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
// scrollLeft runs negative in RTL, so compare on distance travelled.
|
||||
const travelled = Math.abs(track.scrollLeft);
|
||||
const max = track.scrollWidth - track.clientWidth;
|
||||
setAtStart(travelled < 8);
|
||||
setAtEnd(travelled > max - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sync();
|
||||
window.addEventListener("resize", sync);
|
||||
return () => window.removeEventListener("resize", sync);
|
||||
}, [sync]);
|
||||
|
||||
const step = (direction: 1 | -1) => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const card = track.querySelector("li");
|
||||
const amount = card ? card.getBoundingClientRect().width + 24 : track.clientWidth * 0.8;
|
||||
const rtl = getComputedStyle(track).direction === "rtl";
|
||||
track.scrollBy({ left: amount * direction * (rtl ? -1 : 1), behavior: "smooth" });
|
||||
};
|
||||
|
||||
const formatDate = (value: string | null) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
|
||||
new Date(value)
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<ul
|
||||
ref={trackRef}
|
||||
onScroll={sync}
|
||||
className="flex snap-x snap-mandatory gap-6 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{posts.map((post) => (
|
||||
<li key={post.id} className="w-[85%] shrink-0 snap-start sm:w-[48%] lg:w-[31.5%]">
|
||||
<Link
|
||||
href={`/${locale}/blog/${post.id}/${post.slug}`}
|
||||
className="group flex h-full flex-col border border-brand-100 bg-white transition-colors hover:border-brand"
|
||||
>
|
||||
<div className="relative aspect-16/10 w-full overflow-hidden bg-brand-900">
|
||||
{post.imageUrl && (
|
||||
<Image
|
||||
src={post.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 85vw, (max-width: 1024px) 48vw, 31vw"
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
{post.publishedAt && (
|
||||
<time dateTime={post.publishedAt} className="text-xs text-muted">
|
||||
{formatDate(post.publishedAt)}
|
||||
</time>
|
||||
)}
|
||||
<h3 className="mt-3 text-base font-bold leading-8 text-brand">{post.title}</h3>
|
||||
{post.abstract && (
|
||||
<p className="mt-3 line-clamp-3 flex-1 text-sm leading-7 text-brand/65">
|
||||
{post.abstract}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-5 flex items-center gap-2 text-sm font-bold text-brand">
|
||||
{t.blog.readMore}
|
||||
<span className="block h-px w-6 bg-brand transition-all duration-300 group-hover:w-10" />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={atStart}
|
||||
aria-label={t.common.previous}
|
||||
className="grid h-11 w-11 place-items-center border border-brand-100 text-brand transition-colors hover:border-brand disabled:opacity-35"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" className="rtl:hidden" />
|
||||
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" className="hidden rtl:block" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={atEnd}
|
||||
aria-label={t.common.next}
|
||||
className="grid h-11 w-11 place-items-center border border-brand-100 text-brand transition-colors hover:border-brand disabled:opacity-35"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6" stroke="currentColor" strokeWidth="2" className="rtl:hidden" />
|
||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" className="hidden rtl:block" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
|
||||
type Field = "name" | "email" | "phone" | "subject" | "message";
|
||||
type Errors = Partial<Record<Field, string>>;
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function ContactForm({ t }: { t: Dictionary }) {
|
||||
const f = t.contact.form;
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const value = (key: Field) => String(data.get(key) ?? "").trim();
|
||||
|
||||
const next: Errors = {};
|
||||
for (const key of ["name", "email", "message"] as Field[]) {
|
||||
if (!value(key)) next[key] = f.required;
|
||||
}
|
||||
if (!next.email && !EMAIL_RE.test(value("email"))) next.email = f.invalidEmail;
|
||||
|
||||
setErrors(next);
|
||||
if (Object.keys(next).length > 0) return;
|
||||
|
||||
// No mail backend is wired up yet — swap this for a POST to the send
|
||||
// endpoint (or a server action) once the mail service is chosen.
|
||||
setStatus("sending");
|
||||
window.setTimeout(() => setStatus("sent"), 600);
|
||||
event.currentTarget.reset();
|
||||
}
|
||||
|
||||
const fieldClass = (key: Field) =>
|
||||
`w-full border bg-white px-4 py-3 text-sm text-brand outline-none transition-colors placeholder:text-muted focus:border-brand ${
|
||||
errors[key] ? "border-accent" : "border-brand-100"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-brand">
|
||||
{f.name} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={errors.name ? "name-error" : undefined}
|
||||
className={fieldClass("name")}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="name-error" className="mt-2 text-xs text-accent">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium text-brand">
|
||||
{f.email} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
dir="ltr"
|
||||
autoComplete="email"
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className={`${fieldClass("email")} text-start`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p id="email-error" className="mt-2 text-xs text-accent">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-brand">
|
||||
{f.phone}
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
autoComplete="tel"
|
||||
className={`${fieldClass("phone")} text-start`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="mb-2 block text-sm font-medium text-brand">
|
||||
{f.subject}
|
||||
</label>
|
||||
<input id="subject" name="subject" type="text" className={fieldClass("subject")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="mb-2 block text-sm font-medium text-brand">
|
||||
{f.message} <span className="text-accent">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={6}
|
||||
aria-invalid={Boolean(errors.message)}
|
||||
aria-describedby={errors.message ? "message-error" : undefined}
|
||||
className={`${fieldClass("message")} resize-y`}
|
||||
/>
|
||||
{errors.message && (
|
||||
<p id="message-error" className="mt-2 text-xs text-accent">
|
||||
{errors.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "sending"}
|
||||
className="bg-brand px-8 py-3.5 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
{status === "sending" ? f.sending : f.submit}
|
||||
</button>
|
||||
<p aria-live="polite" className="text-sm text-brand">
|
||||
{status === "sent" ? f.success : ""}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
import { site } from "@/lib/site";
|
||||
|
||||
export default function Footer({ locale, t }: { locale: Locale; t: Dictionary }) {
|
||||
const links = [
|
||||
{ href: `/${locale}`, label: t.nav.home },
|
||||
{ href: `/${locale}/about`, label: t.nav.about },
|
||||
{ href: `/${locale}/contact`, label: t.nav.contact },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-brand-900 text-white">
|
||||
<div className="mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 md:grid-cols-2 lg:grid-cols-4 lg:px-8">
|
||||
<div className="lg:col-span-2">
|
||||
<Image
|
||||
src="/images/brand/logo-white.png"
|
||||
alt={t.meta.company}
|
||||
width={160}
|
||||
height={54}
|
||||
className="h-12 w-auto"
|
||||
/>
|
||||
<p className="mt-5 text-base font-bold">{t.meta.company}</p>
|
||||
<p className="mt-2 text-sm text-white/70">{t.meta.tagline}</p>
|
||||
<a
|
||||
href={site.instagram.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-6 inline-flex items-center gap-2 border border-white/25 px-4 py-2 text-sm transition-colors hover:border-white hover:bg-white/15"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="2.5" y="2.5" width="19" height="19" rx="5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="17.6" cy="6.4" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
<span className="latin-ui">{site.instagram.handle}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-5 text-sm font-bold text-brand-300">{t.footer.linksHeading}</h2>
|
||||
<ul className="space-y-3 text-sm text-white/75">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="transition-colors hover:text-white">
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 text-sm">
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold text-brand-300">{t.footer.addressHeading}</h2>
|
||||
<p className="leading-7 text-white/75">{site.address}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold text-brand-300">{t.footer.phoneHeading}</h2>
|
||||
<ul className="space-y-1.5">
|
||||
{site.phones.map((phone, i) => (
|
||||
<li key={phone}>
|
||||
<a href={`tel:${phone}`} dir="ltr" className="latin-ui text-white/75 transition-colors hover:text-white">
|
||||
{site.phonesDisplay[i]}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-bold text-brand-300">{t.footer.emailHeading}</h2>
|
||||
<ul className="space-y-1.5">
|
||||
{site.emails.map((email) => (
|
||||
<li key={email}>
|
||||
<a href={`mailto:${email}`} dir="ltr" className="latin-ui text-white/75 transition-colors hover:text-white">
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10">
|
||||
<div className="mx-auto max-w-7xl px-4 py-5 text-center text-xs text-white/50 sm:px-6 lg:px-8">
|
||||
{t.footer.rights}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { locales, localeLabels, type Locale } from "@/i18n/config";
|
||||
import { composeCategories } from "@/i18n/compose";
|
||||
import AccountMenu from "./AccountMenu";
|
||||
import { portalLinks, type Session } from "@/lib/portal";
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
import { site } from "@/lib/site";
|
||||
|
||||
export default function Header({
|
||||
locale,
|
||||
t,
|
||||
session,
|
||||
}: {
|
||||
locale: Locale;
|
||||
t: Dictionary;
|
||||
session: Session | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const categories = composeCategories(t);
|
||||
|
||||
// Every route is generated for every locale, so switching language is just a
|
||||
// swap of the first path segment — no per-locale slug lookup needed.
|
||||
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "");
|
||||
|
||||
const links = [
|
||||
{ href: `/${locale}`, label: t.nav.home },
|
||||
{ href: `/${locale}/about`, label: t.nav.about },
|
||||
{ href: `/${locale}/contact`, label: t.nav.contact },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-brand-100 bg-white">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||
<Link href={`/${locale}`} className="flex shrink-0 items-center gap-3">
|
||||
<Image
|
||||
src="/images/brand/logo.png"
|
||||
alt={t.meta.company}
|
||||
width={140}
|
||||
height={48}
|
||||
priority
|
||||
className="h-9 w-auto"
|
||||
/>
|
||||
<span className="hidden text-xs font-bold leading-tight text-brand sm:block">
|
||||
{t.meta.companyShort}
|
||||
<span className="latin block text-[9px] font-normal text-muted">
|
||||
{t.meta.companyLatin}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden h-full items-center gap-1 md:flex" aria-label={t.nav.home}>
|
||||
{links.map((link) => {
|
||||
const active = pathname === link.href;
|
||||
const item = (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`relative whitespace-nowrap px-3 py-2 text-[13px] font-medium transition-colors ${
|
||||
active ? "font-bold text-brand" : "text-brand/65 hover:text-brand"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
{active && (
|
||||
<span className="absolute inset-x-3 -bottom-px h-0.5 bg-brand" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
// The products menu follows the home link.
|
||||
if (link.href !== `/${locale}`) return item;
|
||||
|
||||
return (
|
||||
<div key={link.href} className="contents">
|
||||
{item}
|
||||
{/* Opens on hover and on keyboard focus. `focus-within` only
|
||||
fires if the panel stays focusable, so it is hidden with
|
||||
opacity/visibility rather than `display: none`. */}
|
||||
<div className="group relative flex h-full items-center">
|
||||
<Link
|
||||
href={`/${locale}#products`}
|
||||
className="flex items-center gap-1.5 whitespace-nowrap px-3 py-2 text-[13px] font-medium text-brand/65 transition-colors group-hover:text-brand group-focus-within:text-brand"
|
||||
>
|
||||
{t.nav.products}
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-200 group-hover:rotate-180 group-focus-within:rotate-180"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
<div className="invisible absolute start-0 top-full z-50 min-w-56 -translate-y-1 border border-brand-100 bg-white py-2 opacity-0 transition duration-200 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
|
||||
{categories.map((product) => (
|
||||
<Link
|
||||
key={product.title}
|
||||
href={`/${locale}/products/${product.slug}`}
|
||||
className="block px-5 py-2.5 text-[13px] text-brand/70 transition-colors hover:bg-surface hover:text-brand"
|
||||
>
|
||||
{product.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* RTL reverses DOM order on screen, so this reads right-to-left:
|
||||
language · divider · phone · divider · account.
|
||||
On screen that lands as account at the far end, then phone, then
|
||||
language nearest the nav. */}
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<div className="group relative flex h-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t.common.switchLanguage}
|
||||
className="flex items-center gap-1.5 px-2 py-2 text-[13px] font-medium text-brand/65 transition-colors group-hover:text-brand group-focus-within:text-brand"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden lg:inline">{localeLabels[locale]}</span>
|
||||
</button>
|
||||
|
||||
<div className="invisible absolute end-0 top-full z-50 min-w-36 -translate-y-1 border border-brand-100 bg-white py-2 opacity-0 transition duration-200 group-hover:visible group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:visible group-focus-within:translate-y-0 group-focus-within:opacity-100">
|
||||
{locales.map((code) => (
|
||||
<Link
|
||||
key={code}
|
||||
href={`/${code}${pathWithoutLocale}`}
|
||||
hrefLang={code}
|
||||
aria-current={code === locale ? "true" : undefined}
|
||||
className={`block px-5 py-2.5 text-[13px] transition-colors hover:bg-surface hover:text-brand ${
|
||||
code === locale ? "font-bold text-brand" : "text-brand/70"
|
||||
}`}
|
||||
>
|
||||
{localeLabels[code]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span aria-hidden="true" className="h-4 w-px bg-brand-100" />
|
||||
|
||||
{/* Icon and number are one link. dir="ltr" keeps the icon to the left
|
||||
of the number and the number itself readable. */}
|
||||
<a
|
||||
href={`tel:${site.phones[0]}`}
|
||||
dir="ltr"
|
||||
aria-label={site.phonesDisplay[0]}
|
||||
className="flex items-center gap-2 text-brand/75 transition-colors hover:text-brand"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6.2 3.5h3l1.5 4-2 1.4a12.5 12.5 0 0 0 6.4 6.4l1.4-2 4 1.5v3c0 1-.8 1.8-1.8 1.7C10.6 19.9 4.1 13.4 3.5 5.3c-.1-1 .7-1.8 1.7-1.8z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="latin-ui hidden whitespace-nowrap text-[13px] font-medium lg:inline">
|
||||
{site.phonesDisplay[0]}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<span aria-hidden="true" className="h-4 w-px bg-brand-100" />
|
||||
|
||||
<AccountMenu session={session} t={t} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label="menu"
|
||||
className="grid h-11 w-11 place-items-center border border-brand-100 text-brand md:hidden"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
{open ? (
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" />
|
||||
) : (
|
||||
<path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" strokeWidth="2" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<nav id="mobile-nav" className="border-t border-brand-100 bg-white md:hidden">
|
||||
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`block border-b border-brand-100 py-3 text-sm font-medium last:border-0 ${
|
||||
pathname === link.href ? "font-bold text-brand" : "text-brand/70"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-b border-brand-100 py-3">
|
||||
<Link
|
||||
href={`/${locale}#products`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block text-sm font-medium text-brand/70"
|
||||
>
|
||||
{t.nav.products}
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-col border-e-2 border-brand-100 pe-3">
|
||||
{categories.map((product) => (
|
||||
<Link
|
||||
key={product.title}
|
||||
href={`/${locale}/products/${product.slug}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="py-2 text-sm text-brand/60"
|
||||
>
|
||||
{product.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-brand-100 py-3">
|
||||
{session ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-brand">
|
||||
{session.displayName}
|
||||
{t.account.honorific ? ` ${t.account.honorific}` : ""}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-col">
|
||||
<a href={portalLinks.profile} className="py-2 text-sm text-brand/60">
|
||||
{t.account.profile}
|
||||
</a>
|
||||
{session.isAdmin && (
|
||||
<a href={portalLinks.admin} className="py-2 text-sm text-brand/60">
|
||||
{t.account.manageWebsite}
|
||||
</a>
|
||||
)}
|
||||
<a href={portalLinks.logout} className="py-2 text-sm text-brand/60">
|
||||
{t.account.logout}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a href={portalLinks.login} className="block text-sm font-medium text-brand">
|
||||
{t.account.login}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 border-b border-brand-100 py-3">
|
||||
{locales.map((code) => (
|
||||
<Link
|
||||
key={code}
|
||||
href={`/${code}${pathWithoutLocale}`}
|
||||
hrefLang={code}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`text-sm ${
|
||||
code === locale ? "font-bold text-brand" : "text-brand/60"
|
||||
}`}
|
||||
>
|
||||
{localeLabels[code]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`tel:${site.phones[0]}`}
|
||||
dir="ltr"
|
||||
className="latin-ui block py-3 text-sm text-muted"
|
||||
>
|
||||
{site.phonesDisplay[0]}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
import { instruments, type MarketSnapshot } from "@/lib/market";
|
||||
|
||||
/**
|
||||
* Server component — the fetch happens during the (revalidated) render, so the
|
||||
* upstream is never called from a visitor's browser. livedata.ir sends no CORS
|
||||
* headers, so a client-side fetch would be blocked anyway.
|
||||
*/
|
||||
export default function MarketFrame({
|
||||
snapshot,
|
||||
t,
|
||||
locale,
|
||||
}: {
|
||||
snapshot: MarketSnapshot;
|
||||
t: Dictionary;
|
||||
locale: string;
|
||||
}) {
|
||||
const labels = t.market.labels as Record<string, string | undefined>;
|
||||
|
||||
const asOf = new Intl.DateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(snapshot.fetchedAt));
|
||||
|
||||
/**
|
||||
* Upstream groups some figures ("29,136,700") and not others ("199000").
|
||||
* Group the ungrouped ones so the frame is internally consistent — purely
|
||||
* presentational, the digits themselves are never altered.
|
||||
*/
|
||||
const group = (value: string) =>
|
||||
/^\d+$/.test(value) && value.length > 4
|
||||
? value.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
: value;
|
||||
|
||||
/** Upstream publishes "Aug 31, 2026"; show it in the reader's own calendar. */
|
||||
const formatReopen = (value: string) => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-brand-100 bg-white">
|
||||
<div className="grid divide-y divide-brand-100 lg:grid-cols-[1.1fr_1fr_0.8fr] lg:divide-x lg:divide-y-0 rtl:lg:divide-x-reverse">
|
||||
{/* Figures */}
|
||||
<ul className="divide-y divide-brand-100">
|
||||
{snapshot.quotes.map((quote) => (
|
||||
<li key={quote.id} className="flex items-baseline justify-between gap-4 px-6 py-4">
|
||||
<span className="text-sm text-brand/70">
|
||||
{labels[quote.id] ?? instruments[quote.id].fa}
|
||||
</span>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span dir="ltr" className="latin-ui text-base font-bold text-brand">
|
||||
{group(quote.price)}
|
||||
</span>
|
||||
{quote.direction !== "flat" && (
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`latin-ui text-xs ${
|
||||
quote.direction === "up" ? "text-up" : "text-down"
|
||||
}`}
|
||||
>
|
||||
{quote.direction === "up" ? "▲" : "▼"} {quote.changePercent}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Gold by purity */}
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-xs font-bold text-brand-500">{t.market.gold.heading}</h3>
|
||||
<table className="mt-3 w-full text-sm">
|
||||
<thead>
|
||||
{/* Headers and values both align to the page's start edge. The
|
||||
numbers carry dir="ltr" on an inner span, not on the cell —
|
||||
putting it on the cell flipped what `text-end` meant and left
|
||||
each header sitting on the opposite side of its own column. */}
|
||||
<tr className="text-[11px] text-muted">
|
||||
<th scope="col" className="pb-2 text-start font-normal" />
|
||||
<th scope="col" className="pb-2 text-start font-normal">
|
||||
{t.market.gold.toman}
|
||||
</th>
|
||||
<th scope="col" className="pb-2 text-start font-normal">
|
||||
{t.market.gold.usd}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-brand-100">
|
||||
{snapshot.gold.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<th scope="row" className="py-2 text-start font-normal text-brand/70">
|
||||
{t.market.gold.rows[row.key]}
|
||||
</th>
|
||||
<td className="latin-ui py-2 text-start font-bold text-brand">
|
||||
{row.toman ? <span dir="ltr">{group(row.toman)}</span> : "—"}
|
||||
</td>
|
||||
<td className="latin-ui py-2 text-start text-brand/60">
|
||||
<span dir="ltr">{row.usd}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Exchange sessions */}
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-xs font-bold text-brand-500">{t.market.sessions.heading}</h3>
|
||||
<ul className="mt-3 divide-y divide-brand-100">
|
||||
{snapshot.sessions.map((session) => (
|
||||
<li
|
||||
key={session.key}
|
||||
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="text-sm text-brand/70">
|
||||
{t.market.sessions.names[session.key]}
|
||||
</span>
|
||||
<span className="flex flex-col items-end gap-1">
|
||||
{/* Square chip, not a pill — nothing else on the site is rounded. */}
|
||||
<span
|
||||
className={`px-2 py-0.5 text-[11px] font-bold ${
|
||||
session.open ? "bg-up/12 text-up" : "bg-muted/15 text-muted"
|
||||
}`}
|
||||
>
|
||||
{session.open ? t.market.sessions.open : t.market.sessions.closed}
|
||||
</span>
|
||||
{session.reopensOn && (
|
||||
<span className="text-[11px] text-muted">
|
||||
{t.market.sessions.reopens}{" "}
|
||||
<span className="latin-ui">{formatReopen(session.reopensOn)}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attribution is not decoration — these figures are compiled by someone else. */}
|
||||
<p className="border-t border-brand-100 px-6 py-3 text-xs text-muted">
|
||||
{t.market.asOf} <span className="latin-ui">{asOf}</span>
|
||||
{" · "}
|
||||
{t.market.source}{" "}
|
||||
<a
|
||||
href={snapshot.source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="latin-ui text-brand/70 underline-offset-2 hover:text-brand hover:underline"
|
||||
>
|
||||
{snapshot.source.name}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Image from "next/image";
|
||||
import { balanceLines } from "@/lib/text";
|
||||
|
||||
/** Compact page banner used by the inner pages, in place of the home slider. */
|
||||
export default function PageHero({
|
||||
title,
|
||||
latin,
|
||||
image,
|
||||
}: {
|
||||
title: string;
|
||||
latin: string;
|
||||
image: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative h-56 w-full overflow-hidden bg-brand-900 sm:h-72">
|
||||
<Image src={image} alt="" fill priority sizes="100vw" className="object-cover" />
|
||||
<div className="absolute inset-0 bg-brand-900/80" />
|
||||
<div className="relative mx-auto flex h-full max-w-7xl items-center px-4 sm:px-6 lg:px-8">
|
||||
{/* w-full is required: `container-type: inline-size` stops an element
|
||||
sizing to its own content, so as a bare flex item it collapses. */}
|
||||
<div className="@container relative w-full text-start">
|
||||
{/* Empty in English, where the gloss would just repeat the heading. */}
|
||||
{latin && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 select-none font-display text-[clamp(1.75rem,12cqw,3.5rem)] uppercase leading-[0.95] tracking-[0.05em] text-white/10"
|
||||
>
|
||||
{balanceLines(latin).map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="relative text-3xl font-bold text-white sm:text-4xl">{title}</h1>
|
||||
<span className="relative mt-5 block h-1 w-16 bg-brand-300" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type ProductIconName =
|
||||
| "oil"
|
||||
| "mineral"
|
||||
| "food"
|
||||
| "chemical"
|
||||
| "livestock"
|
||||
| "appliance"
|
||||
| "construction";
|
||||
|
||||
/**
|
||||
* Minimal line icons for the product categories. Single weight, no fills, and
|
||||
* they draw in `currentColor` so a card can recolour them on hover.
|
||||
*/
|
||||
const shapes: Record<ProductIconName, ReactNode> = {
|
||||
// Droplet — oil derivatives
|
||||
oil: <path d="M12 3.5c3.2 3.7 5 6.5 5 8.7a5 5 0 0 1-10 0c0-2.2 1.8-5 5-8.7Z" />,
|
||||
|
||||
// Mountain range — mineral products
|
||||
mineral: (
|
||||
<>
|
||||
<path d="M2.5 19.5h19L14.6 7.5l-3.1 5.2-2.2-3.1z" />
|
||||
<path d="M6.2 14.2h3.4" />
|
||||
</>
|
||||
),
|
||||
|
||||
// Wheat stalk — food materials and products
|
||||
food: (
|
||||
<>
|
||||
<path d="M12 21V7" />
|
||||
<path d="M12 11.5 8.4 8.8M12 11.5l3.6-2.7" />
|
||||
<path d="M12 16.2l-3.6-2.7M12 16.2l3.6-2.7" />
|
||||
</>
|
||||
),
|
||||
|
||||
// Conical flask — chemical products
|
||||
chemical: (
|
||||
<>
|
||||
<path d="M9.5 3h5" />
|
||||
<path d="M10.6 3v6.3L5.8 18a2 2 0 0 0 1.8 3h8.8a2 2 0 0 0 1.8-3l-4.8-8.7V3" />
|
||||
</>
|
||||
),
|
||||
|
||||
// Feed sack — livestock and poultry inputs
|
||||
livestock: (
|
||||
<>
|
||||
<path d="M9.2 3h5.6l-.8 3.2H10z" />
|
||||
<path d="M10 6.2h4c2 1 3.3 3 3.3 5.2v5.3A4.3 4.3 0 0 1 13 21h-2a4.3 4.3 0 0 1-4.3-4.3v-5.3c0-2.2 1.3-4.2 3.3-5.2Z" />
|
||||
</>
|
||||
),
|
||||
|
||||
// Washing machine — home appliances
|
||||
appliance: (
|
||||
<>
|
||||
<rect x="4.5" y="3" width="15" height="18" rx="2" />
|
||||
<circle cx="12" cy="14" r="3.8" />
|
||||
<path d="M8 6.6h.01M11 6.6h.01" />
|
||||
</>
|
||||
),
|
||||
|
||||
// Brick wall — building materials and equipment
|
||||
construction: (
|
||||
<>
|
||||
<path d="M3.5 5.5h17v13h-17z" />
|
||||
<path d="M3.5 12h17" />
|
||||
<path d="M9.5 5.5V12M14.5 12v6.5" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export default function ProductIcon({
|
||||
name,
|
||||
className = "h-11 w-11 sm:h-12 sm:w-12",
|
||||
}: {
|
||||
name: ProductIconName;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
{shapes[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type RevealTag = "div" | "li" | "article" | "section";
|
||||
|
||||
/**
|
||||
* Fades and lifts its content into place the first time it scrolls into view.
|
||||
* Pass an increasing `delay` across a list to stagger the sequence.
|
||||
*
|
||||
* Renders as whatever element the slot needs (`li` inside a list, `article` in a
|
||||
* grid) so it never introduces a wrapper that would break list semantics or grid
|
||||
* placement. Honours `prefers-reduced-motion` by showing content immediately.
|
||||
*
|
||||
* The starting state is hidden, so every instance carries `data-reveal` and the
|
||||
* layout ships a `<noscript>` rule that forces those elements visible. Without
|
||||
* it, a visitor with JS disabled would get a page of blank sections.
|
||||
*/
|
||||
export default function Reveal({
|
||||
children,
|
||||
as = "div",
|
||||
delay = 0,
|
||||
className = "",
|
||||
id,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
as?: RevealTag;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
// Show immediately when the animation should not or cannot run.
|
||||
if (
|
||||
!("IntersectionObserver" in window) ||
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
setShown(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
setShown(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const Tag = as;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ref={ref as any}
|
||||
id={id}
|
||||
data-reveal=""
|
||||
style={{ transitionDelay: shown ? `${delay}ms` : "0ms" }}
|
||||
className={`transition-[opacity,transform] duration-700 ease-out ${
|
||||
shown ? "translate-y-0 opacity-100" : "translate-y-8 opacity-0"
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { balanceLines } from "@/lib/text";
|
||||
|
||||
/**
|
||||
* Shared section heading.
|
||||
*
|
||||
* The Latin line sits *behind* the whole lockup — title, subtitle and rule — as
|
||||
* a faint backdrop, centred on the group rather than on the title alone. It is
|
||||
* decorative repetition of the heading, so it is `aria-hidden`.
|
||||
*
|
||||
* Note the backdrop carries `font-display` rather than `.latin-title`: 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 it inside an RTL layout.
|
||||
*/
|
||||
export function SectionHeading({
|
||||
title,
|
||||
latin,
|
||||
subtitle,
|
||||
align = "center",
|
||||
tone = "dark",
|
||||
}: {
|
||||
title: string;
|
||||
latin?: string;
|
||||
subtitle?: string;
|
||||
align?: "center" | "start";
|
||||
/** `dark` = navy text on a light section, `light` = white text on a navy one. */
|
||||
tone?: "dark" | "light";
|
||||
}) {
|
||||
const light = tone === "light";
|
||||
const centred = align === "center";
|
||||
|
||||
return (
|
||||
/* `@container` + cqw units size the backdrop against this heading's own
|
||||
column, not the viewport — otherwise it overflows a narrow column. */
|
||||
<div className={`@container relative ${centred ? "text-center" : "text-start"}`}>
|
||||
{latin && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 select-none font-display text-[clamp(1.25rem,8cqw,3rem)] uppercase leading-[0.95] tracking-[0.05em] ${
|
||||
light ? "text-white/10" : "text-brand/10"
|
||||
}`}
|
||||
>
|
||||
{balanceLines(latin).map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h2
|
||||
className={`relative text-2xl font-bold sm:text-3xl ${
|
||||
light ? "text-white" : "text-brand"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{subtitle && (
|
||||
<p
|
||||
className={`relative mt-2 text-sm leading-6 ${
|
||||
centred ? "mx-auto max-w-2xl" : "max-w-2xl"
|
||||
} ${light ? "text-white/70" : "text-brand/70"}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={`relative mt-3 block h-1 w-16 ${light ? "bg-brand-300" : "bg-brand"} ${
|
||||
centred ? "mx-auto" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`px-4 py-16 sm:px-6 sm:py-20 lg:px-8 ${className}`}>
|
||||
<div className="mx-auto max-w-7xl">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { Dictionary } from "@/i18n/dictionaries";
|
||||
import type { ComposedSlide } from "@/i18n/compose";
|
||||
|
||||
const AUTOPLAY_MS = 6000;
|
||||
|
||||
/** Full-bleed hero slider that sits directly under the header. */
|
||||
export default function Slider({ slides, t }: { slides: ComposedSlide[]; t: Dictionary }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const count = slides.length;
|
||||
|
||||
const go = useCallback((next: number) => setIndex((next + count) % count), [count]);
|
||||
|
||||
// Respect the user's motion preference: no autoplay when reduced motion is on.
|
||||
const reduced = useRef(false);
|
||||
useEffect(() => {
|
||||
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || reduced.current) return;
|
||||
const id = window.setInterval(() => setIndex((i) => (i + 1) % count), AUTOPLAY_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [paused, count]);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-roledescription="carousel"
|
||||
aria-label={t.meta.company}
|
||||
className="relative h-[70vh] min-h-[420px] w-full overflow-hidden bg-brand-900 sm:h-[78vh]"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocusCapture={() => setPaused(true)}
|
||||
onBlurCapture={() => setPaused(false)}
|
||||
>
|
||||
{slides.map((slide, i) => {
|
||||
// The Latin line is set one word per line as an oversized backdrop.
|
||||
const words = slide.subtitle.split(" ").filter(Boolean);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slide.image}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${i + 1} / ${count}`}
|
||||
aria-hidden={i !== index}
|
||||
className={`absolute inset-0 transition-opacity duration-700 ${
|
||||
i === index ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={slide.image}
|
||||
alt=""
|
||||
fill
|
||||
priority={i === 0}
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-brand-900/95 from-0% via-brand-900/55 via-25% to-transparent to-60%" />
|
||||
|
||||
<div className="relative mx-auto flex h-full max-w-7xl items-end px-4 pb-28 sm:px-6 sm:pb-32 lg:px-8">
|
||||
<div className="relative max-w-2xl">
|
||||
{/* Content animates in only while its slide is the active one, so
|
||||
the sequence replays on every change rather than once on load. */}
|
||||
{/* Oversized, faint Latin title sitting behind the Persian one.
|
||||
Decorative — the same words are already in the heading lockup,
|
||||
so it is hidden from assistive tech. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-0 top-1/2 select-none text-start transition-all duration-1000 ease-out ${
|
||||
i === index ? "-translate-y-1/2 opacity-100" : "-translate-y-[40%] opacity-0"
|
||||
}`}
|
||||
>
|
||||
{words.map((word) => (
|
||||
<span
|
||||
key={word}
|
||||
className="block font-display text-[clamp(2.75rem,7.5vw,6rem)] uppercase leading-[0.85] tracking-[0.08em] text-white/10"
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<h2
|
||||
className={`relative text-3xl font-bold leading-[1.35] text-white transition-all duration-700 ease-out sm:text-5xl sm:leading-[1.3] ${
|
||||
i === index ? "translate-y-0 opacity-100 delay-150" : "translate-y-5 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{slide.title}
|
||||
</h2>
|
||||
<p
|
||||
className={`relative mt-5 max-w-xl text-sm leading-8 text-white/75 transition-all duration-700 ease-out sm:text-base sm:leading-9 ${
|
||||
i === index ? "translate-y-0 opacity-100 delay-300" : "translate-y-5 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{slide.description}
|
||||
</p>
|
||||
<span
|
||||
className={`relative mt-7 block h-1 bg-brand-300 transition-all duration-700 ease-out ${
|
||||
i === index ? "w-24 opacity-100 delay-500" : "w-0 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 pb-8 sm:px-6 lg:px-8">
|
||||
<div className="flex gap-2">
|
||||
{slides.map((slide, i) => (
|
||||
<button
|
||||
key={slide.image}
|
||||
type="button"
|
||||
onClick={() => go(i)}
|
||||
aria-label={`${t.common.goToSlide} ${i + 1}`}
|
||||
aria-current={i === index}
|
||||
className={`h-1.5 transition-all ${
|
||||
i === index ? "w-10 bg-white" : "w-5 bg-white/40 hover:bg-white/70"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(index - 1)}
|
||||
aria-label={t.common.previous}
|
||||
className="grid h-11 w-11 place-items-center border border-white/35 text-white transition-colors hover:border-white hover:bg-white/20"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M15 6l-6 6 6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="rtl:hidden"
|
||||
/>
|
||||
<path
|
||||
d="M9 6l6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="hidden rtl:block"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(index + 1)}
|
||||
aria-label={t.common.next}
|
||||
className="grid h-11 w-11 place-items-center border border-white/35 text-white transition-colors hover:border-white hover:bg-white/20"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 6l6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="rtl:hidden"
|
||||
/>
|
||||
<path
|
||||
d="M15 6l-6 6 6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="hidden rtl:block"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ProductIconName } from "@/components/ProductIcon";
|
||||
|
||||
/**
|
||||
* Structure, not copy.
|
||||
*
|
||||
* Everything here is identical in every language: ids, slugs, image paths and
|
||||
* ordering. It stays in TypeScript so slugs keep their literal types (see
|
||||
* `ProductIcon`) and so an image path exists exactly once rather than being
|
||||
* copied into each locale file. Translators never see this file.
|
||||
*
|
||||
* The ids below are the keys used in `src/i18n/locales/<locale>.json`.
|
||||
*/
|
||||
|
||||
export const slides = [
|
||||
{ id: "company", image: "/images/sample/deck-containers.jpg" },
|
||||
{ id: "trade", image: "/images/sample/aerial-vessel.jpg" },
|
||||
{ id: "partner", image: "/images/sample/container-canyon.jpg" },
|
||||
] as const;
|
||||
|
||||
export const services = [
|
||||
{ id: "transport", image: "/images/sample/port-vessel.jpg" },
|
||||
{ id: "technical", image: "/images/sample/container-canyon.jpg" },
|
||||
{ id: "clearance", image: "/images/sample/container-stack.jpg" },
|
||||
{ id: "trade", image: "/images/sample/aerial-vessel.jpg" },
|
||||
] as const;
|
||||
|
||||
export const productsBackground = "/images/sample/deck-containers.jpg";
|
||||
|
||||
export const productCategories = [
|
||||
{ slug: "oil", image: "/images/slider/slider3.jpg" },
|
||||
{ slug: "mineral", image: "/images/sample/container-canyon.jpg" },
|
||||
{ slug: "food", image: "/images/slider/slider5.jpg" },
|
||||
{ slug: "chemical", image: "/images/slider/slider2.jpg" },
|
||||
{ slug: "livestock", image: "/images/slider/slider6.jpg" },
|
||||
{ slug: "construction", image: "/images/slider/slider4.jpg" },
|
||||
] as const;
|
||||
|
||||
export const pageImages = {
|
||||
about: "/images/slider/slider2.jpg",
|
||||
contact: "/images/slider/slider4.jpg",
|
||||
intro: "/images/slider/slider5.jpg",
|
||||
aboutPortrait: "/images/slider/slider1.jpg",
|
||||
} as const;
|
||||
|
||||
export type SlideId = (typeof slides)[number]["id"];
|
||||
export type ServiceId = (typeof services)[number]["id"];
|
||||
export type ProductSlug = (typeof productCategories)[number]["slug"];
|
||||
|
||||
// Compile-time proof that every category still has an icon behind it.
|
||||
const _iconCheck: Record<ProductSlug, ProductIconName> = {
|
||||
oil: "oil",
|
||||
mineral: "mineral",
|
||||
food: "food",
|
||||
chemical: "chemical",
|
||||
livestock: "livestock",
|
||||
construction: "construction",
|
||||
};
|
||||
void _iconCheck;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { productCategories, services, slides } from "@/content";
|
||||
import type { Dictionary } from "./types";
|
||||
|
||||
/**
|
||||
* Joins structure (`src/content`) to copy (`src/i18n/locales/*.json`) on the id.
|
||||
*
|
||||
* Keeping these apart is what stops an image path being copied into three locale
|
||||
* files and drifting; these helpers put them back together at render time.
|
||||
*/
|
||||
export function composeSlides(t: Dictionary) {
|
||||
return slides.map((slide) => ({ ...slide, ...t.slides[slide.id] }));
|
||||
}
|
||||
|
||||
export function composeServices(t: Dictionary) {
|
||||
return services.map((service) => ({ ...service, ...t.services.items[service.id] }));
|
||||
}
|
||||
|
||||
export function composeCategories(t: Dictionary) {
|
||||
return productCategories.map((category) => ({
|
||||
...category,
|
||||
...t.products.categories[category.slug],
|
||||
}));
|
||||
}
|
||||
|
||||
export function composeCategory(t: Dictionary, slug: string) {
|
||||
const category = productCategories.find((item) => item.slug === slug);
|
||||
if (!category) return null;
|
||||
return { ...category, ...t.products.categories[category.slug] };
|
||||
}
|
||||
|
||||
export type ComposedSlide = ReturnType<typeof composeSlides>[number];
|
||||
export type ComposedService = ReturnType<typeof composeServices>[number];
|
||||
export type ComposedCategory = ReturnType<typeof composeCategories>[number];
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Locale configuration.
|
||||
*
|
||||
* Static-page copy is translated into all three. The blog is not — posts are
|
||||
* single-language and live in `src/lib/posts.ts`; see README, "Multi-language".
|
||||
*/
|
||||
export const locales = ["fa", "en", "ar"] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "fa";
|
||||
|
||||
export const localeDirection: Record<Locale, "rtl" | "ltr"> = {
|
||||
fa: "rtl",
|
||||
ar: "rtl",
|
||||
en: "ltr",
|
||||
};
|
||||
|
||||
/** Shown in the language switcher, each in its own language. */
|
||||
export const localeLabels: Record<Locale, string> = {
|
||||
fa: "فارسی",
|
||||
en: "English",
|
||||
ar: "العربية",
|
||||
};
|
||||
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return (locales as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Locale } from "./config";
|
||||
import type { Dictionary } from "./types";
|
||||
|
||||
const loaders: Record<Locale, () => Promise<{ default: Dictionary }>> = {
|
||||
fa: () => import("./locales/fa"),
|
||||
en: () => import("./locales/en"),
|
||||
ar: () => import("./locales/ar"),
|
||||
};
|
||||
|
||||
export async function getDictionary(locale: Locale): Promise<Dictionary> {
|
||||
const mod = await loaders[locale]();
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
export type { Dictionary };
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"meta": {
|
||||
"company": "مجموعة آريو مجد آسيا التجارية الدولية",
|
||||
"companyShort": "مجموعة آريو مجد آسيا التجارية",
|
||||
"companyLatin": "ARIO MAJD ASIA",
|
||||
"tagline": "شريككم الدائم في التجارة الدولية",
|
||||
"description": "مجموعة آريو مجد آسيا التجارية الدولية؛ الخدمات الفنية والهندسية، التجارة، النقل الدولي، التأمين والتخليص الجمركي."
|
||||
},
|
||||
"nav": {
|
||||
"home": "الصفحة الرئيسية",
|
||||
"products": "المنتجات",
|
||||
"about": "من نحن",
|
||||
"contact": "اتصل بنا"
|
||||
},
|
||||
"slides": {
|
||||
"company": {
|
||||
"title": "مجموعة آريو مجد آسيا التجارية",
|
||||
"subtitle": "ARIO MAJD ASIA",
|
||||
"description": "تقديم الخدمات الفنية والهندسية والتجارية واللوجستية على المستوى الدولي، استناداً إلى خبرة إدارة المجموعة والتعاون مع شركات محلية ودولية معتمدة."
|
||||
},
|
||||
"trade": {
|
||||
"title": "استيراد وتصدير البضائع",
|
||||
"subtitle": "IMPORT AND EXPORT",
|
||||
"description": "استيراد وتصدير وإعادة تصدير الحبوب والمواد الغذائية ومدخلات الثروة الحيوانية والمشتقات النفطية والمنتجات الكيميائية والمعدنية."
|
||||
},
|
||||
"partner": {
|
||||
"title": "شريككم الدائم في التجارة الدولية",
|
||||
"subtitle": "YOUR TRADE PARTNER",
|
||||
"description": "من التأمين والتوريد إلى النقل الدولي والتخليص من جميع جمارك البلاد؛ إلى جانبكم منذ عام ٢٠١٩."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"heading": "خدماتنا",
|
||||
"headingLatin": "OUR SERVICES",
|
||||
"subtitle": "مجالات الخدمة الأربعة الأساسية لمجموعة آريو مجد آسيا التجارية الدولية",
|
||||
"items": {
|
||||
"transport": {
|
||||
"title": "النقل وتأمين البضائع التجارية",
|
||||
"latin": "TRANSPORT",
|
||||
"description": "النقل الدولي وتأمين البضائع التجارية حتى الوجهة النهائية."
|
||||
},
|
||||
"technical": {
|
||||
"title": "الخدمات الفنية والهندسية",
|
||||
"latin": "TECHNICAL SERVICES",
|
||||
"description": "التخطيط العمراني والعمارة والهندسة المدنية والمساحة والتمديدات الكهربائية والميكانيكية."
|
||||
},
|
||||
"clearance": {
|
||||
"title": "التخليص الجمركي",
|
||||
"latin": "DESIRED PRODUCTS",
|
||||
"description": "تخليص البضائع التجارية المسموح بها من جميع جمارك البلاد."
|
||||
},
|
||||
"trade": {
|
||||
"title": "استيراد وتصدير البضائع",
|
||||
"latin": "IMPORT AND EXPORT",
|
||||
"description": "الاستيراد والتصدير وإعادة التصدير في مجالات تخصص المجموعة."
|
||||
}
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"heading": "المنتجات",
|
||||
"headingLatin": "PRODUCTS",
|
||||
"lead": "المجالات الرئيسية للنشاط التجاري للمجموعة",
|
||||
"goodsHeading": "الأصناف المتوفرة",
|
||||
"goodsLatin": "AVAILABLE GOODS",
|
||||
"servicesNote": "تتولى المجموعة أيضاً لجميع أصناف هذه الفئة خدمات النقل الدولي وتأمين البضائع والتخليص من جمارك البلاد.",
|
||||
"otherHeading": "الفئات الأخرى",
|
||||
"otherLatin": "OTHER CATEGORIES",
|
||||
"inquiryHeading": "اطلب عرض سعر لهذه الفئة",
|
||||
"inquiryBody": "للحصول على الأسعار وشروط التوريد ومواعيد التسليم، تواصلوا مع فريقنا التجاري.",
|
||||
"inquiryButton": "اتصل بنا",
|
||||
"categories": {
|
||||
"oil": {
|
||||
"title": "المشتقات النفطية",
|
||||
"latin": "OIL DERIVATIVES",
|
||||
"intro": "تُعد المشتقات النفطية من أهم مجالات النشاط التجاري لمجموعة آريو مجد آسيا. يتم توريد هذه المنتجات من المصافي والوحدات الإنتاجية المحلية وتصديرها مع تنسيق كامل للنقل والتأمين والتخليص.",
|
||||
"goods": [
|
||||
"القير",
|
||||
"زيت الأساس",
|
||||
"الكبريت",
|
||||
"البارافين",
|
||||
"الشمع",
|
||||
"النفتا",
|
||||
"زيت الغاز",
|
||||
"القطران"
|
||||
]
|
||||
},
|
||||
"mineral": {
|
||||
"title": "المنتجات المعدنية",
|
||||
"latin": "MINERAL PRODUCTS",
|
||||
"intro": "تُعد إيران من أغنى دول العالم من حيث الاحتياطيات المعدنية. تتولى المجموعة، اعتماداً على علاقتها بالمناجم ووحدات المعالجة المحلية، توريد وتصدير المنتجات المعدنية بمختلف الحمولات.",
|
||||
"goods": [
|
||||
"خام الحديد",
|
||||
"المركزات",
|
||||
"الكريات",
|
||||
"حجر الجبس",
|
||||
"الفلسبار",
|
||||
"الباريت",
|
||||
"البنتونيت",
|
||||
"السيليكا"
|
||||
]
|
||||
},
|
||||
"food": {
|
||||
"title": "المواد والمنتجات الغذائية",
|
||||
"latin": "FOOD PRODUCTS",
|
||||
"intro": "يُعد استيراد وتصدير وإعادة تصدير الحبوب والمواد الغذائية من أكبر أنشطة المجموعة حجماً. يتم توريد هذه الأصناف من الأسواق الدولية وإيصالها إلى السوق المحلية مع مراعاة المعايير الصحية والحجر الزراعي.",
|
||||
"goods": [
|
||||
"الحبوب",
|
||||
"القمح",
|
||||
"الأرز",
|
||||
"الذرة",
|
||||
"السكر",
|
||||
"زيت الطعام",
|
||||
"البقوليات",
|
||||
"المكسرات والفواكه المجففة"
|
||||
]
|
||||
},
|
||||
"chemical": {
|
||||
"title": "المنتجات الكيميائية",
|
||||
"latin": "CHEMICAL PRODUCTS",
|
||||
"intro": "تُعد المنتجات الكيميائية والبوليمرية من أهم مخرجات صناعة البتروكيماويات في البلاد. تتولى المجموعة توريد هذه الأصناف للوحدات الصناعية المحلية وتصديرها إلى أسواق المنطقة.",
|
||||
"goods": [
|
||||
"البولي إيثيلين",
|
||||
"البولي بروبيلين",
|
||||
"البي في سي",
|
||||
"الصودا الكاوية",
|
||||
"اليوريا",
|
||||
"الميثانول",
|
||||
"الأسمدة الكيميائية",
|
||||
"الراتنج"
|
||||
]
|
||||
},
|
||||
"livestock": {
|
||||
"title": "مدخلات الثروة الحيوانية والدواجن",
|
||||
"latin": "LIVESTOCK INPUTS",
|
||||
"intro": "يُعد توريد مدخلات الثروة الحيوانية والدواجن من الأسواق الدولية وتخليصها من جمارك البلاد من الخدمات المتخصصة للمجموعة لمزارع الماشية والدواجن ومصانع الأعلاف.",
|
||||
"goods": [
|
||||
"ذرة العلف",
|
||||
"كسبة فول الصويا",
|
||||
"الشعير",
|
||||
"النخالة",
|
||||
"مسحوق السمك",
|
||||
"البرسيم",
|
||||
"مكملات الأعلاف"
|
||||
]
|
||||
},
|
||||
"construction": {
|
||||
"title": "مواد ومعدات البناء",
|
||||
"latin": "BUILDING MATERIALS",
|
||||
"intro": "إلى جانب الخدمات الفنية والهندسية، تتولى المجموعة توريد المواد والمعدات والآليات اللازمة لمشاريع البنية التحتية والبناء.",
|
||||
"goods": [
|
||||
"الإسمنت",
|
||||
"حديد التسليح",
|
||||
"الكمرات الحديدية",
|
||||
"البلاط والسيراميك",
|
||||
"الخلاطات الصحية",
|
||||
"العوازل الإنشائية",
|
||||
"معدات التمديدات"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"introHeading": "عن آريو مجد آسيا",
|
||||
"introLatin": "ABOUT US",
|
||||
"introBody": "بدأت مجموعة آريو مجد آسيا التجارية الدولية نشاطها الرسمي عام ٢٠١٩، مستندة إلى التعاون مع شركات محلية ودولية معتمدة وإلى خبرة إدارة المجموعة في تقديم الخدمات الفنية والهندسية والتوريد والتسويق والتجارة الدولية والتأمين والنقل الدولي وخدمات تخليص البضائع التجارية المسموح بها.",
|
||||
"introCta": "اعرف المزيد",
|
||||
"partnersHeading": "شركاؤنا",
|
||||
"partnersLatin": "OUR PARTNERS",
|
||||
"partnersSubtitle": "الهيئات والمؤسسات التي تتعامل معها المجموعة",
|
||||
"ctaHeading": "نحن جاهزون للعمل معكم",
|
||||
"ctaBody": "للحصول على الاستشارة في مجالات الاستيراد والتصدير والتخليص الجمركي والخدمات الفنية والهندسية، تواصلوا معنا.",
|
||||
"ctaButton": "اتصل بنا"
|
||||
},
|
||||
"about": {
|
||||
"heading": "من نحن",
|
||||
"headingLatin": "ABOUT US",
|
||||
"paragraphs": [
|
||||
"بدأت مجموعة آريو مجد آسيا التجارية الدولية نشاطها الرسمي عام ٢٠١٩، مستندة إلى التعاون مع شركات محلية ودولية معتمدة وإلى خبرة إدارة المجموعة في تقديم الخدمات الفنية والهندسية والتوريد والتسويق والتجارة الدولية والتأمين والنقل الدولي وخدمات تخليص البضائع التجارية المسموح بها.",
|
||||
"تتركز الخدمات الفنية والهندسية للمجموعة في مجالات التخطيط العمراني والعمارة والهندسة المدنية والمساحة والنقل والمرور والتمديدات الكهربائية والميكانيكية وتوريد البضائع والمعدات ومواد البناء.",
|
||||
"أما الأنشطة التجارية للمجموعة فتتركز في استيراد وتصدير وإعادة تصدير الحبوب والمواد الغذائية ومدخلات الثروة الحيوانية والمشتقات النفطية والمنتجات الكيميائية والمعدنية والأجهزة المنزلية، إضافة إلى التأمين والنقل الدولي وتخليصها من جميع جمارك البلاد.",
|
||||
"يشرفنا أن نكون شريككم الدائم في التجارة الدولية، بالتعاون مع الخبراء والمستشارين في الوحدات القانونية والتجارية والمالية وإدارة الصناعات والمشاريع والهندسة والتأمين والنقل والتخليص الجمركي في المجموعة."
|
||||
],
|
||||
"vision": {
|
||||
"heading": "الرؤية",
|
||||
"headingLatin": "PERSPECTIVE",
|
||||
"subtitle": "المكانة التي نرسمها للمجموعة خلال العقد القادم",
|
||||
"paragraphs": [
|
||||
"من خلال تخطيط متخصصيها والاستفادة من الخبرات والفرص الحديثة في تقديم الخدمات الفنية والهندسية والتجارية واللوجستية بما يواكب تقنيات العالم المعاصر، ستُعرف مجموعة آريو مجد آسيا التجارية الدولية خلال العقد القادم كواحدة من الشركات الخاصة العشر الرائدة والموثوقة في البلاد.",
|
||||
"نأمل، عبر تسهيل التعاون التجاري الدولي من خلال تقديم خدمات متميزة ومتنوعة وكسب ثقة ورضا العملاء المحليين والدوليين، أن نخطو خطوة ولو صغيرة نحو التقدم الاقتصادي وازدهار وطننا إيران."
|
||||
]
|
||||
},
|
||||
"mission": {
|
||||
"heading": "الرسالة والأهداف",
|
||||
"headingLatin": "MISSION AND GOALS",
|
||||
"subtitle": "المبادئ التي توجّه العمل اليومي للمجموعة",
|
||||
"items": [
|
||||
"وضع رضا العملاء على رأس أولويات عملنا",
|
||||
"الاستعانة بكوادر ذات خبرة وتعليم، وتحديث معارفهم ورفع مهاراتهم ومستوى صحتهم",
|
||||
"الاستفادة من إدارة المشاريع وإدارة التكاليف في تقديم الخدمات",
|
||||
"تقليل الوقت والتكلفة والمخاطر في تقديم الخدمات الفنية والهندسية والتجارية والنقلية للعملاء",
|
||||
"تقديم أفضل الخدمات الفنية والهندسية والتجارية والنقلية وأعلاها جودة للعملاء",
|
||||
"الوصول إلى الأساليب والتقنيات العالمية الحديثة ونقل معارفها الفنية إلى البلاد، مواكبةً لوتيرة التقدم العالمي",
|
||||
"تطوير التكنولوجيا والمعرفة الهندسية المرتبطة بنشاط الشركة في القطاعين الخدمي والتجاري",
|
||||
"التحول إلى مؤسسة متميزة ورائدة على المستوى الدولي",
|
||||
"توسيع نطاق السوق عبر التسويق والتعاون مع شركات محلية ودولية معتمدة",
|
||||
"تقديم الاستشارة لتيسير الوصول إلى المعلومات والظروف والفرص الاقتصادية في السوق المستهدفة",
|
||||
"التعاون مع الشركات الاستشارية والقانونية في دول التصدير المستهدفة للتعرف على الفرص الجديدة وتهيئة ظروف تجارية مناسبة للعملاء وتحقيق رضاهم",
|
||||
"تطوير موارد تقديم وتوريد الخدمات والسلع التجارية بما يتناسب مع نمو السوق",
|
||||
"الالتزام بحماية البيئة والإيمان بالتنمية المستدامة",
|
||||
"الاستفادة من الإمكانات المحلية لصالح الشركات الدولية"
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"heading": "قيم المؤسسة",
|
||||
"headingLatin": "VALUES OF THE ORGANIZATION",
|
||||
"subtitle": "ما نلتزم به في كل تعاون",
|
||||
"items": [
|
||||
"الالتزام بالقيم الأخلاقية والإسلامية",
|
||||
"شعور الكوادر بالمسؤولية تجاه العملاء",
|
||||
"حفظ وضمان أمن معلومات العملاء",
|
||||
"حماية مصالح المساهمين وكوادر الشركة",
|
||||
"احترام القانون والعدالة",
|
||||
"الصدق والأمانة",
|
||||
"تكريم المراجعين",
|
||||
"الإبداع والابتكار",
|
||||
"الجدارة والاستحقاق",
|
||||
"الثقة المتبادلة"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"heading": "اتصل بنا",
|
||||
"headingLatin": "CONTACT US",
|
||||
"lead": "التواصل مع آريو مجد آسيا",
|
||||
"leadLatin": "CONTACT WITH ARIO MAJD ASIA",
|
||||
"leadSubtitle": "أرسلوا رسالتكم وسيرد عليكم خبراؤنا في أقرب وقت ممكن.",
|
||||
"addressHeading": "عنواننا",
|
||||
"addressLatin": "ADDRESS",
|
||||
"emailHeading": "راسلونا",
|
||||
"emailLatin": "EMAIL",
|
||||
"phoneHeading": "معلومات الاتصال",
|
||||
"phoneLatin": "CONTACT US",
|
||||
"socialHeading": "تابعونا",
|
||||
"socialLatin": "STAY WITH US",
|
||||
"viewOnMap": "عرض على الخريطة",
|
||||
"form": {
|
||||
"name": "الاسم الكامل",
|
||||
"email": "البريد الإلكتروني",
|
||||
"phone": "رقم الهاتف",
|
||||
"subject": "الموضوع",
|
||||
"message": "رسالتكم",
|
||||
"submit": "إرسال الرسالة",
|
||||
"sending": "جارٍ الإرسال…",
|
||||
"success": "تم استلام رسالتكم بنجاح. سنتواصل معكم قريباً.",
|
||||
"required": "هذا الحقل مطلوب.",
|
||||
"invalidEmail": "يرجى إدخال بريد إلكتروني صحيح."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"heading": "الأخبار والمقالات",
|
||||
"headingLatin": "BLOG",
|
||||
"subtitle": "مقالات خبراء المجموعة في مجالات التجارة والنقل والتخليص الجمركي",
|
||||
"readMore": "اقرأ المزيد",
|
||||
"publishedOn": "تاريخ النشر",
|
||||
"viewAll": "جميع المقالات",
|
||||
"backToList": "العودة إلى قائمة المقالات",
|
||||
"empty": "لم يتم نشر أي مقال بعد."
|
||||
},
|
||||
"market": {
|
||||
"heading": "السوق اليوم",
|
||||
"headingLatin": "MARKET TODAY",
|
||||
"subtitle": "أسعار العملات والذهب والنفط، مع حالة أهم بورصات العالم",
|
||||
"asOf": "آخر تحديث",
|
||||
"source": "المصدر:",
|
||||
"labels": {
|
||||
"300106": "أونصة الذهب",
|
||||
"400106": "نفط برنت",
|
||||
"200103": "دولار تيثر",
|
||||
"200101": "الدولار (الصرافة)",
|
||||
"200201": "اليورو (الصرافة)"
|
||||
},
|
||||
"gold": {
|
||||
"heading": "الذهب حسب العيار",
|
||||
"toman": "السوق الإيرانية (تومان)",
|
||||
"usd": "السوق العالمية (دولار)",
|
||||
"rows": {
|
||||
"g24": "غرام الذهب عيار ٢٤",
|
||||
"g22": "غرام الذهب عيار ٢٢",
|
||||
"g18": "غرام الذهب عيار ١٨",
|
||||
"ounce": "أونصة الذهب العالمية"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"heading": "البورصات العالمية",
|
||||
"open": "مفتوحة",
|
||||
"closed": "مغلقة",
|
||||
"reopens": "تُفتح:",
|
||||
"names": {
|
||||
"newYork": "نيويورك",
|
||||
"london": "لندن",
|
||||
"tokyo": "طوكيو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"partners": {
|
||||
"isipo": "منظمة الصناعات الصغيرة والمدن الصناعية في إيران",
|
||||
"mimt": "وزارة الصناعة والمناجم والتجارة",
|
||||
"iccima": "غرفة التجارة والصناعة والمناجم والزراعة في إيران",
|
||||
"qomccima": "غرفة التجارة والصناعة والمناجم والزراعة في قم",
|
||||
"irica": "مصلحة الجمارك في الجمهورية الإسلامية الإيرانية",
|
||||
"iranfair": "شركة معارض إيران الدولية"
|
||||
},
|
||||
"footer": {
|
||||
"linksHeading": "الأقسام",
|
||||
"addressHeading": "العنوان",
|
||||
"phoneHeading": "رقم الهاتف",
|
||||
"emailHeading": "البريد الإلكتروني",
|
||||
"rights": "جميع حقوق هذا الموقع محفوظة لمجموعة آريو مجد آسيا التجارية الدولية."
|
||||
},
|
||||
"account": {
|
||||
"login": "تسجيل الدخول",
|
||||
"honorific": "العزيز",
|
||||
"profile": "ملفي الشخصي",
|
||||
"logout": "تسجيل الخروج",
|
||||
"manageWebsite": "إدارة الموقع",
|
||||
"menu": "الحساب"
|
||||
},
|
||||
"common": {
|
||||
"skipToContent": "الانتقال إلى المحتوى الرئيسي",
|
||||
"previous": "السابق",
|
||||
"next": "التالي",
|
||||
"goToSlide": "الانتقال إلى الشريحة",
|
||||
"switchLanguage": "اللغة"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Dictionary } from "../types";
|
||||
import raw from "./ar.json";
|
||||
|
||||
/* The annotation is the safety net: a key missing from ar.json fails the build. */
|
||||
const ar: Dictionary = raw;
|
||||
export default ar;
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"meta": {
|
||||
"company": "Ario Majd Asia International Trading Group",
|
||||
"companyShort": "Ario Majd Asia Trading Group",
|
||||
"companyLatin": "ARIO MAJD ASIA",
|
||||
"tagline": "Your lasting partner in international trade",
|
||||
"description": "Ario Majd Asia International Trading Group — technical and engineering services, trading, international transport, insurance and customs clearance."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"products": "Products",
|
||||
"about": "About Us",
|
||||
"contact": "Contact Us"
|
||||
},
|
||||
"slides": {
|
||||
"company": {
|
||||
"title": "Ario Majd Asia Trading Group",
|
||||
"subtitle": "",
|
||||
"description": "Technical and engineering services, trading and logistics on an international scale, built on our directors' experience and on partnerships with established Iranian and international companies."
|
||||
},
|
||||
"trade": {
|
||||
"title": "Import and export of goods",
|
||||
"subtitle": "",
|
||||
"description": "Import, export and re-export of grain, foodstuffs, livestock inputs, oil derivatives, chemical and mineral products."
|
||||
},
|
||||
"partner": {
|
||||
"title": "Your lasting partner in international trade",
|
||||
"subtitle": "",
|
||||
"description": "From sourcing and insurance to international transport and clearance through every customs office in the country — alongside you since 2019."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"heading": "Our Services",
|
||||
"headingLatin": "",
|
||||
"subtitle": "The four core service areas of Ario Majd Asia International Trading Group",
|
||||
"items": {
|
||||
"transport": {
|
||||
"title": "Transport and cargo insurance",
|
||||
"latin": "",
|
||||
"description": "International freight and insurance of commercial cargo through to destination."
|
||||
},
|
||||
"technical": {
|
||||
"title": "Technical and engineering services",
|
||||
"latin": "",
|
||||
"description": "Urban planning, architecture, civil works, surveying, electrical and mechanical installations."
|
||||
},
|
||||
"clearance": {
|
||||
"title": "Customs clearance",
|
||||
"latin": "",
|
||||
"description": "Clearance of permitted commercial goods through every customs office in the country."
|
||||
},
|
||||
"trade": {
|
||||
"title": "Import and export of goods",
|
||||
"latin": "",
|
||||
"description": "Import, export and re-export across the group's specialist fields."
|
||||
}
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"heading": "Products",
|
||||
"headingLatin": "",
|
||||
"lead": "The group's principal areas of commercial activity",
|
||||
"goodsHeading": "Available goods",
|
||||
"goodsLatin": "",
|
||||
"servicesNote": "For every item in this category the group also handles international transport, cargo insurance and clearance through the country's customs offices.",
|
||||
"otherHeading": "Other categories",
|
||||
"otherLatin": "",
|
||||
"inquiryHeading": "Request a quote for this category",
|
||||
"inquiryBody": "Contact our commercial team for pricing, supply terms and delivery times.",
|
||||
"inquiryButton": "Contact us",
|
||||
"categories": {
|
||||
"oil": {
|
||||
"title": "Oil derivatives",
|
||||
"latin": "",
|
||||
"intro": "Oil derivatives are one of the group's principal areas of commercial activity. These products are sourced from Iranian refineries and production units, and exported with transport, insurance and clearance fully coordinated.",
|
||||
"goods": [
|
||||
"Bitumen",
|
||||
"Base oil",
|
||||
"Sulphur",
|
||||
"Paraffin",
|
||||
"Wax",
|
||||
"Naphtha",
|
||||
"Gas oil",
|
||||
"Coal tar"
|
||||
]
|
||||
},
|
||||
"mineral": {
|
||||
"title": "Mineral products",
|
||||
"latin": "",
|
||||
"intro": "Iran ranks among the world's richest countries in mineral reserves. Drawing on its relationships with domestic mines and processing plants, the group handles the supply and export of mineral products across a range of tonnages.",
|
||||
"goods": [
|
||||
"Iron ore",
|
||||
"Concentrate",
|
||||
"Pellets",
|
||||
"Gypsum",
|
||||
"Feldspar",
|
||||
"Barite",
|
||||
"Bentonite",
|
||||
"Silica"
|
||||
]
|
||||
},
|
||||
"food": {
|
||||
"title": "Food materials and products",
|
||||
"latin": "",
|
||||
"intro": "Import, export and re-export of grain and foodstuffs is one of the group's highest-volume activities. These goods are sourced from international markets and brought to the domestic market in line with health and quarantine standards.",
|
||||
"goods": [
|
||||
"Grain",
|
||||
"Wheat",
|
||||
"Rice",
|
||||
"Maize",
|
||||
"Sugar",
|
||||
"Edible oil",
|
||||
"Pulses",
|
||||
"Dried fruit and nuts"
|
||||
]
|
||||
},
|
||||
"chemical": {
|
||||
"title": "Chemical products",
|
||||
"latin": "",
|
||||
"intro": "Chemical and polymer products are among the most important outputs of Iran's petrochemical industry. The group supplies these to domestic industrial units and exports them to regional markets.",
|
||||
"goods": [
|
||||
"Polyethylene",
|
||||
"Polypropylene",
|
||||
"PVC",
|
||||
"Caustic soda flakes",
|
||||
"Urea",
|
||||
"Methanol",
|
||||
"Chemical fertilisers",
|
||||
"Resin"
|
||||
]
|
||||
},
|
||||
"livestock": {
|
||||
"title": "Livestock and poultry inputs",
|
||||
"latin": "",
|
||||
"intro": "Sourcing livestock and poultry inputs from international markets and clearing them through the country's customs offices is one of the group's specialist services for livestock farms, poultry units and feed mills.",
|
||||
"goods": [
|
||||
"Feed maize",
|
||||
"Soybean meal",
|
||||
"Barley",
|
||||
"Bran",
|
||||
"Fishmeal",
|
||||
"Alfalfa",
|
||||
"Feed supplements"
|
||||
]
|
||||
},
|
||||
"construction": {
|
||||
"title": "Building materials and equipment",
|
||||
"latin": "",
|
||||
"intro": "Alongside its technical and engineering services, the group supplies the materials, equipment and machinery required by civil and construction projects.",
|
||||
"goods": [
|
||||
"Cement",
|
||||
"Rebar",
|
||||
"I-beams",
|
||||
"Tiles and ceramics",
|
||||
"Sanitary fittings",
|
||||
"Building insulation",
|
||||
"Mechanical installations"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"introHeading": "About Ario Majd Asia",
|
||||
"introLatin": "",
|
||||
"introBody": "Ario Majd Asia International Trading Group began formal operations in 2019, backed by partnerships with established Iranian and international companies and by its directors' experience in technical and engineering services, sourcing, marketing and international trade, insurance, international transport and the clearance of permitted commercial goods.",
|
||||
"introCta": "Read more",
|
||||
"partnersHeading": "Our Partners",
|
||||
"partnersLatin": "",
|
||||
"partnersSubtitle": "The bodies and organisations the group works with",
|
||||
"ctaHeading": "We are ready to work with you",
|
||||
"ctaBody": "Get in touch for advice on import, export, customs clearance and technical and engineering services.",
|
||||
"ctaButton": "Contact us"
|
||||
},
|
||||
"about": {
|
||||
"heading": "About Us",
|
||||
"headingLatin": "",
|
||||
"paragraphs": [
|
||||
"Ario Majd Asia International Trading Group began formal operations in 2019, backed by partnerships with established Iranian and international companies and by its directors' experience in technical and engineering services, sourcing, marketing and international trade, insurance, international transport and the clearance of permitted commercial goods.",
|
||||
"The group's technical and engineering work covers urban planning, architecture, civil engineering, surveying, transport and traffic, electrical and mechanical installations, and the supply of building materials, goods and equipment.",
|
||||
"Its commercial activity centres on the import, export and re-export of grain, foodstuffs, livestock inputs, oil derivatives, chemical products, mineral products and home appliances, together with insurance, international transport and clearance through every customs office in the country.",
|
||||
"Working with specialists and advisers across our legal, commercial, financial, industrial and project management, engineering, insurance, transport and clearance units, we are proud to be your lasting partner in international trade."
|
||||
],
|
||||
"vision": {
|
||||
"heading": "Our vision",
|
||||
"headingLatin": "",
|
||||
"subtitle": "Where we intend to stand a decade from now",
|
||||
"paragraphs": [
|
||||
"Through the planning of its specialists and by drawing on new experience and opportunities in technical and engineering services, trade and logistics aligned with current world technology, Ario Majd Asia International Trading Group intends to be recognised within the coming decade as one of the country's ten leading, credible and dependable private companies.",
|
||||
"By making international commercial cooperation easier through distinctive and varied services, and by earning the growing trust and satisfaction of domestic and international clients, we hope to take a step — however small — towards the economic progress and prosperity of Iran."
|
||||
]
|
||||
},
|
||||
"mission": {
|
||||
"heading": "Mission and goals",
|
||||
"headingLatin": "",
|
||||
"subtitle": "The principles that guide the group's daily work",
|
||||
"items": [
|
||||
"Placing client satisfaction at the head of everything we do",
|
||||
"Employing experienced, educated staff and continually updating their knowledge, skills and wellbeing",
|
||||
"Applying project management and cost management to the delivery of our services",
|
||||
"Reducing the time, cost and risk of the technical, engineering, commercial and transport services we provide",
|
||||
"Delivering technical, engineering, commercial and transport services of the highest quality",
|
||||
"Reaching new global methods and technologies and transferring that expertise into the country, in step with the pace of world progress",
|
||||
"Developing the technology and engineering knowledge related to the company's service and commercial activity",
|
||||
"Becoming an outstanding organisation of international standing",
|
||||
"Expanding our market through marketing and cooperation with established domestic and international companies",
|
||||
"Advising clients so that information on target-market conditions and economic opportunities is easier to reach",
|
||||
"Working with consulting and legal firms in target export countries to identify new opportunities and create favourable trading conditions for our clients",
|
||||
"Developing the resources behind our services and commercial goods in step with market growth",
|
||||
"A commitment to protecting the environment and a belief in sustainable development",
|
||||
"Making local capability available to international companies"
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"heading": "Our values",
|
||||
"headingLatin": "",
|
||||
"subtitle": "What we hold to in every engagement",
|
||||
"items": [
|
||||
"Adherence to ethical and Islamic values",
|
||||
"A sense of responsibility towards clients across our staff",
|
||||
"Protecting and guaranteeing the security of client information",
|
||||
"Protecting the interests of shareholders and staff",
|
||||
"Rule of law and fairness",
|
||||
"Honesty and trustworthiness",
|
||||
"Respect for those we serve",
|
||||
"Creativity and innovation",
|
||||
"Merit-based advancement",
|
||||
"Mutual trust"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"heading": "Contact Us",
|
||||
"headingLatin": "",
|
||||
"lead": "Get in touch with Ario Majd Asia",
|
||||
"leadLatin": "",
|
||||
"leadSubtitle": "Send us a message and our team will get back to you as soon as possible.",
|
||||
"addressHeading": "Our address",
|
||||
"addressLatin": "",
|
||||
"emailHeading": "Email us",
|
||||
"emailLatin": "",
|
||||
"phoneHeading": "Call us",
|
||||
"phoneLatin": "",
|
||||
"socialHeading": "Stay with us",
|
||||
"socialLatin": "",
|
||||
"viewOnMap": "View on map",
|
||||
"form": {
|
||||
"name": "Full name",
|
||||
"email": "Email",
|
||||
"phone": "Phone number",
|
||||
"subject": "Subject",
|
||||
"message": "Your message",
|
||||
"submit": "Send message",
|
||||
"sending": "Sending…",
|
||||
"success": "Your message has been received. We will be in touch shortly.",
|
||||
"required": "This field is required.",
|
||||
"invalidEmail": "Please enter a valid email address."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"heading": "News and articles",
|
||||
"headingLatin": "",
|
||||
"subtitle": "Notes from our specialists on trade, transport and customs clearance",
|
||||
"readMore": "Read more",
|
||||
"publishedOn": "Published on",
|
||||
"viewAll": "All articles",
|
||||
"backToList": "Back to all articles",
|
||||
"empty": "No articles have been published yet."
|
||||
},
|
||||
"market": {
|
||||
"heading": "Market today",
|
||||
"headingLatin": "",
|
||||
"subtitle": "Currency, gold and oil rates, with the status of the world's main exchanges",
|
||||
"asOf": "Updated",
|
||||
"source": "Source:",
|
||||
"labels": {
|
||||
"300106": "Gold ounce",
|
||||
"400106": "Brent crude",
|
||||
"200103": "USDT",
|
||||
"200101": "USD (exchange)",
|
||||
"200201": "EUR (exchange)"
|
||||
},
|
||||
"gold": {
|
||||
"heading": "Gold by purity",
|
||||
"toman": "Iran (Toman)",
|
||||
"usd": "World (USD)",
|
||||
"rows": {
|
||||
"g24": "24k gold per gram",
|
||||
"g22": "22k gold per gram",
|
||||
"g18": "18k gold per gram",
|
||||
"ounce": "World gold ounce"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"heading": "World exchanges",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"reopens": "Reopens:",
|
||||
"names": {
|
||||
"newYork": "New York",
|
||||
"london": "London",
|
||||
"tokyo": "Tokyo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"partners": {
|
||||
"isipo": "Iran Small Industries and Industrial Parks Organization",
|
||||
"mimt": "Ministry of Industry, Mine and Trade",
|
||||
"iccima": "Iran Chamber of Commerce, Industries, Mines and Agriculture",
|
||||
"qomccima": "Qom Chamber of Commerce, Industries, Mines and Agriculture",
|
||||
"irica": "Islamic Republic of Iran Customs Administration",
|
||||
"iranfair": "Iran International Exhibitions Company"
|
||||
},
|
||||
"footer": {
|
||||
"linksHeading": "Sections",
|
||||
"addressHeading": "Address",
|
||||
"phoneHeading": "Phone",
|
||||
"emailHeading": "Email",
|
||||
"rights": "All rights to this website belong to Ario Majd Asia International Trading Group."
|
||||
},
|
||||
"account": {
|
||||
"login": "Sign in",
|
||||
"honorific": "",
|
||||
"profile": "My profile",
|
||||
"logout": "Sign out",
|
||||
"manageWebsite": "Manage website",
|
||||
"menu": "Account"
|
||||
},
|
||||
"common": {
|
||||
"skipToContent": "Skip to main content",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"goToSlide": "Go to slide",
|
||||
"switchLanguage": "Language"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Dictionary } from "../types";
|
||||
import raw from "./en.json";
|
||||
|
||||
/* The annotation is the safety net: a key missing from en.json fails the build. */
|
||||
const en: Dictionary = raw;
|
||||
export default en;
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"meta": {
|
||||
"company": "گروه بازرگانی بین المللی آریو مجد آسیا",
|
||||
"companyShort": "گروه بازرگانی آریو مجد آسیا",
|
||||
"companyLatin": "ARIO MAJD ASIA",
|
||||
"tagline": "همراه همیشگی تجارت بین المللی شما",
|
||||
"description": "گروه بازرگانی بین المللی آریو مجد آسیا؛ ارائه دهنده خدمات فنی و مهندسی، بازرگانی، حمل و نقل بین المللی، بیمه و ترخیص کالا."
|
||||
},
|
||||
"nav": {
|
||||
"home": "صفحه اصلی",
|
||||
"products": "محصولات",
|
||||
"about": "درباره ی ما",
|
||||
"contact": "تماس با ما"
|
||||
},
|
||||
"slides": {
|
||||
"company": {
|
||||
"title": "گروه بازرگانی آریو مجد آسیا",
|
||||
"subtitle": "ARIO MAJD ASIA",
|
||||
"description": "ارائه خدمات فنی و مهندسی، بازرگانی و لجستیک در سطح بین المللی، با تکیه بر تجربه مدیران و همکاری با شرکت های معتبر داخلی و خارجی."
|
||||
},
|
||||
"trade": {
|
||||
"title": "واردات و صادرات کالا",
|
||||
"subtitle": "IMPORT AND EXPORT",
|
||||
"description": "واردات، صادرات و صادرات مجدد غلات، مواد غذایی، نهاده های دامی، مشتقات نفتی، محصولات شیمیایی و معدنی."
|
||||
},
|
||||
"partner": {
|
||||
"title": "همراه همیشگی تجارت بین المللی شما",
|
||||
"subtitle": "YOUR TRADE PARTNER",
|
||||
"description": "از تأمین و بیمه تا حمل و نقل بین المللی و ترخیص از کلیه گمرک های کشور؛ در کنار شما از سال ۱۳۹۸."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"heading": "خدمات ما",
|
||||
"headingLatin": "OUR SERVICES",
|
||||
"subtitle": "چهار حوزه اصلی خدمات گروه بازرگانی بین المللی آریو مجد آسیا",
|
||||
"items": {
|
||||
"transport": {
|
||||
"title": "حمل و نقل و بیمه کالاهای بازرگانی",
|
||||
"latin": "TRANSPORT",
|
||||
"description": "حمل و نقل بین المللی و بیمه کالاهای بازرگانی تا مقصد."
|
||||
},
|
||||
"technical": {
|
||||
"title": "خدمات فنی و مهندسی",
|
||||
"latin": "TECHNICAL SERVICES",
|
||||
"description": "شهرسازی، معماری، عمران، نقشه برداری، تأسیسات برق و مکانیک."
|
||||
},
|
||||
"clearance": {
|
||||
"title": "ترخیص کالا",
|
||||
"latin": "DESIRED PRODUCTS",
|
||||
"description": "ترخیص کالاهای مجاز بازرگانی از کلیه گمرک های کشور."
|
||||
},
|
||||
"trade": {
|
||||
"title": "واردات و صادرات کالا",
|
||||
"latin": "IMPORT AND EXPORT",
|
||||
"description": "واردات، صادرات و صادرات مجدد در حوزه های تخصصی مجموعه."
|
||||
}
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"heading": "محصولات",
|
||||
"headingLatin": "PRODUCTS",
|
||||
"lead": "زمینه های اصلی فعالیت بازرگانی مجموعه",
|
||||
"goodsHeading": "اقلام قابل تأمین",
|
||||
"goodsLatin": "AVAILABLE GOODS",
|
||||
"servicesNote": "برای همه ی اقلام این دسته، خدمات حمل و نقل بین المللی، بیمه بار و ترخیص از گمرک های کشور نیز توسط مجموعه انجام می شود.",
|
||||
"otherHeading": "سایر دسته ها",
|
||||
"otherLatin": "OTHER CATEGORIES",
|
||||
"inquiryHeading": "برای این دسته استعلام بگیرید",
|
||||
"inquiryBody": "برای دریافت قیمت، شرایط تأمین و زمان تحویل، با کارشناسان بازرگانی مجموعه در تماس باشید.",
|
||||
"inquiryButton": "تماس با ما",
|
||||
"categories": {
|
||||
"oil": {
|
||||
"title": "مشتقات نفتی",
|
||||
"latin": "OIL DERIVATIVES",
|
||||
"intro": "مشتقات نفتی یکی از اصلی ترین حوزه های فعالیت بازرگانی گروه آریو مجد آسیا است. تأمین این محصولات از پالایشگاه ها و واحدهای تولیدی داخلی انجام می شود و صادرات آنها با هماهنگی کامل در زمینه حمل، بیمه و ترخیص صورت می گیرد.",
|
||||
"goods": [
|
||||
"قیر",
|
||||
"روغن پایه",
|
||||
"گوگرد",
|
||||
"پارافین",
|
||||
"وکس",
|
||||
"نفتا",
|
||||
"گازوئیل",
|
||||
"قطران"
|
||||
]
|
||||
},
|
||||
"mineral": {
|
||||
"title": "محصولات معدنی",
|
||||
"latin": "MINERAL PRODUCTS",
|
||||
"intro": "ایران از نظر ذخایر معدنی در زمره کشورهای غنی جهان قرار دارد. مجموعه با تکیه بر ارتباط با معادن و واحدهای فرآوری داخلی، تأمین و صادرات محصولات معدنی را در تناژهای مختلف بر عهده می گیرد.",
|
||||
"goods": [
|
||||
"سنگ آهن",
|
||||
"کنسانتره",
|
||||
"گندله",
|
||||
"سنگ گچ",
|
||||
"فلدسپات",
|
||||
"باریت",
|
||||
"بنتونیت",
|
||||
"سیلیس"
|
||||
]
|
||||
},
|
||||
"food": {
|
||||
"title": "مواد و محصولات غذایی",
|
||||
"latin": "FOOD PRODUCTS",
|
||||
"intro": "واردات، صادرات و صادرات مجدد غلات و مواد غذایی از حوزه های پرحجم فعالیت مجموعه است. تأمین این اقلام از بازارهای بین المللی و رساندن آنها به بازار داخلی با رعایت استانداردهای بهداشتی و قرنطینه ای انجام می شود.",
|
||||
"goods": [
|
||||
"غلات",
|
||||
"گندم",
|
||||
"برنج",
|
||||
"ذرت",
|
||||
"شکر",
|
||||
"روغن خوراکی",
|
||||
"حبوبات",
|
||||
"خشکبار"
|
||||
]
|
||||
},
|
||||
"chemical": {
|
||||
"title": "محصولات شیمیایی",
|
||||
"latin": "CHEMICAL PRODUCTS",
|
||||
"intro": "محصولات شیمیایی و پلیمری از مهم ترین تولیدات صنعت پتروشیمی کشور به شمار می روند. مجموعه تأمین این اقلام برای واحدهای صنعتی داخلی و صادرات آنها به بازارهای منطقه را انجام می دهد.",
|
||||
"goods": [
|
||||
"پلی اتیلن",
|
||||
"پلی پروپیلن",
|
||||
"پی وی سی",
|
||||
"سود پرک",
|
||||
"اوره",
|
||||
"متانول",
|
||||
"کودهای شیمیایی",
|
||||
"رزین"
|
||||
]
|
||||
},
|
||||
"livestock": {
|
||||
"title": "نهاده های دام و طیور",
|
||||
"latin": "LIVESTOCK INPUTS",
|
||||
"intro": "تأمین نهاده های دام و طیور از بازارهای بین المللی و ترخیص آنها از گمرک های کشور، از خدمات تخصصی مجموعه برای واحدهای دامپروری، مرغداری و کارخانه های خوراک دام است.",
|
||||
"goods": [
|
||||
"ذرت دامی",
|
||||
"کنجاله سویا",
|
||||
"جو",
|
||||
"سبوس",
|
||||
"پودر ماهی",
|
||||
"یونجه",
|
||||
"مکمل های دامی"
|
||||
]
|
||||
},
|
||||
"construction": {
|
||||
"title": "مصالح و تجهیزات ساختمانی",
|
||||
"latin": "BUILDING MATERIALS",
|
||||
"intro": "در کنار خدمات فنی و مهندسی، مجموعه تأمین مصالح، تجهیزات و ماشین آلات مورد نیاز پروژه های عمرانی و ساختمانی را نیز بر عهده می گیرد.",
|
||||
"goods": [
|
||||
"سیمان",
|
||||
"میلگرد",
|
||||
"تیرآهن",
|
||||
"کاشی و سرامیک",
|
||||
"شیرآلات",
|
||||
"عایق های ساختمانی",
|
||||
"تجهیزات تأسیساتی"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"introHeading": "درباره آریو مجد آسیا",
|
||||
"introLatin": "ABOUT US",
|
||||
"introBody": "گروه بازرگانی بین المللی آریو مجد آسیا به پشتوانه همکاری با شرکت های معتبر داخلی و بین المللی و همچنین تجربه مدیران مجموعه در زمینه ارائه خدمات فنی و مهندسی، تأمین، بازاریابی و تجارت بین المللی، بیمه، حمل و نقل بین المللی و خدمات ترخیص کالاهای مجاز بازرگانی؛ فعالیت رسمی خود را در سال ۱۳۹۸ آغاز نموده است.",
|
||||
"introCta": "بیشتر بدانید",
|
||||
"partnersHeading": "همکاران ما",
|
||||
"partnersLatin": "OUR PARTNERS",
|
||||
"partnersSubtitle": "نهادها و سازمان هایی که مجموعه با آنها در ارتباط است",
|
||||
"ctaHeading": "آماده همکاری با شما هستیم",
|
||||
"ctaBody": "برای دریافت مشاوره در زمینه واردات، صادرات، ترخیص کالا و خدمات فنی و مهندسی با ما در تماس باشید.",
|
||||
"ctaButton": "تماس با ما"
|
||||
},
|
||||
"about": {
|
||||
"heading": "درباره ما",
|
||||
"headingLatin": "ABOUT US",
|
||||
"paragraphs": [
|
||||
"گروه بازرگانی بین المللی آریو مجد آسیا به پشتوانه همکاری با شرکت های معتبر داخلی و بین المللی و همچنین تجربه مدیران مجموعه در زمینه ارائه خدمات فنی و مهندسی، تأمین، بازاریابی و تجارت بین المللی، بیمه، حمل و نقل بین المللی و خدمات ترخیص کالاهای مجاز بازرگانی؛ فعالیت رسمی خود را در سال ۱۳۹۸ آغاز نموده است.",
|
||||
"عمده خدمات فنی و مهندسی مجموعه در زمینه شهرسازی، معماری، عمران، نقشه برداری، حمل و نقل و ترافیک، تأسیسات برق و تأسیسات مکانیک و تأمین کالاها، تجهیزات و مصالح ساختمانی می باشد.",
|
||||
"عمده فعالیت های بازرگانی مجموعه نیز در زمینه واردات، صادرات و صادرات مجدد غلات، مواد غذایی، نهاده های دامی، مشتقات نفتی، محصولات شیمیایی، محصولات معدنی و لوازم خانگی و بیمه، حمل و نقل بین المللی و ترخیص آنها از کلیه گمرک های کشور می باشد.",
|
||||
"مفتخریم با همکاری کارشناسان و مشاوران واحدهای حقوقی، بازرگانی، مالی، مدیریت صنایع و پروژه، مهندسی، بیمه، حمل و نقل و ترخیص کالا در گروه بازرگانی بین المللی آریو مجد آسیا؛ همراه همیشگی تجارت بین المللی شما باشیم."
|
||||
],
|
||||
"vision": {
|
||||
"heading": "چشم انداز",
|
||||
"headingLatin": "PERSPECTIVE",
|
||||
"subtitle": "جایگاهی که در یک دهه آینده برای مجموعه ترسیم کرده ایم",
|
||||
"paragraphs": [
|
||||
"گروه بازرگانی بین المللی آریو مجد آسیا با برنامه ریزی های متخصصین خود و با بهره گیری از تجربیات و فرصت های نوین در ارائه خدمات فنی و مهندسی، بازرگانی و لجستیک منطبق با تکنولوژی های روز جهان؛ در یک دهه آینده به عنوان ۱۰ شرکت خصوصی برتر، معتبر و قابل اعتماد کشور شناخته خواهد شد.",
|
||||
"امید است با آسان سازی همکاری های بازرگانی بین المللی به واسطه ارائه خدمات متمایز، متنوع و کسب اعتبار و رضایت روزافزون مشتریان داخلی و بین المللی، قدمی هرچند کوچک در راستای پیشرفت و اعتلای اهداف اقتصادی و آبادانی میهن عزیزمان ایران برداریم."
|
||||
]
|
||||
},
|
||||
"mission": {
|
||||
"heading": "مأموریت و اهداف",
|
||||
"headingLatin": "MISSION AND GOALS",
|
||||
"subtitle": "اصولی که فعالیت روزانه مجموعه را هدایت می کند",
|
||||
"items": [
|
||||
"قراردادن جلب رضایت مشتریان در سرلوحه کارهای خود",
|
||||
"بکارگیری پرسنل مجرب و تحصیلکرده و همچنین به روز رسانی دانش و ارتقای مهارت و سطح سلامت آنان",
|
||||
"بهره گیری از مدیریت پروژه و مدیریت هزینه در ارائه خدمات",
|
||||
"کاهش زمان، هزینه و ریسک در ارائه خدمات فنی و مهندسی، بازرگانی و حمل و نقلی به مشتریان",
|
||||
"ارائه بهترین و با کیفیت ترین خدمات فنی و مهندسی، بازرگانی و حمل و نقلی به مشتریان",
|
||||
"دستیابی به روشها و فناوری نوین جهانی و انتقال دانش فنی آنها به کشور، همگام با روند سریع پیشرفت های جهانی",
|
||||
"توسعه تکنولوژیکی و دانش مهندسی مرتبط با فعالیت شرکت در بخش های خدماتی و بازرگانی",
|
||||
"تبدیل شدن به یک سازمان متعالی و سرآمد در سطح بین المللی",
|
||||
"توسعه حوزه بازار از طریق بازاریابی و همکاری با شرکت های معتبر داخلی و بین المللی",
|
||||
"مشاوره جهت ایجاد و امکان دستیابی آسانتر به اطلاعات و شرایط و موقعیتهای اقتصادی بازار هدف",
|
||||
"همکاری با شرکتهای مشاوره ای و حقوقی در کشورهای هدف صادراتی جهت شناخت و معرفی فرصت های جدید و فراهم آوردن شرایط مناسب بازرگانی برای مشتریان و رضایت مندی آنان",
|
||||
"توسعه منابع ارائه و تأمین خدمات و کالای تجاری متناسب با توسعه بازار",
|
||||
"تعهد به حفظ محیط زیست و اعتقاد به توسعه پایدار",
|
||||
"استفاده از امکانات محلی برای شرکت های بین المللی"
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"heading": "ارزش های سازمان",
|
||||
"headingLatin": "VALUES OF THE ORGANIZATION",
|
||||
"subtitle": "آنچه در هر همکاری به آن پایبند هستیم",
|
||||
"items": [
|
||||
"پایبندی به ارزش های اخلاقی و اسلامی",
|
||||
"احساس مسئولیت پرسنل نسبت به مشتریان",
|
||||
"حفظ و تضمین امنیت اطلاعات مشتریان",
|
||||
"حفظ منافع سهامداران و پرسنل شرکت",
|
||||
"قانون مداری و عدالت محوری",
|
||||
"صداقت و امانت داری",
|
||||
"تکریم ارباب رجوع",
|
||||
"خلاقیت و نوآوری",
|
||||
"شایسته سالاری",
|
||||
"اعتماد متقابل"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"heading": "تماس با ما",
|
||||
"headingLatin": "CONTACT US",
|
||||
"lead": "ارتباط با آریو مجد آسیا",
|
||||
"leadLatin": "CONTACT WITH ARIO MAJD ASIA",
|
||||
"leadSubtitle": "پیام خود را ارسال کنید؛ کارشناسان ما در اسرع وقت پاسخگوی شما خواهند بود.",
|
||||
"addressHeading": "نشانی ما",
|
||||
"addressLatin": "ADDRESS",
|
||||
"emailHeading": "به ما ایمیل بزنید",
|
||||
"emailLatin": "EMAIL",
|
||||
"phoneHeading": "اطلاعات تماس با ما",
|
||||
"phoneLatin": "CONTACT US",
|
||||
"socialHeading": "با ما همراه باشید",
|
||||
"socialLatin": "STAY WITH US",
|
||||
"viewOnMap": "مشاهده روی نقشه",
|
||||
"form": {
|
||||
"name": "نام و نام خانوادگی",
|
||||
"email": "ایمیل",
|
||||
"phone": "شماره تماس",
|
||||
"subject": "موضوع",
|
||||
"message": "پیام شما",
|
||||
"submit": "ارسال پیام",
|
||||
"sending": "در حال ارسال…",
|
||||
"success": "پیام شما با موفقیت ثبت شد. به زودی با شما تماس می گیریم.",
|
||||
"required": "تکمیل این فیلد الزامی است.",
|
||||
"invalidEmail": "لطفاً یک ایمیل معتبر وارد کنید."
|
||||
}
|
||||
},
|
||||
"blog": {
|
||||
"heading": "اخبار و مقالات",
|
||||
"headingLatin": "BLOG",
|
||||
"subtitle": "یادداشت های کارشناسان مجموعه در حوزه بازرگانی، حمل و نقل و ترخیص کالا",
|
||||
"readMore": "ادامه مطلب",
|
||||
"publishedOn": "تاریخ انتشار",
|
||||
"viewAll": "همه ی مطالب",
|
||||
"backToList": "بازگشت به فهرست مطالب",
|
||||
"empty": "هنوز مطلبی منتشر نشده است."
|
||||
},
|
||||
"market": {
|
||||
"heading": "بازار امروز",
|
||||
"headingLatin": "MARKET TODAY",
|
||||
"subtitle": "نرخ های ارز، طلا و نفت به همراه وضعیت بازارهای بورس جهانی",
|
||||
"asOf": "به روزرسانی",
|
||||
"source": "منبع:",
|
||||
"labels": {
|
||||
"300106": "انس طلا",
|
||||
"400106": "نفت برنت",
|
||||
"200103": "دلار تتر",
|
||||
"200101": "دلار صرافی",
|
||||
"200201": "یورو صرافی"
|
||||
},
|
||||
"gold": {
|
||||
"heading": "عیارهای مختلف طلا",
|
||||
"toman": "بازار ایران (تومان)",
|
||||
"usd": "بازار جهانی (دلار)",
|
||||
"rows": {
|
||||
"g24": "هر گرم طلای ۲۴ عیار",
|
||||
"g22": "هر گرم طلای ۲۲ عیار",
|
||||
"g18": "هر گرم طلای ۱۸ عیار",
|
||||
"ounce": "اونس جهانی طلا"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"heading": "وضعیت بورس های جهانی",
|
||||
"open": "باز",
|
||||
"closed": "بسته",
|
||||
"reopens": "بازگشایی:",
|
||||
"names": {
|
||||
"newYork": "نیویورک",
|
||||
"london": "لندن",
|
||||
"tokyo": "توکیو"
|
||||
}
|
||||
}
|
||||
},
|
||||
"partners": {
|
||||
"isipo": "سازمان صنایع کوچک و شهرک های صنعتی ایران",
|
||||
"mimt": "وزارت صنعت، معدن و تجارت",
|
||||
"iccima": "اتاق بازرگانی، صنایع، معادن و کشاورزی ایران",
|
||||
"qomccima": "اتاق بازرگانی، صنایع، معادن و کشاورزی قم",
|
||||
"irica": "گمرک جمهوری اسلامی ایران",
|
||||
"iranfair": "نمایشگاه بین المللی جمهوری اسلامی ایران"
|
||||
},
|
||||
"footer": {
|
||||
"linksHeading": "زیر شاخه ها",
|
||||
"addressHeading": "ادرس",
|
||||
"phoneHeading": "شماره تماس",
|
||||
"emailHeading": "ایمیل",
|
||||
"rights": "کلیه حقوق این وب سایت متعلق به گروه بازرگانی بین المللی آریو مجد آسیا است."
|
||||
},
|
||||
"account": {
|
||||
"login": "ورود",
|
||||
"honorific": "عزیز",
|
||||
"profile": "پروفایل من",
|
||||
"logout": "خروج",
|
||||
"manageWebsite": "مدیریت وبسایت",
|
||||
"menu": "حساب کاربری"
|
||||
},
|
||||
"common": {
|
||||
"skipToContent": "رفتن به محتوای اصلی",
|
||||
"previous": "قبلی",
|
||||
"next": "بعدی",
|
||||
"goToSlide": "رفتن به اسلاید",
|
||||
"switchLanguage": "زبان"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Dictionary } from "../types";
|
||||
import raw from "./fa.json";
|
||||
|
||||
/* The annotation is the safety net: a key missing from fa.json fails the build. */
|
||||
const fa: Dictionary = raw;
|
||||
export default fa;
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { ProductSlug, ServiceId, SlideId } from "@/content";
|
||||
import type { PartnerId } from "@/lib/site";
|
||||
import type { GoldKey, SessionKey } from "@/lib/market/types";
|
||||
|
||||
/**
|
||||
* Shape of a locale file. Prose only — no image paths, no slugs, no ordering.
|
||||
*
|
||||
* Each locale's JSON is assigned to this type in its wrapper module, so a
|
||||
* missing key is a build error. (Assignment does not flag *extra* keys; the
|
||||
* `npm run i18n:check` script covers stale ones left behind after an edit.)
|
||||
*/
|
||||
export type Dictionary = {
|
||||
meta: {
|
||||
company: string;
|
||||
companyShort: string;
|
||||
companyLatin: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
};
|
||||
nav: { home: string; products: string; about: string; contact: string };
|
||||
slides: Record<SlideId, { title: string; subtitle: string; description: string }>;
|
||||
services: {
|
||||
heading: string;
|
||||
headingLatin: string;
|
||||
subtitle: string;
|
||||
items: Record<ServiceId, { title: string; latin: string; description: string }>;
|
||||
};
|
||||
products: {
|
||||
heading: string;
|
||||
headingLatin: string;
|
||||
lead: string;
|
||||
goodsHeading: string;
|
||||
goodsLatin: string;
|
||||
servicesNote: string;
|
||||
otherHeading: string;
|
||||
otherLatin: string;
|
||||
inquiryHeading: string;
|
||||
inquiryBody: string;
|
||||
inquiryButton: string;
|
||||
categories: Record<
|
||||
ProductSlug,
|
||||
{ title: string; latin: string; intro: string; goods: string[] }
|
||||
>;
|
||||
};
|
||||
home: {
|
||||
introHeading: string;
|
||||
introLatin: string;
|
||||
introBody: string;
|
||||
introCta: string;
|
||||
partnersHeading: string;
|
||||
partnersLatin: string;
|
||||
partnersSubtitle: string;
|
||||
ctaHeading: string;
|
||||
ctaBody: string;
|
||||
ctaButton: string;
|
||||
};
|
||||
about: {
|
||||
heading: string;
|
||||
headingLatin: string;
|
||||
paragraphs: string[];
|
||||
vision: { heading: string; headingLatin: string; subtitle: string; paragraphs: string[] };
|
||||
mission: { heading: string; headingLatin: string; subtitle: string; items: string[] };
|
||||
values: { heading: string; headingLatin: string; subtitle: string; items: string[] };
|
||||
};
|
||||
contact: {
|
||||
heading: string;
|
||||
headingLatin: string;
|
||||
lead: string;
|
||||
leadLatin: string;
|
||||
leadSubtitle: string;
|
||||
addressHeading: string;
|
||||
addressLatin: string;
|
||||
emailHeading: string;
|
||||
emailLatin: string;
|
||||
phoneHeading: string;
|
||||
phoneLatin: string;
|
||||
socialHeading: string;
|
||||
socialLatin: string;
|
||||
viewOnMap: string;
|
||||
form: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
submit: string;
|
||||
sending: string;
|
||||
success: string;
|
||||
required: string;
|
||||
invalidEmail: string;
|
||||
};
|
||||
};
|
||||
/** Labels only. Posts themselves are single-language — see src/lib/posts.ts. */
|
||||
blog: {
|
||||
heading: string;
|
||||
headingLatin: string;
|
||||
subtitle: string;
|
||||
readMore: string;
|
||||
viewAll: string;
|
||||
backToList: string;
|
||||
publishedOn: string;
|
||||
/** Shown when the backend has no published posts yet. */
|
||||
empty: string;
|
||||
};
|
||||
/**
|
||||
* Market ticker chrome. `labels` is keyed by upstream instrument id and is
|
||||
* intentionally partial — add an entry only for the ids listed in
|
||||
* `src/lib/market/displayed.ts`; anything missing falls back to the upstream
|
||||
* Persian label.
|
||||
*/
|
||||
market: {
|
||||
heading: string;
|
||||
headingLatin: string;
|
||||
subtitle: string;
|
||||
asOf: string;
|
||||
source: string;
|
||||
/** Keyed by upstream instrument id — see `src/lib/market/displayed.ts`. */
|
||||
labels: Record<string, string>;
|
||||
gold: {
|
||||
heading: string;
|
||||
toman: string;
|
||||
usd: string;
|
||||
rows: Record<GoldKey, string>;
|
||||
};
|
||||
sessions: {
|
||||
heading: string;
|
||||
open: string;
|
||||
closed: string;
|
||||
reopens: string;
|
||||
names: Record<SessionKey, string>;
|
||||
};
|
||||
};
|
||||
/** Partner organisation names, keyed by the ids in `src/lib/site.ts`. */
|
||||
partners: Record<PartnerId, string>;
|
||||
footer: {
|
||||
linksHeading: string;
|
||||
addressHeading: string;
|
||||
phoneHeading: string;
|
||||
emailHeading: string;
|
||||
rights: string;
|
||||
};
|
||||
/** Account menu. Sign-in itself happens on the customer portal. */
|
||||
account: {
|
||||
login: string;
|
||||
/** Appended after the user's name, e.g. "علی عزیز". */
|
||||
honorific: string;
|
||||
profile: string;
|
||||
logout: string;
|
||||
manageWebsite: string;
|
||||
menu: string;
|
||||
};
|
||||
common: {
|
||||
skipToContent: string;
|
||||
previous: string;
|
||||
next: string;
|
||||
goToSlide: string;
|
||||
switchLanguage: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Meshkee Website API client.
|
||||
*
|
||||
* Contract: https://api.meshkee.com/docs/website
|
||||
* Public content lives under /tenants/{domain}/… and needs no auth.
|
||||
*/
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_MESHKEE_API ?? "https://api.meshkee.com/api/v1";
|
||||
|
||||
/** Apex host only — no www / api / customer / business prefix. */
|
||||
export const TENANT_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN ?? "majdtrading.com";
|
||||
|
||||
/** Where the login link sends people. The portal lives on its own subdomain. */
|
||||
export const CUSTOMER_PORTAL = `https://customer.${TENANT_DOMAIN}`;
|
||||
|
||||
type FetchOptions = {
|
||||
/** Seconds. Public content is cached; per-user calls must pass `false`. */
|
||||
revalidate?: number | false;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns null instead of throwing. A marketing page must still render when the
|
||||
* API is down — every caller degrades to hiding its section.
|
||||
*/
|
||||
export async function apiGet<T>(path: string, options: FetchOptions = {}): Promise<T | null> {
|
||||
const { revalidate = 300, token } = options;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(revalidate === false
|
||||
? { cache: "no-store" as const }
|
||||
: { next: { revalidate } }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 401 on /auth/me is the normal "signed out" path, not an incident.
|
||||
if (response.status !== 401) {
|
||||
console.error(`[api] ${path} responded ${response.status}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
console.error(`[api] ${path} failed:`, (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantPath = (path: string) => `/tenants/${TENANT_DOMAIN}${path}`;
|
||||
@@ -0,0 +1,73 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { apiGet } from "./api";
|
||||
import type { Session } from "./portal";
|
||||
|
||||
/**
|
||||
* Who is signed in, as far as this site can tell.
|
||||
*
|
||||
* Sign-in happens on the customer portal (a different subdomain), so this site
|
||||
* never sees a password and never calls POST /auth/login. It only reads a
|
||||
* session token and asks the API who it belongs to.
|
||||
*
|
||||
* ── The open question ────────────────────────────────────────────────────────
|
||||
* The Meshkee API authenticates with a Bearer JWT, which portals usually keep
|
||||
* in localStorage — and localStorage is NOT shared across subdomains. So for
|
||||
* majdtrading.com to see a session created on customer.majdtrading.com, the
|
||||
* portal must write the token to a cookie scoped to the apex (`.majdtrading.com`,
|
||||
* readable by both hosts).
|
||||
*
|
||||
* The API docs do not say whether it does, or under what name. `SESSION_COOKIE`
|
||||
* below is the single place to correct once that is confirmed. Until it matches,
|
||||
* this returns null and the header shows the sign-in link — which is the correct
|
||||
* fallback, just not the whole feature.
|
||||
*/
|
||||
export const SESSION_COOKIE = process.env.MESHKEE_SESSION_COOKIE ?? "meshkee_token";
|
||||
|
||||
/** Roles the API may use for someone who can administer the website. */
|
||||
const ADMIN_ROLES = new Set(["admin", "owner", "superadmin", "business_owner"]);
|
||||
|
||||
/**
|
||||
* Shape-tolerant on purpose: /auth/me has no documented response schema and
|
||||
* needs a token we do not hold, so it could not be sampled. These are the
|
||||
* plausible spellings; unknown extras are ignored rather than crashing.
|
||||
*/
|
||||
type ApiMe = {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
name?: string | null;
|
||||
fullName?: string | null;
|
||||
cellNumber?: string | null;
|
||||
role?: string | null;
|
||||
roles?: string[] | null;
|
||||
isAdmin?: boolean | null;
|
||||
user?: ApiMe | null;
|
||||
};
|
||||
|
||||
function pickName(me: ApiMe): string | null {
|
||||
const composed = [me.firstName, me.lastName].filter(Boolean).join(" ").trim();
|
||||
return composed || me.fullName?.trim() || me.name?.trim() || me.cellNumber?.trim() || null;
|
||||
}
|
||||
|
||||
function pickAdmin(me: ApiMe): boolean {
|
||||
if (me.isAdmin === true) return true;
|
||||
const roles = [me.role, ...(me.roles ?? [])].filter(Boolean) as string[];
|
||||
return roles.some((role) => ADMIN_ROLES.has(role.toLowerCase()));
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
// Never cached — this is per-visitor.
|
||||
const data = await apiGet<ApiMe>("/auth/me", { token, revalidate: false });
|
||||
if (!data) return null;
|
||||
|
||||
const me = data.user ?? data;
|
||||
const displayName = pickName(me);
|
||||
if (!displayName) return null;
|
||||
|
||||
return { displayName, isAdmin: pickAdmin(me) };
|
||||
}
|
||||
|
||||
export type { Session };
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiGet, tenantPath } from "./api";
|
||||
import { slugify } from "./slug";
|
||||
|
||||
/**
|
||||
* Blog posts, from the Meshkee backend.
|
||||
*
|
||||
* Note the two endpoints disagree in shape: the list returns `{ items: [...] }`
|
||||
* and the detail returns `{ blog: {...} }`. Both are normalised here so callers
|
||||
* see one type.
|
||||
*
|
||||
* Posts are single-language by design — the backend stores one body per post,
|
||||
* with no locale variants. See README, "Multi-language".
|
||||
*/
|
||||
export type BlogPost = {
|
||||
id: number;
|
||||
/** Canonical, built from the title. Never the API's own legacy `slug`. */
|
||||
slug: string;
|
||||
title: string;
|
||||
abstract: string | null;
|
||||
bodyHtml: string | null;
|
||||
imageUrl: string | null;
|
||||
categoryName: string | null;
|
||||
author: string | null;
|
||||
publishedAt: string | null;
|
||||
};
|
||||
|
||||
type ApiBlog = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
categoryName?: string | null;
|
||||
author?: { firstName?: string | null; lastName?: string | null } | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
function normalise(blog: ApiBlog): BlogPost {
|
||||
const name = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ").trim();
|
||||
return {
|
||||
id: blog.id,
|
||||
slug: slugify(blog.title),
|
||||
title: blog.title,
|
||||
abstract: blog.abstract ?? null,
|
||||
bodyHtml: blog.mainTextHtml ?? null,
|
||||
imageUrl: blog.titleImageUrl ?? null,
|
||||
categoryName: blog.categoryName ?? null,
|
||||
author: name || null,
|
||||
publishedAt: blog.publishedAt ?? blog.createdAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPosts(limit = 12): Promise<BlogPost[]> {
|
||||
const data = await apiGet<{ items?: ApiBlog[] }>(
|
||||
tenantPath(`/blogs?page=1&pageSize=${limit}`)
|
||||
);
|
||||
return (data?.items ?? []).map(normalise);
|
||||
}
|
||||
|
||||
/** Detail pages resolve by id — the slug segment is decorative. */
|
||||
export async function getPostById(id: number): Promise<BlogPost | null> {
|
||||
const data = await apiGet<{ blog?: ApiBlog }>(tenantPath(`/blogs/by-id/${id}`));
|
||||
return data?.blog ? normalise(data.blog) : null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Column count that follows the data.
|
||||
*
|
||||
* Tailwind extracts class names as literal strings, so `lg:grid-cols-${n}` never
|
||||
* reaches the stylesheet. Mapping a length onto static classes keeps the grid
|
||||
* matching the number of items — adding or removing a category no longer leaves
|
||||
* an empty column behind, which is exactly what happened when the home
|
||||
* appliances category was dropped from a hard-coded seven.
|
||||
*/
|
||||
const columnsByCount: Record<number, string> = {
|
||||
1: "lg:grid-cols-1",
|
||||
2: "lg:grid-cols-2",
|
||||
3: "lg:grid-cols-3",
|
||||
4: "lg:grid-cols-4",
|
||||
5: "lg:grid-cols-5",
|
||||
6: "lg:grid-cols-6",
|
||||
7: "lg:grid-cols-7",
|
||||
8: "lg:grid-cols-8",
|
||||
};
|
||||
|
||||
export function lgColumns(count: number, fallback = "lg:grid-cols-6"): string {
|
||||
return columnsByCount[count] ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InstrumentId } from "./instruments";
|
||||
|
||||
/**
|
||||
* THE ONE LIST TO EDIT.
|
||||
*
|
||||
* Which figures appear in the market frame, in this order. Empty means the frame
|
||||
* is not rendered at all — no gap, no placeholder.
|
||||
*
|
||||
* Add ids from `instruments.ts`, and a label for each under `market.labels` in
|
||||
* every `src/i18n/locales/*.json`; without a translation the upstream Persian
|
||||
* label is used as a fallback.
|
||||
*
|
||||
* The gold table and the session list have their own parsers and are always
|
||||
* included. Note the gold table's last row is the world ounce (id 300106), so
|
||||
* listing that id here too would print the same figure twice.
|
||||
*/
|
||||
export const displayed: InstrumentId[] = [
|
||||
"400106", // نفت برنت
|
||||
"200103", // دلار تتر
|
||||
"200101", // دلار - صرافی
|
||||
"200201", // یورو - صرافی
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { displayed } from "./displayed";
|
||||
import { livedataSource } from "./livedata";
|
||||
import type { MarketSnapshot, MarketSource } from "./types";
|
||||
|
||||
/** Swap this line to change where the figures come from. */
|
||||
const source: MarketSource = livedataSource;
|
||||
|
||||
/**
|
||||
* Never throws. Nothing selected, or an unreachable upstream, returns null and
|
||||
* the caller renders nothing — a build must not depend on a third party being
|
||||
* up, and a visitor should never see a broken widget.
|
||||
*/
|
||||
export async function getMarketSnapshot(): Promise<MarketSnapshot | null> {
|
||||
try {
|
||||
return await source.fetchSnapshot(displayed);
|
||||
} catch (error) {
|
||||
console.error("[market] snapshot unavailable:", (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { instruments, type InstrumentId } from "./instruments";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Catalogue of everything livedata.ir publishes, keyed by its stable upstream id.
|
||||
*
|
||||
* Ids are the anchor because the page is ad-heavy and its layout shifts, but an
|
||||
* id has a fixed meaning. `fa` is the label as published upstream — used to
|
||||
* identify the row when choosing, and as a fallback if no translation exists yet.
|
||||
*
|
||||
* Nothing here is displayed on its own. Pick what to show in `displayed.ts`.
|
||||
*/
|
||||
export const instruments = {
|
||||
"200101": { fa: "دلار - صرافی" },
|
||||
"200103": { fa: "دلار تتر" },
|
||||
"200201": { fa: "یورو - صرافی" },
|
||||
"2004": { fa: "درهم امارات" },
|
||||
"300106": { fa: "انس طلا" },
|
||||
"300206": { fa: "نقره" },
|
||||
"300306": { fa: "پلاتین" },
|
||||
"400106": { fa: "نفت برنت" },
|
||||
"400206": { fa: "نفت سبک آمریکا" },
|
||||
"6001": { fa: "بورس تهران" },
|
||||
"600206": { fa: "داوجونز" },
|
||||
"600306": { fa: "اس اند پی" },
|
||||
"600406": { fa: "نزدک" },
|
||||
"600506": { fa: "کک فرانسه" },
|
||||
"600606": { fa: "فوتسی بریتانیا" },
|
||||
"600706": { fa: "استاکس اروپا" },
|
||||
"600806": { fa: "نیکی ژاپن" },
|
||||
"600906": { fa: "استرالیا" },
|
||||
"601006": { fa: "دکس آلمان" },
|
||||
"601106": { fa: "شانگهای چین" },
|
||||
"601206": { fa: "بمبئی هند" },
|
||||
"601306": { fa: "بورس سوئیس" },
|
||||
"601406": { fa: "تداول عربستان" },
|
||||
"601506": { fa: "استانبول" },
|
||||
"601606": { fa: "هنگ سنگ" },
|
||||
"700007": { fa: "شاخص دلار" },
|
||||
"700106": { fa: "یورودلار" },
|
||||
"700206": { fa: "پوند به دلار" },
|
||||
"700306": { fa: "دلار استرالیا به دلار آمریکا" },
|
||||
"700406": { fa: "دلار آمریکا به کانادا" },
|
||||
"700506": { fa: "دلار به ین" },
|
||||
"700607": { fa: "دلار به روپیه" },
|
||||
"700706": { fa: "یورو به پوند" },
|
||||
"700806": { fa: "یورو به ین" },
|
||||
"700906": { fa: "دلار به لیر" },
|
||||
"701107": { fa: "دلار به یوان چین" },
|
||||
"701207": { fa: "دلار به درهم" },
|
||||
"701607": { fa: "دلار به روبل" },
|
||||
"701707": { fa: "بیت کوین" },
|
||||
} as const;
|
||||
|
||||
export type InstrumentId = keyof typeof instruments;
|
||||
|
||||
/* Also published but unlabelled upstream, so left out until identified:
|
||||
300406, 300606, 300706, 300806, 300906, 301006, 301106, 400306, 400406, 400506, 500106, 500206, 500306, 500407, 500607, 500706, 500806, 500907
|
||||
*/
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { InstrumentId } from "./instruments";
|
||||
import {
|
||||
goldKeys,
|
||||
type Direction,
|
||||
type GoldRow,
|
||||
type MarketSnapshot,
|
||||
type MarketSource,
|
||||
type Quote,
|
||||
type Session,
|
||||
type SessionKey,
|
||||
} from "./types";
|
||||
|
||||
const SOURCE_URL = "https://livedata.ir/";
|
||||
|
||||
/* ── shared helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
const stripTags = (value: string) => value.replace(/<[^>]*>/g, "").trim();
|
||||
|
||||
function directionOf(change: string): Direction {
|
||||
const value = Number.parseFloat(change.replace(/,/g, ""));
|
||||
if (!Number.isFinite(value) || value === 0) return "flat";
|
||||
return value > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
/* ── 1. id-anchored figures ─────────────────────────────────────────────── */
|
||||
|
||||
function readSpan(html: string, prefix: string, id: string) {
|
||||
const match = html.match(new RegExp(`id="${prefix}_${id}"[^>]*>([^<]*)<`));
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function parseQuotes(html: string, ids: readonly InstrumentId[]): Quote[] {
|
||||
const quotes: Quote[] = [];
|
||||
for (const id of ids) {
|
||||
const price = readSpan(html, "s", id);
|
||||
// An instrument that moved or vanished upstream is skipped, not fatal.
|
||||
if (!price) continue;
|
||||
const change = readSpan(html, "c", id) ?? "0";
|
||||
quotes.push({
|
||||
id,
|
||||
price,
|
||||
change,
|
||||
changePercent: readSpan(html, "cp", id) ?? "0%",
|
||||
direction: directionOf(change),
|
||||
});
|
||||
}
|
||||
return quotes;
|
||||
}
|
||||
|
||||
/* ── 2. gold purities ───────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* This table carries no ids, so it is anchored on its section heading and then
|
||||
* on each row's fixed Persian label. More brittle than the id-based figures —
|
||||
* if upstream renames a row, that row drops out and the rest survive.
|
||||
*/
|
||||
const GOLD_ROW_LABELS: Record<string, (typeof goldKeys)[number]> = {
|
||||
"هر گرم 24": "g24",
|
||||
"هر گرم 22": "g22",
|
||||
"هر گرم 18": "g18",
|
||||
};
|
||||
|
||||
/** Upstream id for the world gold ounce, shown as the table's last row. */
|
||||
const GOLD_OUNCE_ID = "300106";
|
||||
|
||||
function parseGold(html: string): GoldRow[] {
|
||||
const start = html.indexOf("قیمت عیارهای مختلف طلا");
|
||||
if (start === -1) return [];
|
||||
const table = html.slice(start, html.indexOf("</table>", start));
|
||||
|
||||
const rows: GoldRow[] = [];
|
||||
const pattern = /<td class="tabna">([^<]+)<\/td>\s*<td[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g;
|
||||
|
||||
for (const match of table.matchAll(pattern)) {
|
||||
const key = GOLD_ROW_LABELS[stripTags(match[1])];
|
||||
if (!key) continue;
|
||||
rows.push({ key, toman: match[2].trim(), usd: match[3].trim() });
|
||||
}
|
||||
|
||||
// The world ounce lives elsewhere on the page, under its own id.
|
||||
const ouncePrice = readSpan(html, "s", GOLD_OUNCE_ID);
|
||||
if (ouncePrice) rows.push({ key: "ounce", toman: null, usd: ouncePrice });
|
||||
|
||||
// Keep our declared order rather than the upstream's.
|
||||
return goldKeys
|
||||
.map((key) => rows.find((row) => row.key === key))
|
||||
.filter((row): row is GoldRow => Boolean(row));
|
||||
}
|
||||
|
||||
/* ── 3. exchange sessions ───────────────────────────────────────────────── */
|
||||
|
||||
const SESSION_NAMES: Record<string, SessionKey> = {
|
||||
نیویورک: "newYork",
|
||||
لندن: "london",
|
||||
توکیو: "tokyo",
|
||||
};
|
||||
|
||||
function parseSessions(html: string): Session[] {
|
||||
const start = html.indexOf("ساعت و وضعیت کاری");
|
||||
if (start === -1) return [];
|
||||
const block = html.slice(start, html.indexOf("</ul>", start));
|
||||
|
||||
const sessions: Session[] = [];
|
||||
// name, then the status icon class, then optional reopening date after the text
|
||||
const pattern =
|
||||
/<span class="nma">([^<]+)<\/span>\s*<span class="sp ([^"]*)"><\/span>\s*<span>[^<]*<\/span>\s*([^<]*)/g;
|
||||
|
||||
for (const match of block.matchAll(pattern)) {
|
||||
const key = SESSION_NAMES[stripTags(match[1])];
|
||||
if (!key) continue;
|
||||
const closed = /close/i.test(match[2]);
|
||||
const reopensOn = match[3].trim();
|
||||
sessions.push({ key, open: !closed, reopensOn: closed && reopensOn ? reopensOn : null });
|
||||
}
|
||||
|
||||
return sessionKeysOrder(sessions);
|
||||
}
|
||||
|
||||
function sessionKeysOrder(sessions: Session[]) {
|
||||
const order: SessionKey[] = ["newYork", "london", "tokyo"];
|
||||
return order
|
||||
.map((key) => sessions.find((session) => session.key === key))
|
||||
.filter((session): session is Session => Boolean(session));
|
||||
}
|
||||
|
||||
/* ── source ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* livedata.ir publishes no API — the figures sit in a static page its server
|
||||
* regenerates once a minute, on the minute. We read only the numbers and label
|
||||
* them ourselves, in our own three languages.
|
||||
*/
|
||||
export const livedataSource: MarketSource = {
|
||||
name: "livedata.ir",
|
||||
url: SOURCE_URL,
|
||||
|
||||
async fetchSnapshot(ids: readonly InstrumentId[]): Promise<MarketSnapshot> {
|
||||
const response = await fetch(SOURCE_URL, {
|
||||
// One upstream request per minute for the whole site, not one per visitor.
|
||||
// Matches their regeneration cadence — polling faster gains nothing.
|
||||
next: { revalidate: 60 },
|
||||
headers: { "User-Agent": "majdtrading.com market widget" },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`livedata.ir responded ${response.status}`);
|
||||
|
||||
const html = await response.text();
|
||||
const quotes = parseQuotes(html, ids);
|
||||
const gold = parseGold(html);
|
||||
const sessions = parseSessions(html);
|
||||
|
||||
if (quotes.length === 0 && gold.length === 0 && sessions.length === 0) {
|
||||
throw new Error("livedata.ir markup produced nothing — parsers need updating");
|
||||
}
|
||||
|
||||
return {
|
||||
quotes,
|
||||
gold,
|
||||
sessions,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
source: { name: livedataSource.name, url: SOURCE_URL },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { InstrumentId } from "./instruments";
|
||||
|
||||
export type Direction = "up" | "down" | "flat";
|
||||
|
||||
/** A single figure from the id-anchored part of the page. */
|
||||
export type Quote = {
|
||||
id: InstrumentId;
|
||||
/** Kept as published — formatted decimal strings, not numbers to compute with. */
|
||||
price: string;
|
||||
change: string;
|
||||
changePercent: string;
|
||||
direction: Direction;
|
||||
};
|
||||
|
||||
export const goldKeys = ["g24", "g22", "g18", "ounce"] as const;
|
||||
export type GoldKey = (typeof goldKeys)[number];
|
||||
|
||||
/**
|
||||
* Gold by purity: Toman on the domestic market, USD on the world market.
|
||||
*
|
||||
* `toman` is null for the world ounce — upstream publishes no Toman price for
|
||||
* it, and deriving one from the gram rate would be inventing a figure.
|
||||
*/
|
||||
export type GoldRow = { key: GoldKey; toman: string | null; usd: string };
|
||||
|
||||
export const sessionKeys = ["newYork", "london", "tokyo"] as const;
|
||||
export type SessionKey = (typeof sessionKeys)[number];
|
||||
|
||||
/**
|
||||
* Trading session state. Taken from upstream rather than computed from opening
|
||||
* hours, because upstream accounts for public holidays — it is currently
|
||||
* reporting Monday reopenings across a weekend.
|
||||
*/
|
||||
export type Session = { key: SessionKey; open: boolean; reopensOn: string | null };
|
||||
|
||||
export type MarketSnapshot = {
|
||||
quotes: Quote[];
|
||||
gold: GoldRow[];
|
||||
sessions: Session[];
|
||||
/** When we fetched, not when the upstream last recomputed. */
|
||||
fetchedAt: string;
|
||||
source: { name: string; url: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* One method, so the data source is a single swappable file.
|
||||
*
|
||||
* When the company backend exists, add `src/lib/market/backend.ts` implementing
|
||||
* this and change one import in `index.ts` — nothing else moves.
|
||||
*/
|
||||
export interface MarketSource {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
fetchSnapshot(ids: readonly InstrumentId[]): Promise<MarketSnapshot>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CUSTOMER_PORTAL } from "./api";
|
||||
|
||||
/**
|
||||
* Plain values only — no `next/headers`, so client components can import this.
|
||||
* Session *reading* lives in `auth.ts`, which is server-only.
|
||||
*/
|
||||
export type Session = {
|
||||
displayName: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export const portalLinks = {
|
||||
login: `${CUSTOMER_PORTAL}/auth/login`,
|
||||
profile: `${CUSTOMER_PORTAL}/profile`,
|
||||
logout: `${CUSTOMER_PORTAL}/auth/logout`,
|
||||
admin: `${CUSTOMER_PORTAL}/admin`,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Company details, shared by every locale — sourced from majdtrading.com. */
|
||||
export const site = {
|
||||
url: "https://www.majdtrading.com",
|
||||
address: "ایران- قم- بلوار جمهوری اسلامی- میدان سپاه",
|
||||
phones: ["+989101920966", "+989112199185"],
|
||||
phonesDisplay: ["(+98) 910 192 0966", "(+98) 911 219 9185"],
|
||||
emails: ["ceo.majd@hotmail.com", "commercial.majd@hotmail.com"],
|
||||
instagram: {
|
||||
handle: "@ariomajdasia",
|
||||
url: "https://www.instagram.com/ariomajdasia/",
|
||||
},
|
||||
foundedYear: 1398,
|
||||
/** Sepah Square, Qom — used for the contact-page map. */
|
||||
map: {
|
||||
lat: 34.6475,
|
||||
lon: 50.885,
|
||||
embed:
|
||||
"https://www.openstreetmap.org/export/embed.html?bbox=50.860%2C34.630%2C50.910%2C34.665&layer=mapnik&marker=34.6475%2C50.8850",
|
||||
link: "https://www.openstreetmap.org/?mlat=34.6475&mlon=50.8850#map=15/34.6475/50.8850",
|
||||
},
|
||||
/* Logo -> link mapping taken from majdtrading.com's own markup. The names are
|
||||
prose and live in the locale files, keyed by these ids — these bodies have
|
||||
official names in all three languages. */
|
||||
partners: [
|
||||
{ id: "isipo", logo: "/images/customers/item1.png", url: "https://isipo.ir/" },
|
||||
{ id: "mimt", logo: "/images/customers/item2.png", url: "https://dotic.ir/" },
|
||||
{ id: "iccima", logo: "/images/customers/item3.png", url: "https://iccima.ir/" },
|
||||
{ id: "qomccima", logo: "/images/customers/item4.png", url: "https://qomccima.ir/" },
|
||||
{ id: "irica", logo: "/images/customers/item5.png", url: "https://irica.ir/" },
|
||||
{ id: "iranfair", logo: "/images/customers/item6.png", url: "https://iranfair.com/" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type PartnerId = (typeof site.partners)[number]["id"];
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Meshkee canonical slug rules: keep Persian/Arabic letters, spaces become
|
||||
* hyphens, other punctuation is stripped.
|
||||
*
|
||||
* Public detail URLs are `/{id}/{slug}` and the slug segment is SEO-only — the
|
||||
* id resolves the page. The API's own `slug` field must NOT be used here: on
|
||||
* real data it holds legacy values like `item-old-2945`.
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// Strip anything that is not a letter, digit, space or hyphen. Keeps
|
||||
// Arabic-script letters (-ۿ) and Latin/other letters.
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-|-$/g, "") || "post"
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonical blog detail path, without the locale prefix. */
|
||||
export const blogPath = (id: number, title: string) => `/blog/${id}/${slugify(title)}`;
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Splits a short Latin heading into at most two lines, broken where the two
|
||||
* halves come closest to equal length. Used for the faint backdrop behind
|
||||
* Persian titles: a single long line runs the width of the section, while a
|
||||
* balanced stack sits behind the heading lockup.
|
||||
*
|
||||
* Short strings ("ABOUT US") stay on one line — breaking them would strand a
|
||||
* two-letter word on its own row.
|
||||
*/
|
||||
export function balanceLines(text: string, minLengthToBreak = 10): string[] {
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words.length < 2 || text.length <= minLengthToBreak) return [text];
|
||||
|
||||
let breakAt = 1;
|
||||
let smallestDifference = Infinity;
|
||||
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const head = words.slice(0, i).join(" ").length;
|
||||
const tail = words.slice(i).join(" ").length;
|
||||
const difference = Math.abs(head - tail);
|
||||
if (difference < smallestDifference) {
|
||||
smallestDifference = difference;
|
||||
breakAt = i;
|
||||
}
|
||||
}
|
||||
|
||||
return [words.slice(0, breakAt).join(" "), words.slice(breakAt).join(" ")];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { locales, defaultLocale } from "@/i18n/config";
|
||||
|
||||
const PUBLIC_FILE = /\.(.*)$/;
|
||||
|
||||
/**
|
||||
* Sends every un-prefixed path to a locale-prefixed one. Today `fa` is the only
|
||||
* shipped locale; once `en` / `ar` are added, negotiate on `Accept-Language`
|
||||
* here and the rest of the app needs no changes.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const hasLocale = locales.some(
|
||||
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
|
||||
);
|
||||
if (hasLocale || PUBLIC_FILE.test(pathname)) return NextResponse.next();
|
||||
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = `/${defaultLocale}${pathname === "/" ? "" : pathname}`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|images|fonts|favicon.ico).*)"],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||