From 4c90916ec5fb5ed2a443302bbb663a16c7851be2 Mon Sep 17 00:00:00 2001 From: Bilal Mansouri <124762008+bighadj22@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:50:00 +0100 Subject: [PATCH] feat(products): add show-in-store toggle with landing-page exemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merchants can now hide a product from the storefront catalog while keeping it sellable through its published landing pages — the common COD pattern of running ad traffic to dedicated funnels without listing the product publicly. Server: - getStoreProductByHandle gains an allowUnlisted option that drops only the showInStore gate; status/visibility/soft-delete still apply - the landing-page render path passes allowUnlisted so LPs render the full store-product shape (variants, offers, inventory, review stats) and accept orders for store-hidden products - catalog list and the product's own store page remain gated by all four green lights (unchanged) Dashboard: - "Show in store" toggle in the product form Settings card, sent on create and update, loaded when editing - "Hidden from store" badge on product list rows (desktop + mobile) and the product detail page - ar/en/fr translations for the new strings Docs: store README + landing-page route description updated to match the shipped behavior. Tests: new e2e on real D1 proving the contract — hidden product renders and sells on its LP (pricing, attribution, stock deduction), is absent from the store catalog, and 404s on its own store page. --- cod-client-astro/locales/ar/products.json | 5 +- cod-client-astro/locales/en/products.json | 5 +- cod-client-astro/locales/fr/products.json | 5 +- .../products/components/ProductDetail.tsx | 3 + .../products/components/ProductForm.tsx | 5 + .../products/components/ProductRow.tsx | 11 +- .../components/ProductSettingsCard.tsx | 25 ++ .../landing-pages.hidden-product-e2e.test.ts | 225 ++++++++++++++++++ cod-server/src/endpoints/store/README.md | 1 + cod-server/src/endpoints/store/handlers.ts | 11 +- cod-server/src/endpoints/store/routes.ts | 2 +- cod-shared/queries/store.ts | 32 ++- 12 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 cod-server/src/endpoints/landing-pages/landing-pages.hidden-product-e2e.test.ts diff --git a/cod-client-astro/locales/ar/products.json b/cod-client-astro/locales/ar/products.json index 8fe8e2ae..36e0949c 100644 --- a/cod-client-astro/locales/ar/products.json +++ b/cod-client-astro/locales/ar/products.json @@ -40,7 +40,8 @@ "status": { "available": "متاح", "out_of_stock": "غير متوفر", - "low_stock": "مخزون منخفض" + "low_stock": "مخزون منخفض", + "hidden_from_store": "مخفي من المتجر" }, "stock": { "in_stock": "في المخزون", @@ -141,6 +142,8 @@ "threshold_label": "حد التنبيه", "threshold_hint": "تنبيه عند وصول المخزون لهذا الحد", "track_stock_hint": "مراقبة مستويات المخزون تلقائياً", + "show_in_store_label": "إظهار في المتجر", + "show_in_store_hint": "المنتجات المخفية لا تظهر في قائمة المتجر ولا في صفحتها — لكن صفحات الهبوط الخاصة بها تظل تعمل كالمعتاد.", "bulk_fill": "ملء جماعي", "bulk_price_placeholder": "السعر", "bulk_price_apply": "تطبيق السعر", diff --git a/cod-client-astro/locales/en/products.json b/cod-client-astro/locales/en/products.json index b990662d..8eb78098 100644 --- a/cod-client-astro/locales/en/products.json +++ b/cod-client-astro/locales/en/products.json @@ -40,7 +40,8 @@ "status": { "available": "Available", "out_of_stock": "Out of Stock", - "low_stock": "Low Stock" + "low_stock": "Low Stock", + "hidden_from_store": "Hidden from store" }, "stock": { "in_stock": "In Stock", @@ -141,6 +142,8 @@ "threshold_label": "Alert Threshold", "threshold_hint": "Alert when stock drops to this level", "track_stock_hint": "Monitor inventory levels automatically", + "show_in_store_label": "Show in store", + "show_in_store_hint": "Hidden products don't appear in the storefront catalog or product page — their landing pages keep selling as normal.", "bulk_fill": "Bulk fill", "bulk_price_placeholder": "Price", "bulk_price_apply": "Set price", diff --git a/cod-client-astro/locales/fr/products.json b/cod-client-astro/locales/fr/products.json index 18154d1d..7de81892 100644 --- a/cod-client-astro/locales/fr/products.json +++ b/cod-client-astro/locales/fr/products.json @@ -40,7 +40,8 @@ "status": { "available": "Disponible", "out_of_stock": "Rupture de stock", - "low_stock": "Stock faible" + "low_stock": "Stock faible", + "hidden_from_store": "Masqué de la boutique" }, "stock": { "in_stock": "En stock", @@ -141,6 +142,8 @@ "threshold_label": "Seuil d'alerte", "threshold_hint": "Alerte lorsque le stock descend à ce niveau", "track_stock_hint": "Surveiller automatiquement les niveaux d'inventaire", + "show_in_store_label": "Afficher dans la boutique", + "show_in_store_hint": "Les produits masqués n'apparaissent ni dans le catalogue ni sur leur page — leurs pages de destination continuent de vendre normalement.", "bulk_fill": "Remplissage groupé", "bulk_price_placeholder": "Prix", "bulk_price_apply": "Appliquer le prix", diff --git a/cod-client-astro/src/features/products/components/ProductDetail.tsx b/cod-client-astro/src/features/products/components/ProductDetail.tsx index 6cf46375..3ccf7615 100644 --- a/cod-client-astro/src/features/products/components/ProductDetail.tsx +++ b/cod-client-astro/src/features/products/components/ProductDetail.tsx @@ -200,6 +200,9 @@ export function ProductDetail({ productId }: { productId: string }) {
+ {product.showInStore === false && ( + {t("status.hidden_from_store")} + )} {product.sku && ( {product.sku} diff --git a/cod-client-astro/src/features/products/components/ProductForm.tsx b/cod-client-astro/src/features/products/components/ProductForm.tsx index eb461616..97ee5d4b 100644 --- a/cod-client-astro/src/features/products/components/ProductForm.tsx +++ b/cod-client-astro/src/features/products/components/ProductForm.tsx @@ -81,6 +81,7 @@ export function ProductForm({ productId }: { productId?: string }) { const [costPrice, setCostPrice] = useState(""); const [status, setStatus] = useState("ACTIVE"); const [trackInventory, setTrackInventory] = useState(true); + const [showInStore, setShowInStore] = useState(true); const [inventory, setInventory] = useState("0"); const [lowStockThreshold, setLowStockThreshold] = useState("5"); @@ -168,6 +169,7 @@ export function ProductForm({ productId }: { productId?: string }) { setCostPrice(product.costPrice ? String(product.costPrice) : ""); setStatus(product.status); setTrackInventory(product.trackInventory); + setShowInStore(product.showInStore); setInventory(String(product.inventory)); setLowStockThreshold(String(product.lowStockThreshold ?? 5)); if (product.hasVariants) { @@ -304,6 +306,7 @@ export function ProductForm({ productId }: { productId?: string }) { costPrice: costPrice ? Math.round(Number(costPrice)) : undefined, status, trackInventory, + showInStore, ...(editing ? {} : { inventory: hasVariants ? 0 : Number(inventory) || 0 }), @@ -508,6 +511,8 @@ export function ProductForm({ productId }: { productId?: string }) { setLowStockThreshold={setLowStockThreshold} trackInventory={trackInventory} setTrackInventory={setTrackInventory} + showInStore={showInStore} + setShowInStore={setShowInStore} hasVariantsSwitch={hasVariantsSwitch} editing={editing} busy={busy} diff --git a/cod-client-astro/src/features/products/components/ProductRow.tsx b/cod-client-astro/src/features/products/components/ProductRow.tsx index 2f2a373d..bc455fa5 100644 --- a/cod-client-astro/src/features/products/components/ProductRow.tsx +++ b/cod-client-astro/src/features/products/components/ProductRow.tsx @@ -16,6 +16,11 @@ function ProductStatusBadge({ product }: { product: Product }) { return {t("status_options.archived")}; } +function HiddenFromStoreBadge() { + const t = useT("products"); + return {t("status.hidden_from_store")}; +} + function StockValue({ product }: { product: Product }) { const t = useT("products"); const total = product.totalInventory ?? product.inventory ?? 0; @@ -93,7 +98,10 @@ export function ProductDesktopRow({ product, categoryMap, canManage, onDelete }: - +
+ + {product.showInStore === false && } +
{formatMoneyValue(product.price, locale)} @@ -169,6 +177,7 @@ export function ProductMobileCard({ product, categoryMap, canManage, onDelete }: )}
+ {product.showInStore === false && } {category && ( {category.name} diff --git a/cod-client-astro/src/features/products/components/ProductSettingsCard.tsx b/cod-client-astro/src/features/products/components/ProductSettingsCard.tsx index 437f7517..b4725d57 100644 --- a/cod-client-astro/src/features/products/components/ProductSettingsCard.tsx +++ b/cod-client-astro/src/features/products/components/ProductSettingsCard.tsx @@ -13,6 +13,8 @@ interface ProductSettingsCardProps { setLowStockThreshold: (val: string) => void; trackInventory: boolean; setTrackInventory: (val: boolean) => void; + showInStore: boolean; + setShowInStore: (val: boolean) => void; hasVariantsSwitch: boolean; editing: boolean; busy: boolean; @@ -27,6 +29,8 @@ export function ProductSettingsCard({ setLowStockThreshold, trackInventory, setTrackInventory, + showInStore, + setShowInStore, hasVariantsSwitch, editing, busy, @@ -97,6 +101,27 @@ export function ProductSettingsCard({ />
+
+ +
); } diff --git a/cod-server/src/endpoints/landing-pages/landing-pages.hidden-product-e2e.test.ts b/cod-server/src/endpoints/landing-pages/landing-pages.hidden-product-e2e.test.ts new file mode 100644 index 00000000..d93fc8eb --- /dev/null +++ b/cod-server/src/endpoints/landing-pages/landing-pages.hidden-product-e2e.test.ts @@ -0,0 +1,225 @@ +/** + * Landing Pages — store-hidden products: the showInStore=false contract. + * + * A product hidden from the storefront catalog (showInStore=false) is a + * landing-page-only product: its published LP renders and sells as normal, + * while the catalog list and the product's own store page stay hidden. + * Proven end-to-end on real D1 through the real store router. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Miniflare } from "miniflare"; +import { readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/d1"; +import { eq } from "drizzle-orm"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import type { AppContext } from "@/types"; +import { errorHandler } from "@/middleware/error"; +import { openApiValidationHook } from "@/openapi/validation-hook"; +import { storeAuthMiddleware } from "@/middleware/storeAuth"; +import storeRouter from "../store/routes"; +import * as schema from "@/db/schema"; +import type { AppDb } from "@/db"; + +let db: AppDb; +let rawD1: D1Database; +let app: OpenAPIHono; +const STORE_KEY = "store-key-hidden-test"; + +beforeAll(async () => { + const mf = new Miniflare({ + script: "export default { fetch() { return new Response('ok'); } }", + modules: true, + d1Databases: { DB: "test-db" }, + }); + rawD1 = await mf.getD1Database("DB"); + const dir = resolve(__dirname, "../../db/migrations"); + const prepared: D1PreparedStatement[] = []; + for (const file of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) { + const statements = readFileSync(`${dir}/${file}`, "utf8") + .split("--> statement-breakpoint") + .flatMap((s) => s.split(/;\s*\n/)) + .map((s) => s.replace(/;+\s*$/, "").trim()) + .filter((s) => s.replace(/--[^\n]*/g, "").trim().length > 0); + for (const statement of statements) prepared.push(rawD1.prepare(statement)); + } + for (let i = 0; i < prepared.length; i += 50) { + await rawD1.batch(prepared.slice(i, i + 50)); + } + db = drizzle(rawD1 as unknown as D1Database, { schema }) as unknown as AppDb; + + app = new OpenAPIHono({ defaultHook: openApiValidationHook }); + app.use("*", async (c, next) => { + c.env = { DB: rawD1 as any } as any; + await next(); + }); + app.use("/store/*", storeAuthMiddleware); + app.onError(errorHandler); + app.route("/store", storeRouter); + + registry.push(mf); +}, 120_000); + +const registry: Miniflare[] = []; +afterAll(async () => { + for (const mf of registry) await mf.dispose(); +}); + +const NOW = () => new Date().toISOString(); +let seq = 0; + +const STORE_HEADERS = { "X-Store-API-Key": STORE_KEY }; + +async function getAndSettle(path: string): Promise { + const pending: Promise[] = []; + const executionCtx = { + waitUntil: (p: Promise) => pending.push(p), + passThroughOnException: () => {}, + props: {} as Record, + }; + const res = await app.request(path, { headers: STORE_HEADERS }, undefined, executionCtx); + await Promise.allSettled(pending); + return res; +} + +async function postAndSettle(path: string, body: unknown): Promise { + const pending: Promise[] = []; + const executionCtx = { + waitUntil: (p: Promise) => pending.push(p), + passThroughOnException: () => {}, + props: {} as Record, + }; + const res = await app.request( + path, + { + method: "POST", + headers: { ...STORE_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + undefined, + executionCtx, + ); + await Promise.allSettled(pending); + return res; +} + +async function sha256hex(text: string): Promise { + const data = new TextEncoder().encode(text); + const hash = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function seedStore(): Promise { + await db.insert(schema.stores).values({ + id: "store-hidden-1", name: "Hidden Product Store", lang: "ar", currency: "DZD", + currencySymbol: "دج", createdAt: NOW(), updatedAt: NOW(), + }); + await db.insert(schema.storeApiKeys).values({ + id: "sak-hidden-1", storeId: "store-hidden-1", + keyHash: await sha256hex(STORE_KEY), name: "test", + createdAt: NOW(), + }); + return "store-hidden-1"; +} + +async function seedProduct(opts: { showInStore: boolean }): Promise<{ id: string; handle: string }> { + const id = `prod-hid-${++seq}`; + const handle = `hid-product-${seq}`; + await db.insert(schema.products).values({ + id, name: `HID Product ${seq}`, handle, price: 5000, + sku: `HID-SKU-${seq}`, + hasVariants: false, inventory: 10, trackInventory: true, lowStockThreshold: 2, + status: "ACTIVE", visibility: true, showInStore: opts.showInStore, storeFeatured: false, + createdAt: NOW(), updatedAt: NOW(), + }); + return { id, handle }; +} + +async function seedPublishedLp(slug: string, productId: string) { + const now = NOW(); + await db.insert(schema.landingPages).values({ + id: slug, slug, name: `LP ${slug}`, productId, status: "published", + publishedAt: now, createdAt: now, updatedAt: now, + }); + await db.insert(schema.landingPageImages).values({ + id: `${slug}-img-1`, landingPageId: slug, + r2Key: `landing/${slug}-1.jpg`, src: `https://m.example/${slug}-1.jpg`, + altText: "صورة 1", source: "upload", position: 1, createdAt: now, + }); +} + +describe("store-hidden products (showInStore=false) — landing pages keep selling", () => { + beforeAll(async () => { + await seedStore(); + }); + + it("a store-hidden product renders on its published LP in the full product shape", async () => { + const { id, handle } = await seedProduct({ showInStore: false }); + await seedPublishedLp("hidden-live", id); + + const res = await getAndSettle("/store/landing-pages/hidden-live"); + expect(res.status).toBe(200); + const body: any = await res.json(); + + expect(body.data.product).toMatchObject({ + id, handle, price: 5000, inventory: 10, showInStore: false, + }); + expect(Array.isArray(body.data.product.offers)).toBe(true); + }); + + it("the same product is absent from the catalog list while visible products remain", async () => { + const hidden = await seedProduct({ showInStore: false }); + const visible = await seedProduct({ showInStore: true }); + + const res = await getAndSettle("/store/products?limit=100"); + expect(res.status).toBe(200); + const body: any = await res.json(); + const handles = body.data.map((p: any) => p.handle); + + expect(handles).toContain(visible.handle); + expect(handles).not.toContain(hidden.handle); + }); + + it("the hidden product's own store page 404s", async () => { + const { handle } = await seedProduct({ showInStore: false }); + + const res = await getAndSettle(`/store/products/${handle}`); + expect(res.status).toBe(404); + }); + + it("orders for the hidden product via its LP succeed — catalog price, attribution, stock", async () => { + const { id, handle } = await seedProduct({ showInStore: false }); + await seedPublishedLp("hidden-order", id); + + const res = await postAndSettle("/store/orders", { + customerName: "Hidden Product Buyer", + phone: "0555987654", + wilayaId: 16, + communeId: "c-16-001", + address: "x", + deliveryType: "home", + productId: id, + productName: `HID Product ${seq}`, + quantity: 2, + pricePerUnit: 5000, + landingPageSlug: "hidden-order", + }); + expect(res.status).toBe(201); + const body: any = await res.json(); + expect(body.data.price).toBe(10000); + + const order = await db.select().from(schema.orders) + .where(eq(schema.orders.id, body.data.orderId)).get(); + expect(order?.landingPageId).toBe("hidden-order"); + + const product = await db.select().from(schema.products) + .where(eq(schema.products.id, id)).get(); + expect(product?.inventory).toBe(8); + + // The handle stays resolvable only through the LP — the store page still 404s. + const page = await getAndSettle(`/store/products/${handle}`); + expect(page.status).toBe(404); + }); +}); diff --git a/cod-server/src/endpoints/store/README.md b/cod-server/src/endpoints/store/README.md index 5d05cd9c..14401296 100644 --- a/cod-server/src/endpoints/store/README.md +++ b/cod-server/src/endpoints/store/README.md @@ -26,6 +26,7 @@ Unlike other `api/*` endpoints, this module uses a dedicated authentication head - `GET /store/config`: Get public branding, theme, and SEO settings. - `GET /store/products`: Paginated product catalog. Only returns products that are `ACTIVE`, `visibility=true`, `showInStore=true`, and not soft-deleted — ordered featured-first, then newest. - `GET /store/products/:handle`: Detailed product info fetched by URL slug (handle). Includes variants, images, and approved review stats. +- `GET /store/landing-pages/:slug`: Published landing page with its linked product in full store-product shape. Store-hidden products (`showInStore=false`) still render here — the landing page is their sales channel; only the catalog list and the product's own page stay hidden. - `GET /store/categories`: List all categories in display order. ### 2. Location & Shipping diff --git a/cod-server/src/endpoints/store/handlers.ts b/cod-server/src/endpoints/store/handlers.ts index ff91423b..3a9f5c02 100644 --- a/cod-server/src/endpoints/store/handlers.ts +++ b/cod-server/src/endpoints/store/handlers.ts @@ -57,11 +57,14 @@ export async function getStoreLandingPage(c: Context) { // The page renders the store product exactly like the product page does — // same shape (variants, offers, inventory, review stats) so the theme's - // form + scripts work unmodified. A hidden/unavailable product still - // renders: the merchant published the link deliberately; the order engine - // guards sellability. + // form + scripts work unmodified. A store-hidden product (showInStore=false) + // still renders: the merchant published the link deliberately, and the + // landing page is its sales channel. The other gates (ACTIVE, visibility, + // not deleted) still apply, and the order engine guards sellability. const product = lp.product?.handle - ? await queries.getStoreProductByHandle(db, lp.product.handle) + ? await queries.getStoreProductByHandle(db, lp.product.handle, { + allowUnlisted: true, + }) : null; // One render = one view. Atomic single-row UPDATE, deferred via waitUntil diff --git a/cod-server/src/endpoints/store/routes.ts b/cod-server/src/endpoints/store/routes.ts index 2d7f271c..6821f305 100644 --- a/cod-server/src/endpoints/store/routes.ts +++ b/cod-server/src/endpoints/store/routes.ts @@ -145,7 +145,7 @@ const getStoreLandingPageRoute = defineRoute({ tags: ["Store API"], summary: "Get published landing page", description: - "Get a published landing page by its public slug: the ordered image stack, spacing settings, and the linked product in its full store-product shape (variants, offers, inventory) so the storefront order form works unmodified. Draft/archived/unknown slugs return 404. Each successful GET increments the page's view counter (non-unique in v1).", + "Get a published landing page by its public slug: the ordered image stack, spacing settings, and the linked product in its full store-product shape (variants, offers, inventory) so the storefront order form works unmodified. Store-hidden products (`showInStore=false`) still render here — the landing page is their sales channel; the catalog list and the product's own page stay hidden. Draft/archived/unknown slugs return 404. Each successful GET increments the page's view counter (non-unique in v1).", operationId: "getStoreLandingPage", params: lpSlugParams, responses: { diff --git a/cod-shared/queries/store.ts b/cod-shared/queries/store.ts index 4d6fa73b..478d91f0 100644 --- a/cod-shared/queries/store.ts +++ b/cod-shared/queries/store.ts @@ -198,19 +198,31 @@ export async function getStoreProducts( }); } -export async function getStoreProductByHandle(db: AppDb, handle: string) { +/** + * Resolve a product by handle in its full store-product shape. + * + * The four catalog green lights apply by default (status=ACTIVE, visibility, + * showInStore, not soft-deleted). `allowUnlisted` drops ONLY the showInStore + * gate — landing pages link their product deliberately, so a store-hidden + * (unlisted) product still renders there; the other gates still apply. + */ +export async function getStoreProductByHandle( + db: AppDb, + handle: string, + opts?: { allowUnlisted?: boolean }, +) { + const conditions = [ + eq(products.handle, handle), + eq(products.status, "ACTIVE"), + eq(products.visibility, true), + isNull(products.deletedAt), + ]; + if (!opts?.allowUnlisted) conditions.push(eq(products.showInStore, true)); + const product = await db .select() .from(products) - .where( - and( - eq(products.handle, handle), - eq(products.showInStore, true), - eq(products.status, "ACTIVE"), - eq(products.visibility, true), - isNull(products.deletedAt), - ), - ) + .where(and(...conditions)) .get(); if (!product) return null;