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} -

-

پاسخگوی پرسش‌های شما هستیم

+
+
+
+ © {new Date().getFullYear()} {site.name} +
+ + + + + + + + {/* eslint-disable-next-line @next/next/no-img-element */} + Meshkee + + + Designed And Developed By + + + Meshkee E-Commerce Team + + + +
diff --git a/src/components/PageBanner.tsx b/src/components/PageBanner.tsx index 1d90d04..a2da8c2 100644 --- a/src/components/PageBanner.tsx +++ b/src/components/PageBanner.tsx @@ -11,7 +11,7 @@ export function PageBanner({ kicker, title, description }: PageBannerProps) {
; + 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(tabs[0]?.id ?? "comments"); @@ -120,12 +139,10 @@ export function ProductTabs({
) : null} - {technicalValues ? ( - - ) : null} + {deliveryNotes ? (
@@ -141,8 +158,8 @@ export function ProductTabs({ ) : null} {activeTab === "comments" ? ( -
-
+
+

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; + 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({ {rows.map((row) => ( - + + + {row.label || "—"} + + {row.value} + ))} @@ -42,14 +56,3 @@ export function TechnicalValuesTable({
); } - -function TechnicalValueTableRow({ row }: { row: TechnicalValueRow }) { - return ( - - - {row.label || "—"} - - {row.value} - - ); -} diff --git a/src/lib/api/product-fields.ts b/src/lib/api/product-fields.ts deleted file mode 100644 index b669630..0000000 --- a/src/lib/api/product-fields.ts +++ /dev/null @@ -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, -) { - 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 {}; -} diff --git a/src/lib/api/user-products.ts b/src/lib/api/user-products.ts index 435fd0a..6201150 100644 --- a/src/lib/api/user-products.ts +++ b/src/lib/api/user-products.ts @@ -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 = (page = 1, pageSize = 12): Paginated => ({ 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( + `/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 { + 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 = { new: "نو", stock: "کارکرده", diff --git a/src/lib/product-technical-values.ts b/src/lib/product-technical-values.ts index 7380f7e..2d901e7 100644 --- a/src/lib/product-technical-values.ts +++ b/src/lib/product-technical-values.ts @@ -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) { @@ -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 = {}, @@ -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) - .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 }] : []; } diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json new file mode 100644 index 0000000..d09a0e4 --- /dev/null +++ b/src/lib/sitemap-config.generated.json @@ -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}" + } +}