Add published user products to the storefront sitemap.
Expose /sitemap-user-products.xml when customer_products is enabled, invalidate on listing mutations, and proxy the new path from nginx provision scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
fb04425df7
commit
0d9c2fd8ea
@@ -270,6 +270,7 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` |
|
| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` |
|
||||||
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instructions/{slug}` |
|
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instructions/{slug}` |
|
||||||
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
|
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) |
|
||||||
| 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-id/:categoryId` | Public category by id (for category landing pages) |
|
| GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) |
|
||||||
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug |
|
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug |
|
||||||
|
|||||||
@@ -152,9 +152,10 @@ Minimal example:
|
|||||||
- **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/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
|
- Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
|
||||||
|
- User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`.
|
||||||
- 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 for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`.
|
- 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/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`.
|
||||||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
||||||
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
||||||
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
|
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
|
||||||
@@ -165,6 +166,7 @@ Minimal example:
|
|||||||
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
|
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
|
||||||
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
|
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
|
||||||
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
|
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
|
||||||
|
- `/sitemap-user-products.xml` — published user products / customer listings (when `customer_products` module is enabled)
|
||||||
- `/robots.txt` — points at `/sitemap.xml`
|
- `/robots.txt` — points at `/sitemap.xml`
|
||||||
|
|
||||||
### On-page SEO (every public page)
|
### On-page SEO (every public page)
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "XML sitemap for search engines",
|
"summary": "XML sitemap for search engines",
|
||||||
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`, `/sitemap-user-products.xml` (when `customer_products` is enabled). Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -409,6 +409,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/sitemap-user-products.xml": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"SEO"
|
||||||
|
],
|
||||||
|
"summary": "User products sitemap urlset",
|
||||||
|
"description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Sitemap XML (urlset) of published user products",
|
||||||
|
"content": {
|
||||||
|
"application/xml": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/robots.txt": {
|
"/tenants/{domain}/robots.txt": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ paths = [
|
|||||||
("/sitemap-videos.xml", "sitemap-videos.xml"),
|
("/sitemap-videos.xml", "sitemap-videos.xml"),
|
||||||
("/sitemap-instructions.xml", "sitemap-instructions.xml"),
|
("/sitemap-instructions.xml", "sitemap-instructions.xml"),
|
||||||
("/sitemap-workshops.xml", "sitemap-workshops.xml"),
|
("/sitemap-workshops.xml", "sitemap-workshops.xml"),
|
||||||
|
("/sitemap-user-products.xml", "sitemap-user-products.xml"),
|
||||||
("/robots.txt", "robots.txt"),
|
("/robots.txt", "robots.txt"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,16 @@ PROXY_COMMON=$(cat <<PROXY
|
|||||||
add_header Cache-Control "public, max-age=900";
|
add_header Cache-Control "public, max-age=900";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /sitemap-user-products.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-user-products.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
location = /robots.txt {
|
location = /robots.txt {
|
||||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
||||||
proxy_set_header Host api.meshkee.com;
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ResolvedSitemapConfig = {
|
|||||||
| 'video'
|
| 'video'
|
||||||
| 'instruction'
|
| 'instruction'
|
||||||
| 'workshop'
|
| 'workshop'
|
||||||
|
| 'userProduct'
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export function resolveSitemapConfig(
|
|||||||
DEFAULT_SITEMAP_PATH_TEMPLATES.instruction,
|
DEFAULT_SITEMAP_PATH_TEMPLATES.instruction,
|
||||||
workshop:
|
workshop:
|
||||||
stored?.templates?.workshop ?? DEFAULT_SITEMAP_PATH_TEMPLATES.workshop,
|
stored?.templates?.workshop ?? DEFAULT_SITEMAP_PATH_TEMPLATES.workshop,
|
||||||
|
userProduct:
|
||||||
|
stored?.templates?.userProduct ??
|
||||||
|
DEFAULT_SITEMAP_PATH_TEMPLATES.userProduct,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
|||||||
video: '/videos/{slug}',
|
video: '/videos/{slug}',
|
||||||
instruction: '/instructions/{slug}',
|
instruction: '/instructions/{slug}',
|
||||||
workshop: '/workshops/{slug}',
|
workshop: '/workshops/{slug}',
|
||||||
|
userProduct: '/user-products/{slug}',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json';
|
export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json';
|
||||||
@@ -27,6 +28,7 @@ export const SITEMAP_DYNAMIC_SECTIONS = [
|
|||||||
'videos',
|
'videos',
|
||||||
'instructions',
|
'instructions',
|
||||||
'workshops',
|
'workshops',
|
||||||
|
'userProducts',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SitemapDynamicSection = (typeof SITEMAP_DYNAMIC_SECTIONS)[number];
|
export type SitemapDynamicSection = (typeof SITEMAP_DYNAMIC_SECTIONS)[number];
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ export class SitemapController {
|
|||||||
await this.sendDynamic(host, 'workshops', res);
|
await this.sendDynamic(host, 'workshops', res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('sitemap-user-products.xml')
|
||||||
|
@Header('Cache-Control', 'public, max-age=900')
|
||||||
|
async getUserProductsSitemap(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
await this.sendDynamic(host, 'userProducts', res);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('robots.txt')
|
@Get('robots.txt')
|
||||||
@Header('Cache-Control', 'public, max-age=900')
|
@Header('Cache-Control', 'public, max-age=900')
|
||||||
async getRobots(@Param('host') host: string, @Res() res: Response) {
|
async getRobots(@Param('host') host: string, @Res() res: Response) {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const SECTION_MODULE: Record<SitemapDynamicSection, BusinessModuleId> = {
|
|||||||
videos: 'videos',
|
videos: 'videos',
|
||||||
instructions: 'instructions',
|
instructions: 'instructions',
|
||||||
workshops: 'workshops',
|
workshops: 'workshops',
|
||||||
|
userProducts: 'customer_products',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTION_FILE: Record<SitemapDynamicSection, string> = {
|
const SECTION_FILE: Record<SitemapDynamicSection, string> = {
|
||||||
@@ -50,6 +51,7 @@ const SECTION_FILE: Record<SitemapDynamicSection, string> = {
|
|||||||
videos: '/sitemap-videos.xml',
|
videos: '/sitemap-videos.xml',
|
||||||
instructions: '/sitemap-instructions.xml',
|
instructions: '/sitemap-instructions.xml',
|
||||||
workshops: '/sitemap-workshops.xml',
|
workshops: '/sitemap-workshops.xml',
|
||||||
|
userProducts: '/sitemap-user-products.xml',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -258,6 +260,14 @@ export class SitemapService {
|
|||||||
config.templates.workshop,
|
config.templates.workshop,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
case 'userProducts':
|
||||||
|
return buildSitemapXml(
|
||||||
|
await this.loadPublishedUserProducts(
|
||||||
|
businessId,
|
||||||
|
config.baseUrl,
|
||||||
|
config.templates.userProduct,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +468,33 @@ export class SitemapService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadPublishedUserProducts(
|
||||||
|
businessId: bigint,
|
||||||
|
baseUrl: string,
|
||||||
|
template: string,
|
||||||
|
): Promise<SitemapUrlEntry[]> {
|
||||||
|
const rows = await this.prisma.userProduct.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((row) =>
|
||||||
|
this.toEntry(baseUrl, template, {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private toEntry(
|
private toEntry(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
template: string,
|
template: string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { CategoriesModule } from '../categories/categories.module';
|
import { CategoriesModule } from '../categories/categories.module';
|
||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
import { UserProductsAdminController } from './user-products.admin.controller';
|
import { UserProductsAdminController } from './user-products.admin.controller';
|
||||||
import { UserProductsController } from './user-products.controller';
|
import { UserProductsController } from './user-products.controller';
|
||||||
@@ -9,7 +10,13 @@ import { PublicUserProductsController } from './user-products.public.controller'
|
|||||||
import { UserProductsService } from './user-products.service';
|
import { UserProductsService } from './user-products.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, CategoriesModule, MediaModule, TenantModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
CategoriesModule,
|
||||||
|
MediaModule,
|
||||||
|
TenantModule,
|
||||||
|
SitemapModule,
|
||||||
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
UserProductsController,
|
UserProductsController,
|
||||||
UserProductsAdminController,
|
UserProductsAdminController,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
UserProductTechnicalValueDto,
|
UserProductTechnicalValueDto,
|
||||||
} from './dto/user-product.dto';
|
} from './dto/user-product.dto';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import { SitemapService } from '../sitemap/sitemap.service';
|
||||||
|
|
||||||
function slugify(value: string): string {
|
function slugify(value: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -109,6 +110,7 @@ export class UserProductsService {
|
|||||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||||
private readonly mediaService: MediaService,
|
private readonly mediaService: MediaService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
|
private readonly sitemap: SitemapService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listPublic(host: string, query: ListPublicUserProductsDto) {
|
async listPublic(host: string, query: ListPublicUserProductsDto) {
|
||||||
@@ -404,6 +406,8 @@ export class UserProductsService {
|
|||||||
include: userProductAdminListInclude,
|
include: userProductAdminListInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'User product created successfully',
|
message: 'User product created successfully',
|
||||||
product: withUser
|
product: withUser
|
||||||
@@ -621,6 +625,8 @@ export class UserProductsService {
|
|||||||
}
|
}
|
||||||
await this.mediaService.deleteUnused(businessId, dropped);
|
await this.mediaService.deleteUnused(businessId, dropped);
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'User product updated successfully',
|
message: 'User product updated successfully',
|
||||||
product: this.serializeAdminListItem(updated, category ?? undefined),
|
product: this.serializeAdminListItem(updated, category ?? undefined),
|
||||||
@@ -680,6 +686,8 @@ export class UserProductsService {
|
|||||||
|
|
||||||
await this.mediaService.deleteUnused(businessId, mediaIds);
|
await this.mediaService.deleteUnused(businessId, mediaIds);
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return { message: 'User product deleted successfully' };
|
return { message: 'User product deleted successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +887,8 @@ export class UserProductsService {
|
|||||||
updated.id,
|
updated.id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'User product status updated successfully',
|
message: 'User product status updated successfully',
|
||||||
product: this.serializeAdminListItem(
|
product: this.serializeAdminListItem(
|
||||||
@@ -961,6 +971,8 @@ export class UserProductsService {
|
|||||||
await tx.userProduct.delete({ where: { id: productId } });
|
await tx.userProduct.delete({ where: { id: productId } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.sitemap.invalidateForBusiness(businessId);
|
||||||
|
|
||||||
return { message: 'User product deleted successfully' };
|
return { message: 'User product deleted successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,9 +152,10 @@ Minimal example:
|
|||||||
- **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/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
|
- Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
|
||||||
|
- User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`.
|
||||||
- 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 for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`.
|
- 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/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`.
|
||||||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
||||||
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
||||||
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
|
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
|
||||||
@@ -165,6 +166,7 @@ Minimal example:
|
|||||||
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
|
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
|
||||||
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
|
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
|
||||||
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
|
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
|
||||||
|
- `/sitemap-user-products.xml` — published user products / customer listings (when `customer_products` module is enabled)
|
||||||
- `/robots.txt` — points at `/sitemap.xml`
|
- `/robots.txt` — points at `/sitemap.xml`
|
||||||
|
|
||||||
### On-page SEO (every public page)
|
### On-page SEO (every public page)
|
||||||
|
|||||||
@@ -203,7 +203,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "XML sitemap for search engines",
|
"summary": "XML sitemap for search engines",
|
||||||
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`, `/sitemap-user-products.xml` (when `customer_products` is enabled). Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -409,6 +409,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/sitemap-user-products.xml": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"SEO"
|
||||||
|
],
|
||||||
|
"summary": "User products sitemap urlset",
|
||||||
|
"description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Sitemap XML (urlset) of published user products",
|
||||||
|
"content": {
|
||||||
|
"application/xml": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/robots.txt": {
|
"/tenants/{domain}/robots.txt": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user