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>
26 KiB
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
- Resolve tenant first:
GET /tenants/<WEBSITE_DOMAIN>→ savebusinessIdfromid. - All public content uses
/tenants/<WEBSITE_DOMAIN>/...(no auth). - 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/cartroutes (or equivalent pages) on the storefront. Do not call/businesses/{businessId}/cartor/cart/checkoutfrom this site. - 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.
- 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.
- 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 returns409withCELL_EXISTS_OTHER_SITE:.... Retry register with"acknowledgeExistingAccount": trueto link that account (profile unchanged; password is replaced with the new signup password), then complete SMS OTP. - Cell numbers are E.164 (
+98912...). - Do not call dashboard/CMS routes (
/businesses/.../productswrite APIs, media upload, domain-admin, etc.). - Partner SMS (
POST /public/sms/send) is for external partner backends with an issuedX-Api-Keyonly — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md - Technical details (labels + values): Product/user-product detail responses may include
technicalValueswith values only (fieldId+textValue/optionId/optionIds— no field labels). To render a label→value specs table you must call the matching.../technical-infoendpoint and joinform.fields[].id↔values[].fieldId. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API. - Torob: Do not add a Next.js route for
/torob_api. Meshkee nginx on the store apex proxiesPOST /torob_api/v3/productsto 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. - Production runtime (required): Meshkee hosts many Next.js storefronts on one shared websites VM. Every Next site must set
output: "standalone"innext.config(.ts/.mjs/.js). Deploy detects.next/standalone/server.js, points PM2 at thatserver.js, and deletes the fullnode_modules. Target RSS is ~80–120 MB. Do not shipnext startwith a fullnode_modulesruntime (that uses ~150–500+ MB and OOMs the host). Do not useoutput: "export"unless the project explicitly asks for a static export. Vinext apps (vinextinpackage.json/vinext start) are a separate intentional stack — do not pretend they use Next standalone.
Production runtime (Next.js)
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
GET /tenants/{domain}→ branding +businessId+specialProductsSource(productorstore_item)GET /tenants/{domain}/website/favicon→faviconUrlfor<link rel="icon">/ Next.jsmetadata.icons(falls back tologoUrlwhen no dedicated favicon). Also returnslogoUrl,logoDarkUrl,hasDedicatedFavicon.- Homepage: business-info, static-images, sliders, category-groups, brand-groups, store-specials (
sourcerepeats the tenant setting; items are store listings whensourceisstore_item) - Catalog: categories, products (
GET /products/{slug}includesrelatedProducts: same category then same brand, in-stock first), store-items (nameinstant search: in-stock first, thenupdatedAt), user-products (customer stock listings). Portfolios list newest first (sortOrderdesc, thenpublishedAt/createdAtdesc); each portfolio may include nullableprojectUrl(external website link). List filters: products/blogs/portfolios/videos accept?tag=(exact match onmetadata.tags). - 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. - 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
faviconUrlin layout metadata (Next.js:icons: [{ url: faviconUrl, type: 'image/png' }]when set). - When
hasDedicatedFaviconis false,faviconUrlequals the lightlogoUrl— still safe to use as tab icon. - Header/footer logos:
logoUrlon light backgrounds,logoDarkUrlon dark (fallback tologoUrlin 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 intobusiness.<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
0when empty). - Click → popup: lines, qty, remove, total, Continue.
Add to cart (always a variant, never a product id)
GET /tenants/{domain}/store-items/by-product/{productId}→{ storeItem: { variants: [...] } | null }.- If
storeItemis null orvariantsis empty → hide Add to cart (not for sale). - In-stock only:
stockQuantity === null(unlimited) orstockQuantity > 0. Disable / omit the rest. - If more than one in-stock variant: list them first (
labelis already joined, e.g.قرمز · XLfromselections). The shopper must pick one — do not add until they choose. - If exactly one in-stock variant: add that variant (no picker required).
- Push that variant into the guest cart.
id= variantid(storeItemVariantId). If thatidis already in the cart, incrementquantityinstead of adding a second line. - Selling price:
discountedPrice ?? price. OptionaloriginalPrice=pricewhendiscountedPriceis 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:
{
"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):
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_tokenpresent):
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 includesid,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 idGET /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 fromGET /tenants/{domain}/categories?entityType=productfor 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:
- Call
technical-info(same slug/id as the detail page). - For each
values[]entry, findform.fieldswherefield.id === value.fieldId. - Show
field.label(or localized label if the site uses FA/EN) next to the value (textValue, or resolve option labels fromfield.optionswhenoptionId/optionIdsare set). - Follow
form.fieldssortOrderfor display order. - If
formis null/empty orvaluesis 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(orcontact, etc.) → counted asstatic_pagewithpath=/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/viewsbody{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }kindvalues: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 aliaseswebsite/product/portfolio/blogstill accepted). Prefer detail /home/static_pagefor real page visits.- Events kept ~6 months for charts; lifetime totals kept forever in counters.
- The business dashboard Product views chart counts both
product_detailanduser_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 slotsGET /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
{
"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:
{
"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}— buildnameFaSlugfromnameFa(fallbacktitle); resolve page viaGET /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 fromnameFa(fallbackname); resolve viaGET /tenants/{domain}/categories/by-id/{id}, then list products withcategoryId. - User product (customer listing):
/user-products/{id}/{pathSlug}— useid+pathSlugfrom the list/detail API (or slugifytitleFa/titleEn); resolve viaGET /tenants/{domain}/user-products/by-id/{id}(slug segment is SEO-only). - Blog:
/blog/{slug}— use the blog’sslugfrom the API; resolve viaGET /tenants/{domain}/blogs/{slug}(or list + match). Prefer slug routes over id. - Portfolio:
/portfolio/{slug}— use the portfolio’sslugfrom the API; resolve viaGET /tenants/{domain}/portfolios/{slug}. - Instruction:
/instruction/{slug}— use the instruction’sslugfrom the API; resolve viaGET /tenants/{domain}/instructions/{slug}. - Video:
/videos/{slug}— use the video’sslugfrom the API. - Workshop:
/workshops/{slug}— use the workshop’sslugfrom the API.
- Product:
- 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
templatesinsitemap-config.jsonunless 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 (whencustomer_productsmodule 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.jsImagemust have a non-emptyaltdescribing the image (product name, slide title, banner purpose). - Prefer CMS title /
titleFa/titleEn/ media alt when available; for decorative icons usealt=""only when the image adds no information. - Never leave missing
alton content images (hero, product gallery, blog cover, static-image slots, sliders).
Open Graph (recommended)
- Set
og:title,og:description, andog:imageon 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.
GET /tenants/{domain}/website/business-info- For each item in
metaTags(array of{ html }), emit the full HTML once in the root layout<head>.
{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.
- For each item in
trustBadges(array of{ kind, embedHtml }), renderembedHtmlin 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.
GET /tenants/{domain}/website/business-info- If
schema.enabledandschema.jsonLdis an object, render once in the root layout (or every public page):
<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
jsonLdis present — use the CMS object. - When
schema.enabledis false,jsonLdisnull— 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
sameAsautomatically; 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:
{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.jsonLdisnull— omit the tag. - Do not invent Product/Article fields locally when
jsonLdis 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:
- Prefer
seoMetaTitlefor<title>when non-null; else use the normal title/nameFa. - Prefer
seoMetaDescriptionfor<meta name="description">when non-null; else summary/abstract/about. - Product/blog
schema.jsonLdalready prefers these overrides when set. - Empty/null means “no override” — never show placeholder text as meta.
Checklist before shipping a page
- Unique
<title>and meta description (useseoMeta*when present) - One clear H1 + sensible H2 sections
- All content images have alt text
- Public URL is included via CMS sitemap and/or
sitemap-config.jsonstatic pages - Site-wide Organization/LocalBusiness JSON-LD from
business-info.schema.jsonLdwhen enabled - Product/blog detail pages emit
product.schema.jsonLd/blog.schema.jsonLdwhen present - Site-wide
metaTags+ footertrustBadgesfrom 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).