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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.0",
|
"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 { slug } = await params;
|
||||||
const blog = await getBlogBySlugOrNull(slug);
|
const blog = await getBlogBySlugOrNull(slug);
|
||||||
if (!blog) return { title: "مقاله یافت نشد" };
|
if (!blog) return { title: "مقاله یافت نشد" };
|
||||||
|
const description = blog.abstract ?? blog.title;
|
||||||
|
const image = resolveBlogImage(blog);
|
||||||
return {
|
return {
|
||||||
title: blog.title,
|
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);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-rise,
|
.animate-rise,
|
||||||
.animate-rise-delay-1,
|
.animate-rise-delay-1,
|
||||||
@@ -287,4 +444,8 @@ img {
|
|||||||
.search-shell:focus-within {
|
.search-shell:focus-within {
|
||||||
animation: none;
|
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 { Products } from "@/components/Products";
|
||||||
import { SiteShell } from "@/components/SiteShell";
|
import { SiteShell } from "@/components/SiteShell";
|
||||||
import { site } from "@/data/home";
|
import { site } from "@/data/home";
|
||||||
import { getCategoryVariationFieldMap } from "@/lib/api/product-fields";
|
|
||||||
import {
|
import {
|
||||||
formatCondition,
|
formatCondition,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
getUserProductBySlugOrNull,
|
getUserProductBySlugOrNull,
|
||||||
|
getUserProductTechnicalInfo,
|
||||||
listUserProducts,
|
listUserProducts,
|
||||||
productCategory,
|
productCategory,
|
||||||
productTitle,
|
productTitle,
|
||||||
@@ -29,9 +29,17 @@ export async function generateMetadata({
|
|||||||
const product = await getUserProductBySlugOrNull(slug);
|
const product = await getUserProductBySlugOrNull(slug);
|
||||||
if (!product) return { title: "محصول یافت نشد" };
|
if (!product) return { title: "محصول یافت نشد" };
|
||||||
const title = productTitle(product);
|
const title = productTitle(product);
|
||||||
|
const description =
|
||||||
|
product.descriptionFa || product.description || product.descriptionEn || title;
|
||||||
|
const image = product.imageUrl || product.images?.[0]?.url || undefined;
|
||||||
return {
|
return {
|
||||||
title,
|
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 =
|
const description =
|
||||||
product.descriptionFa || product.description || product.descriptionEn;
|
product.descriptionFa || product.description || product.descriptionEn;
|
||||||
const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null;
|
const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null;
|
||||||
const technicalFieldLabels = await getCategoryVariationFieldMap(
|
const [technicalInfo, similarResult] = await Promise.all([
|
||||||
product.categoryId,
|
getUserProductTechnicalInfo(slug),
|
||||||
);
|
product.categoryId
|
||||||
|
? listUserProducts({
|
||||||
const similarResult = product.categoryId
|
|
||||||
? await listUserProducts({
|
|
||||||
categoryId: product.categoryId,
|
categoryId: product.categoryId,
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
})
|
})
|
||||||
: { items: [], total: 0 };
|
: Promise.resolve({ items: [], total: 0 }),
|
||||||
|
]);
|
||||||
|
|
||||||
const similarProducts = similarResult.items
|
const similarProducts = similarResult.items
|
||||||
.filter((item) => item.id !== product.id)
|
.filter((item) => item.id !== product.id)
|
||||||
@@ -149,7 +156,7 @@ export default async function UserProductDetailPage({ params }: PageProps) {
|
|||||||
technicalNotes={product.technicalNotes}
|
technicalNotes={product.technicalNotes}
|
||||||
deliveryNotes={deliveryNotes}
|
deliveryNotes={deliveryNotes}
|
||||||
technicalValues={product.technicalValues}
|
technicalValues={product.technicalValues}
|
||||||
technicalFieldLabels={technicalFieldLabels}
|
technicalInfo={technicalInfo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{similarProducts.length > 0 ? (
|
{similarProducts.length > 0 ? (
|
||||||
|
|||||||
@@ -56,11 +56,42 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-white/10">
|
<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">
|
<div className="container-page py-5">
|
||||||
<p>
|
<div className="footer-bottom-bar">
|
||||||
© {new Date().getFullYear()} {site.name} — {site.nameEn}
|
<div className="footer-bottom-site">
|
||||||
</p>
|
© {new Date().getFullYear()} {site.name}
|
||||||
<p>پاسخگوی پرسشهای شما هستیم</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function PageBanner({ kicker, title, description }: PageBannerProps) {
|
|||||||
<section className="relative overflow-hidden bg-brand-ink text-white">
|
<section className="relative overflow-hidden bg-brand-ink text-white">
|
||||||
<Image
|
<Image
|
||||||
src="/images/hero/search-bg.jpg"
|
src="/images/hero/search-bg.jpg"
|
||||||
alt=""
|
alt={`بنر ${title}`}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
className="object-cover object-[center_40%]"
|
className="object-cover object-[center_40%]"
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { site } from "@/data/home";
|
import { site } from "@/data/home";
|
||||||
import { TechnicalValuesTable } from "@/components/TechnicalValuesTable";
|
import { TechnicalValuesTable } from "@/components/TechnicalValuesTable";
|
||||||
|
import type { UserProductTechnicalInfo } from "@/lib/api/user-products";
|
||||||
|
import { rowsFromTechnicalInfo } from "@/lib/product-technical-values";
|
||||||
|
|
||||||
type ProductTabsProps = {
|
type ProductTabsProps = {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
technicalNotes?: string | null;
|
technicalNotes?: string | null;
|
||||||
deliveryNotes?: string | null;
|
deliveryNotes?: string | null;
|
||||||
technicalValues?: unknown;
|
technicalValues?: unknown;
|
||||||
technicalFieldLabels?: Record<string, string>;
|
technicalInfo?: UserProductTechnicalInfo | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabId = "description" | "technical" | "comments";
|
type TabId = "description" | "technical" | "comments";
|
||||||
@@ -23,13 +25,28 @@ const tabMeta: Record<
|
|||||||
comments: { label: "نظرات", labelEn: "Comments" },
|
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({
|
export function ProductTabs({
|
||||||
description,
|
description,
|
||||||
technicalNotes,
|
technicalNotes,
|
||||||
deliveryNotes,
|
deliveryNotes,
|
||||||
technicalValues,
|
technicalValues,
|
||||||
technicalFieldLabels = {},
|
technicalInfo = null,
|
||||||
}: ProductTabsProps) {
|
}: ProductTabsProps) {
|
||||||
|
const technicalRows = useMemo(
|
||||||
|
() => rowsFromTechnicalInfo(technicalInfo),
|
||||||
|
[technicalInfo],
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
const items: { id: TabId; label: string; labelEn: string }[] = [];
|
const items: { id: TabId; label: string; labelEn: string }[] = [];
|
||||||
|
|
||||||
@@ -40,12 +57,8 @@ export function ProductTabs({
|
|||||||
const hasTechnical =
|
const hasTechnical =
|
||||||
Boolean(technicalNotes?.trim()) ||
|
Boolean(technicalNotes?.trim()) ||
|
||||||
Boolean(deliveryNotes?.trim()) ||
|
Boolean(deliveryNotes?.trim()) ||
|
||||||
(technicalValues !== null &&
|
technicalRows.length > 0 ||
|
||||||
technicalValues !== undefined &&
|
hasTechnicalValues(technicalValues);
|
||||||
!(typeof technicalValues === "object" &&
|
|
||||||
!Array.isArray(technicalValues) &&
|
|
||||||
Object.keys(technicalValues as object).length === 0) &&
|
|
||||||
!(Array.isArray(technicalValues) && technicalValues.length === 0));
|
|
||||||
|
|
||||||
if (hasTechnical) {
|
if (hasTechnical) {
|
||||||
items.push({ id: "technical", ...tabMeta.technical });
|
items.push({ id: "technical", ...tabMeta.technical });
|
||||||
@@ -54,7 +67,13 @@ export function ProductTabs({
|
|||||||
items.push({ id: "comments", ...tabMeta.comments });
|
items.push({ id: "comments", ...tabMeta.comments });
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [deliveryNotes, description, technicalNotes, technicalValues]);
|
}, [
|
||||||
|
deliveryNotes,
|
||||||
|
description,
|
||||||
|
technicalNotes,
|
||||||
|
technicalRows.length,
|
||||||
|
technicalValues,
|
||||||
|
]);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>(tabs[0]?.id ?? "comments");
|
const [activeTab, setActiveTab] = useState<TabId>(tabs[0]?.id ?? "comments");
|
||||||
|
|
||||||
@@ -120,12 +139,10 @@ export function ProductTabs({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{technicalValues ? (
|
|
||||||
<TechnicalValuesTable
|
<TechnicalValuesTable
|
||||||
|
technicalInfo={technicalInfo}
|
||||||
value={technicalValues}
|
value={technicalValues}
|
||||||
fieldLabels={technicalFieldLabels}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{deliveryNotes ? (
|
{deliveryNotes ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -141,8 +158,8 @@ export function ProductTabs({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === "comments" ? (
|
{activeTab === "comments" ? (
|
||||||
<div role="tabpanel" className="text-center">
|
<div role="tabpanel">
|
||||||
<div className="mx-auto max-w-md py-3">
|
<div className="mx-auto max-w-md py-3 text-center">
|
||||||
<p className="font-industrial text-sm tracking-[0.16em] text-brand">
|
<p className="font-industrial text-sm tracking-[0.16em] text-brand">
|
||||||
COMMENTS
|
COMMENTS
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
normalizeTechnicalValueRows,
|
normalizeTechnicalValueRows,
|
||||||
|
rowsFromTechnicalInfo,
|
||||||
type TechnicalValueRow,
|
type TechnicalValueRow,
|
||||||
} from "@/lib/product-technical-values";
|
} from "@/lib/product-technical-values";
|
||||||
|
import type { UserProductTechnicalInfo } from "@/lib/api/user-products";
|
||||||
|
|
||||||
type TechnicalValuesTableProps = {
|
type TechnicalValuesTableProps = {
|
||||||
value: unknown;
|
value?: unknown;
|
||||||
fieldLabels?: Record<string, string>;
|
fieldLabels?: Record<string, string>;
|
||||||
|
technicalInfo?: UserProductTechnicalInfo | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TechnicalValuesTable({
|
export function TechnicalValuesTable({
|
||||||
value,
|
value,
|
||||||
fieldLabels = {},
|
fieldLabels = {},
|
||||||
|
technicalInfo,
|
||||||
}: TechnicalValuesTableProps) {
|
}: 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;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +43,12 @@ export function TechnicalValuesTable({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -42,14 +56,3 @@ export function TechnicalValuesTable({
|
|||||||
</div>
|
</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;
|
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> => ({
|
const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
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> = {
|
export const conditionLabels: Record<string, string> = {
|
||||||
new: "نو",
|
new: "نو",
|
||||||
stock: "کارکرده",
|
stock: "کارکرده",
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import type {
|
||||||
|
TechnicalFormField,
|
||||||
|
TechnicalInfoValue,
|
||||||
|
UserProductTechnicalInfo,
|
||||||
|
} from "@/lib/api/user-products";
|
||||||
|
|
||||||
export type TechnicalValueItem = {
|
export type TechnicalValueItem = {
|
||||||
fieldId?: string | number | null;
|
fieldId?: string | number | null;
|
||||||
textValue?: string | null;
|
textValue?: string | null;
|
||||||
numberValue?: number | string | null;
|
numberValue?: number | string | null;
|
||||||
|
optionId?: string | null;
|
||||||
|
optionIds?: string[] | null;
|
||||||
value?: string | number | null;
|
value?: string | number | null;
|
||||||
fieldName?: string | null;
|
fieldName?: string | null;
|
||||||
fieldNameFa?: string | null;
|
fieldNameFa?: string | null;
|
||||||
@@ -20,6 +28,7 @@ export type TechnicalValueRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
sortOrder: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function pickString(...values: Array<string | number | null | undefined>) {
|
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(
|
export function normalizeTechnicalValueRows(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
fieldLabels: Record<string, string> = {},
|
fieldLabels: Record<string, string> = {},
|
||||||
@@ -59,7 +133,7 @@ export function normalizeTechnicalValueRows(
|
|||||||
if (parsed !== null) return normalizeTechnicalValueRows(parsed, fieldLabels);
|
if (parsed !== null) return normalizeTechnicalValueRows(parsed, fieldLabels);
|
||||||
|
|
||||||
const text = value.trim();
|
const text = value.trim();
|
||||||
return text ? [{ id: "text", label: "", value: text }] : [];
|
return text ? [{ id: "text", label: "", value: text, sortOrder: 0 }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -75,6 +149,7 @@ export function normalizeTechnicalValueRows(
|
|||||||
id: `item-${index}`,
|
id: `item-${index}`,
|
||||||
label: "",
|
label: "",
|
||||||
value: item.trim(),
|
value: item.trim(),
|
||||||
|
sortOrder: index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,29 +171,26 @@ export function normalizeTechnicalValueRows(
|
|||||||
id ? fieldLabels[id] : undefined,
|
id ? fieldLabels[id] : undefined,
|
||||||
) || "";
|
) || "";
|
||||||
|
|
||||||
const rowValue = pickString(
|
const rowValue = resolveValueText(field);
|
||||||
formatValue(field.textValue),
|
|
||||||
formatValue(field.numberValue),
|
|
||||||
formatValue(field.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rowValue) return null;
|
if (!rowValue) return null;
|
||||||
|
|
||||||
return { id, label, value: rowValue };
|
return { id, label, value: rowValue, sortOrder: index };
|
||||||
})
|
})
|
||||||
.filter((row): row is TechnicalValueRow => Boolean(row));
|
.filter((row): row is TechnicalValueRow => Boolean(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
return Object.entries(value as Record<string, unknown>)
|
return Object.entries(value as Record<string, unknown>)
|
||||||
.map(([key, val]) => ({
|
.map(([key, val], index) => ({
|
||||||
id: key,
|
id: key,
|
||||||
label: fieldLabels[key] || key,
|
label: fieldLabels[key] || key,
|
||||||
value: formatValue(val),
|
value: formatValue(val),
|
||||||
|
sortOrder: index,
|
||||||
}))
|
}))
|
||||||
.filter((row) => row.value);
|
.filter((row) => row.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = formatValue(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