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>
This commit is contained in:
Alireza Hassani
2026-08-29 19:16:32 +03:30
co-authored by Claude Opus 5
commit 02aaf61bb6
95 changed files with 7050 additions and 0 deletions
+54
View File
@@ -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}`;
+73
View File
@@ -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 };
+66
View File
@@ -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;
}
+23
View File
@@ -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;
}
+22
View File
@@ -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", // یورو - صرافی
];
+23
View File
@@ -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";
+56
View File
@@ -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
*/
+164
View File
@@ -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 },
};
},
};
+55
View File
@@ -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>;
}
+17
View File
@@ -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`,
};
+34
View File
@@ -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"];
+24
View File
@@ -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)}`;
+28
View File
@@ -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(" ")];
}