Include product category URLs in sitemaps and document by-slug lookup.
Adds /products/category/{slug} to default sitemap generation and a public categories/by-slug endpoint for storefront category pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
df17925d3a
commit
c4367d8eb0
@@ -262,8 +262,9 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
||||||
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
||||||
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
|
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
|
||||||
| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static pages + `/products|blog|portfolios/{id}/{faSlug}`) |
|
| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static + categories `/products/category/{slug}` + `/products|blog|portfolios/{id}/{faSlug}`) |
|
||||||
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
|
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
|
||||||
|
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by slug (`?entityType=product` default) |
|
||||||
| GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) |
|
| GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) |
|
||||||
| GET | `/tenants/:host/blogs/by-id/:blogId` | Public blog by id |
|
| GET | `/tenants/:host/blogs/by-id/:blogId` | Public blog by id |
|
||||||
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
||||||
|
|||||||
@@ -116,10 +116,11 @@ Minimal example:
|
|||||||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||||
- **Canonical detail URLs (Meshkee default for all sites):**
|
- **Canonical detail URLs (Meshkee default for all sites):**
|
||||||
- Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts).
|
- Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts).
|
||||||
|
- Product category: `/products/category/{categorySlug}` — use category `slug` from `GET /tenants/{domain}/categories?entityType=product`; resolve via `GET /tenants/{domain}/categories/by-slug/{slug}?entityType=product`, then list products with `categoryId`.
|
||||||
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
|
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
|
||||||
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}`
|
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}`
|
||||||
- When linking from lists/cards, use the same `{id}/{slug}` shape (slugify Farsi title the same way: spaces → `-`, keep Persian letters).
|
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{slug}`.
|
||||||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}`).
|
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{slug}`).
|
||||||
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself).
|
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself).
|
||||||
- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires).
|
- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires).
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "XML sitemap for search engines",
|
"summary": "XML sitemap for search engines",
|
||||||
"description": "Published products, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
"description": "Published products, product categories, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/products/category/{categorySlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -446,6 +446,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/categories/by-slug/{slug}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Categories"
|
||||||
|
],
|
||||||
|
"summary": "Category by slug (for /products/category/{categorySlug} pages)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slug",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "entityType",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"product",
|
||||||
|
"blog",
|
||||||
|
"portfolio",
|
||||||
|
"video"
|
||||||
|
],
|
||||||
|
"default": "product"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ category } — use category.id as categoryId when listing products"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Category not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/products": {
|
"/tenants/{domain}/products": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export type SitemapStaticPageConfig = {
|
|||||||
|
|
||||||
export type SitemapPathTemplatesConfig = {
|
export type SitemapPathTemplatesConfig = {
|
||||||
product?: string;
|
product?: string;
|
||||||
|
productCategory?: string;
|
||||||
blog?: string;
|
blog?: string;
|
||||||
portfolio?: string;
|
portfolio?: string;
|
||||||
storeItemByProduct?: string;
|
storeItemByProduct?: string;
|
||||||
|
|||||||
@@ -173,4 +173,13 @@ export class PublicCategoriesController {
|
|||||||
list(@Param('host') host: string, @Query() query: ListCategoriesDto) {
|
list(@Param('host') host: string, @Query() query: ListCategoriesDto) {
|
||||||
return this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('by-slug/:slug')
|
||||||
|
getBySlug(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('slug') slug: string,
|
||||||
|
@Query() query: ListCategoriesDto,
|
||||||
|
) {
|
||||||
|
return this.service.getPublicBySlug(host, slug, query.entityType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
import { CategoriesController, PublicCategoriesController } from './categories.controller';
|
import { CategoriesController, PublicCategoriesController } from './categories.controller';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
@@ -9,7 +10,7 @@ import { CategoryTechnicalFormService } from './category-technical-form.service'
|
|||||||
import { CategoryVariationsService } from './category-variations.service';
|
import { CategoryVariationsService } from './category-variations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule],
|
imports: [AuthModule, SitemapModule, TenantModule],
|
||||||
controllers: [CategoriesController, PublicCategoriesController],
|
controllers: [CategoriesController, PublicCategoriesController],
|
||||||
providers: [
|
providers: [
|
||||||
CategoriesService,
|
CategoriesService,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MediaEntityType } from '@prisma/client';
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SitemapService } from '../sitemap/sitemap.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export class CategoriesService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
|
private readonly sitemap: SitemapService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(businessIdRaw: string, query: ListCategoriesDto, actor: AuthUser) {
|
async list(businessIdRaw: string, query: ListCategoriesDto, actor: AuthUser) {
|
||||||
@@ -65,6 +67,26 @@ export class CategoriesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPublicBySlug(host: string, slug: string, entityType?: MediaEntityType) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const resolvedType = entityType ?? MediaEntityType.product;
|
||||||
|
|
||||||
|
const category = await this.prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId: business.id,
|
||||||
|
entityType: resolvedType,
|
||||||
|
slug,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { category: this.serialize(category) };
|
||||||
|
}
|
||||||
|
|
||||||
async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) {
|
async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) {
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
await this.assertPermission(businessId, actor.id, 'categories.create');
|
await this.assertPermission(businessId, actor.id, 'categories.create');
|
||||||
@@ -99,6 +121,8 @@ export class CategoriesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Category created successfully',
|
message: 'Category created successfully',
|
||||||
category: this.serialize(created),
|
category: this.serialize(created),
|
||||||
@@ -178,6 +202,8 @@ export class CategoriesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Category updated successfully',
|
message: 'Category updated successfully',
|
||||||
category: this.serialize(updated),
|
category: this.serialize(updated),
|
||||||
@@ -205,6 +231,8 @@ export class CategoriesService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Category deleted successfully',
|
message: 'Category deleted successfully',
|
||||||
deletedIds: descendantIds.map((id) => id.toString()),
|
deletedIds: descendantIds.map((id) => id.toString()),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveAiProvider,
|
resolveAiProvider,
|
||||||
} from '../common/ai-provider.util';
|
} from '../common/ai-provider.util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SitemapService } from '../sitemap/sitemap.service';
|
||||||
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
||||||
|
|
||||||
type AiCategoryNode = {
|
type AiCategoryNode = {
|
||||||
@@ -44,6 +45,7 @@ export class CategoryAiService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly sitemap: SitemapService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateProductCategories(
|
async generateProductCategories(
|
||||||
@@ -67,6 +69,8 @@ export class CategoryAiService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: `Created ${created.length} categor${created.length === 1 ? 'y' : 'ies'} with AI.`,
|
message: `Created ${created.length} categor${created.length === 1 ? 'y' : 'ies'} with AI.`,
|
||||||
categories: created.map((item) => this.serialize(item)),
|
categories: created.map((item) => this.serialize(item)),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const BASE_URL_PATTERN = /^https:\/\/[a-z0-9.-]+(?::\d+)?\/?$/i;
|
|||||||
|
|
||||||
const TEMPLATE_KEYS = [
|
const TEMPLATE_KEYS = [
|
||||||
'product',
|
'product',
|
||||||
|
'productCategory',
|
||||||
'blog',
|
'blog',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
'storeItemByProduct',
|
'storeItemByProduct',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type ResolvedSitemapConfig = {
|
|||||||
templates: Required<
|
templates: Required<
|
||||||
Pick<
|
Pick<
|
||||||
import('../business-settings/business-settings.types').SitemapPathTemplatesConfig,
|
import('../business-settings/business-settings.types').SitemapPathTemplatesConfig,
|
||||||
'product' | 'blog' | 'portfolio'
|
'product' | 'productCategory' | 'blog' | 'portfolio'
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export function resolveSitemapConfig(
|
|||||||
templates: {
|
templates: {
|
||||||
product:
|
product:
|
||||||
stored?.templates?.product ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
|
stored?.templates?.product ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
|
||||||
|
productCategory:
|
||||||
|
stored?.templates?.productCategory ??
|
||||||
|
DEFAULT_SITEMAP_PATH_TEMPLATES.productCategory,
|
||||||
blog: stored?.templates?.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
|
blog: stored?.templates?.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
|
||||||
portfolio:
|
portfolio:
|
||||||
stored?.templates?.portfolio ??
|
stored?.templates?.portfolio ??
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const SITEMAP_URLSET_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9';
|
|||||||
/** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */
|
/** Default storefront path templates (overridable via /meshkee/sitemap-config.json). */
|
||||||
export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
||||||
product: '/products/{id}/{slug}',
|
product: '/products/{id}/{slug}',
|
||||||
|
productCategory: '/products/category/{slug}',
|
||||||
blog: '/blog/{id}/{slug}',
|
blog: '/blog/{id}/{slug}',
|
||||||
portfolio: '/portfolios/{id}/{slug}',
|
portfolio: '/portfolios/{id}/{slug}',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ContentStatus, Prisma } from '@prisma/client';
|
import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
|
||||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
import type { BusinessModuleId } from '../business-settings/business-settings.types';
|
import type { BusinessModuleId } from '../business-settings/business-settings.types';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -119,6 +119,13 @@ export class SitemapService {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (this.isModuleEnabled(enabledModules, 'products')) {
|
if (this.isModuleEnabled(enabledModules, 'products')) {
|
||||||
|
entries.push(
|
||||||
|
...(await this.loadProductCategories(
|
||||||
|
businessId,
|
||||||
|
config.baseUrl,
|
||||||
|
config.templates.productCategory,
|
||||||
|
)),
|
||||||
|
);
|
||||||
entries.push(
|
entries.push(
|
||||||
...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)),
|
...(await this.loadPublishedProducts(businessId, config.baseUrl, config.templates.product)),
|
||||||
);
|
);
|
||||||
@@ -143,6 +150,34 @@ export class SitemapService {
|
|||||||
return buildSitemapXml(entries);
|
return buildSitemapXml(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadProductCategories(
|
||||||
|
businessId: bigint,
|
||||||
|
baseUrl: string,
|
||||||
|
template: string,
|
||||||
|
): Promise<SitemapUrlEntry[]> {
|
||||||
|
const rows = await this.prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: MediaEntityType.product,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((row) =>
|
||||||
|
this.toEntry(baseUrl, template, {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPublishedProducts(
|
private async loadPublishedProducts(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
|
|||||||
@@ -116,10 +116,11 @@ Minimal example:
|
|||||||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||||
- **Canonical detail URLs (Meshkee default for all sites):**
|
- **Canonical detail URLs (Meshkee default for all sites):**
|
||||||
- Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts).
|
- Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts).
|
||||||
|
- Product category: `/products/category/{categorySlug}` — use category `slug` from `GET /tenants/{domain}/categories?entityType=product`; resolve via `GET /tenants/{domain}/categories/by-slug/{slug}?entityType=product`, then list products with `categoryId`.
|
||||||
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
|
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
|
||||||
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}`
|
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}`
|
||||||
- When linking from lists/cards, use the same `{id}/{slug}` shape (slugify Farsi title the same way: spaces → `-`, keep Persian letters).
|
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{slug}`.
|
||||||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}`).
|
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths (Meshkee sitemap defaults already use `{id}/{slug}` and `/products/category/{slug}`).
|
||||||
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself).
|
- After deploy, business owner: **Website → Settings → Sync sitemap config** (imports static pages; does not rebuild XML by itself).
|
||||||
- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires).
|
- Dynamic URLs come from the CMS automatically. Hitting `/sitemap.xml` serves a Redis-cached XML that regenerates after CMS publish/update/delete (or when cache expires).
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "XML sitemap for search engines",
|
"summary": "XML sitemap for search engines",
|
||||||
"description": "Published products, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
"description": "Published products, product categories, blogs, and portfolios for this tenant (respects enabled modules), plus static pages and URL templates from the synced website manifest (`GET https://{domain}/meshkee/sitemap-config.json`). Default detail paths: `/products/{id}/{nameFaSlug}`, `/products/category/{categorySlug}`, `/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`. Proxied from `https://{domain}/sitemap.xml` on the storefront. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -446,6 +446,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/categories/by-slug/{slug}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Categories"
|
||||||
|
],
|
||||||
|
"summary": "Category by slug (for /products/category/{categorySlug} pages)",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slug",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "entityType",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"product",
|
||||||
|
"blog",
|
||||||
|
"portfolio",
|
||||||
|
"video"
|
||||||
|
],
|
||||||
|
"default": "product"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ category } — use category.id as categoryId when listing products"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Category not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/products": {
|
"/tenants/{domain}/products": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user