Use id+slug paths for user products and count their visits.
Align storefront/sitemap URLs with catalog products, add by-id public APIs, and include user-product detail views in the product visit chart. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
3aef62c1b8
commit
6517877bed
@@ -0,0 +1,4 @@
|
|||||||
|
-- Track public customer marketplace (user product) list/detail page views.
|
||||||
|
|
||||||
|
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_list';
|
||||||
|
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_detail';
|
||||||
@@ -274,7 +274,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 `/instruction/{slug}` |
|
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instruction/{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/sitemap-user-products.xml` | Published user products `/user-products/{id}/{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` |
|
||||||
| POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` |
|
| POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` |
|
||||||
| 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) |
|
||||||
@@ -363,9 +363,11 @@ Base: `/tenants/:host/user-products`
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) |
|
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination); items include `pathSlug` |
|
||||||
| GET | `/:slug` | Details + gallery (`images`, `galleryMediaIds`) + technical values |
|
| GET | `/by-id/:id` | Details by id (preferred for `/user-products/{id}/{pathSlug}` pages) |
|
||||||
| GET | `/:slug/technical-info` | Category technical form + values |
|
| GET | `/by-id/:id/technical-info` | Category technical form + values by id |
|
||||||
|
| GET | `/:slug` | Details by DB slug (legacy) |
|
||||||
|
| GET | `/:slug/technical-info` | Category technical form + values by slug (legacy) |
|
||||||
|
|
||||||
#### Cart (customer — JWT, must be business customer)
|
#### Cart (customer — JWT, must be business customer)
|
||||||
|
|
||||||
|
|||||||
@@ -172,9 +172,11 @@ Nginx on the shop apex still proxies bank callbacks (`https://<WEBSITE_DOMAIN>/m
|
|||||||
|
|
||||||
### User products (customer listings)
|
### User products (customer listings)
|
||||||
Public marketplace listings owned by customers — not catalog `products`.
|
Public marketplace listings owned by customers — not catalog `products`.
|
||||||
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
|
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`.
|
||||||
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery (`technicalValues` = values only, no labels)
|
- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only)
|
||||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — **required for specs UI**: `{ form: { fields: [{ id, label, key, type, ... }] }, values: [...] }`
|
- `GET /tenants/{domain}/user-products/by-id/{id}/technical-info` — specs labels + values by id
|
||||||
|
- `GET /tenants/{domain}/user-products/{slug}` — legacy resolve by DB slug (still supported)
|
||||||
|
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — legacy specs by slug
|
||||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||||
|
|
||||||
### Technical details / specs table (catalog products + user products)
|
### Technical details / specs table (catalog products + user products)
|
||||||
@@ -183,7 +185,7 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
|
|||||||
| Detail page | Specs endpoint (labels + values) |
|
| Detail page | Specs endpoint (labels + values) |
|
||||||
|-------------|----------------------------------|
|
|-------------|----------------------------------|
|
||||||
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
|
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
|
||||||
| `GET .../user-products/{slug}` | `GET .../user-products/{slug}/technical-info` |
|
| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) |
|
||||||
|
|
||||||
**How to render:**
|
**How to render:**
|
||||||
1. Call `technical-info` (same slug/id as the detail page).
|
1. Call `technical-info` (same slug/id as the detail page).
|
||||||
@@ -196,12 +198,13 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
|
|||||||
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
||||||
|
|
||||||
### Analytics (dashboard charts)
|
### Analytics (dashboard charts)
|
||||||
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
|
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, user-products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
|
||||||
|
|
||||||
Optional explicit record (e.g. custom home without sliders):
|
Optional explicit record (e.g. custom home without sliders):
|
||||||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||||
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
|
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
|
||||||
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
||||||
|
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
@@ -274,13 +277,13 @@ 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}`.
|
- User product (customer listing): `/user-products/{id}/{pathSlug}` — use `id` + `pathSlug` from the list/detail API (or slugify `titleFa` / `titleEn`); resolve via `GET /tenants/{domain}/user-products/by-id/{id}` (slug segment is SEO-only).
|
||||||
- Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id.
|
- Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id.
|
||||||
- Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`.
|
- Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`.
|
||||||
- Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`.
|
- Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`.
|
||||||
- Video: `/videos/{slug}` — use the video’s `slug` from the API.
|
- Video: `/videos/{slug}` — use the video’s `slug` from the API.
|
||||||
- Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API.
|
- Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API.
|
||||||
- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`.
|
- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{id}/{pathSlug}`.
|
||||||
- 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)
|
||||||
|
|||||||
@@ -1354,11 +1354,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Get user product by id",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get user product technical info by id",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Get user product by slug",
|
"name": "Get user product by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"description": "Listing detail + gallery. technicalValues are values only (fieldId + text/option) — no labels. Use technical-info for specs UI.",
|
"description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1366,7 +1382,7 @@
|
|||||||
"name": "Get user product technical info by slug",
|
"name": "Get user product technical info by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"description": "Required for technical-details UI: form.fields (labels) + values. Join field.id ↔ value.fieldId. No separate category-variation-fields public endpoint.",
|
"description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,16 +143,17 @@
|
|||||||
<h2>User products (customer listings)</h2>
|
<h2>User products (customer listings)</h2>
|
||||||
<p>
|
<p>
|
||||||
Marketplace-style stock listings created by customers. Public read-only under
|
Marketplace-style stock listings created by customers. Public read-only under
|
||||||
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
|
<code>/tenants/{domain}/user-products</code> (list / by-id / details / technical-info).
|
||||||
|
Storefront URLs: <code>/user-products/{id}/{pathSlug}</code>.
|
||||||
See OpenAPI tag <strong>User Products</strong>.
|
See OpenAPI tag <strong>User Products</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Technical details:</strong> detail responses include
|
<strong>Technical details:</strong> detail responses include
|
||||||
<code>technicalValues</code> with <em>values only</em> (no field labels).
|
<code>technicalValues</code> with <em>values only</em> (no field labels).
|
||||||
For a label→value specs table, call
|
For a label→value specs table, call
|
||||||
<code>GET /tenants/{domain}/user-products/{slug}/technical-info</code>
|
<code>GET /tenants/{domain}/user-products/by-id/{id}/technical-info</code>
|
||||||
(catalog products:
|
(catalog products:
|
||||||
<code>.../products/{slug}/technical-info</code>) and join
|
<code>.../products/by-id/{id}/technical-info</code>) and join
|
||||||
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
|
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
|
||||||
There is no public <code>product-category-variation-fields</code> route.
|
There is no public <code>product-category-variation-fields</code> route.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "User Products",
|
"name": "User Products",
|
||||||
"description": "Customer marketplace listings. Detail returns `technicalValues` without labels; use `GET .../user-products/{slug}/technical-info` for form field labels + values."
|
"description": "Customer marketplace listings. Prefer `GET .../user-products/by-id/{id}` for storefront pages. Detail returns `technicalValues` without labels; use `.../technical-info` for form field labels + values."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Store"
|
"name": "Store"
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "User products sitemap urlset",
|
"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`.",
|
"description": "Published customer marketplace listings. Default paths: `/user-products/{id}/{slug}` (slug from titleFa/titleEn). Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -659,6 +659,8 @@
|
|||||||
"blog_detail",
|
"blog_detail",
|
||||||
"video_list",
|
"video_list",
|
||||||
"video_detail",
|
"video_detail",
|
||||||
|
"user_product_list",
|
||||||
|
"user_product_detail",
|
||||||
"website",
|
"website",
|
||||||
"product",
|
"product",
|
||||||
"portfolio",
|
"portfolio",
|
||||||
@@ -668,7 +670,7 @@
|
|||||||
},
|
},
|
||||||
"entityId": {
|
"entityId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
|
"description": "Required for *_detail kinds (product, user product, portfolio, blog, video, store item)."
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1262,7 +1264,64 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
|
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, pathSlug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/user-products/by-id/{productId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"User Products"
|
||||||
|
],
|
||||||
|
"summary": "User product by id (preferred for /user-products/{id}/{pathSlug} pages)",
|
||||||
|
"description": "Full published listing resolved by id. Prefer this for storefront detail pages — the path slug segment is SEO-only.\n\n**Important:** `technicalValues` items are **values only**. For a label→value table, call `GET /tenants/{domain}/user-products/by-id/{productId}/technical-info`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "productId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ product } with gallery, pathSlug, technicalValues (values only), countryId, cityId"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found or not published"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/user-products/by-id/{productId}/technical-info": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"User Products"
|
||||||
|
],
|
||||||
|
"summary": "User product technical form + values by id",
|
||||||
|
"description": "**Use this for the listing specs UI** when the page is resolved by id. Same shape as the slug technical-info route.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "productId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ form: { id, categoryId, fields: [...] } | null, values: [...] }"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1745,6 +1745,8 @@ enum WebsitePageKind {
|
|||||||
blog_detail
|
blog_detail
|
||||||
video_list
|
video_list
|
||||||
video_detail
|
video_detail
|
||||||
|
user_product_list
|
||||||
|
user_product_detail
|
||||||
|
|
||||||
@@map("website_page_kind")
|
@@map("website_page_kind")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,9 +111,16 @@ 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:
|
userProduct: resolveUserProductTemplate(stored?.templates?.userProduct),
|
||||||
stored?.templates?.userProduct ??
|
|
||||||
DEFAULT_SITEMAP_PATH_TEMPLATES.userProduct,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer id+slug paths; upgrade legacy slug-only defaults still stored on tenants. */
|
||||||
|
function resolveUserProductTemplate(stored: string | undefined): string {
|
||||||
|
const raw = stored?.trim();
|
||||||
|
if (!raw || raw === '/user-products/{slug}') {
|
||||||
|
return DEFAULT_SITEMAP_PATH_TEMPLATES.userProduct;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const DEFAULT_SITEMAP_PATH_TEMPLATES = {
|
|||||||
video: '/videos/{slug}',
|
video: '/videos/{slug}',
|
||||||
instruction: '/instruction/{slug}',
|
instruction: '/instruction/{slug}',
|
||||||
workshop: '/workshops/{slug}',
|
workshop: '/workshops/{slug}',
|
||||||
userProduct: '/user-products/{slug}',
|
userProduct: '/user-products/{id}/{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';
|
||||||
|
|||||||
@@ -479,19 +479,27 @@ export class SitemapService {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
title: true,
|
||||||
|
content: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows.map((row) =>
|
return rows.map((row) => {
|
||||||
this.toEntry(baseUrl, template, {
|
const content = this.asRecord(row.content);
|
||||||
id: row.id,
|
const titleEn =
|
||||||
slug: row.slug,
|
typeof content.titleEn === 'string' ? content.titleEn.trim() : '';
|
||||||
updatedAt: row.updatedAt,
|
const pathSlug = slugifyForUrl(
|
||||||
}),
|
row.title?.trim() || titleEn,
|
||||||
|
'user-product',
|
||||||
);
|
);
|
||||||
|
return this.toEntry(baseUrl, template, {
|
||||||
|
id: row.id,
|
||||||
|
slug: pathSlug,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private toEntry(
|
private toEntry(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.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';
|
||||||
import { PublicUserProductsController } from './user-products.public.controller';
|
import { PublicUserProductsController } from './user-products.public.controller';
|
||||||
@@ -16,6 +17,7 @@ import { UserProductsService } from './user-products.service';
|
|||||||
MediaModule,
|
MediaModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
UserProductsController,
|
UserProductsController,
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ListPublicUserProductsDto } from './dto/user-product.dto';
|
import { ListPublicUserProductsDto } from './dto/user-product.dto';
|
||||||
import { UserProductsService } from './user-products.service';
|
import { UserProductsService } from './user-products.service';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
|
|
||||||
@Controller('tenants/:host/user-products')
|
@Controller('tenants/:host/user-products')
|
||||||
export class PublicUserProductsController {
|
export class PublicUserProductsController {
|
||||||
constructor(private readonly service: UserProductsService) {}
|
constructor(
|
||||||
|
private readonly service: UserProductsService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
async list(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Query() query: ListPublicUserProductsDto,
|
@Query() query: ListPublicUserProductsDto,
|
||||||
) {
|
) {
|
||||||
return this.service.listPublic(host, query);
|
const result = await this.service.listPublic(host, query);
|
||||||
|
this.analytics.trackPublicPage(host, 'user_product_list');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-id/:productId/technical-info')
|
||||||
|
getTechnicalInfoById(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('productId') productId: string,
|
||||||
|
) {
|
||||||
|
return this.service.getPublicTechnicalInfoById(host, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-id/:productId')
|
||||||
|
async getById(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Param('productId') productId: string,
|
||||||
|
) {
|
||||||
|
const result = await this.service.getPublicById(host, productId);
|
||||||
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'user_product_detail',
|
||||||
|
result.product.id,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug/technical-info')
|
@Get(':slug/technical-info')
|
||||||
@@ -20,7 +48,13 @@ export class PublicUserProductsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
return this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
|
this.analytics.trackPublicPage(
|
||||||
|
host,
|
||||||
|
'user_product_detail',
|
||||||
|
result.product.id,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ import {
|
|||||||
} 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';
|
import { SitemapService } from '../sitemap/sitemap.service';
|
||||||
|
import { slugifyForUrl } from '../sitemap/seo-slug.util';
|
||||||
|
|
||||||
function slugify(value: string): string {
|
function buildUserProductSlug(titleFa: string, titleEn?: string | null): string {
|
||||||
return (
|
return slugifyForUrl(
|
||||||
value
|
titleFa.trim() || titleEn?.trim() || '',
|
||||||
.toLowerCase()
|
'user-product',
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '') || 'user-product'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +166,9 @@ export class UserProductsService {
|
|||||||
|
|
||||||
async getPublicBySlug(host: string, slug: string) {
|
async getPublicBySlug(host: string, slug: string) {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
const businessId = business.id;
|
|
||||||
|
|
||||||
const product = await this.prisma.userProduct.findFirst({
|
const product = await this.prisma.userProduct.findFirst({
|
||||||
where: {
|
where: {
|
||||||
businessId,
|
businessId: business.id,
|
||||||
slug,
|
slug,
|
||||||
status: ContentStatus.published,
|
status: ContentStatus.published,
|
||||||
},
|
},
|
||||||
@@ -183,6 +179,81 @@ export class UserProductsService {
|
|||||||
throw new NotFoundException('User product not found');
|
throw new NotFoundException('User product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.serializePublicDetail(business.id, product);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicById(host: string, productIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
let productId: bigint;
|
||||||
|
try {
|
||||||
|
productId = BigInt(productIdRaw);
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException('User product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await this.prisma.userProduct.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId: business.id,
|
||||||
|
id: productId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
include: userProductDetailInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('User product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.serializePublicDetail(business.id, product);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicTechnicalInfoBySlug(host: string, slug: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const product = await this.prisma.userProduct.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId: business.id,
|
||||||
|
slug,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('User product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPublicTechnicalInfoForProduct(business.id, product.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicTechnicalInfoById(host: string, productIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
let productId: bigint;
|
||||||
|
try {
|
||||||
|
productId = BigInt(productIdRaw);
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException('User product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await this.prisma.userProduct.findFirst({
|
||||||
|
where: {
|
||||||
|
businessId: business.id,
|
||||||
|
id: productId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new NotFoundException('User product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPublicTechnicalInfoForProduct(business.id, product.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async serializePublicDetail(
|
||||||
|
businessId: bigint,
|
||||||
|
product: UserProductDetailRow,
|
||||||
|
) {
|
||||||
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
||||||
product.id,
|
product.id,
|
||||||
]);
|
]);
|
||||||
@@ -197,27 +268,14 @@ export class UserProductsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublicTechnicalInfoBySlug(host: string, slug: string) {
|
private async getPublicTechnicalInfoForProduct(
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
businessId: bigint,
|
||||||
const businessId = business.id;
|
productId: bigint,
|
||||||
|
) {
|
||||||
const product = await this.prisma.userProduct.findFirst({
|
|
||||||
where: {
|
|
||||||
businessId,
|
|
||||||
slug,
|
|
||||||
status: ContentStatus.published,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
throw new NotFoundException('User product not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
const categoryByEntity = await this.loadCategoriesForProducts(businessId, [
|
||||||
product.id,
|
productId,
|
||||||
]);
|
]);
|
||||||
const category = categoryByEntity.get(product.id.toString());
|
const category = categoryByEntity.get(productId.toString());
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return {
|
return {
|
||||||
form: null,
|
form: null,
|
||||||
@@ -235,7 +293,7 @@ export class UserProductsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detail = await this.prisma.userProduct.findFirst({
|
const detail = await this.prisma.userProduct.findFirst({
|
||||||
where: { id: product.id },
|
where: { id: productId },
|
||||||
include: userProductDetailInclude,
|
include: userProductDetailInclude,
|
||||||
});
|
});
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
@@ -337,7 +395,10 @@ export class UserProductsService {
|
|||||||
const technicalValues = dto.technicalValues ?? [];
|
const technicalValues = dto.technicalValues ?? [];
|
||||||
this.validateTechnicalValues(form?.fields ?? [], technicalValues);
|
this.validateTechnicalValues(form?.fields ?? [], technicalValues);
|
||||||
|
|
||||||
const slug = await this.ensureUniqueSlug(businessId, slugify(titleFa));
|
const slug = await this.ensureUniqueSlug(
|
||||||
|
businessId,
|
||||||
|
buildUserProductSlug(titleFa, dto.titleEn),
|
||||||
|
);
|
||||||
const priceCurrency = dto.priceCurrency ?? 'IRT';
|
const priceCurrency = dto.priceCurrency ?? 'IRT';
|
||||||
const content: Prisma.InputJsonValue = {
|
const content: Prisma.InputJsonValue = {
|
||||||
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
||||||
@@ -542,7 +603,7 @@ export class UserProductsService {
|
|||||||
if (titleFa !== existing.title) {
|
if (titleFa !== existing.title) {
|
||||||
slug = await this.ensureUniqueSlug(
|
slug = await this.ensureUniqueSlug(
|
||||||
businessId,
|
businessId,
|
||||||
slugify(titleFa),
|
buildUserProductSlug(titleFa, dto.titleEn),
|
||||||
productId,
|
productId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1333,7 @@ export class UserProductsService {
|
|||||||
return {
|
return {
|
||||||
id: product.id.toString(),
|
id: product.id.toString(),
|
||||||
slug: product.slug,
|
slug: product.slug,
|
||||||
|
pathSlug: buildUserProductSlug(product.title, titleEn),
|
||||||
title: product.title,
|
title: product.title,
|
||||||
titleFa: product.title,
|
titleFa: product.title,
|
||||||
titleEn,
|
titleEn,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
|||||||
import {
|
import {
|
||||||
normalizeWebsitePageKind,
|
normalizeWebsitePageKind,
|
||||||
pageKindRequiresEntity,
|
pageKindRequiresEntity,
|
||||||
|
DAILY_VIEW_KIND_EXPAND,
|
||||||
VIEW_SUMMARY_GROUP_IDS,
|
VIEW_SUMMARY_GROUP_IDS,
|
||||||
VIEW_SUMMARY_GROUP_KINDS,
|
VIEW_SUMMARY_GROUP_KINDS,
|
||||||
type ViewPeriodCounts,
|
type ViewPeriodCounts,
|
||||||
@@ -129,7 +130,9 @@ export class WebsiteAnalyticsService {
|
|||||||
COUNT(*)::int AS count
|
COUNT(*)::int AS count
|
||||||
FROM website_page_views
|
FROM website_page_views
|
||||||
WHERE business_id = ${businessId}
|
WHERE business_id = ${businessId}
|
||||||
AND page_kind = ${kind}::website_page_kind
|
AND page_kind::text IN (${Prisma.join(
|
||||||
|
[...(DAILY_VIEW_KIND_EXPAND[kind!] ?? [kind!])],
|
||||||
|
)})
|
||||||
AND viewed_at >= ${from}
|
AND viewed_at >= ${from}
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
ORDER BY 1
|
ORDER BY 1
|
||||||
@@ -382,6 +385,15 @@ export class WebsiteAnalyticsService {
|
|||||||
throw new NotFoundException('Store item not found');
|
throw new NotFoundException('Store item not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === 'user_product_detail') {
|
||||||
|
const userProduct = await this.prisma.userProduct.findFirst({
|
||||||
|
where: { id: entityId, businessId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!userProduct) throw new NotFoundException('User product not found');
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
return entityId;
|
return entityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const WEBSITE_PAGE_KINDS = [
|
|||||||
'blog_detail',
|
'blog_detail',
|
||||||
'video_list',
|
'video_list',
|
||||||
'video_detail',
|
'video_detail',
|
||||||
|
'user_product_list',
|
||||||
|
'user_product_detail',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number];
|
export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number];
|
||||||
@@ -23,8 +25,19 @@ const DETAIL_KINDS = new Set<WebsitePageKind>([
|
|||||||
'store_item_detail',
|
'store_item_detail',
|
||||||
'blog_detail',
|
'blog_detail',
|
||||||
'video_detail',
|
'video_detail',
|
||||||
|
'user_product_detail',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a dashboard chart asks for one kind, also count these siblings.
|
||||||
|
* Product visit chart includes catalog products + customer marketplace listings.
|
||||||
|
*/
|
||||||
|
export const DAILY_VIEW_KIND_EXPAND: Partial<
|
||||||
|
Record<WebsitePageKind, readonly WebsitePageKind[]>
|
||||||
|
> = {
|
||||||
|
product_detail: ['product_detail', 'user_product_detail'],
|
||||||
|
};
|
||||||
|
|
||||||
/** Legacy chart / POST aliases → canonical page kinds. */
|
/** Legacy chart / POST aliases → canonical page kinds. */
|
||||||
const KIND_ALIASES: Record<string, WebsitePageKind> = {
|
const KIND_ALIASES: Record<string, WebsitePageKind> = {
|
||||||
website: 'home',
|
website: 'home',
|
||||||
@@ -80,6 +93,8 @@ export const VIEW_SUMMARY_GROUP_KINDS: Record<
|
|||||||
'product_detail',
|
'product_detail',
|
||||||
'store_item_list',
|
'store_item_list',
|
||||||
'store_item_detail',
|
'store_item_detail',
|
||||||
|
'user_product_list',
|
||||||
|
'user_product_detail',
|
||||||
],
|
],
|
||||||
blog: ['blog_list', 'blog_detail'],
|
blog: ['blog_list', 'blog_detail'],
|
||||||
portfolio: ['portfolio_list', 'portfolio_detail'],
|
portfolio: ['portfolio_list', 'portfolio_detail'],
|
||||||
|
|||||||
@@ -172,9 +172,11 @@ Nginx on the shop apex still proxies bank callbacks (`https://<WEBSITE_DOMAIN>/m
|
|||||||
|
|
||||||
### User products (customer listings)
|
### User products (customer listings)
|
||||||
Public marketplace listings owned by customers — not catalog `products`.
|
Public marketplace listings owned by customers — not catalog `products`.
|
||||||
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
|
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`.
|
||||||
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery (`technicalValues` = values only, no labels)
|
- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only)
|
||||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — **required for specs UI**: `{ form: { fields: [{ id, label, key, type, ... }] }, values: [...] }`
|
- `GET /tenants/{domain}/user-products/by-id/{id}/technical-info` — specs labels + values by id
|
||||||
|
- `GET /tenants/{domain}/user-products/{slug}` — legacy resolve by DB slug (still supported)
|
||||||
|
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — legacy specs by slug
|
||||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||||
|
|
||||||
### Technical details / specs table (catalog products + user products)
|
### Technical details / specs table (catalog products + user products)
|
||||||
@@ -183,7 +185,7 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
|
|||||||
| Detail page | Specs endpoint (labels + values) |
|
| Detail page | Specs endpoint (labels + values) |
|
||||||
|-------------|----------------------------------|
|
|-------------|----------------------------------|
|
||||||
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
|
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
|
||||||
| `GET .../user-products/{slug}` | `GET .../user-products/{slug}/technical-info` |
|
| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) |
|
||||||
|
|
||||||
**How to render:**
|
**How to render:**
|
||||||
1. Call `technical-info` (same slug/id as the detail page).
|
1. Call `technical-info` (same slug/id as the detail page).
|
||||||
@@ -196,12 +198,13 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
|
|||||||
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
||||||
|
|
||||||
### Analytics (dashboard charts)
|
### Analytics (dashboard charts)
|
||||||
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
|
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, user-products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
|
||||||
|
|
||||||
Optional explicit record (e.g. custom home without sliders):
|
Optional explicit record (e.g. custom home without sliders):
|
||||||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||||
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
|
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
|
||||||
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
||||||
|
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
@@ -274,13 +277,13 @@ 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}`.
|
- User product (customer listing): `/user-products/{id}/{pathSlug}` — use `id` + `pathSlug` from the list/detail API (or slugify `titleFa` / `titleEn`); resolve via `GET /tenants/{domain}/user-products/by-id/{id}` (slug segment is SEO-only).
|
||||||
- Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id.
|
- Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id.
|
||||||
- Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`.
|
- Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`.
|
||||||
- Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`.
|
- Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`.
|
||||||
- Video: `/videos/{slug}` — use the video’s `slug` from the API.
|
- Video: `/videos/{slug}` — use the video’s `slug` from the API.
|
||||||
- Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API.
|
- Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API.
|
||||||
- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`.
|
- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{id}/{pathSlug}`.
|
||||||
- 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)
|
||||||
|
|||||||
@@ -1354,11 +1354,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Get user product by id",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get user product technical info by id",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Get user product by slug",
|
"name": "Get user product by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"description": "Listing detail + gallery. technicalValues are values only (fieldId + text/option) — no labels. Use technical-info for specs UI.",
|
"description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1366,7 +1382,7 @@
|
|||||||
"name": "Get user product technical info by slug",
|
"name": "Get user product technical info by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"description": "Required for technical-details UI: form.fields (labels) + values. Join field.id ↔ value.fieldId. No separate category-variation-fields public endpoint.",
|
"description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,16 +143,17 @@
|
|||||||
<h2>User products (customer listings)</h2>
|
<h2>User products (customer listings)</h2>
|
||||||
<p>
|
<p>
|
||||||
Marketplace-style stock listings created by customers. Public read-only under
|
Marketplace-style stock listings created by customers. Public read-only under
|
||||||
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
|
<code>/tenants/{domain}/user-products</code> (list / by-id / details / technical-info).
|
||||||
|
Storefront URLs: <code>/user-products/{id}/{pathSlug}</code>.
|
||||||
See OpenAPI tag <strong>User Products</strong>.
|
See OpenAPI tag <strong>User Products</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Technical details:</strong> detail responses include
|
<strong>Technical details:</strong> detail responses include
|
||||||
<code>technicalValues</code> with <em>values only</em> (no field labels).
|
<code>technicalValues</code> with <em>values only</em> (no field labels).
|
||||||
For a label→value specs table, call
|
For a label→value specs table, call
|
||||||
<code>GET /tenants/{domain}/user-products/{slug}/technical-info</code>
|
<code>GET /tenants/{domain}/user-products/by-id/{id}/technical-info</code>
|
||||||
(catalog products:
|
(catalog products:
|
||||||
<code>.../products/{slug}/technical-info</code>) and join
|
<code>.../products/by-id/{id}/technical-info</code>) and join
|
||||||
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
|
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
|
||||||
There is no public <code>product-category-variation-fields</code> route.
|
There is no public <code>product-category-variation-fields</code> route.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "User Products",
|
"name": "User Products",
|
||||||
"description": "Customer marketplace listings. Detail returns `technicalValues` without labels; use `GET .../user-products/{slug}/technical-info` for form field labels + values."
|
"description": "Customer marketplace listings. Prefer `GET .../user-products/by-id/{id}` for storefront pages. Detail returns `technicalValues` without labels; use `.../technical-info` for form field labels + values."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Store"
|
"name": "Store"
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
"SEO"
|
"SEO"
|
||||||
],
|
],
|
||||||
"summary": "User products sitemap urlset",
|
"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`.",
|
"description": "Published customer marketplace listings. Default paths: `/user-products/{id}/{slug}` (slug from titleFa/titleEn). Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -659,6 +659,8 @@
|
|||||||
"blog_detail",
|
"blog_detail",
|
||||||
"video_list",
|
"video_list",
|
||||||
"video_detail",
|
"video_detail",
|
||||||
|
"user_product_list",
|
||||||
|
"user_product_detail",
|
||||||
"website",
|
"website",
|
||||||
"product",
|
"product",
|
||||||
"portfolio",
|
"portfolio",
|
||||||
@@ -668,7 +670,7 @@
|
|||||||
},
|
},
|
||||||
"entityId": {
|
"entityId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
|
"description": "Required for *_detail kinds (product, user product, portfolio, blog, video, store item)."
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1262,7 +1264,64 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
|
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, pathSlug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/user-products/by-id/{productId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"User Products"
|
||||||
|
],
|
||||||
|
"summary": "User product by id (preferred for /user-products/{id}/{pathSlug} pages)",
|
||||||
|
"description": "Full published listing resolved by id. Prefer this for storefront detail pages — the path slug segment is SEO-only.\n\n**Important:** `technicalValues` items are **values only**. For a label→value table, call `GET /tenants/{domain}/user-products/by-id/{productId}/technical-info`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "productId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ product } with gallery, pathSlug, technicalValues (values only), countryId, cityId"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found or not published"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/user-products/by-id/{productId}/technical-info": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"User Products"
|
||||||
|
],
|
||||||
|
"summary": "User product technical form + values by id",
|
||||||
|
"description": "**Use this for the listing specs UI** when the page is resolved by id. Same shape as the slug technical-info route.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "productId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ form: { id, categoryId, fields: [...] } | null, values: [...] }"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user