diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 280dafc..9e46bd0 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -30,16 +30,23 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) -2. 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`) -3. 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`). -4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). -5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` +2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `` / 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. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). +6. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` - Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank - Meshkee registers the bank `callback_url` on the **store apex** (`https://YOUR_WEBSITE_DOMAIN/meshkee/payments/{gateway}/callback`), which nginx proxies to the API. ZarinPal/Mellat domain checks must match the store domain, not `api.meshkee.com`. - After verify, API redirects the browser to `returnUrl?status=success|failed&orderId=…` - Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods` +### 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). + ### 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) diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index be3e6e5..63ecf17 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -1494,6 +1494,13 @@ "url": "{{baseUrl}}/tenants/{{domain}}/website/business-info" } }, + { + "name": "Get favicon + logos (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/favicon" + } + }, { "name": "List category groups (website)", "request": { diff --git a/docs/website-api/index.html b/docs/website-api/index.html index d3e5f38..213be2e 100644 --- a/docs/website-api/index.html +++ b/docs/website-api/index.html @@ -92,6 +92,15 @@
  • Cart / orders / favorites: /businesses/{businessId}/... + Bearer JWT.
  • +

    Branding (favicon + logos)

    +

    + GET /tenants/{domain}/website/favicon returns + faviconUrl, logoUrl, logoDarkUrl, and + hasDedicatedFavicon. Use faviconUrl for the browser tab icon + (Next.js metadata.icons). When no dedicated favicon is uploaded, + faviconUrl falls back to logoUrl. +

    +

    User products (customer listings)

    Marketplace-style stock listings created by customers. Public read-only under diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index c074032..1427e6c 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -176,7 +176,8 @@ }, "faviconUrl": { "type": "string", - "nullable": true + "nullable": true, + "description": "Tab icon URL. Dedicated favicon when uploaded; otherwise same as logoUrl." }, "specialProductsSource": { "type": "string", @@ -332,7 +333,99 @@ ], "responses": { "200": { - "description": "Business public profile" + "description": "Business public profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "nameFa": { "type": "string" }, + "about": { "type": "string" }, + "vision": { "type": "string" }, + "logoUrl": { "type": "string", "nullable": true }, + "logoDarkUrl": { + "type": "string", + "nullable": true, + "description": "Logo for dark backgrounds; null when not set." + }, + "faviconUrl": { + "type": "string", + "nullable": true, + "description": "Dedicated favicon when uploaded; otherwise same as logoUrl." + }, + "emails": { + "type": "array", + "items": { "type": "string" } + }, + "phoneNumbers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["landline", "cell"] }, + "number": { "type": "string" } + } + } + }, + "socialMedia": { "type": "object" }, + "addresses": { "type": "array", "items": { "type": "object" } } + } + } + } + } + } + } + } + }, + "/tenants/{domain}/website/favicon": { + "get": { + "tags": [ + "Homepage" + ], + "summary": "Favicon and logo URLs for site chrome", + "description": "Use `faviconUrl` for `` or Next.js `metadata.icons`. When no dedicated favicon is uploaded, `faviconUrl` equals `logoUrl`. Prefer this endpoint over tenant resolve when you only need branding assets.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Branding asset URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "faviconUrl": { + "type": "string", + "nullable": true, + "description": "Use for browser tab icon. Falls back to logoUrl when no dedicated favicon exists." + }, + "logoUrl": { + "type": "string", + "nullable": true, + "description": "Light-theme logo." + }, + "logoDarkUrl": { + "type": "string", + "nullable": true, + "description": "Dark-theme logo; null when not set." + }, + "hasDedicatedFavicon": { + "type": "boolean", + "description": "True when the business uploaded a separate favicon (not auto-derived from logo)." + } + }, + "required": ["faviconUrl", "logoUrl", "logoDarkUrl", "hasDedicatedFavicon"] + } + } + } + }, + "404": { + "description": "Unknown domain" } } } diff --git a/src/business-profile/business-profile.service.ts b/src/business-profile/business-profile.service.ts index a619e98..5ad18ab 100644 --- a/src/business-profile/business-profile.service.ts +++ b/src/business-profile/business-profile.service.ts @@ -163,7 +163,12 @@ export class BusinessProfileService { await this.assertLogoMedia(businessId, BigInt(dto.logoDarkMediaId)); } + if (dto.faviconMediaId !== undefined && dto.faviconMediaId !== null) { + await this.assertLogoMedia(businessId, BigInt(dto.faviconMediaId)); + } + const previousFaviconMediaId = business.faviconMediaId; + const faviconExplicitlySet = dto.faviconMediaId !== undefined; const logoChanged = dto.logoMediaId !== undefined && (dto.logoMediaId === null @@ -210,6 +215,13 @@ export class BusinessProfileService { : { connect: { id: BigInt(dto.logoDarkMediaId) } }; } + if (dto.faviconMediaId !== undefined) { + data.faviconMedia = + dto.faviconMediaId === null + ? { disconnect: true } + : { connect: { id: BigInt(dto.faviconMediaId) } }; + } + if (Object.keys(data).length > 0) { await tx.business.update({ where: { id: businessId }, @@ -234,7 +246,7 @@ export class BusinessProfileService { } }); - if (logoChanged) { + if (logoChanged && !faviconExplicitlySet) { if (dto.logoMediaId === null) { await this.deleteFaviconMedia(previousFaviconMediaId); } else if (dto.logoMediaId != null) { diff --git a/src/business-profile/dto/update-business-profile.dto.ts b/src/business-profile/dto/update-business-profile.dto.ts index ab8730e..155138c 100644 --- a/src/business-profile/dto/update-business-profile.dto.ts +++ b/src/business-profile/dto/update-business-profile.dto.ts @@ -115,6 +115,11 @@ export class UpdateBusinessProfileDto { @IsInt() logoDarkMediaId?: number | null; + @IsOptional() + @Type(() => Number) + @IsInt() + faviconMediaId?: number | null; + @IsOptional() @IsArray() @Type(() => Number) diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 280dafc..9e46bd0 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -30,16 +30,23 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) -2. 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`) -3. 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`). -4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). -5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` +2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `` / 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. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). +6. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` - Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank - Meshkee registers the bank `callback_url` on the **store apex** (`https://YOUR_WEBSITE_DOMAIN/meshkee/payments/{gateway}/callback`), which nginx proxies to the API. ZarinPal/Mellat domain checks must match the store domain, not `api.meshkee.com`. - After verify, API redirects the browser to `returnUrl?status=success|failed&orderId=…` - Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods` +### 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). + ### 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) diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index be3e6e5..63ecf17 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -1494,6 +1494,13 @@ "url": "{{baseUrl}}/tenants/{{domain}}/website/business-info" } }, + { + "name": "Get favicon + logos (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/favicon" + } + }, { "name": "List category groups (website)", "request": { diff --git a/src/website-docs/static/index.html b/src/website-docs/static/index.html index d3e5f38..213be2e 100644 --- a/src/website-docs/static/index.html +++ b/src/website-docs/static/index.html @@ -92,6 +92,15 @@

  • Cart / orders / favorites: /businesses/{businessId}/... + Bearer JWT.
  • +

    Branding (favicon + logos)

    +

    + GET /tenants/{domain}/website/favicon returns + faviconUrl, logoUrl, logoDarkUrl, and + hasDedicatedFavicon. Use faviconUrl for the browser tab icon + (Next.js metadata.icons). When no dedicated favicon is uploaded, + faviconUrl falls back to logoUrl. +

    +

    User products (customer listings)

    Marketplace-style stock listings created by customers. Public read-only under diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index c074032..1427e6c 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -176,7 +176,8 @@ }, "faviconUrl": { "type": "string", - "nullable": true + "nullable": true, + "description": "Tab icon URL. Dedicated favicon when uploaded; otherwise same as logoUrl." }, "specialProductsSource": { "type": "string", @@ -332,7 +333,99 @@ ], "responses": { "200": { - "description": "Business public profile" + "description": "Business public profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "nameFa": { "type": "string" }, + "about": { "type": "string" }, + "vision": { "type": "string" }, + "logoUrl": { "type": "string", "nullable": true }, + "logoDarkUrl": { + "type": "string", + "nullable": true, + "description": "Logo for dark backgrounds; null when not set." + }, + "faviconUrl": { + "type": "string", + "nullable": true, + "description": "Dedicated favicon when uploaded; otherwise same as logoUrl." + }, + "emails": { + "type": "array", + "items": { "type": "string" } + }, + "phoneNumbers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["landline", "cell"] }, + "number": { "type": "string" } + } + } + }, + "socialMedia": { "type": "object" }, + "addresses": { "type": "array", "items": { "type": "object" } } + } + } + } + } + } + } + } + }, + "/tenants/{domain}/website/favicon": { + "get": { + "tags": [ + "Homepage" + ], + "summary": "Favicon and logo URLs for site chrome", + "description": "Use `faviconUrl` for `` or Next.js `metadata.icons`. When no dedicated favicon is uploaded, `faviconUrl` equals `logoUrl`. Prefer this endpoint over tenant resolve when you only need branding assets.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + } + ], + "responses": { + "200": { + "description": "Branding asset URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "faviconUrl": { + "type": "string", + "nullable": true, + "description": "Use for browser tab icon. Falls back to logoUrl when no dedicated favicon exists." + }, + "logoUrl": { + "type": "string", + "nullable": true, + "description": "Light-theme logo." + }, + "logoDarkUrl": { + "type": "string", + "nullable": true, + "description": "Dark-theme logo; null when not set." + }, + "hasDedicatedFavicon": { + "type": "boolean", + "description": "True when the business uploaded a separate favicon (not auto-derived from logo)." + } + }, + "required": ["faviconUrl", "logoUrl", "logoDarkUrl", "hasDedicatedFavicon"] + } + } + } + }, + "404": { + "description": "Unknown domain" } } } diff --git a/src/website/website-favicon.controller.ts b/src/website/website-favicon.controller.ts new file mode 100644 index 0000000..d7782d1 --- /dev/null +++ b/src/website/website-favicon.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { WebsiteFaviconService } from './website-favicon.service'; + +@Controller('tenants/:host/website/favicon') +export class PublicWebsiteFaviconController { + constructor(private readonly service: WebsiteFaviconService) {} + + @Get() + get(@Param('host') host: string) { + return this.service.getPublic(host); + } +} diff --git a/src/website/website-favicon.service.ts b/src/website/website-favicon.service.ts new file mode 100644 index 0000000..f5c6345 --- /dev/null +++ b/src/website/website-favicon.service.ts @@ -0,0 +1,40 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; + +@Injectable() +export class WebsiteFaviconService { + constructor( + private readonly prisma: PrismaService, + private readonly tenant: TenantService, + ) {} + + async getPublic(host: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + + const record = await this.prisma.business.findUnique({ + where: { id: business.id }, + select: { + faviconMediaId: true, + logoMedia: { select: { publicUrl: true } }, + logoDarkMedia: { select: { publicUrl: true } }, + faviconMedia: { select: { publicUrl: true } }, + }, + }); + + if (!record) { + throw new NotFoundException('Business not found'); + } + + const logoUrl = record.logoMedia?.publicUrl ?? null; + const logoDarkUrl = record.logoDarkMedia?.publicUrl ?? null; + const faviconUrl = record.faviconMedia?.publicUrl ?? logoUrl; + + return { + faviconUrl, + logoUrl, + logoDarkUrl, + hasDedicatedFavicon: record.faviconMediaId != null, + }; + } +} diff --git a/src/website/website.module.ts b/src/website/website.module.ts index a96b29b..868ec18 100644 --- a/src/website/website.module.ts +++ b/src/website/website.module.ts @@ -16,6 +16,8 @@ import { PublicWebsiteBusinessInfoController, } from './website-business-info.controller'; import { WebsiteBusinessInfoService } from './website-business-info.service'; +import { PublicWebsiteFaviconController } from './website-favicon.controller'; +import { WebsiteFaviconService } from './website-favicon.service'; import { PublicWebsiteSlidersController, WebsiteSlidersController, @@ -39,6 +41,7 @@ import { WebsiteStaticImagesService } from './website-static-images.service'; PublicWebsiteStaticImagesController, WebsiteStaticImagesController, PublicWebsiteBusinessInfoController, + PublicWebsiteFaviconController, ], providers: [ WebsiteCategoryGroupsService, @@ -46,6 +49,7 @@ import { WebsiteStaticImagesService } from './website-static-images.service'; WebsiteSlidersService, WebsiteStaticImagesService, WebsiteBusinessInfoService, + WebsiteFaviconService, ], }) export class WebsiteModule {}