Files
imen_pishghaman/apps/web/shared/seo/schema.ts
T
S. EsmaeiliandClaude Opus 5.5 789d224ea0 feat: monorepo with NestJS API, admin panel and DB-driven website
Restructure the repo into a pnpm + Turborepo monorepo:

- apps/web: Nuxt 4 site, now server-rendered and reading all content
  from the API; pages cached with SWR and purged on content changes
- apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived
  access tokens and rotating httpOnly refresh cookies, roles (ADMIN,
  EDITOR), catalog, editable site sections, media upload to S3-compatible
  storage (converted to WebP), contact messages, audit log
- apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for
  products, categories, site sections, messages, media and users
- packages/shared: Zod schemas and types shared by API and admin

Also:
- seed script that loads the original 8 categories / 89 products and
  section content
- Docker images for api (with a one-off migrate service), web, admin,
  and an nginx gateway for both domains
- dev and production compose files, daily database backup service
- API e2e tests

Removes the committed static out/ build and the old root Dockerfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:31:05 +03:30

109 lines
3.4 KiB
TypeScript

import type { PublicCategory, SiteSettings } from "@imen/shared";
import { siteConfig } from "../config/site";
type Json = Record<string, unknown>;
/** Values every schema builder needs: editable settings + the absolute site URL */
export interface SeoContext {
settings: SiteSettings;
siteUrl: string;
}
const abs = (ctx: SeoContext, p: string) => new URL(p, ctx.siteUrl).toString();
/** Social profile links worth listing in `sameAs` (not mail/phone/own site) */
const PROFILE_TYPES = new Set(["telegram", "instagram", "linkedin", "whatsapp", "bale", "eitaa", "aparat", "youtube"]);
const clean = <T extends object>(o: T): Json =>
JSON.parse(
JSON.stringify(o, (_, v) =>
v === "" || (Array.isArray(v) && !v.length) ? undefined : v,
),
);
export const organizationSchema = (ctx: SeoContext) => {
const s = ctx.settings;
return clean({
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": `${ctx.siteUrl}/#organization`,
name: s.name,
alternateName: s.nameEn,
url: abs(ctx, "/"),
logo: abs(ctx, siteConfig.logo),
image: abs(ctx, s.ogImage.url),
description: s.description,
email: s.email,
telephone: s.phone,
foundingDate: s.foundingDate,
areaServed: siteConfig.areaServed,
address: {
"@type": "PostalAddress",
streetAddress: s.address.street,
addressLocality: s.address.locality,
addressCountry: s.address.country,
},
openingHoursSpecification: {
"@type": "OpeningHoursSpecification",
dayOfWeek: s.openingHours.days,
opens: s.openingHours.opens,
closes: s.openingHours.closes,
},
sameAs: s.socials.filter((x) => PROFILE_TYPES.has(x.type)).map((x) => x.url),
});
};
export const websiteSchema = (ctx: SeoContext) => ({
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${ctx.siteUrl}/#website`,
url: abs(ctx, "/"),
name: ctx.settings.name,
inLanguage: siteConfig.lang,
publisher: { "@id": `${ctx.siteUrl}/#organization` },
});
export const serviceSchema = (
ctx: SeoContext,
opts: { name: string; description: string; areaServed?: string },
) =>
clean({
"@context": "https://schema.org",
"@type": "Service",
name: opts.name,
description: opts.description,
areaServed: opts.areaServed ?? siteConfig.areaServed,
provider: { "@id": `${ctx.siteUrl}/#organization` },
});
export const categoryPageSchema = (ctx: SeoContext, category: PublicCategory) =>
clean({
"@context": "https://schema.org",
"@type": "CollectionPage",
"@id": `${ctx.siteUrl}/categories/${category.slug}`,
name: category.name,
description: category.seoDescription || category.fullDesc,
url: abs(ctx, `/categories/${category.slug}`),
isPartOf: { "@id": `${ctx.siteUrl}/#website` },
provider: { "@id": `${ctx.siteUrl}/#organization` },
mainEntity: {
"@type": "ItemList",
numberOfItems: category.products.length,
itemListElement: category.products.map((p, i) => ({
"@type": "ListItem",
position: i + 1,
item: {
"@type": "Product",
name: p.name,
description: p.desc,
sku: p.code,
...(p.image && { image: abs(ctx, p.image) }),
},
})),
},
});
/** Serialize JSON-LD safely for an inline <script> (escape "<"). */
export const toJsonLd = (data: Json) =>
JSON.stringify(data).replace(/</g, "\\u003c");