first commit

This commit is contained in:
amirhosein.ashourloo
2026-09-02 17:03:23 +03:30
commit 1bd131e9d8
91 changed files with 11211 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
import { API_BASE, SITE_DOMAIN } from "./config";
import type {
ApiBlog,
ApiCategory,
ApiProduct,
AuthUser,
BusinessInfo,
ContactFormInput,
EntityComment,
PagedResult,
ProductVariation,
SliderItem,
StaticImageSlot,
StoreItem,
StoreSpecial,
Tenant,
TechnicalInfoForm,
TechnicalInfoValue,
} from "./types";
type QueryParams = Record<string, string | number | boolean | undefined>;
async function apiGet<T>(path: string, params?: QueryParams): Promise<T> {
const url = new URL(`${API_BASE}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
const res = await fetch(url.toString(), { next: { revalidate: 60 } });
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
async function apiPost<T>(path: string, body: unknown, token?: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// /auth/me is the one call in this file that needs a bearer token, so it
// bypasses apiGet's no-auth default rather than growing every call site an
// optional header param it will never use.
async function apiGetAuthed<T>(path: string, token: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// Swallows errors so one empty/broken section degrades instead of taking
// the whole page down — several modules on this tenant are still unfilled.
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
try {
return await promise;
} catch {
return fallback;
}
}
const t = (path: string) => `/tenants/${SITE_DOMAIN}${path}`;
export function getTenant(): Promise<Tenant> {
return apiGet<Tenant>(t(""));
}
export function getBusinessInfo(): Promise<BusinessInfo> {
return apiGet<BusinessInfo>(t("/website/business-info"));
}
export function getSliders(): Promise<SliderItem[]> {
return apiGet<{ items: SliderItem[] }>(t("/website/sliders")).then((r) => r.items);
}
export function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
return apiGet<{ items: StaticImageSlot[] }>(t("/website/static-images"), { pageKey }).then(
(r) => r.items
);
}
export function getCategories(
entityType: "product" | "blog" | "portfolio" | "video" = "product"
): Promise<ApiCategory[]> {
return apiGet<{ items: ApiCategory[] }>(t("/categories"), { entityType }).then((r) => r.items);
}
export function getCategoryById(id: string): Promise<ApiCategory> {
return apiGet<{ category: ApiCategory }>(t(`/categories/by-id/${id}`)).then((r) => r.category);
}
export function getProducts(
params: {
page?: number;
pageSize?: number;
name?: string;
categoryId?: string;
brandId?: string;
tag?: string;
inStore?: boolean;
} = {}
): Promise<PagedResult<ApiProduct>> {
return apiGet<PagedResult<ApiProduct>>(t("/products"), params);
}
export function getProductById(
id: string
): Promise<{ product: ApiProduct; relatedProducts: ApiProduct[] }> {
return apiGet(t(`/products/by-id/${id}`));
}
// Curated merchandising groups managed from the business dashboard
// ("special-discount", "top-selling", …). `source` repeats the tenant's
// specialProductsSource — store_item for this tenant.
export function getStoreSpecials(): Promise<StoreSpecial[]> {
return apiGet<{ source: string; items: StoreSpecial[] }>(t("/store-specials")).then(
(r) => r.items
);
}
export function getBlogs(
params: { page?: number; pageSize?: number; categoryId?: string; title?: string } = {}
): Promise<PagedResult<ApiBlog>> {
return apiGet<PagedResult<ApiBlog>>(t("/blogs"), params);
}
export function getBlogById(id: string): Promise<{ blog: ApiBlog }> {
return apiGet(t(`/blogs/by-id/${id}`));
}
export function getProductVariations(
productId: string
): Promise<{ variations: ProductVariation[] }> {
return apiGet(t(`/products/by-id/${productId}/variations`));
}
// Labels live in form.fields, values in values[] — join by fieldId. Never
// render values[] alone (see AGENTS.md "Technical specs").
export function getProductTechnicalInfo(
productId: string
): Promise<{ form: TechnicalInfoForm | null; values: TechnicalInfoValue[] }> {
return apiGet(t(`/products/by-id/${productId}/technical-info`));
}
// Not /store-items/by-product/{id} — that one returns a single { storeItem }
// (or null), not the full variant list a buy panel needs. The paginated
// /store-items list, filtered by productId, is the one that returns every
// purchasable variant.
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
return apiGet(t("/store-items"), { productId, pageSize: 100 });
}
export function getComments(
entityType: "product" | "blog" | "portfolio" | "video",
entityId: string
): Promise<{ items: EntityComment[] }> {
return apiGet(t("/comments"), { entityType, entityId });
}
// Public endpoint (no bearer required by the API), but the storefront gates
// the form behind login per the product-page brief — see ProductInfoTabs.
export function postComment(input: {
entityType: "product" | "blog" | "portfolio" | "video";
entityId: string;
authorName: string;
authorEmail?: string;
text: string;
}): Promise<{ comment: EntityComment }> {
return apiPost(t("/comments"), input);
}
// { user } from a bearer token — see src/lib/auth.ts for where the token
// comes from (a cookie the customer portal is expected to set).
export function getMe(token: string): Promise<{ user: AuthUser }> {
return apiGetAuthed("/auth/me", token);
}
export function submitContactForm(
data: ContactFormInput
): Promise<{ submission: unknown; message: string }> {
return apiPost(t("/contact-submissions"), data);
}
export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const slot = slots.find((s) => s.key === key);
if (!slot || slot.images.length === 0) return undefined;
return {
...slot,
images: [...slot.images].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
};
}
+19
View File
@@ -0,0 +1,19 @@
import { cookies } from "next/headers";
import { getMe, safe } from "./api";
import { AUTH_TOKEN_COOKIE } from "./config";
import type { AuthUser } from "./types";
export { displayName, isAdmin } from "./authUser";
// Server-side "am I logged in" check for the header/account menu. See the
// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency
// this relies on. Resolves to null whenever the cookie is absent or /auth/me
// fails — never throws, so a missing session degrades to the anonymous view.
export async function getSession(): Promise<AuthUser | null> {
const store = await cookies();
const token = store.get(AUTH_TOKEN_COOKIE)?.value;
if (!token) return null;
const result = await safe(getMe(token), null);
return result?.user ?? null;
}
+16
View File
@@ -0,0 +1,16 @@
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
// which reads next/headers) — safe to import from client components.
import type { AuthUser } from "./types";
// "علی عزیز" — first name (or the fullest name field available) + عزیز.
export function displayName(user: AuthUser): string {
const name = user.firstName || user.nameFa || user.name || "کاربر";
return `${name} عزیز`;
}
// Best-effort: the OpenAPI schema doesn't document a role/admin field on the
// user object, so this reads every plausible shape defensively instead of
// assuming one. Absent all of them, the admin menu item is simply hidden.
export function isAdmin(user: AuthUser): boolean {
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
}
+52
View File
@@ -0,0 +1,52 @@
import { discountPercent } from "./format";
import { productHref } from "./slug";
import { DEMO_PRICING, demoPrice } from "./demoPricing";
import type { ApiProduct, ProductCardData, StoreSpecial } from "./types";
export function productToCard(product: ApiProduct): ProductCardData {
const base = {
id: product.id,
href: productHref(product.id, product.nameFa, product.title),
title: product.nameFa || product.title,
image: product.thumbnailUrl || product.image || product.thumbnail,
};
if (product.store.minPrice !== null) {
return { ...base, price: product.store.minPrice, oldPrice: null, percent: 0, inStock: product.store.inStock };
}
if (DEMO_PRICING) {
const { price, oldPrice } = demoPrice(product);
return {
...base,
price,
oldPrice,
percent: oldPrice ? discountPercent(price, oldPrice) : 0,
inStock: true,
};
}
return { ...base, price: null, oldPrice: null, percent: 0, inStock: false };
}
// A curated store-special group carries its own variant pricing, so the
// discount shown there is the real one rather than a product-level guess.
export function specialToCards(special: StoreSpecial): ProductCardData[] {
return special.items
.filter((item) => item.isActive)
.map((item) => {
const variant = item.variants.find((v) => v.discountedPrice !== null) ?? item.variants[0];
const price = variant?.discountedPrice ?? variant?.price ?? null;
const oldPrice = variant?.discountedPrice !== null ? variant?.price ?? null : null;
return {
id: item.id,
href: productHref(item.productId, item.productNameFa, item.productTitle),
title: item.productNameFa || item.productTitle,
image: item.productImage,
price,
oldPrice,
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
inStock: (variant?.stockQuantity ?? 0) > 0,
};
});
}
+75
View File
@@ -0,0 +1,75 @@
import type { ApiCategory, CategoryNode } from "./types";
export function buildCategoryTree(categories: ApiCategory[]): CategoryNode[] {
const byId = new Map<string, CategoryNode>();
for (const c of categories) byId.set(c.id, { ...c, children: [] });
const roots: CategoryNode[] = [];
for (const node of byId.values()) {
const parent = node.parentId ? byId.get(node.parentId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
const sort = (nodes: CategoryNode[]) => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder || a.nameFa.localeCompare(b.nameFa, "fa"));
nodes.forEach((n) => sort(n.children));
};
sort(roots);
return roots;
}
// The category carousel shows level 2 (children of the single root
// "کالای دیجیتال"), which is what shoppers actually browse by.
export function carouselCategories(roots: CategoryNode[]): CategoryNode[] {
const level2 = roots.flatMap((r) => r.children);
return level2.length > 0 ? level2 : roots;
}
export function findNode(nodes: CategoryNode[], id: string): CategoryNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const hit = findNode(node.children, id);
if (hit) return hit;
}
return undefined;
}
// Products are filed against leaf categories, so a parent like "لوازم جانبی"
// matches almost nothing on its own — a carousel for it has to ask for the
// whole subtree. Capped for the homepage's small carousels; a full category
// listing page needs every id, so use subtreeIdsAll there instead.
export function subtreeIds(node: CategoryNode, limit = 8): string[] {
const ids: string[] = [];
const walk = (n: CategoryNode) => {
if (ids.length >= limit) return;
ids.push(n.id);
n.children.forEach(walk);
};
walk(node);
return ids;
}
export function subtreeIdsAll(node: CategoryNode): string[] {
const ids: string[] = [];
const walk = (n: CategoryNode) => {
ids.push(n.id);
n.children.forEach(walk);
};
walk(node);
return ids;
}
// Root-first breadcrumb chain, e.g. [کالای دیجیتال, لوازم جانبی, هدفون].
export function pathToRoot(roots: CategoryNode[], id: string): CategoryNode[] {
const walk = (nodes: CategoryNode[], trail: CategoryNode[]): CategoryNode[] | null => {
for (const node of nodes) {
const nextTrail = [...trail, node];
if (node.id === id) return nextTrail;
const hit = walk(node.children, nextTrail);
if (hit) return hit;
}
return null;
};
return walk(roots, []) ?? [];
}
+24
View File
@@ -0,0 +1,24 @@
export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "moalem.shop";
export const API_BASE = "https://api.meshkee.com/api/v1";
export const SITE_URL = `https://${SITE_DOMAIN}`;
export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
// Business dashboard — the AI brief reserves "business." alongside "api."
// and "customer." as a subdomain on every Meshkee site. Used only for the
// "مدیریت وبسایت" account-menu item shown to admins.
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
// Login happens on the customer portal (a separate app), not on this site.
// For the account menu to know a visitor is signed in, that portal needs to
// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not
// HttpOnly) after login, so requests to this site carry it too. Until it
// does, getSession() in src/lib/auth.ts always resolves to null and the
// header simply shows the anonymous "ورود | ثبت‌نام" link — nothing breaks,
// the logged-in state just never activates.
export const AUTH_TOKEN_COOKIE = "meshkee_token";
// Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color).
export const BRAND = {
primary: "#E51E22",
primaryDark: "#B10123",
secondary: "#3B3B3B",
} as const;
+48
View File
@@ -0,0 +1,48 @@
// TEMPORARY. This tenant has 479 products but zero store-items, so the API
// reports every product as priceless and out of stock and the homepage
// carousels would render as a wall of "ناموجود".
//
// While NEXT_PUBLIC_DEMO_PRICING=1 the cards fall back to a deterministic
// price derived from the product id (same product -> same number on server
// and client, so hydration stays stable). Real store-items always win.
// Delete this file and the env flag once pricing is entered in the dashboard.
import type { ApiProduct } from "./types";
export const DEMO_PRICING = process.env.NEXT_PUBLIC_DEMO_PRICING === "1";
// Sequential product ids ("4216", "4217") differ by one character, so the
// hash needs an avalanche step or neighbouring products land on the same price.
function hash(id: string): number {
let h = 2166136261;
for (let i = 0; i < id.length; i++) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 16777619);
}
h ^= h >>> 15;
h = Math.imul(h, 2246822507);
h ^= h >>> 13;
return h >>> 0;
}
const BANDS: { match: RegExp; min: number; max: number }[] = [
{ match: /موبایل|گوشی|آیفون/, min: 12_000_000, max: 95_000_000 },
{ match: /لپ ?تاپ|مک ?بوک/, min: 35_000_000, max: 180_000_000 },
{ match: /تبلت/, min: 9_000_000, max: 60_000_000 },
{ match: /کنسول|پلی ?استیشن/, min: 25_000_000, max: 70_000_000 },
{ match: /ساعت|بند/, min: 1_500_000, max: 18_000_000 },
{ match: /هدفون|هندزفری|اسپیکر/, min: 800_000, max: 12_000_000 },
];
export function demoPrice(product: ApiProduct): { price: number; oldPrice: number | null } {
const name = product.nameFa || product.title;
const band = BANDS.find((b) => b.match.test(name)) ?? { min: 300_000, max: 4_000_000 };
const h = hash(product.id);
const span = band.max - band.min;
const raw = band.min + (h % span);
const price = Math.round(raw / 10_000) * 10_000;
// Roughly one product in three carries a discount, 5–25%.
const hasDiscount = h % 3 === 0;
const percent = 5 + (h % 21);
const oldPrice = hasDiscount ? Math.round((price / (1 - percent / 100)) / 10_000) * 10_000 : null;
return { price, oldPrice };
}
+23
View File
@@ -0,0 +1,23 @@
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function toFaDigits(input: string | number): string {
return String(input).replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
// Prices come from the API in Toman.
export function formatPrice(value: number | null | undefined): string {
if (value === null || value === undefined) return "";
return toFaDigits(value.toLocaleString("en-US"));
}
export function discountPercent(price: number, oldPrice: number): number {
if (!oldPrice || oldPrice <= price) return 0;
return Math.round(((oldPrice - price) / oldPrice) * 100);
}
// The static-image-slot API documents aspectRatio as "16:9", but CSS's
// aspect-ratio property needs a slash ("16/9"), not a colon.
export function toCssAspectRatio(value: string | number | null | undefined): string | undefined {
if (value === null || value === undefined || value === "") return undefined;
return String(value).replace(":", "/");
}
+24
View File
@@ -0,0 +1,24 @@
// Canonical Meshkee detail URLs are /{id}/{slug} — the slug segment is
// SEO-only, so it is built from the Farsi name rather than the DB slug.
export function faSlug(input: string | null | undefined): string {
if (!input) return "-";
return (
input
.trim()
.replace(/[\/\?%*:|"'<>.,()]/g, "")
.replace(/\s+/g, "-")
.slice(0, 80) || "-"
);
}
export function productHref(id: string, nameFa?: string | null, title?: string | null): string {
return `/products/${id}/${faSlug(nameFa || title)}`;
}
export function categoryHref(id: string, nameFa?: string | null, name?: string | null): string {
return `/products/category/${id}/${faSlug(nameFa || name)}`;
}
export function blogHref(id: string, title?: string | null): string {
return `/blog/${id}/${faSlug(title)}`;
}
+42
View File
@@ -0,0 +1,42 @@
import type { SpecRow, TechnicalInfoForm, TechnicalInfoValue } from "./types";
// technical-info values carry no label — only fieldId + a raw value. This
// joins them against form.fields (by id) to build the label→value rows the
// specs tab actually renders. See AGENTS.md "Technical specs" for why this
// join is required and what NOT to do (never read a label off values[]).
export function buildSpecRows(
form: TechnicalInfoForm | null | undefined,
values: TechnicalInfoValue[]
): SpecRow[] {
if (!form || values.length === 0) return [];
const fieldsById = new Map(form.fields.map((f) => [f.id, f]));
const rows: SpecRow[] = [];
for (const value of values) {
const field = fieldsById.get(value.fieldId);
if (!field) continue;
const label = field.labelFa || field.label || field.key || value.fieldId;
let display = "";
if (value.textValue) {
display = value.textValue;
} else if (value.optionId && field.options) {
const opt = field.options.find((o) => o.id === value.optionId);
display = opt ? opt.labelFa || opt.label || opt.id : "";
} else if (value.optionIds && field.options) {
display = value.optionIds
.map((id) => field.options?.find((o) => o.id === id))
.filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => o.labelFa || o.label || o.id)
.join("، ");
}
if (display) rows.push({ key: field.id, label, value: display });
}
return rows.sort(
(a, b) => (fieldsById.get(a.key)?.sortOrder ?? 0) - (fieldsById.get(b.key)?.sortOrder ?? 0)
);
}
+302
View File
@@ -0,0 +1,302 @@
export interface SocialMediaLinks {
whatsapp?: string;
telegram?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
aparat?: string;
}
export interface Tenant {
id: number;
name: string;
nameFa: string;
slug: string;
domain: string;
primaryColor: string | null;
defaultLocale: string;
themeMode: string;
enabledModules: string[];
specialProductsSource: "product" | "store_item";
logoUrl: string | null;
logoDarkUrl: string | null;
faviconUrl: string | null;
}
// This tenant has no emails/phones/addresses filled in yet, so the loose
// shapes are read defensively at render time.
export interface BusinessInfo {
id: string;
name: string;
nameFa: string;
about: string;
vision: string;
logoUrl: string | null;
logoDarkUrl: string | null;
faviconUrl: string | null;
emails: unknown[];
phoneNumbers: unknown[];
socialMedia: SocialMediaLinks;
addresses: unknown[];
}
export interface ApiCategory {
id: string;
businessId: string;
entityType: string;
parentId: string | null;
name: string;
nameFa: string;
slug: string;
description: string | null;
sortOrder: number;
isActive: boolean;
variationCount: number;
}
export interface CategoryNode extends ApiCategory {
children: CategoryNode[];
}
export interface ProductImage {
mediaId: string;
url: string;
}
export interface ProductStoreSummary {
variantCount: number;
minPrice: number | null;
maxPrice: number | null;
inStock: boolean;
}
export interface ApiProduct {
id: string;
businessId: string;
title: string;
nameFa: string;
summary: string;
descriptionHtml: string;
slug: string;
status: string;
categoryId: string | null;
categoryName: string | null;
categoryNameFa: string | null;
brandId: string | null;
brand: string | null;
tags: string[];
thumbnailUrl: string | null;
image: string | null;
thumbnail: string | null;
images: ProductImage[];
commentCount: number;
variantCount: number;
createdAt: string;
updatedAt: string;
store: ProductStoreSummary;
}
export interface PagedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// The blog module is enabled but empty on this tenant, so every field is
// optional and consumers must read defensively.
export interface ApiBlog {
id: string;
title?: string;
nameFa?: string;
summary?: string;
descriptionHtml?: string;
contentHtml?: string;
slug: string;
thumbnailUrl?: string | null;
image?: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
createdAt?: string;
publishedAt?: string;
}
export interface SliderItem {
id?: string;
title?: string | null;
subtitle?: string | null;
imageUrl?: string | null;
mobileImageUrl?: string | null;
linkUrl?: string | null;
sortOrder?: number;
}
export interface StaticImageItem {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
sortOrder?: number;
}
export interface StaticImageSlot {
key: string;
pageKey: string;
kind: "single" | "list";
label: string;
aspectRatio?: string | number | null;
recommendedWidth?: number | null;
itemCount?: number | null;
images: StaticImageItem[];
}
export interface StoreItemSelection {
variationId: string;
variationName: string;
optionId: string;
value: string;
}
export interface StoreSpecialVariant {
id: string;
label: string;
selections: StoreItemSelection[];
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
isFestival: boolean;
sortOrder: number;
}
export interface StoreSpecialItem {
id: string;
productId: string;
productTitle: string;
productNameFa: string;
productImage: string | null;
isActive: boolean;
sortOrder: number;
variants: StoreSpecialVariant[];
}
export interface StoreSpecial {
id: string;
key: string;
title: string;
sortOrder: number;
isActive: boolean;
items: StoreSpecialItem[];
}
// A card the carousels render: a product plus the pricing actually shown.
export interface ProductCardData {
id: string;
href: string;
title: string;
image: string | null;
price: number | null;
oldPrice: number | null;
percent: number;
inStock: boolean;
}
export interface ContactFormInput {
title: string;
name: string;
text: string;
email?: string;
cellNumber?: string;
}
export interface VariationOption {
id: string;
label: string;
value: string;
colorHex: string | null;
sortOrder: number;
}
export interface ProductVariation {
id: string;
name: string;
type: string;
sortOrder: number;
options: VariationOption[];
}
// A concrete, purchasable variant of a product (one combination of
// variation options), as listed under /store-items.
export interface StoreItem {
id: string;
storeItemId: string;
productId: string;
productTitle: string;
productNameFa: string;
productImage: string | null;
productTotalStock: number;
selections: StoreItemSelection[];
label: string;
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
rewardPoints: number | null;
isFestival: boolean;
sortOrder: number;
}
// Technical-info values carry no label (only fieldId + a value) — the label
// comes from the matching technical-info() form.fields entry. See
// AGENTS.md "Technical specs" for the required join.
export interface TechnicalInfoField {
id: string;
label?: string;
labelFa?: string;
key?: string;
type?: string;
sortOrder?: number;
options?: { id: string; label?: string; labelFa?: string }[];
}
export interface TechnicalInfoForm {
fields: TechnicalInfoField[];
}
export interface TechnicalInfoValue {
fieldId: string;
textValue?: string | null;
optionId?: string | null;
optionIds?: string[] | null;
}
// A joined, display-ready spec row — built by src/lib/technicalInfo.ts from
// the raw form + values, never rendered from values[] alone.
export interface SpecRow {
key: string;
label: string;
value: string;
}
// Comment/user shapes aren't documented beyond a few fields, so every
// optional field is read defensively at render time.
export interface EntityComment {
id: string;
authorName?: string;
text?: string;
createdAt?: string;
}
// GET /auth/me → { user }. Field names beyond id/name aren't in the OpenAPI
// schema, so this is intentionally loose and every field but id is read
// defensively (see src/lib/auth.ts).
export interface AuthUser {
id: string;
name?: string;
nameFa?: string;
firstName?: string;
lastName?: string;
cellNumber?: string;
isAdmin?: boolean;
role?: string;
}