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:
@@ -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(" ")];
|
||||
}
|
||||
Reference in New Issue
Block a user