Public business-info now exposes metaTags as { html }, matching how admins paste complete <meta> tags in Tags & Badges.
Co-authored-by: Cursor <cursoragent@cursor.com>
428 lines
26 KiB
Markdown
428 lines
26 KiB
Markdown
# Meshkee Website API — AI / designer brief
|
||
|
||
Copy everything below into a new AI chat when building a Meshkee storefront.
|
||
|
||
---
|
||
|
||
## System context (paste this)
|
||
|
||
You are building a **Meshkee business website (storefront)**. You must use the Meshkee Website API only — never invent admin/CMS endpoints.
|
||
|
||
**Canonical docs (always prefer these):**
|
||
- Hub: https://api.meshkee.com/docs/website
|
||
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
|
||
- Postman: https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json
|
||
|
||
**API base URL:** `https://api.meshkee.com/api/v1`
|
||
(Optional alias if configured: `https://api.<WEBSITE_DOMAIN>/api/v1` — same backend.)
|
||
|
||
**This website’s apex domain:** `<WEBSITE_DOMAIN>`
|
||
(example: `sanihome.ir` — no `www.`, no `api.`, no `customer.`, no `business.`)
|
||
|
||
### Hard rules
|
||
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
|
||
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
|
||
3. **Auth + checkout are not this website.** Login, register, OTP, shopping-cart process, addresses, and payment already exist for every Meshkee site at `https://customer.<WEBSITE_DOMAIN>`. Do **not** add `/login`, `/register`, `/checkout`, or `/cart` routes (or equivalent pages) on the storefront. Do **not** call `/businesses/{businessId}/cart` or `/cart/checkout` from this site.
|
||
4. **Detect login** via parent-domain cookies (see Shared login). Not token handoff, not an API SSO endpoint. If the shopper needs to sign in, **redirect** to the customer dashboard login — do not invent a login UI.
|
||
5. Storefront cart = header icon + quantity badge + mini-cart popup + **Continue** redirect (see Shopping cart). Server cart / orders / payment APIs are customer-dashboard only. Favorites may still use Bearer if cookies exist.
|
||
6. Customer-dashboard auth APIs (storefronts must **not** call these): register body includes `"domain": "<WEBSITE_DOMAIN>"`. If the cell already exists on another Meshkee site and the password differs, API returns `409` with `CELL_EXISTS_OTHER_SITE:...`. Retry register with `"acknowledgeExistingAccount": true` to link that account (profile unchanged; **password is replaced** with the new signup password), then complete SMS OTP.
|
||
7. Cell numbers are E.164 (`+98912...`).
|
||
8. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
|
||
9. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
|
||
10. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API.
|
||
11. **Torob:** Do **not** add a Next.js route for `/torob_api`. Meshkee nginx on the store apex proxies `POST /torob_api/v3/products` to the API. Only businesses with the **store** module **and** Store settings → Torob switch on return products (otherwise 404). Storefront UI must not call this endpoint.
|
||
12. **Production runtime (required):** Meshkee hosts many Next.js storefronts on one shared websites VM. Every Next site **must** set `output: "standalone"` in `next.config` (`.ts` / `.mjs` / `.js`). Deploy detects `.next/standalone/server.js`, points PM2 at that `server.js`, and **deletes the full `node_modules`**. Target RSS is **~80–120 MB**. Do **not** ship `next start` with a full `node_modules` runtime (that uses ~150–500+ MB and OOMs the host). Do **not** use `output: "export"` unless the project explicitly asks for a static export. Vinext apps (`vinext` in `package.json` / `vinext start`) are a separate intentional stack — do not pretend they use Next standalone.
|
||
|
||
### Production runtime (Next.js)
|
||
|
||
```ts
|
||
import type { NextConfig } from "next";
|
||
|
||
const nextConfig: NextConfig = {
|
||
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
|
||
output: "standalone",
|
||
// ...images, rewrites, etc.
|
||
};
|
||
|
||
export default nextConfig;
|
||
```
|
||
|
||
After changing `next.config`, commit, push, and redeploy so PM2 switches to standalone. A correct deploy log says `standalone build detected` / `deploy ok (standalone)`; PM2 script is `.next/standalone/server.js`, not `node_modules/next/dist/bin/next`.
|
||
|
||
### Typical bootstrap sequence
|
||
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
||
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
|
||
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
|
||
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
|
||
5. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
|
||
6. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
|
||
|
||
### Favicon + logos
|
||
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
|
||
- Use `faviconUrl` in layout metadata (Next.js: `icons: [{ url: faviconUrl, type: 'image/png' }]` when set).
|
||
- When `hasDedicatedFavicon` is false, `faviconUrl` equals the light `logoUrl` — still safe to use as tab icon.
|
||
- Header/footer logos: `logoUrl` on light backgrounds, `logoDarkUrl` on dark (fallback to `logoUrl` in CSS when null).
|
||
|
||
### Shared login with customer dashboard (`customer.<WEBSITE_DOMAIN>`)
|
||
|
||
The Meshkee **customer dashboard** lives at `https://customer.<WEBSITE_DOMAIN>` (e.g. `customer.sgemed.com`). **That** app owns login, register, OTP, and password reset for every storefront. The shop and the dashboard stay in sync via **parent-domain cookies** — not a login page on `{apex}`.
|
||
|
||
**Storefront requirements:**
|
||
- Do **not** build login / register / OTP / forgot-password UI.
|
||
- If the shopper must authenticate, send them to `https://customer.<WEBSITE_DOMAIN>/login?redirect=…` (relative redirect path only, e.g. `/checkout/cart`).
|
||
- On page load: if the access-token cookie exists, treat them as logged in (Continue can skip login).
|
||
- Do not invent OAuth/SSO APIs. Do not use `POST /auth/handoff` (staff-only into `business.<WEBSITE_DOMAIN>`).
|
||
|
||
**Cookie contract** (written by the **customer dashboard** on login; storefronts only **read** them):
|
||
|
||
| Cookie name | Value |
|
||
|-------------|--------|
|
||
| `meshkee_customer_access_token` | access JWT (URL-encoded; may be chunked as `name_n` + `name_0`…) |
|
||
| `meshkee_customer_refresh_token` | refresh JWT (same) |
|
||
|
||
| Attribute | Value |
|
||
|-----------|--------|
|
||
| `Domain` | `.<WEBSITE_DOMAIN>` (leading dot), e.g. `.sgemed.com` |
|
||
| `Path` | `/` |
|
||
| `SameSite` | `Lax` |
|
||
| `Max-Age` | ~30 days (cleared on logout) |
|
||
| `Secure` | set on HTTPS |
|
||
|
||
API calls (favorites, etc.) still send `Authorization: Bearer <accessToken>` from that cookie. Cookies are **not** sent to the API for auth.
|
||
|
||
**Wrong:** a custom login page on the storefront, or calling `/auth/login` from shop UI.
|
||
**Right:** redirect to `customer.<WEBSITE_DOMAIN>` and read apex cookies.
|
||
|
||
### Shopping cart on the storefront
|
||
|
||
Website scope is a **mini-cart only**. The full cart, checkout, addresses, and payment run on the customer dashboard.
|
||
|
||
**UI**
|
||
- Header shopping-cart icon.
|
||
- Badge = sum of line quantities (hide or `0` when empty).
|
||
- Click → popup: lines, qty, remove, total, **Continue**.
|
||
|
||
**Add to cart** (always a **variant**, never a product id)
|
||
|
||
1. `GET /tenants/{domain}/store-items/by-product/{productId}` → `{ storeItem: { variants: [...] } | null }`.
|
||
2. If `storeItem` is null or `variants` is empty → hide Add to cart (not for sale).
|
||
3. **In-stock only:** `stockQuantity === null` (unlimited) or `stockQuantity > 0`. Disable / omit the rest.
|
||
4. **If more than one in-stock variant:** list them first (`label` is already joined, e.g. `قرمز · XL` from `selections`). The shopper **must pick one** — do not add until they choose.
|
||
5. **If exactly one in-stock variant:** add that variant (no picker required).
|
||
6. Push that variant into the guest cart. `id` = variant `id` (`storeItemVariantId`). If that `id` is already in the cart, **increment `quantity`** instead of adding a second line.
|
||
7. Selling price: `discountedPrice ?? price`. Optional `originalPrice` = `price` when `discountedPrice` is set.
|
||
|
||
Do not call `POST /businesses/{id}/cart/items` from the storefront. Mini-cart is local only.
|
||
|
||
**Guest cart** (must match the customer dashboard):
|
||
|
||
| | |
|
||
|--|--|
|
||
| Storage key / cookie name | `meshkee-guest-cart` |
|
||
| Persist | `localStorage` **and** cookie `Domain=.<WEBSITE_DOMAIN>`, `Path=/`, `SameSite=Lax`, ~30 days |
|
||
| Cookie size | if `encodeURIComponent(json)` is longer than ~3500 chars, skip the cookie and rely on the URL param |
|
||
|
||
Each line:
|
||
|
||
```json
|
||
{
|
||
"id": "<storeItemVariantId>",
|
||
"name": "...",
|
||
"slug": "...",
|
||
"price": 123000,
|
||
"originalPrice": 150000,
|
||
"image": "...",
|
||
"quantity": 1
|
||
}
|
||
```
|
||
|
||
`id` **must** be `storeItemVariantId` (not product id, not store-item id). `price` is IRT, numeric.
|
||
|
||
**URL payload** (always pass on Continue — cookies can be dropped or too large):
|
||
|
||
```js
|
||
function encodeGuestCart(items) {
|
||
const json = JSON.stringify(items)
|
||
return btoa(unescape(encodeURIComponent(json)))
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=+$/, '')
|
||
}
|
||
```
|
||
|
||
Query/hash param name: `guestCart`.
|
||
|
||
**Continue** (replace `<WEBSITE_DOMAIN>`; `encoded = encodeGuestCart(items)`):
|
||
|
||
- Logged in (`meshkee_customer_access_token` present):
|
||
`https://customer.<WEBSITE_DOMAIN>/checkout/cart?guestCart=<encoded>`
|
||
- Not logged in:
|
||
`https://customer.<WEBSITE_DOMAIN>/login?redirect=${encodeURIComponent('/checkout/cart?guestCart=' + encoded)}`
|
||
|
||
The dashboard reads `guestCart` (query or hash), then `localStorage`, then the shared cookie, and syncs lines into the server cart after login.
|
||
|
||
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
|
||
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
|
||
|
||
### Checkout & payments (customer dashboard — not this website)
|
||
|
||
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
|
||
|
||
Nginx on the shop apex still proxies bank callbacks (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) to the API. Do not add a Next.js page for that path. After pay, the API redirects to the dashboard `returnUrl` (`/checkout/result?status=…`).
|
||
|
||
### User products (customer listings)
|
||
Public marketplace listings owned by customers — not catalog `products`.
|
||
- `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/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only)
|
||
- `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.
|
||
|
||
### Technical details / specs table (catalog products + user products)
|
||
**Do not** render only `technicalValues` from the detail endpoint — users will see bare values (e.g. `SAMP`, `1992`) with no labels.
|
||
|
||
| 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 .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) |
|
||
|
||
**How to render:**
|
||
1. Call `technical-info` (same slug/id as the detail page).
|
||
2. For each `values[]` entry, find `form.fields` where `field.id === value.fieldId`.
|
||
3. Show `field.label` (or localized label if the site uses FA/EN) next to the value (`textValue`, or resolve option labels from `field.options` when `optionId` / `optionIds` are set).
|
||
4. Follow `form.fields` `sortOrder` for display order.
|
||
5. If `form` is null/empty or `values` is empty, hide the technical section.
|
||
|
||
**Wrong:** `GET .../product-category-variation-fields?categoryId=…` (does not exist → 404).
|
||
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
||
|
||
### Analytics (dashboard charts)
|
||
Page views are recorded **automatically** for roughly one count per page load:
|
||
|
||
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
|
||
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
|
||
- **Content detail** — product, user-product, blog, portfolio, video, and store-item **detail** GETs
|
||
|
||
List APIs (`products`, `blogs`, …) and homepage sliders **do not** count (avoids multi-API inflation). Known bots (and empty User-Agent) are skipped. A browser refresh counts again.
|
||
|
||
Call `static-images?pageKey=…` on About/Contact (and similar) so those pages appear in dashboard statistics.
|
||
|
||
Optional explicit record (custom pages without static-images):
|
||
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||
- `kind` values: `home`, `static_page`, `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). Prefer detail / `home` / `static_page` for real page visits.
|
||
- 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`.
|
||
- The **Pages** statistics row counts `static_page` (about, contact, …).
|
||
|
||
### Static images
|
||
Named slots the business dashboard can replace. Fetch once per page:
|
||
- `GET /tenants/{domain}/website/static-images` — all slots
|
||
- `GET /tenants/{domain}/website/static-images?pageKey=home` — slots for one page (`home`, `products`, `about`, …)
|
||
- `GET /tenants/{domain}/website/static-images/{key}` — one slot (e.g. `home-hero`)
|
||
|
||
Use `slot.key` in the placeholder. Match `pageKey` to the website page. For a single image: `images[0]?.url`. For a list: map `images` (if `itemCount` is set, that many images are expected). Each image may include `titleFa`, `titleEn`, `subtext`, and `linkUrl`. If `linkUrl` is set, wrap in `<a href={linkUrl}>`. Pick `titleFa` or `titleEn` from the site locale. If `images` is empty, keep the local fallback.
|
||
|
||
**Also publish a slot catalog on this website** so the business dashboard Refresh button can import keys from the main domain:
|
||
|
||
`GET https://<WEBSITE_DOMAIN>/meshkee/static-image-slots`
|
||
|
||
```json
|
||
{
|
||
"slots": [
|
||
{
|
||
"key": "slider",
|
||
"label": "Homepage slider",
|
||
"kind": "list",
|
||
"pageKey": "home",
|
||
"aspectRatio": "16:9",
|
||
"itemCount": null,
|
||
"recommendedWidth": 1440
|
||
},
|
||
{
|
||
"key": "home-side-banner",
|
||
"label": "Side banner",
|
||
"kind": "single",
|
||
"pageKey": "home",
|
||
"aspectRatio": "12:19",
|
||
"itemCount": 1,
|
||
"recommendedWidth": 480
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs.
|
||
|
||
### Sitemap + robots.txt (SEO)
|
||
|
||
Meshkee generates both files on the API. Nginx on the storefront proxies them — **do not** ship `public/sitemap.xml` or `public/robots.txt` (and do not add Next.js `app/sitemap.ts` / `app/robots.ts` that override these paths).
|
||
|
||
| URL on this site | Served by |
|
||
|------------------|-----------|
|
||
| `https://<WEBSITE_DOMAIN>/sitemap.xml` | Meshkee API (proxied) |
|
||
| `https://<WEBSITE_DOMAIN>/robots.txt` | Meshkee API (proxied) — includes `Sitemap: https://<WEBSITE_DOMAIN>/sitemap.xml` |
|
||
|
||
**What the website must publish** (for static pages only):
|
||
|
||
`GET https://<WEBSITE_DOMAIN>/meshkee/sitemap-config.json`
|
||
|
||
Prefer generating this **at build time from app routes** (scan public static pages). Do not hand-maintain long lists in prompts.
|
||
|
||
Minimal example:
|
||
|
||
```json
|
||
{
|
||
"baseUrl": "https://<WEBSITE_DOMAIN>",
|
||
"staticPages": [
|
||
{ "path": "/", "changefreq": "daily", "priority": 1.0 },
|
||
{ "path": "/about", "priority": 0.6 },
|
||
{ "path": "/contact", "priority": 0.5 }
|
||
]
|
||
}
|
||
```
|
||
|
||
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||
- **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 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/{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.
|
||
- 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}`.
|
||
- Video: `/videos/{slug}` — use the video’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/{id}/{pathSlug}`.
|
||
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
|
||
- **Sitemap files (proxied by nginx — do not ship local copies):**
|
||
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
|
||
- `/sitemap-main.xml` — static pages + product categories
|
||
- `/sitemap-products.xml` — published products (when products module is enabled)
|
||
- `/sitemap-blogs.xml` — published blogs (when blog module is enabled)
|
||
- `/sitemap-portfolios.xml` — published portfolios (when portfolio module is enabled)
|
||
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
|
||
- `/sitemap-instructions.xml` — published instructions (when instructions 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`
|
||
|
||
### On-page SEO (every public page)
|
||
|
||
These are **required on every crawlable page** (home, listing, product/blog/portfolio detail, about, contact, etc.). Auth/checkout may be noindex.
|
||
|
||
**Document title (`<title>`)**
|
||
- Unique per page; include the primary topic + business name when space allows.
|
||
- Prefer CMS fields when present (`title`, `nameFa`/`nameEn`, product title, blog title). Never leave the Next.js default title.
|
||
|
||
**Meta description**
|
||
- Unique `<meta name="description" content="…">` on every public page (roughly 120–160 characters).
|
||
- Prefer CMS summary/excerpt/description when available; otherwise write a short page-specific sentence. Never empty, never identical across all pages.
|
||
|
||
**Headings**
|
||
- Exactly **one** `<h1>` per page — the main topic (product name, blog title, page title). Do not hide it with CSS-only “fake” headings.
|
||
- Use `<h2>` (then `<h3>`…) for real section structure under the H1. Do not skip levels for styling (don’t use H4 as a visual label without H2/H3).
|
||
- Do not use headings for nav logos, button labels, or decorative text.
|
||
|
||
**Images**
|
||
- Every meaningful `<img>` / Next.js `Image` must have a non-empty **`alt`** describing the image (product name, slide title, banner purpose).
|
||
- Prefer CMS title / `titleFa` / `titleEn` / media alt when available; for decorative icons use `alt=""` only when the image adds no information.
|
||
- Never leave missing `alt` on content images (hero, product gallery, blog cover, static-image slots, sliders).
|
||
|
||
**Open Graph (recommended)**
|
||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||
|
||
### Site-wide meta tags + trust badges (eNamad / Samandehi)
|
||
|
||
Business admins configure this under **Website → Tags & Badges**.
|
||
|
||
1. `GET /tenants/{domain}/website/business-info`
|
||
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
|
||
|
||
```html
|
||
{html}
|
||
```
|
||
|
||
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
|
||
|
||
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
|
||
|
||
- When arrays are empty, omit tags / footer badges.
|
||
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
|
||
|
||
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||
|
||
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||
|
||
1. `GET /tenants/{domain}/website/business-info`
|
||
2. If `schema.enabled` and `schema.jsonLd` is an object, render once in the root layout (or every public page):
|
||
|
||
```html
|
||
<script type="application/ld+json">
|
||
…paste schema.jsonLd…
|
||
</script>
|
||
```
|
||
|
||
In Next.js App Router, put it in the root layout via `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema.jsonLd) }} />` when `jsonLd` is non-null.
|
||
|
||
- Do **not** invent Organization fields locally when `jsonLd` is present — use the CMS object.
|
||
- When `schema.enabled` is false, `jsonLd` is `null` — omit the script tag.
|
||
- Tenant resolve also exposes `{ schema: { enabled, type } }` for lightweight checks; full payload is on business-info.
|
||
- Social profile URLs from business profile are merged into `sameAs` automatically; admin can add extra https URLs.
|
||
|
||
### Product + blog Schema.org JSON-LD (auto)
|
||
|
||
When **Website → Settings → Structured data** is enabled, detail endpoints include a ready `schema.jsonLd` (no admin fields to edit):
|
||
|
||
| Page | Endpoint | `@type` |
|
||
|------|----------|---------|
|
||
| Product detail | `GET /tenants/{domain}/products/by-id/{id}` (or by slug) | `Product` (+ `Offer` when store min price exists) |
|
||
| Blog detail | `GET /tenants/{domain}/blogs/{slug}` (or by id) | `BlogPosting` |
|
||
|
||
On the product/blog page:
|
||
|
||
```tsx
|
||
{product.schema?.jsonLd ? (
|
||
<script
|
||
type="application/ld+json"
|
||
dangerouslySetInnerHTML={{ __html: JSON.stringify(product.schema.jsonLd) }}
|
||
/>
|
||
) : null}
|
||
```
|
||
|
||
- Emit **in addition to** the site-wide Organization/LocalBusiness script from business-info.
|
||
- When structured data is disabled, `schema.jsonLd` is `null` — omit the tag.
|
||
- Do not invent Product/Article fields locally when `jsonLd` is present.
|
||
|
||
### Per-page SEO meta overrides (optional)
|
||
|
||
CMS forms expose optional **SEO title** / **SEO description** on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories.
|
||
|
||
Public detail payloads include:
|
||
|
||
- `seoMetaTitle` (`string | null`)
|
||
- `seoMetaDescription` (`string | null`)
|
||
|
||
Website rules:
|
||
|
||
1. Prefer `seoMetaTitle` for `<title>` when non-null; else use the normal title/`nameFa`.
|
||
2. Prefer `seoMetaDescription` for `<meta name="description">` when non-null; else summary/abstract/about.
|
||
3. Product/blog `schema.jsonLd` already prefers these overrides when set.
|
||
4. Empty/null means “no override” — never show placeholder text as meta.
|
||
|
||
**Checklist before shipping a page**
|
||
1. Unique `<title>` and meta description (use `seoMeta*` when present)
|
||
2. One clear H1 + sensible H2 sections
|
||
3. All content images have alt text
|
||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||
|
||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||
|
||
---
|
||
|
||
## What to tell each website team
|
||
|
||
Replace `<WEBSITE_DOMAIN>` once per project. Everything else is global — same Postman, same OpenAPI, same base URL. Remind them: **`output: "standalone"` is mandatory** for Meshkee Next deploys (shared VM RAM).
|