Route user products as /user-products/{id}/{slug}.
Load details via by-id, build links from pathSlug/title, redirect legacy slug URLs, and update the sitemap template. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b36c07e28f
commit
5a6ef9e0e0
@@ -96,7 +96,7 @@ function main() {
|
|||||||
staticPages,
|
staticPages,
|
||||||
// Mashinify public paths differ from Meshkee catalog defaults.
|
// Mashinify public paths differ from Meshkee catalog defaults.
|
||||||
templates: {
|
templates: {
|
||||||
userProduct: "/user-products/{slug}",
|
userProduct: "/user-products/{id}/{slug}",
|
||||||
blog: "/blog/{slug}",
|
blog: "/blog/{slug}",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ProductGallery } from "@/components/ProductGallery";
|
||||||
|
import { ProductMarketplaceCtas } from "@/components/ProductMarketplaceCtas";
|
||||||
|
import { ProductTabs } from "@/components/ProductTabs";
|
||||||
|
import { Products } from "@/components/Products";
|
||||||
|
import { SiteShell } from "@/components/SiteShell";
|
||||||
|
import { getPrimaryPhoneNumber } from "@/lib/api/business";
|
||||||
|
import {
|
||||||
|
formatCondition,
|
||||||
|
formatPrice,
|
||||||
|
getUserProductByIdOrNull,
|
||||||
|
getUserProductTechnicalInfoById,
|
||||||
|
listUserProducts,
|
||||||
|
productCategory,
|
||||||
|
productTitle,
|
||||||
|
userProductHref,
|
||||||
|
userProductPathSlug,
|
||||||
|
} from "@/lib/api/user-products";
|
||||||
|
import { resolveProductGallery } from "@/lib/user-product-gallery";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: PageProps): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const product = await getUserProductByIdOrNull(id);
|
||||||
|
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,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
...(image ? { images: [{ url: image, alt: title }] } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserProductDetailPage({ params }: PageProps) {
|
||||||
|
const { id, slug } = await params;
|
||||||
|
const product = await getUserProductByIdOrNull(id);
|
||||||
|
if (!product) notFound();
|
||||||
|
|
||||||
|
const canonicalSlug = userProductPathSlug(product);
|
||||||
|
if (slug !== canonicalSlug) {
|
||||||
|
redirect(userProductHref(product));
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = productTitle(product);
|
||||||
|
const category = productCategory(product);
|
||||||
|
const condition = formatCondition(product.condition);
|
||||||
|
const price = formatPrice(product.price, product.priceCurrency);
|
||||||
|
const gallery = resolveProductGallery(product);
|
||||||
|
const city = product.cityNameFa || product.cityName || null;
|
||||||
|
const country = product.countryNameFa || product.countryName || null;
|
||||||
|
const description =
|
||||||
|
product.descriptionFa || product.description || product.descriptionEn;
|
||||||
|
const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null;
|
||||||
|
const [technicalInfo, similarResult, phone] = await Promise.all([
|
||||||
|
getUserProductTechnicalInfoById(id),
|
||||||
|
product.categoryId
|
||||||
|
? listUserProducts({
|
||||||
|
categoryId: product.categoryId,
|
||||||
|
pageSize: 5,
|
||||||
|
})
|
||||||
|
: Promise.resolve({ items: [], total: 0 }),
|
||||||
|
getPrimaryPhoneNumber(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const similarProducts = similarResult.items
|
||||||
|
.filter((item) => item.id !== product.id)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell
|
||||||
|
banner={{
|
||||||
|
kicker: "Product Details",
|
||||||
|
title,
|
||||||
|
description: [category, condition].filter(Boolean).join(" · ") || undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<article className="section">
|
||||||
|
<div className="container-page">
|
||||||
|
<div className="mb-6 text-sm text-muted">
|
||||||
|
<Link href="/user-products" className="text-brand hover:underline">
|
||||||
|
محصولات
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
<ProductGallery images={gallery} alt={title} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{product.titleEn ? (
|
||||||
|
<p className="font-industrial text-sm tracking-[0.14em] text-brand">
|
||||||
|
{product.titleEn}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h2 className="mt-2 text-3xl font-extrabold leading-tight text-brand-ink md:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{category ? (
|
||||||
|
<p className="mt-3 text-sm font-bold text-muted">{category}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
{condition ? (
|
||||||
|
<div className="border border-brand/25 bg-brand/[0.06] px-4 py-3">
|
||||||
|
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-brand">
|
||||||
|
CONDITION
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-extrabold text-brand-ink">
|
||||||
|
{condition}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{city || country ? (
|
||||||
|
<div className="border border-line bg-[#f7fafc] px-4 py-3">
|
||||||
|
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-steel">
|
||||||
|
LOCATION
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-base font-extrabold text-brand-ink">
|
||||||
|
{[city, country].filter(Boolean).join("، ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{price ? (
|
||||||
|
<p
|
||||||
|
dir="ltr"
|
||||||
|
className="font-industrial mt-6 text-left text-2xl font-extrabold tracking-wide text-brand"
|
||||||
|
>
|
||||||
|
{price}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-6 text-base font-bold text-muted">
|
||||||
|
قیمت با استعلام
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 flex justify-end">
|
||||||
|
<a href={`tel:${phone}`} className="btn-primary">
|
||||||
|
تماس برای استعلام
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductTabs
|
||||||
|
description={description}
|
||||||
|
technicalNotes={product.technicalNotes}
|
||||||
|
deliveryNotes={deliveryNotes}
|
||||||
|
technicalValues={product.technicalValues}
|
||||||
|
technicalInfo={technicalInfo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{similarProducts.length > 0 ? (
|
||||||
|
<Products
|
||||||
|
embedded
|
||||||
|
items={similarProducts}
|
||||||
|
total={similarResult.total}
|
||||||
|
kicker="Similar Products"
|
||||||
|
title="محصولات مشابه"
|
||||||
|
description="سایر ماشینآلات موجود در همین دستهبندی که ممکن است برای شما مناسب باشند."
|
||||||
|
showMoreLink={false}
|
||||||
|
titleHref={
|
||||||
|
product.categoryId
|
||||||
|
? `/user-products?categoryId=${product.categoryId}`
|
||||||
|
: "/user-products"
|
||||||
|
}
|
||||||
|
moreLabel="مشاهده همه در این دسته"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ProductMarketplaceCtas phone={phone} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,186 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { ProductGallery } from "@/components/ProductGallery";
|
|
||||||
import { ProductMarketplaceCtas } from "@/components/ProductMarketplaceCtas";
|
|
||||||
import { ProductTabs } from "@/components/ProductTabs";
|
|
||||||
import { Products } from "@/components/Products";
|
|
||||||
import { SiteShell } from "@/components/SiteShell";
|
|
||||||
import { getPrimaryPhoneNumber } from "@/lib/api/business";
|
|
||||||
import {
|
import {
|
||||||
formatCondition,
|
|
||||||
formatPrice,
|
|
||||||
getUserProductBySlugOrNull,
|
getUserProductBySlugOrNull,
|
||||||
getUserProductTechnicalInfo,
|
userProductHref,
|
||||||
listUserProducts,
|
|
||||||
productCategory,
|
|
||||||
productTitle,
|
|
||||||
} from "@/lib/api/user-products";
|
} from "@/lib/api/user-products";
|
||||||
import { resolveProductGallery } from "@/lib/user-product-gallery";
|
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({
|
/** Legacy `/user-products/{db-slug}` → canonical `/user-products/{id}/{pathSlug}`. */
|
||||||
params,
|
export default async function LegacyUserProductRedirect({ params }: PageProps) {
|
||||||
}: PageProps): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
|
||||||
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,
|
|
||||||
openGraph: {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
...(image ? { images: [{ url: image, alt: title }] } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function UserProductDetailPage({ params }: PageProps) {
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const product = await getUserProductBySlugOrNull(slug);
|
const product = await getUserProductBySlugOrNull(slug);
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
redirect(userProductHref(product));
|
||||||
const title = productTitle(product);
|
|
||||||
const category = productCategory(product);
|
|
||||||
const condition = formatCondition(product.condition);
|
|
||||||
const price = formatPrice(product.price, product.priceCurrency);
|
|
||||||
const gallery = resolveProductGallery(product);
|
|
||||||
const city = product.cityNameFa || product.cityName || null;
|
|
||||||
const country = product.countryNameFa || product.countryName || null;
|
|
||||||
const description =
|
|
||||||
product.descriptionFa || product.description || product.descriptionEn;
|
|
||||||
const deliveryNotes = product.deliveryNotes ?? product.deliveryNote ?? null;
|
|
||||||
const [technicalInfo, similarResult, phone] = await Promise.all([
|
|
||||||
getUserProductTechnicalInfo(slug),
|
|
||||||
product.categoryId
|
|
||||||
? listUserProducts({
|
|
||||||
categoryId: product.categoryId,
|
|
||||||
pageSize: 5,
|
|
||||||
})
|
|
||||||
: Promise.resolve({ items: [], total: 0 }),
|
|
||||||
getPrimaryPhoneNumber(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const similarProducts = similarResult.items
|
|
||||||
.filter((item) => item.id !== product.id)
|
|
||||||
.slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SiteShell
|
|
||||||
banner={{
|
|
||||||
kicker: "Product Details",
|
|
||||||
title,
|
|
||||||
description: [category, condition].filter(Boolean).join(" · ") || undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<article className="section">
|
|
||||||
<div className="container-page">
|
|
||||||
<div className="mb-6 text-sm text-muted">
|
|
||||||
<Link href="/user-products" className="text-brand hover:underline">
|
|
||||||
محصولات
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
|
||||||
<span>{title}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<ProductGallery images={gallery} alt={title} />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{product.titleEn ? (
|
|
||||||
<p className="font-industrial text-sm tracking-[0.14em] text-brand">
|
|
||||||
{product.titleEn}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<h2 className="mt-2 text-3xl font-extrabold leading-tight text-brand-ink md:text-4xl">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{category ? (
|
|
||||||
<p className="mt-3 text-sm font-bold text-muted">{category}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
|
||||||
{condition ? (
|
|
||||||
<div className="border border-brand/25 bg-brand/[0.06] px-4 py-3">
|
|
||||||
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-brand">
|
|
||||||
CONDITION
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-base font-extrabold text-brand-ink">
|
|
||||||
{condition}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{city || country ? (
|
|
||||||
<div className="border border-line bg-[#f7fafc] px-4 py-3">
|
|
||||||
<p className="font-industrial text-[0.65rem] tracking-[0.16em] text-steel">
|
|
||||||
LOCATION
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-base font-extrabold text-brand-ink">
|
|
||||||
{[city, country].filter(Boolean).join("، ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{price ? (
|
|
||||||
<p
|
|
||||||
dir="ltr"
|
|
||||||
className="font-industrial mt-6 text-left text-2xl font-extrabold tracking-wide text-brand"
|
|
||||||
>
|
|
||||||
{price}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-6 text-base font-bold text-muted">
|
|
||||||
قیمت با استعلام
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-8 flex justify-end">
|
|
||||||
<a href={`tel:${phone}`} className="btn-primary">
|
|
||||||
تماس برای استعلام
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProductTabs
|
|
||||||
description={description}
|
|
||||||
technicalNotes={product.technicalNotes}
|
|
||||||
deliveryNotes={deliveryNotes}
|
|
||||||
technicalValues={product.technicalValues}
|
|
||||||
technicalInfo={technicalInfo}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{similarProducts.length > 0 ? (
|
|
||||||
<Products
|
|
||||||
embedded
|
|
||||||
items={similarProducts}
|
|
||||||
total={similarResult.total}
|
|
||||||
kicker="Similar Products"
|
|
||||||
title="محصولات مشابه"
|
|
||||||
description="سایر ماشینآلات موجود در همین دستهبندی که ممکن است برای شما مناسب باشند."
|
|
||||||
showMoreLink={false}
|
|
||||||
titleHref={
|
|
||||||
product.categoryId
|
|
||||||
? `/user-products?categoryId=${product.categoryId}`
|
|
||||||
: "/user-products"
|
|
||||||
}
|
|
||||||
moreLabel="مشاهده همه در این دسته"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ProductMarketplaceCtas phone={phone} />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</SiteShell>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
formatPrice,
|
formatPrice,
|
||||||
productCategory,
|
productCategory,
|
||||||
productTitle,
|
productTitle,
|
||||||
|
userProductHref,
|
||||||
} from "@/lib/api/user-products";
|
} from "@/lib/api/user-products";
|
||||||
import type { UserProductListItem } from "@/lib/api/types";
|
import type { UserProductListItem } from "@/lib/api/types";
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ export function Products({
|
|||||||
const category = productCategory(product);
|
const category = productCategory(product);
|
||||||
const condition = formatCondition(product.condition);
|
const condition = formatCondition(product.condition);
|
||||||
const price = formatPrice(product.price, product.priceCurrency);
|
const price = formatPrice(product.price, product.priceCurrency);
|
||||||
const href = `/user-products/${product.slug}`;
|
const href = userProductHref(product);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export type UserProductCondition =
|
|||||||
export type UserProductListItem = {
|
export type UserProductListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
/** SEO path segment for `/user-products/{id}/{pathSlug}`. */
|
||||||
|
pathSlug?: string | null;
|
||||||
titleFa: string | null;
|
titleFa: string | null;
|
||||||
titleEn: string | null;
|
titleEn: string | null;
|
||||||
price: number | string | null;
|
price: number | string | null;
|
||||||
|
|||||||
@@ -61,6 +61,40 @@ const emptyPage = <T,>(page = 1, pageSize = 12): Paginated<T> => ({
|
|||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Match Meshkee `slugifyForUrl` — keep Persian/Latin letters + digits. */
|
||||||
|
export function slugifyForUrl(value: string, fallback = "user-product") {
|
||||||
|
const slug = value
|
||||||
|
.trim()
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(
|
||||||
|
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
|
||||||
|
return slug || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userProductPathSlug(
|
||||||
|
product: Pick<UserProductListItem, "pathSlug" | "titleFa" | "titleEn">,
|
||||||
|
) {
|
||||||
|
const fromApi = product.pathSlug?.trim();
|
||||||
|
if (fromApi) return fromApi;
|
||||||
|
return slugifyForUrl(
|
||||||
|
product.titleFa?.trim() || product.titleEn?.trim() || "",
|
||||||
|
"user-product",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical storefront path: `/user-products/{id}/{pathSlug}`. */
|
||||||
|
export function userProductHref(
|
||||||
|
product: Pick<UserProductListItem, "id" | "pathSlug" | "titleFa" | "titleEn">,
|
||||||
|
) {
|
||||||
|
return `/user-products/${encodeURIComponent(product.id)}/${encodeURIComponent(userProductPathSlug(product))}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listUserProducts(params: ListUserProductsParams = {}) {
|
export async function listUserProducts(params: ListUserProductsParams = {}) {
|
||||||
const page = params.page ?? 1;
|
const page = params.page ?? 1;
|
||||||
const pageSize = params.pageSize ?? 12;
|
const pageSize = params.pageSize ?? 12;
|
||||||
@@ -81,6 +115,24 @@ export async function listUserProducts(params: ListUserProductsParams = {}) {
|
|||||||
return emptyPage<UserProductListItem>(page, pageSize);
|
return emptyPage<UserProductListItem>(page, pageSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserProductById(id: string) {
|
||||||
|
const data = await apiGet<{ product: UserProduct }>(
|
||||||
|
`/user-products/by-id/${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
|
return data.product;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserProductByIdOrNull(id: string) {
|
||||||
|
try {
|
||||||
|
return await getUserProductById(id);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer `getUserProductById` — kept for legacy slug redirects. */
|
||||||
export async function getUserProductBySlug(slug: string) {
|
export async function getUserProductBySlug(slug: string) {
|
||||||
const data = await apiGet<{ product: UserProduct }>(
|
const data = await apiGet<{ product: UserProduct }>(
|
||||||
`/user-products/${encodeURIComponent(slug)}`,
|
`/user-products/${encodeURIComponent(slug)}`,
|
||||||
@@ -97,7 +149,21 @@ export async function getUserProductBySlugOrNull(slug: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Labels + values for specs table. Join form.fields[].id ↔ values[].fieldId. */
|
export async function getUserProductTechnicalInfoById(id: string) {
|
||||||
|
try {
|
||||||
|
return await apiGet<UserProductTechnicalInfo>(
|
||||||
|
`/user-products/by-id/${encodeURIComponent(id)}/technical-info`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) {
|
||||||
|
return { form: null, values: [] } satisfies UserProductTechnicalInfo;
|
||||||
|
}
|
||||||
|
console.error("getUserProductTechnicalInfoById failed", error);
|
||||||
|
return { form: null, values: [] } satisfies UserProductTechnicalInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer `getUserProductTechnicalInfoById`. */
|
||||||
export async function getUserProductTechnicalInfo(slug: string) {
|
export async function getUserProductTechnicalInfo(slug: string) {
|
||||||
try {
|
try {
|
||||||
return await apiGet<UserProductTechnicalInfo>(
|
return await apiGet<UserProductTechnicalInfo>(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"templates": {
|
"templates": {
|
||||||
"userProduct": "/user-products/{slug}",
|
"userProduct": "/user-products/{id}/{slug}",
|
||||||
"blog": "/blog/{slug}"
|
"blog": "/blog/{slug}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user