diff --git a/package.json b/package.json index 56de16b..ebc7b68 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/images/footer/MeshkeeLogo-White.png b/public/images/footer/MeshkeeLogo-White.png new file mode 100755 index 0000000..a10ecd3 Binary files /dev/null and b/public/images/footer/MeshkeeLogo-White.png differ diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..4536f1a --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -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(); diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 1ee507d..72d8bf7 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -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 }] } : {}), + }, }; } diff --git a/src/app/globals.css b/src/app/globals.css index c985b09..096f5e2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } } diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..998558b --- /dev/null +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -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 }); +} diff --git a/src/app/user-products/[slug]/page.tsx b/src/app/user-products/[slug]/page.tsx index e8a7f60..f3aa7e0 100644 --- a/src/app/user-products/[slug]/page.tsx +++ b/src/app/user-products/[slug]/page.tsx @@ -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 ? ( diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 2596c8b..c2093fe 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -56,11 +56,42 @@ export function Footer() {
- © {new Date().getFullYear()} {site.name} — {site.nameEn} -
-پاسخگوی پرسشهای شما هستیم
+
+
+
+ Designed And Developed By
+
+
+ Meshkee E-Commerce Team
+
+
+
+ COMMENTS
diff --git a/src/components/TechnicalValuesTable.tsx b/src/components/TechnicalValuesTable.tsx index 4dcaaae..75344c5 100644 --- a/src/components/TechnicalValuesTable.tsx +++ b/src/components/TechnicalValuesTable.tsx @@ -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