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({
/>
+
+
+
+
+ {t("form.show_in_store_label")}
+
+
+ {t("form.show_in_store_hint")}
+
+
+
+ setShowInStore(event.currentTarget.checked)
+ }
+ disabled={busy}
+ className="size-5 accent-primary"
+ />
+
+
);
}
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;