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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
31076174e2
commit
789d224ea0
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Fixed, deploy-level site settings. Editable content (name, contact info, SEO texts, socials)
|
||||
* lives in the database and is managed from the admin panel ("settings" section).
|
||||
*/
|
||||
export const siteConfig = {
|
||||
/** default production URL; override at runtime with NUXT_PUBLIC_SITE_URL */
|
||||
url: "https://imenpishgaman.ir",
|
||||
locale: "fa_IR",
|
||||
lang: "fa",
|
||||
dir: "rtl" as const,
|
||||
logo: "/images/logo.png",
|
||||
favicon: "/favicon.svg",
|
||||
themeColor: "#0b1224",
|
||||
areaServed: "IR",
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
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");
|
||||
Reference in New Issue
Block a user