Add Meshkee sitemap config, technical-info labels, and footer credit.
Wire specs to /technical-info for field labels, match Safeteb footer credit, and publish build-time /meshkee/sitemap-config.json for SEO sync. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
386787b4b5
commit
d249d958eb
+3
-1
@@ -3,10 +3,12 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.0",
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Scans app page routes and writes src/lib/sitemap-config.generated.json
|
||||
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const appDir = path.join(root, "src", "app");
|
||||
const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json");
|
||||
|
||||
const EXCLUDE_SEGMENTS = new Set([
|
||||
"api",
|
||||
"meshkee",
|
||||
"admin",
|
||||
"auth",
|
||||
"login",
|
||||
"checkout",
|
||||
"cart",
|
||||
"account",
|
||||
]);
|
||||
|
||||
const PRIORITY = {
|
||||
"/": { changefreq: "daily", priority: 1.0 },
|
||||
"/user-products": { changefreq: "daily", priority: 0.9 },
|
||||
"/categories": { changefreq: "weekly", priority: 0.8 },
|
||||
"/blog": { changefreq: "weekly", priority: 0.8 },
|
||||
"/about": { changefreq: "monthly", priority: 0.6 },
|
||||
"/contact": { changefreq: "monthly", priority: 0.5 },
|
||||
};
|
||||
|
||||
function websiteDomain() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_SITE_DOMAIN ||
|
||||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
|
||||
process.env.WEBSITE_DOMAIN ||
|
||||
"www.mashinify.com"
|
||||
);
|
||||
}
|
||||
|
||||
function walkPages(dir, segments = []) {
|
||||
/** @type {string[]} */
|
||||
const pages = [];
|
||||
if (!fs.existsSync(dir)) return pages;
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
|
||||
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
|
||||
if (/^\(.+\)$/.test(entry.name)) {
|
||||
pages.push(...walkPages(full, segments));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pages.push(...walkPages(full, [...segments, entry.name]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.name === "page.tsx" ||
|
||||
entry.name === "page.ts" ||
|
||||
entry.name === "page.jsx" ||
|
||||
entry.name === "page.js"
|
||||
) {
|
||||
const routePath = segments.length ? `/${segments.join("/")}` : "/";
|
||||
pages.push(routePath);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
|
||||
if (a === "/") return -1;
|
||||
if (b === "/") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const staticPages = paths.map((routePath) => {
|
||||
const meta = PRIORITY[routePath] ?? {
|
||||
changefreq: "monthly",
|
||||
priority: 0.5,
|
||||
};
|
||||
return { path: routePath, ...meta };
|
||||
});
|
||||
|
||||
const domain = websiteDomain().replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
|
||||
const config = {
|
||||
baseUrl: `https://${domain}`,
|
||||
staticPages,
|
||||
// Mashinify public paths differ from Meshkee catalog defaults.
|
||||
templates: {
|
||||
userProduct: "/user-products/{slug}",
|
||||
blog: "/blog/{slug}",
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
console.log(
|
||||
`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -15,9 +15,16 @@ export async function generateMetadata({
|
||||
const { slug } = await params;
|
||||
const blog = await getBlogBySlugOrNull(slug);
|
||||
if (!blog) return { title: "مقاله یافت نشد" };
|
||||
const description = blog.abstract ?? blog.title;
|
||||
const image = resolveBlogImage(blog);
|
||||
return {
|
||||
title: blog.title,
|
||||
description: blog.abstract ?? blog.title,
|
||||
description,
|
||||
openGraph: {
|
||||
title: blog.title,
|
||||
description,
|
||||
...(image ? { images: [{ url: image, alt: blog.title }] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,163 @@ img {
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Footer bottom — Meshkee credit (Safeteb / Havaran style) */
|
||||
.footer-bottom-bar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.footer-bottom-site {
|
||||
color: #9c9c9c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meshkee-credit {
|
||||
align-items: center;
|
||||
color: #9c9c9c;
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover {
|
||||
color: #e0e0e0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.meshkee-credit__frame {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.meshkee-credit__line {
|
||||
background: #777;
|
||||
position: absolute;
|
||||
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit__line--l {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
transition-delay: 0s;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--b {
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition-delay: 0.14s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--r {
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: bottom;
|
||||
transition-delay: 0.28s;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--t {
|
||||
height: 1px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: right;
|
||||
transition-delay: 0.42s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__line--l,
|
||||
.meshkee-credit:hover .meshkee-credit__line--r {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__line--t,
|
||||
.meshkee-credit:hover .meshkee-credit__line--b {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
|
||||
transition-delay: 0.42s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
|
||||
transition-delay: 0.28s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
|
||||
transition-delay: 0.14s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.meshkee-credit__logo {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: 42px;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.meshkee-credit__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
min-height: 42px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.meshkee-credit__label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meshkee-credit__name {
|
||||
color: #cfcfcf;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 575px) {
|
||||
.footer-bottom-bar {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-rise,
|
||||
.animate-rise-delay-1,
|
||||
@@ -287,4 +444,8 @@ img {
|
||||
.search-shell:focus-within {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.meshkee-credit__line {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import sitemapConfig from "@/lib/sitemap-config.generated.json";
|
||||
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Cache-Control": "public, max-age=60",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(sitemapConfig, { headers });
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import { ProductTabs } from "@/components/ProductTabs";
|
||||
import { Products } from "@/components/Products";
|
||||
import { SiteShell } from "@/components/SiteShell";
|
||||
import { site } from "@/data/home";
|
||||
import { getCategoryVariationFieldMap } from "@/lib/api/product-fields";
|
||||
import {
|
||||
formatCondition,
|
||||
formatPrice,
|
||||
getUserProductBySlugOrNull,
|
||||
getUserProductTechnicalInfo,
|
||||
listUserProducts,
|
||||
productCategory,
|
||||
productTitle,
|
||||
@@ -29,9 +29,17 @@ export async function generateMetadata({
|
||||
const product = await getUserProductBySlugOrNull(slug);
|
||||
if (!product) return { title: "محصول یافت نشد" };
|
||||
const title = productTitle(product);
|
||||
const description =
|
||||
product.descriptionFa || product.description || product.descriptionEn || title;
|
||||
const image = product.imageUrl || product.images?.[0]?.url || undefined;
|
||||
return {
|
||||
title,
|
||||
description: product.descriptionFa || product.description || title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
...(image ? { images: [{ url: image, alt: title }] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,16 +58,15 @@ export default async function UserProductDetailPage({ params }: PageProps) {
|
||||
const description =
|
||||
product.descriptionFa || product.description || product.descriptionEn;
|
||||
const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null;
|
||||
const technicalFieldLabels = await getCategoryVariationFieldMap(
|
||||
product.categoryId,
|
||||
);
|
||||
|
||||
const similarResult = product.categoryId
|
||||
? await listUserProducts({
|
||||
categoryId: product.categoryId,
|
||||
pageSize: 5,
|
||||
})
|
||||
: { items: [], total: 0 };
|
||||
const [technicalInfo, similarResult] = await Promise.all([
|
||||
getUserProductTechnicalInfo(slug),
|
||||
product.categoryId
|
||||
? listUserProducts({
|
||||
categoryId: product.categoryId,
|
||||
pageSize: 5,
|
||||
})
|
||||
: Promise.resolve({ items: [], total: 0 }),
|
||||
]);
|
||||
|
||||
const similarProducts = similarResult.items
|
||||
.filter((item) => item.id !== product.id)
|
||||
@@ -149,7 +156,7 @@ export default async function UserProductDetailPage({ params }: PageProps) {
|
||||
technicalNotes={product.technicalNotes}
|
||||
deliveryNotes={deliveryNotes}
|
||||
technicalValues={product.technicalValues}
|
||||
technicalFieldLabels={technicalFieldLabels}
|
||||
technicalInfo={technicalInfo}
|
||||
/>
|
||||
|
||||
{similarProducts.length > 0 ? (
|
||||
|
||||
@@ -56,11 +56,42 @@ export function Footer() {
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10">
|
||||
<div className="container-page flex flex-col items-start justify-between gap-3 py-5 text-xs text-white/50 sm:flex-row sm:items-center">
|
||||
<p>
|
||||
© {new Date().getFullYear()} {site.name} — {site.nameEn}
|
||||
</p>
|
||||
<p>پاسخگوی پرسشهای شما هستیم</p>
|
||||
<div className="container-page py-5">
|
||||
<div className="footer-bottom-bar">
|
||||
<div className="footer-bottom-site">
|
||||
© {new Date().getFullYear()} {site.name}
|
||||
</div>
|
||||
<a
|
||||
href="https://meshkee.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
dir="ltr"
|
||||
className="meshkee-credit"
|
||||
>
|
||||
<span className="meshkee-credit__frame" aria-hidden>
|
||||
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||
</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/images/footer/MeshkeeLogo-White.png"
|
||||
alt="Meshkee"
|
||||
className="meshkee-credit__logo"
|
||||
width={42}
|
||||
height={42}
|
||||
/>
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">
|
||||
Designed And Developed By
|
||||
</span>
|
||||
<span className="meshkee-credit__name">
|
||||
Meshkee E-Commerce Team
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function PageBanner({ kicker, title, description }: PageBannerProps) {
|
||||
<section className="relative overflow-hidden bg-brand-ink text-white">
|
||||
<Image
|
||||
src="/images/hero/search-bg.jpg"
|
||||
alt=""
|
||||
alt={`بنر ${title}`}
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-[center_40%]"
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { site } from "@/data/home";
|
||||
import { TechnicalValuesTable } from "@/components/TechnicalValuesTable";
|
||||
import type { UserProductTechnicalInfo } from "@/lib/api/user-products";
|
||||
import { rowsFromTechnicalInfo } from "@/lib/product-technical-values";
|
||||
|
||||
type ProductTabsProps = {
|
||||
description?: string | null;
|
||||
technicalNotes?: string | null;
|
||||
deliveryNotes?: string | null;
|
||||
technicalValues?: unknown;
|
||||
technicalFieldLabels?: Record<string, string>;
|
||||
technicalInfo?: UserProductTechnicalInfo | null;
|
||||
};
|
||||
|
||||
type TabId = "description" | "technical" | "comments";
|
||||
@@ -23,13 +25,28 @@ const tabMeta: Record<
|
||||
comments: { label: "نظرات", labelEn: "Comments" },
|
||||
};
|
||||
|
||||
function hasTechnicalValues(value: unknown) {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "object") {
|
||||
return Object.keys(value as object).length > 0;
|
||||
}
|
||||
if (typeof value === "string") return Boolean(value.trim());
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ProductTabs({
|
||||
description,
|
||||
technicalNotes,
|
||||
deliveryNotes,
|
||||
technicalValues,
|
||||
technicalFieldLabels = {},
|
||||
technicalInfo = null,
|
||||
}: ProductTabsProps) {
|
||||
const technicalRows = useMemo(
|
||||
() => rowsFromTechnicalInfo(technicalInfo),
|
||||
[technicalInfo],
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const items: { id: TabId; label: string; labelEn: string }[] = [];
|
||||
|
||||
@@ -40,12 +57,8 @@ export function ProductTabs({
|
||||
const hasTechnical =
|
||||
Boolean(technicalNotes?.trim()) ||
|
||||
Boolean(deliveryNotes?.trim()) ||
|
||||
(technicalValues !== null &&
|
||||
technicalValues !== undefined &&
|
||||
!(typeof technicalValues === "object" &&
|
||||
!Array.isArray(technicalValues) &&
|
||||
Object.keys(technicalValues as object).length === 0) &&
|
||||
!(Array.isArray(technicalValues) && technicalValues.length === 0));
|
||||
technicalRows.length > 0 ||
|
||||
hasTechnicalValues(technicalValues);
|
||||
|
||||
if (hasTechnical) {
|
||||
items.push({ id: "technical", ...tabMeta.technical });
|
||||
@@ -54,7 +67,13 @@ export function ProductTabs({
|
||||
items.push({ id: "comments", ...tabMeta.comments });
|
||||
|
||||
return items;
|
||||
}, [deliveryNotes, description, technicalNotes, technicalValues]);
|
||||
}, [
|
||||
deliveryNotes,
|
||||
description,
|
||||
technicalNotes,
|
||||
technicalRows.length,
|
||||
technicalValues,
|
||||
]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>(tabs[0]?.id ?? "comments");
|
||||
|
||||
@@ -120,12 +139,10 @@ export function ProductTabs({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{technicalValues ? (
|
||||
<TechnicalValuesTable
|
||||
value={technicalValues}
|
||||
fieldLabels={technicalFieldLabels}
|
||||
/>
|
||||
) : null}
|
||||
<TechnicalValuesTable
|
||||
technicalInfo={technicalInfo}
|
||||
value={technicalValues}
|
||||
/>
|
||||
|
||||
{deliveryNotes ? (
|
||||
<div>
|
||||
@@ -141,8 +158,8 @@ export function ProductTabs({
|
||||
) : null}
|
||||
|
||||
{activeTab === "comments" ? (
|
||||
<div role="tabpanel" className="text-center">
|
||||
<div className="mx-auto max-w-md py-3">
|
||||
<div role="tabpanel">
|
||||
<div className="mx-auto max-w-md py-3 text-center">
|
||||
<p className="font-industrial text-sm tracking-[0.16em] text-brand">
|
||||
COMMENTS
|
||||
</p>
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import {
|
||||
normalizeTechnicalValueRows,
|
||||
rowsFromTechnicalInfo,
|
||||
type TechnicalValueRow,
|
||||
} from "@/lib/product-technical-values";
|
||||
import type { UserProductTechnicalInfo } from "@/lib/api/user-products";
|
||||
|
||||
type TechnicalValuesTableProps = {
|
||||
value: unknown;
|
||||
value?: unknown;
|
||||
fieldLabels?: Record<string, string>;
|
||||
technicalInfo?: UserProductTechnicalInfo | null;
|
||||
};
|
||||
|
||||
export function TechnicalValuesTable({
|
||||
value,
|
||||
fieldLabels = {},
|
||||
technicalInfo,
|
||||
}: TechnicalValuesTableProps) {
|
||||
const rows = normalizeTechnicalValueRows(value, fieldLabels);
|
||||
const fromInfo = rowsFromTechnicalInfo(technicalInfo);
|
||||
const rows: TechnicalValueRow[] =
|
||||
fromInfo.length > 0
|
||||
? fromInfo
|
||||
: normalizeTechnicalValueRows(value, fieldLabels);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -34,7 +43,12 @@ export function TechnicalValuesTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<TechnicalValueTableRow key={row.id} row={row} />
|
||||
<tr key={row.id} className="border-b border-line last:border-b-0">
|
||||
<th className="px-4 py-3 text-right font-bold text-brand-ink">
|
||||
{row.label || "—"}
|
||||
</th>
|
||||
<td className="px-4 py-3 text-muted">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -42,14 +56,3 @@ export function TechnicalValuesTable({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechnicalValueTableRow({ row }: { row: TechnicalValueRow }) {
|
||||
return (
|
||||
<tr className="border-b border-line last:border-b-0">
|
||||
<th className="px-4 py-3 text-right font-bold text-brand-ink">
|
||||
{row.label || "—"}
|
||||
</th>
|
||||
<td className="px-4 py-3 text-muted">{row.value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { apiGet } from "./client";
|
||||
import { findCategoryById, listCategories } from "./categories";
|
||||
|
||||
export type CategoryVariationField = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
function variationFieldLabel(
|
||||
field: Pick<CategoryVariationField, "nameFa" | "name">,
|
||||
) {
|
||||
return field.nameFa || field.name;
|
||||
}
|
||||
|
||||
export async function listCategoryVariationFields(categoryId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ items: CategoryVariationField[] }>(
|
||||
"/product-category-variation-fields",
|
||||
{ categoryId },
|
||||
);
|
||||
return data.items ?? [];
|
||||
} catch (error) {
|
||||
console.error("listCategoryVariationFields failed", error);
|
||||
return [] as CategoryVariationField[];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategoryVariationFieldMap(categoryId?: string | null) {
|
||||
if (!categoryId) return {};
|
||||
|
||||
const categories = await listCategories("product");
|
||||
const category = findCategoryById(categories, categoryId);
|
||||
const categoryIds = [categoryId, category?.parentId].filter(Boolean) as string[];
|
||||
|
||||
for (const id of categoryIds) {
|
||||
const fields = await listCategoryVariationFields(id);
|
||||
if (fields.length > 0) {
|
||||
return Object.fromEntries(
|
||||
fields.map((field) => [field.id, variationFieldLabel(field)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -18,6 +18,42 @@ export type ListUserProductsParams = {
|
||||
promoted?: boolean;
|
||||
};
|
||||
|
||||
export type TechnicalFormFieldOption = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
value?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type TechnicalFormField = {
|
||||
id: string;
|
||||
label: string;
|
||||
key?: string | null;
|
||||
type?: string | null;
|
||||
isRequired?: boolean;
|
||||
sortOrder?: number;
|
||||
options?: TechnicalFormFieldOption[];
|
||||
};
|
||||
|
||||
export type TechnicalForm = {
|
||||
id?: string;
|
||||
categoryId?: string;
|
||||
fields?: TechnicalFormField[];
|
||||
} | null;
|
||||
|
||||
export type TechnicalInfoValue = {
|
||||
fieldId: string;
|
||||
textValue?: string | null;
|
||||
numberValue?: number | string | null;
|
||||
optionId?: string | null;
|
||||
optionIds?: string[] | null;
|
||||
};
|
||||
|
||||
export type UserProductTechnicalInfo = {
|
||||
form: TechnicalForm;
|
||||
values: TechnicalInfoValue[];
|
||||
};
|
||||
|
||||
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
@@ -61,6 +97,32 @@ export async function getUserProductBySlugOrNull(slug: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Labels + values for specs table. Join form.fields[].id ↔ values[].fieldId. */
|
||||
export async function getUserProductTechnicalInfo(slug: string) {
|
||||
try {
|
||||
return await apiGet<UserProductTechnicalInfo>(
|
||||
`/user-products/${encodeURIComponent(slug)}/technical-info`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
return { form: null, values: [] } satisfies UserProductTechnicalInfo;
|
||||
}
|
||||
console.error("getUserProductTechnicalInfo failed", error);
|
||||
return { form: null, values: [] } satisfies UserProductTechnicalInfo;
|
||||
}
|
||||
}
|
||||
|
||||
export function technicalFieldLabelMap(
|
||||
info: UserProductTechnicalInfo | null | undefined,
|
||||
): Record<string, string> {
|
||||
const fields = info?.form?.fields ?? [];
|
||||
return Object.fromEntries(
|
||||
fields
|
||||
.filter((field) => field.id && field.label)
|
||||
.map((field) => [String(field.id), field.label]),
|
||||
);
|
||||
}
|
||||
|
||||
export const conditionLabels: Record<string, string> = {
|
||||
new: "نو",
|
||||
stock: "کارکرده",
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type {
|
||||
TechnicalFormField,
|
||||
TechnicalInfoValue,
|
||||
UserProductTechnicalInfo,
|
||||
} from "@/lib/api/user-products";
|
||||
|
||||
export type TechnicalValueItem = {
|
||||
fieldId?: string | number | null;
|
||||
textValue?: string | null;
|
||||
numberValue?: number | string | null;
|
||||
optionId?: string | null;
|
||||
optionIds?: string[] | null;
|
||||
value?: string | number | null;
|
||||
fieldName?: string | null;
|
||||
fieldNameFa?: string | null;
|
||||
@@ -20,6 +28,7 @@ export type TechnicalValueRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
function pickString(...values: Array<string | number | null | undefined>) {
|
||||
@@ -48,6 +57,71 @@ function parseJsonObject(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOptionLabel(
|
||||
field: TechnicalFormField | undefined,
|
||||
optionId: string | null | undefined,
|
||||
) {
|
||||
if (!field || !optionId) return "";
|
||||
const option = field.options?.find((item) => String(item.id) === String(optionId));
|
||||
return pickString(option?.label, option?.value, optionId);
|
||||
}
|
||||
|
||||
function resolveValueText(
|
||||
value: TechnicalInfoValue | TechnicalValueItem,
|
||||
field?: TechnicalFormField,
|
||||
) {
|
||||
const optionIds =
|
||||
value.optionIds?.filter(Boolean).map(String) ??
|
||||
(value.optionId ? [String(value.optionId)] : []);
|
||||
|
||||
if (optionIds.length > 0) {
|
||||
const labels = optionIds
|
||||
.map((id) => resolveOptionLabel(field, id))
|
||||
.filter(Boolean);
|
||||
if (labels.length > 0) return labels.join("، ");
|
||||
}
|
||||
|
||||
return pickString(
|
||||
formatValue(value.textValue),
|
||||
formatValue(value.numberValue),
|
||||
formatValue("value" in value ? value.value : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Preferred path: join technical-info form fields with submitted values. */
|
||||
export function rowsFromTechnicalInfo(
|
||||
info: UserProductTechnicalInfo | null | undefined,
|
||||
): TechnicalValueRow[] {
|
||||
const fields = info?.form?.fields ?? [];
|
||||
const values = info?.values ?? [];
|
||||
if (fields.length === 0 || values.length === 0) return [];
|
||||
|
||||
const fieldById = new Map(fields.map((field) => [String(field.id), field]));
|
||||
const sortById = new Map(
|
||||
fields.map((field, index) => [
|
||||
String(field.id),
|
||||
field.sortOrder ?? index,
|
||||
]),
|
||||
);
|
||||
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const id = String(value.fieldId ?? `item-${index}`);
|
||||
const field = fieldById.get(id);
|
||||
const rowValue = resolveValueText(value, field);
|
||||
if (!rowValue) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
label: pickString(field?.label) || "",
|
||||
value: rowValue,
|
||||
sortOrder: sortById.get(id) ?? index,
|
||||
};
|
||||
})
|
||||
.filter((row): row is TechnicalValueRow => Boolean(row))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label, "fa"));
|
||||
}
|
||||
|
||||
export function normalizeTechnicalValueRows(
|
||||
value: unknown,
|
||||
fieldLabels: Record<string, string> = {},
|
||||
@@ -59,7 +133,7 @@ export function normalizeTechnicalValueRows(
|
||||
if (parsed !== null) return normalizeTechnicalValueRows(parsed, fieldLabels);
|
||||
|
||||
const text = value.trim();
|
||||
return text ? [{ id: "text", label: "", value: text }] : [];
|
||||
return text ? [{ id: "text", label: "", value: text, sortOrder: 0 }] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
@@ -75,6 +149,7 @@ export function normalizeTechnicalValueRows(
|
||||
id: `item-${index}`,
|
||||
label: "",
|
||||
value: item.trim(),
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,29 +171,26 @@ export function normalizeTechnicalValueRows(
|
||||
id ? fieldLabels[id] : undefined,
|
||||
) || "";
|
||||
|
||||
const rowValue = pickString(
|
||||
formatValue(field.textValue),
|
||||
formatValue(field.numberValue),
|
||||
formatValue(field.value),
|
||||
);
|
||||
const rowValue = resolveValueText(field);
|
||||
|
||||
if (!rowValue) return null;
|
||||
|
||||
return { id, label, value: rowValue };
|
||||
return { id, label, value: rowValue, sortOrder: index };
|
||||
})
|
||||
.filter((row): row is TechnicalValueRow => Boolean(row));
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, val]) => ({
|
||||
.map(([key, val], index) => ({
|
||||
id: key,
|
||||
label: fieldLabels[key] || key,
|
||||
value: formatValue(val),
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((row) => row.value);
|
||||
}
|
||||
|
||||
const text = formatValue(value);
|
||||
return text ? [{ id: "value", label: "", value: text }] : [];
|
||||
return text ? [{ id: "value", label: "", value: text, sortOrder: 0 }] : [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"baseUrl": "https://www.mashinify.com",
|
||||
"staticPages": [
|
||||
{
|
||||
"path": "/",
|
||||
"changefreq": "daily",
|
||||
"priority": 1
|
||||
},
|
||||
{
|
||||
"path": "/about",
|
||||
"changefreq": "monthly",
|
||||
"priority": 0.6
|
||||
},
|
||||
{
|
||||
"path": "/blog",
|
||||
"changefreq": "weekly",
|
||||
"priority": 0.8
|
||||
},
|
||||
{
|
||||
"path": "/categories",
|
||||
"changefreq": "weekly",
|
||||
"priority": 0.8
|
||||
},
|
||||
{
|
||||
"path": "/contact",
|
||||
"changefreq": "monthly",
|
||||
"priority": 0.5
|
||||
},
|
||||
{
|
||||
"path": "/user-products",
|
||||
"changefreq": "daily",
|
||||
"priority": 0.9
|
||||
}
|
||||
],
|
||||
"templates": {
|
||||
"userProduct": "/user-products/{slug}",
|
||||
"blog": "/blog/{slug}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user