@aglyn/plugins-commerce 1.0.0-beta.244 → 1.0.0-beta.247

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +4 -1
  2. package/package.json +13 -13
  3. package/src/lib/components/commerce-console-page.js +10 -0
  4. package/src/lib/components/commerce-console-page.js.map +1 -1
  5. package/src/lib/components/console/local-delivery-settings-card.component.js +10 -13
  6. package/src/lib/components/console/local-delivery-settings-card.component.js.map +1 -1
  7. package/src/lib/components/console/locations-card.component.js +5 -9
  8. package/src/lib/components/console/locations-card.component.js.map +1 -1
  9. package/src/lib/components/console/product-zones.d.ts +7 -1
  10. package/src/lib/components/console/product-zones.js.map +1 -1
  11. package/src/lib/components/console/products-hub-zone.component.d.ts +3 -2
  12. package/src/lib/components/console/products-hub-zone.component.js +9 -1
  13. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  14. package/src/lib/components/console/review-settings-card.component.d.ts +14 -0
  15. package/src/lib/components/console/review-settings-card.component.js +168 -0
  16. package/src/lib/components/console/review-settings-card.component.js.map +1 -0
  17. package/src/lib/components/console/reviews-moderation-card.component.d.ts +14 -0
  18. package/src/lib/components/console/reviews-moderation-card.component.js +85 -11
  19. package/src/lib/components/console/reviews-moderation-card.component.js.map +1 -1
  20. package/src/lib/components/product-reviews.d.ts +29 -1
  21. package/src/lib/components/product-reviews.js +97 -12
  22. package/src/lib/components/product-reviews.js.map +1 -1
  23. package/src/lib/model/commerce.d.ts +11 -2
  24. package/src/lib/model/commerce.js +14 -2
  25. package/src/lib/model/commerce.js.map +1 -1
  26. package/src/lib/model/index.d.ts +1 -0
  27. package/src/lib/model/index.js +1 -0
  28. package/src/lib/model/index.js.map +1 -1
  29. package/src/lib/model/product-reviews.d.ts +124 -0
  30. package/src/lib/model/product-reviews.js +133 -0
  31. package/src/lib/model/product-reviews.js.map +1 -0
  32. package/src/lib/model/store-setup-content.d.ts +145 -0
  33. package/src/lib/model/store-setup-content.js +813 -0
  34. package/src/lib/model/store-setup-content.js.map +1 -0
  35. package/src/lib/server/membership-reset.js +8 -2
  36. package/src/lib/server/membership-reset.js.map +1 -1
  37. package/src/lib/server/order-event-triggers.d.ts +2 -0
  38. package/src/lib/server/order-event-triggers.js +18 -1
  39. package/src/lib/server/order-event-triggers.js.map +1 -1
  40. package/src/lib/server/review-requests.d.ts +102 -0
  41. package/src/lib/server/review-requests.js +346 -0
  42. package/src/lib/server/review-requests.js.map +1 -0
  43. package/src/lib/server/review-tokens.d.ts +82 -0
  44. package/src/lib/server/review-tokens.js +119 -0
  45. package/src/lib/server/review-tokens.js.map +1 -0
  46. package/src/lib/server/reviews.d.ts +36 -3
  47. package/src/lib/server/reviews.js +310 -52
  48. package/src/lib/server/reviews.js.map +1 -1
  49. package/src/lib/server/store-setup-ai-capability.d.ts +61 -0
  50. package/src/lib/server/store-setup-ai-capability.js +343 -0
  51. package/src/lib/server/store-setup-ai-capability.js.map +1 -0
  52. package/src/lib/server/store-setup-ai-writer.console.d.ts +27 -0
  53. package/src/lib/server/store-setup-ai-writer.console.js +44 -0
  54. package/src/lib/server/store-setup-ai-writer.console.js.map +1 -0
  55. package/src/lib/server/store-setup-ai-writer.d.ts +37 -0
  56. package/src/lib/server/store-setup-ai-writer.js +174 -0
  57. package/src/lib/server/store-setup-ai-writer.js.map +1 -0
  58. package/src/lib/server.js +27 -0
  59. package/src/lib/server.js.map +1 -1
  60. package/src/lib/tenant-emails.js +58 -0
  61. package/src/lib/tenant-emails.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/product-zones.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n definePluginZone,\n registerPluginZone,\n} from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { BUNDLE_ID } from '../../constants/bundle-common'\n\n/**\n * The zones this plugin hosts on its product surfaces (AGL-2916): the product\n * editor, the products hub and the import wizard's After import step.\n *\n * Each is drawn through the shell's renderer (`useConsoleWidgetSlot`), so a\n * widget there passes the same enablement, entitlement and permission gates as\n * one on a console page. The props are this plugin's words for its catalog,\n * declared on the zone's token; a widget from another plugin restates the\n * shape it reads rather than importing this package. A widget proposes and\n * never writes: every write is this plugin's, with the guards it carries.\n */\n\n/** A product type, as the commerce catalog names it. */\nexport type ConsoleProductType = 'physical' | 'digital' | 'service'\n\n/** A product option axis: its name and the values a shopper picks from. */\nexport interface ConsoleProductOption {\n name: string\n values: string[]\n}\n\n/** A product as the commerce editor holds it: saved, or staged and unsaved. */\nexport interface ConsoleProductDraft {\n /** `null` for a product nobody has saved yet. */\n id: string | null\n name: string\n type: ConsoleProductType\n description: string\n tags: string[]\n categoryIds: string[]\n options: ConsoleProductOption[]\n /** The product's media, in order; the first is the photo the storefront leads with. */\n mediaUrls: string[]\n seoTitle: string\n seoDescription: string\n /**\n * What one unit ships as (AGL-3612): packed size and customs facts, as the\n * editor holds them. A side nobody set is `null`.\n */\n shipping: {\n lengthCm: number | null\n widthCm: number | null\n heightCm: number | null\n hsCode: string\n originCountry: string\n }\n /**\n * What shopping channels ask of it (AGL-3637), as the editor holds it. A\n * field nobody entered is `''`.\n */\n channel: {\n brand: string\n gtin: string\n mpn: string\n condition: '' | 'new' | 'refurbished' | 'used'\n googleProductCategory: string\n }\n}\n\n/**\n * Copy a widget proposes for one product. A field left out is left alone;\n * `optionNames` renames the product's options in order, keeping every\n * variant, and is ignored unless it names each option once.\n */\nexport interface ConsoleProductCopyValues {\n description?: string\n tags?: string[]\n categoryIds?: string[]\n optionNames?: string[]\n seoTitle?: string\n seoDescription?: string\n /**\n * Packed size and customs facts (AGL-3612), merged over the product's; a\n * side set to `null` is cleared.\n */\n shipping?: Partial<{\n lengthCm: number | null\n widthCm: number | null\n heightCm: number | null\n hsCode: string\n originCountry: string\n }>\n /**\n * Shopping-channel facts (AGL-3637), merged over the product's; a field\n * set to `''` is cleared.\n */\n channel?: Partial<{\n brand: string\n gtin: string\n mpn: string\n condition: '' | 'new' | 'refurbished' | 'used'\n googleProductCategory: string\n }>\n}\n\n/**\n * What the `productEditor` zone hands each widget: the product as the editor\n * holds it, and `proposeValues`, which stages copy in the editor as unsaved\n * edits. The zone sits under the product's description, tags and categories,\n * as the editor's search listing hosts `seoFields`. Save product is the\n * write, with the guards it carries.\n */\nexport interface ConsoleProductEditorZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n product: ConsoleProductDraft\n /** The site's categories, as the editor's picker lists them. */\n categories: ReadonlyArray<{ id: string; name: string }>\n /**\n * Stages `values` in the editor as unsaved edits under `key`. Save product\n * is what stores them; a widget never writes the product itself.\n */\n proposeValues: (values: ConsoleProductCopyValues, key: string) => void\n}\n\n/** A catalog row as the products hub holds it. */\nexport interface ConsoleProductSummary {\n id: string\n name: string\n status: 'draft' | 'active' | 'archived'\n /** Whether a variant of the product has no price yet. */\n priceMissing: boolean\n /** Whether the product has a photo. */\n hasPhoto: boolean\n}\n\n/** A new product a widget asks the hub to create, always as a draft with no price. */\nexport interface ConsoleProposedProduct {\n name: string\n type: ConsoleProductType\n description: string\n tags: string[]\n options: ConsoleProductOption[]\n seoTitle: string\n seoDescription: string\n}\n\n/** A discount a widget asks the hub to create, always switched off. */\nexport interface ConsoleProposedDiscount {\n name: string\n /** The code a shopper types, or `null` for a discount that applies on its own. */\n code: string | null\n kind: 'percent' | 'fixed' | 'free_shipping'\n valuePct: number | null\n valueCents: number | null\n minSubtotalCents: number | null\n}\n\n/**\n * What the `productsHub` zone hands each widget: the catalog rows the hub\n * holds, the products its latest import created, and the hub's own writes a\n * widget may ask it to make — copy onto saved products, new draft products,\n * categories and switched-off discounts. The zone sits above the catalog\n * table. Each write is the hub's, with its seed, allowance and slug checks; a\n * widget writes nothing itself. Every write resolves once it is stored and\n * throws with a message a person can act on when the hub refuses it: an\n * allowance the plan has no room for, a product that is gone.\n *\n * The creates are safe to ask twice: a product or a category named like one\n * the site has, and a discount with the code (or, with no code, the name) of\n * one the site has, are passed over, so a proposal applied again, by anyone,\n * creates nothing it already created.\n */\nexport interface ConsoleProductsHubZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n /** The catalog rows the hub holds, in its order. */\n products: readonly ConsoleProductSummary[]\n /**\n * The products the hub's latest import created in this visit, and the\n * options the `productImport` zone set for them; `null` before any import.\n * `key` is new for each import.\n */\n lastImport: {\n key: string\n productIds: readonly string[]\n options: Readonly<Record<string, boolean>>\n } | null\n /** Writes copy onto a saved product, read fresh from the store. */\n applyProductCopy: (productId: string, values: ConsoleProductCopyValues) => Promise<void>\n /** Creates each product as a draft with no price, and resolves with the ids it created. */\n createProductDrafts: (products: readonly ConsoleProposedProduct[]) => Promise<string[]>\n /** Creates categories by name, and resolves with how many it created. */\n createCategories: (names: readonly string[]) => Promise<number>\n /** Creates each discount switched off, and resolves with how many it created. */\n createDiscountDrafts: (discounts: readonly ConsoleProposedDiscount[]) => Promise<number>\n}\n\n/**\n * What the `productImport` zone hands each widget, in the import wizard's\n * After import step: how many products the import creates, and options a widget sets\n * for what happens to them once they land. The hub hands the options, with\n * the ids of the products the import created, to `productsHub` as its\n * `lastImport`.\n */\nexport interface ConsoleProductImportZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n /** How many products the import creates. */\n count: number\n /** The options set for this import, by key. */\n options: Readonly<Record<string, boolean>>\n /** Sets one option; the hub carries it to `productsHub` with what the import created. */\n setOption: (key: string, on: boolean) => void\n}\n\nexport const PRODUCT_EDITOR_ZONE =\n definePluginZone<ConsoleProductEditorZoneProps>('productEditor')\n\nexport const PRODUCTS_HUB_ZONE =\n definePluginZone<ConsoleProductsHubZoneProps>('productsHub')\n\nexport const PRODUCT_IMPORT_ZONE =\n definePluginZone<ConsoleProductImportZoneProps>('productImport')\n\n/**\n * What the `productsCreate` zone hands each widget (AGL-3596): the site and\n * its org. The zone sits beside Add product on the Products page and in the\n * empty catalog: another way to start a product. A widget here writes nothing\n * through the page — anything it proposes is applied through `productsHub`.\n */\nexport interface ConsoleProductsCreateZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n}\n\nexport const PRODUCTS_CREATE_ZONE =\n definePluginZone<ConsoleProductsCreateZoneProps>('productsCreate')\n\n/**\n * Declares the four zones, from the console registrar. The ids are the ones\n * widgets have always registered under, so a widget written for them keeps\n * working. Each is `bare`: the section is one item of a layout this plugin\n * draws — among the editor's fields, above the catalog table, in the import\n * wizard's After import step, in the row beside Add product — and a wrapper would add a gap the page\n * already spaces.\n */\nexport function registerCommerceZones(): void {\n const owner = { pluginId: BUNDLE_ID }\n registerPluginZone(\n {\n zone: PRODUCT_EDITOR_ZONE,\n label: 'Product editor',\n surface: 'console',\n layout: 'bare',\n description:\n 'Under a product’s description, tags and categories in the product editor. A widget here proposes copy for the fields through `proposeValues`, staged as unsaved edits; Save product is the write.',\n },\n owner,\n )\n registerPluginZone(\n {\n zone: PRODUCTS_HUB_ZONE,\n label: 'Products hub',\n surface: 'console',\n layout: 'bare',\n description:\n 'Above the catalog table on the Products page. A widget here proposes copy, draft products, categories and switched-off discounts, and asks the hub to write them; every write is the hub’s, with its allowance and slug checks.',\n },\n owner,\n )\n registerPluginZone(\n {\n zone: PRODUCTS_CREATE_ZONE,\n label: 'Start a product',\n surface: 'console',\n layout: 'bare',\n description:\n 'Beside Add product on the Products page, and in the empty catalog: another way to start a product. A widget here is handed the site and its org and writes nothing through the page.',\n },\n owner,\n )\n registerPluginZone(\n {\n zone: PRODUCT_IMPORT_ZONE,\n label: 'Product import',\n surface: 'console',\n layout: 'bare',\n description:\n 'In the product import wizard’s After import step. A widget here sets options for what happens to the imported products once they land; the hub hands them to `productsHub` with the ids the import created.',\n },\n owner,\n )\n}\n"],"names":["definePluginZone","registerPluginZone","BUNDLE_ID","PRODUCT_EDITOR_ZONE","PRODUCTS_HUB_ZONE","PRODUCT_IMPORT_ZONE","PRODUCTS_CREATE_ZONE","registerCommerceZones","owner","pluginId","zone","label","surface","layout","description"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,gBAAgB,EAChBC,kBAAkB,QACb,2CAA0C;AACjD,SAASC,SAAS,QAAQ,mCAA+B;AAmNzD,OAAO,MAAMC,sBACXH,iBAAgD,iBAAgB;AAElE,OAAO,MAAMI,oBACXJ,iBAA8C,eAAc;AAE9D,OAAO,MAAMK,sBACXL,iBAAgD,iBAAgB;AAclE,OAAO,MAAMM,uBACXN,iBAAiD,kBAAiB;AAEpE;;;;;;;CAOC,GACD,OAAO,SAASO;IACd,MAAMC,QAAQ;QAAEC,UAAUP;IAAU;IACpCD,mBACE;QACES,MAAMP;QACNQ,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;IAEFP,mBACE;QACES,MAAMN;QACNO,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;IAEFP,mBACE;QACES,MAAMJ;QACNK,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;IAEFP,mBACE;QACES,MAAML;QACNM,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;AAEJ"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/product-zones.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n definePluginZone,\n registerPluginZone,\n} from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { BUNDLE_ID } from '../../constants/bundle-common'\n\n/**\n * The zones this plugin hosts on its product surfaces (AGL-2916): the product\n * editor, the products hub and the import wizard's After import step.\n *\n * Each is drawn through the shell's renderer (`useConsoleWidgetSlot`), so a\n * widget there passes the same enablement, entitlement and permission gates as\n * one on a console page. The props are this plugin's words for its catalog,\n * declared on the zone's token; a widget from another plugin restates the\n * shape it reads rather than importing this package. A widget proposes and\n * never writes: every write is this plugin's, with the guards it carries.\n */\n\n/** A product type, as the commerce catalog names it. */\nexport type ConsoleProductType = 'physical' | 'digital' | 'service'\n\n/** A product option axis: its name and the values a shopper picks from. */\nexport interface ConsoleProductOption {\n name: string\n values: string[]\n}\n\n/** A product as the commerce editor holds it: saved, or staged and unsaved. */\nexport interface ConsoleProductDraft {\n /** `null` for a product nobody has saved yet. */\n id: string | null\n name: string\n type: ConsoleProductType\n description: string\n tags: string[]\n categoryIds: string[]\n options: ConsoleProductOption[]\n /** The product's media, in order; the first is the photo the storefront leads with. */\n mediaUrls: string[]\n seoTitle: string\n seoDescription: string\n /**\n * What one unit ships as (AGL-3612): packed size and customs facts, as the\n * editor holds them. A side nobody set is `null`.\n */\n shipping: {\n lengthCm: number | null\n widthCm: number | null\n heightCm: number | null\n hsCode: string\n originCountry: string\n }\n /**\n * What shopping channels ask of it (AGL-3637), as the editor holds it. A\n * field nobody entered is `''`.\n */\n channel: {\n brand: string\n gtin: string\n mpn: string\n condition: '' | 'new' | 'refurbished' | 'used'\n googleProductCategory: string\n }\n}\n\n/**\n * Copy a widget proposes for one product. A field left out is left alone;\n * `optionNames` renames the product's options in order, keeping every\n * variant, and is ignored unless it names each option once.\n */\nexport interface ConsoleProductCopyValues {\n description?: string\n tags?: string[]\n categoryIds?: string[]\n optionNames?: string[]\n seoTitle?: string\n seoDescription?: string\n /**\n * Packed size and customs facts (AGL-3612), merged over the product's; a\n * side set to `null` is cleared.\n */\n shipping?: Partial<{\n lengthCm: number | null\n widthCm: number | null\n heightCm: number | null\n hsCode: string\n originCountry: string\n }>\n /**\n * Shopping-channel facts (AGL-3637), merged over the product's; a field\n * set to `''` is cleared.\n */\n channel?: Partial<{\n brand: string\n gtin: string\n mpn: string\n condition: '' | 'new' | 'refurbished' | 'used'\n googleProductCategory: string\n }>\n}\n\n/**\n * What the `productEditor` zone hands each widget: the product as the editor\n * holds it, and `proposeValues`, which stages copy in the editor as unsaved\n * edits. The zone sits under the product's description, tags and categories,\n * as the editor's search listing hosts `seoFields`. Save product is the\n * write, with the guards it carries.\n */\nexport interface ConsoleProductEditorZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n product: ConsoleProductDraft\n /** The site's categories, as the editor's picker lists them. */\n categories: ReadonlyArray<{ id: string; name: string }>\n /**\n * Stages `values` in the editor as unsaved edits under `key`. Save product\n * is what stores them; a widget never writes the product itself.\n */\n proposeValues: (values: ConsoleProductCopyValues, key: string) => void\n}\n\n/** A catalog row as the products hub holds it. */\nexport interface ConsoleProductSummary {\n id: string\n name: string\n status: 'draft' | 'active' | 'archived'\n /** Whether a variant of the product has no price yet. */\n priceMissing: boolean\n /** Whether the product has a photo. */\n hasPhoto: boolean\n}\n\n/**\n * A new product a widget asks the hub to create, always as a draft, at its\n * suggested price when it carries one in range, else the default price\n * (`proposedProductPriceUsd`, Zach 2026-10-12).\n */\nexport interface ConsoleProposedProduct {\n name: string\n type: ConsoleProductType\n description: string\n tags: string[]\n options: ConsoleProductOption[]\n seoTitle: string\n seoDescription: string\n /** The suggested price for every variant, in dollars; absent or `null` is the default price. */\n priceUsd?: number | null\n}\n\n/** A discount a widget asks the hub to create, always switched off. */\nexport interface ConsoleProposedDiscount {\n name: string\n /** The code a shopper types, or `null` for a discount that applies on its own. */\n code: string | null\n kind: 'percent' | 'fixed' | 'free_shipping'\n valuePct: number | null\n valueCents: number | null\n minSubtotalCents: number | null\n}\n\n/**\n * What the `productsHub` zone hands each widget: the catalog rows the hub\n * holds, the products its latest import created, and the hub's own writes a\n * widget may ask it to make — copy onto saved products, new draft products,\n * categories and switched-off discounts. The zone sits above the catalog\n * table. Each write is the hub's, with its seed, allowance and slug checks; a\n * widget writes nothing itself. Every write resolves once it is stored and\n * throws with a message a person can act on when the hub refuses it: an\n * allowance the plan has no room for, a product that is gone.\n *\n * The creates are safe to ask twice: a product or a category named like one\n * the site has, and a discount with the code (or, with no code, the name) of\n * one the site has, are passed over, so a proposal applied again, by anyone,\n * creates nothing it already created.\n */\nexport interface ConsoleProductsHubZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n /** The catalog rows the hub holds, in its order. */\n products: readonly ConsoleProductSummary[]\n /**\n * The products the hub's latest import created in this visit, and the\n * options the `productImport` zone set for them; `null` before any import.\n * `key` is new for each import.\n */\n lastImport: {\n key: string\n productIds: readonly string[]\n options: Readonly<Record<string, boolean>>\n } | null\n /** Writes copy onto a saved product, read fresh from the store. */\n applyProductCopy: (productId: string, values: ConsoleProductCopyValues) => Promise<void>\n /** Creates each product as a draft with no price, and resolves with the ids it created. */\n createProductDrafts: (products: readonly ConsoleProposedProduct[]) => Promise<string[]>\n /** Creates categories by name, and resolves with how many it created. */\n createCategories: (names: readonly string[]) => Promise<number>\n /** Creates each discount switched off, and resolves with how many it created. */\n createDiscountDrafts: (discounts: readonly ConsoleProposedDiscount[]) => Promise<number>\n}\n\n/**\n * What the `productImport` zone hands each widget, in the import wizard's\n * After import step: how many products the import creates, and options a widget sets\n * for what happens to them once they land. The hub hands the options, with\n * the ids of the products the import created, to `productsHub` as its\n * `lastImport`.\n */\nexport interface ConsoleProductImportZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n /** How many products the import creates. */\n count: number\n /** The options set for this import, by key. */\n options: Readonly<Record<string, boolean>>\n /** Sets one option; the hub carries it to `productsHub` with what the import created. */\n setOption: (key: string, on: boolean) => void\n}\n\nexport const PRODUCT_EDITOR_ZONE =\n definePluginZone<ConsoleProductEditorZoneProps>('productEditor')\n\nexport const PRODUCTS_HUB_ZONE =\n definePluginZone<ConsoleProductsHubZoneProps>('productsHub')\n\nexport const PRODUCT_IMPORT_ZONE =\n definePluginZone<ConsoleProductImportZoneProps>('productImport')\n\n/**\n * What the `productsCreate` zone hands each widget (AGL-3596): the site and\n * its org. The zone sits beside Add product on the Products page and in the\n * empty catalog: another way to start a product. A widget here writes nothing\n * through the page — anything it proposes is applied through `productsHub`.\n */\nexport interface ConsoleProductsCreateZoneProps {\n hostId: string\n /** The org the page names; `undefined` where the host does not know it. */\n orgId: string | undefined\n}\n\nexport const PRODUCTS_CREATE_ZONE =\n definePluginZone<ConsoleProductsCreateZoneProps>('productsCreate')\n\n/**\n * Declares the four zones, from the console registrar. The ids are the ones\n * widgets have always registered under, so a widget written for them keeps\n * working. Each is `bare`: the section is one item of a layout this plugin\n * draws — among the editor's fields, above the catalog table, in the import\n * wizard's After import step, in the row beside Add product — and a wrapper would add a gap the page\n * already spaces.\n */\nexport function registerCommerceZones(): void {\n const owner = { pluginId: BUNDLE_ID }\n registerPluginZone(\n {\n zone: PRODUCT_EDITOR_ZONE,\n label: 'Product editor',\n surface: 'console',\n layout: 'bare',\n description:\n 'Under a product’s description, tags and categories in the product editor. A widget here proposes copy for the fields through `proposeValues`, staged as unsaved edits; Save product is the write.',\n },\n owner,\n )\n registerPluginZone(\n {\n zone: PRODUCTS_HUB_ZONE,\n label: 'Products hub',\n surface: 'console',\n layout: 'bare',\n description:\n 'Above the catalog table on the Products page. A widget here proposes copy, draft products, categories and switched-off discounts, and asks the hub to write them; every write is the hub’s, with its allowance and slug checks.',\n },\n owner,\n )\n registerPluginZone(\n {\n zone: PRODUCTS_CREATE_ZONE,\n label: 'Start a product',\n surface: 'console',\n layout: 'bare',\n description:\n 'Beside Add product on the Products page, and in the empty catalog: another way to start a product. A widget here is handed the site and its org and writes nothing through the page.',\n },\n owner,\n )\n registerPluginZone(\n {\n zone: PRODUCT_IMPORT_ZONE,\n label: 'Product import',\n surface: 'console',\n layout: 'bare',\n description:\n 'In the product import wizard’s After import step. A widget here sets options for what happens to the imported products once they land; the hub hands them to `productsHub` with the ids the import created.',\n },\n owner,\n )\n}\n"],"names":["definePluginZone","registerPluginZone","BUNDLE_ID","PRODUCT_EDITOR_ZONE","PRODUCTS_HUB_ZONE","PRODUCT_IMPORT_ZONE","PRODUCTS_CREATE_ZONE","registerCommerceZones","owner","pluginId","zone","label","surface","layout","description"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,gBAAgB,EAChBC,kBAAkB,QACb,2CAA0C;AACjD,SAASC,SAAS,QAAQ,mCAA+B;AAyNzD,OAAO,MAAMC,sBACXH,iBAAgD,iBAAgB;AAElE,OAAO,MAAMI,oBACXJ,iBAA8C,eAAc;AAE9D,OAAO,MAAMK,sBACXL,iBAAgD,iBAAgB;AAclE,OAAO,MAAMM,uBACXN,iBAAiD,kBAAiB;AAEpE;;;;;;;CAOC,GACD,OAAO,SAASO;IACd,MAAMC,QAAQ;QAAEC,UAAUP;IAAU;IACpCD,mBACE;QACES,MAAMP;QACNQ,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;IAEFP,mBACE;QACES,MAAMN;QACNO,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;IAEFP,mBACE;QACES,MAAMJ;QACNK,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;IAEFP,mBACE;QACES,MAAML;QACNM,OAAO;QACPC,SAAS;QACTC,QAAQ;QACRC,aACE;IACJ,GACAN;AAEJ"}
@@ -12,8 +12,9 @@ import * as CommerceModel from '../../model';
12
12
  * loaded is kept.
13
13
  * - **A proposed product** is created through the quota-enforcing resources
14
14
  * API, as Add product and Import create one, after the hub's own allowance
15
- * check for the batch. It is a draft at the default price
16
- * (`defaultPricedProductDraft`, AGL-3676), for the owner to change.
15
+ * check for the batch. It is a draft at its suggested price, else the
16
+ * default price (`proposedProductPriceUsd`, `defaultPricedProductDraft`,
17
+ * AGL-3676), for the owner to change.
17
18
  * - **A category** is created as the catalog card creates one.
18
19
  * - **A discount** is created switched off (`switchedOffDiscount`), for a
19
20
  * person to switch on from the discounts card.
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
18
19
  import { jsx as _jsx } from "react/jsx-runtime";
19
20
  import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
20
21
  import { useConsoleWidgetSlot } from "@aglyn/aglyn/app-utils/console-widget-slot-context";
@@ -125,7 +126,14 @@ export function ProductsHubZone(props) {
125
126
  const slugs = productSlugLedger(firestore, hostId);
126
127
  const created = [];
127
128
  try {
128
- const drafts = fresh.map((proposal)=>CommerceModel.defaultPricedProductDraft(proposal, NO_SLUGS, Date.now()));
129
+ // At its suggested price, held to range here, else the default
130
+ // (Zach, 2026-10-12). Only the proposal's own fields reach the draft.
131
+ const drafts = fresh.map((_0)=>{
132
+ let { priceUsd } = _0, proposal = _object_without_properties_loose(_0, [
133
+ "priceUsd"
134
+ ]);
135
+ return CommerceModel.defaultPricedProductDraft(proposal, NO_SLUGS, Date.now(), CommerceModel.proposedProductPriceUsd(priceUsd));
136
+ });
129
137
  await slugs.ask(drafts.map((draft)=>draft.slug));
130
138
  const smartCollections = await readSmartCollections(firestore, hostId);
131
139
  for (const draft of drafts){
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/products-hub-zone.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport {\n PRODUCTS_HUB_ZONE,\n type ConsoleProductCopyValues,\n type ConsoleProductSummary,\n type ConsoleProductsHubZoneProps,\n type ConsoleProposedDiscount,\n type ConsoleProposedProduct,\n} from './product-zones'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n useFirestore,\n useHostResourceApi,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n commitWithSiteWideEntry,\n settleSiteWideChange,\n} from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport {\n collection,\n doc,\n getDocs,\n limit,\n query,\n runTransaction,\n setDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport * as CommerceModel from '../../model'\nimport { productSlugLedger } from './product-slugs'\nimport { readSmartCollections } from './smart-collections'\n\n/**\n * The products hub's zone (AGL-2916): the page of catalog rows the hub's table\n * shows, and the writes a widget there may ask the hub to make. A widget proposes; these are the\n * hub's writes, each through the door the hub's own controls use.\n *\n * - **Copy onto a saved product** is a transaction over the product as the\n * store holds it now, never a row the table was seeded with: it changes\n * only the fields the copy names, and renames options through\n * `renameProductOptions`, so a sale or a colleague's edit since the table\n * loaded is kept.\n * - **A proposed product** is created through the quota-enforcing resources\n * API, as Add product and Import create one, after the hub's own allowance\n * check for the batch. It is a draft at the default price\n * (`defaultPricedProductDraft`, AGL-3676), for the owner to change.\n * - **A category** is created as the catalog card creates one.\n * - **A discount** is created switched off (`switchedOffDiscount`), for a\n * person to switch on from the discounts card.\n *\n * Each create passes over what the site already has — a product of the same\n * name, a category of the same name, a discount with the same code or, with no\n * code, the same name — so a proposal applied twice, by anyone, creates\n * nothing twice. Each check is a read made when a person applies, never while\n * the hub is open, and reaches past the page the hub shows: a product is\n * looked up by its search key, and its slug by the slugs it could take\n * (`productSlugLedger`, AGL-3321).\n */\n\ntype ProductRow = CommerceModel.HostProduct & { $id: string }\n\n/** The hub's allowance check for `count` more products: `null` while the plan loads. */\nexport type ProductsRoomCheck = (count: number) => { allowed: boolean; limit: number } | null\n\nexport interface ProductsHubZoneProps {\n hostId: string\n /**\n * The page of catalog rows the hub's table shows, in its order — the\n * products its filters and search matched, one page at a time (AGL-3321).\n * Never the catalog: nothing here decides what exists from these rows.\n */\n products: readonly ProductRow[]\n roomFor: ProductsRoomCheck\n lastImport: ConsoleProductsHubZoneProps['lastImport']\n /** Called once a create has moved the catalog's count. */\n onCreated: () => void\n}\n\nexport const PRODUCT_GONE_COPY = 'This product is no longer in the catalog.'\nexport const PLAN_LOADING_COPY = 'Your plan is still loading. Try again in a moment.'\n\n/** How many of the site's categories the duplicate check reads. */\nconst CATEGORY_NAMES_READ = 250\n\n/** How a name is compared for a duplicate: case and spacing aside. */\nconst nameKey = (name: string) => name.replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/** The most values one Firestore `in` clause takes. */\nconst IN_CLAUSE_MAX = 10\n\n/** No slug taken: a draft's own base slug, before the store is asked. */\nconst NO_SLUGS: ReadonlySet<string> = new Set()\n\nexport function ProductsHubZone(props: ProductsHubZoneProps) {\n const { hostId, products, roomFor, lastImport, onCreated } = props\n const Slot = useConsoleWidgetSlot()\n const firestore = useFirestore()\n const { data: user } = useUser()\n const createHostResource = useHostResourceApi()\n\n const summaries = useMemo<ConsoleProductSummary[]>(\n () =>\n products.map((product) => ({\n id: product.$id,\n name: product.name,\n status: product.status,\n priceMissing: CommerceModel.productPriceMissing(product),\n hasPhoto: Boolean(product.mediaUrls?.[0] ?? product.imageUrl),\n })),\n [products],\n )\n\n const applyProductCopy = useCallback(\n async (productId: string, values: ConsoleProductCopyValues) => {\n const ref = doc(firestore, 'hosts', hostId, 'products', productId)\n const smartCollections = await readSmartCollections(firestore, hostId)\n // The product page renders the copy with no publish step, so the\n // transaction carries the site's cache drop with it (AGL-3386).\n const entry = await commitWithSiteWideEntry(hostId, (stage) =>\n runTransaction(firestore, async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = snapshot.exists() ? (snapshot.data() as CommerceModel.HostProduct) : null\n if (!stored || stored.deletedAt) throw new Error(PRODUCT_GONE_COPY)\n const lifted = CommerceModel.liftLegacyProduct(stored)\n const patch = CommerceModel.productCopyPatch(lifted, values)\n if (!Object.keys(patch).length) return\n // Tags and categories may move, and with them its smart collections\n // (AGL-3321).\n // Written only when it moved, so a copy that changes no rule input\n // writes only the copy.\n const collectionIds = CommerceModel.productCollectionIds(\n { ...lifted, ...patch },\n smartCollections,\n )\n const held = stored.collectionIds ?? []\n const moved =\n held.length !== collectionIds.length ||\n collectionIds.some((id, at) => held[at] !== id)\n transaction.update(ref, {\n ...patch,\n ...(moved ? { collectionIds } : {}),\n updatedAtMs: Date.now(),\n updatedAt: Timestamp.now(),\n })\n stage?.(transaction, firestore)\n }),\n )\n settleSiteWideChange({ user, hostId, entry })\n },\n [firestore, hostId, user],\n )\n\n const createProductDrafts = useCallback(\n async (proposals: readonly ConsoleProposedProduct[]) => {\n // What exists is asked of the store, never read off the page on screen.\n const held = new Set<string>()\n const searchKeys = [\n ...new Set(proposals.map((proposal) => CommerceModel.productSearchFields({ name: proposal.name.trim() }).nameLower)),\n ].filter(Boolean)\n const productsRef = collection(firestore, 'hosts', hostId, 'products')\n const stored = await Promise.all(\n Array.from({ length: Math.ceil(searchKeys.length / IN_CLAUSE_MAX) }, (_, index) =>\n getDocs(query(productsRef, where('nameLower', 'in', searchKeys.slice(index * IN_CLAUSE_MAX, (index + 1) * IN_CLAUSE_MAX)))),\n ),\n )\n for (const read of stored) {\n for (const existing of read.docs) {\n if (!existing.get('deletedAt')) held.add(nameKey(String(existing.get('name') ?? '')))\n }\n }\n const fresh = proposals.filter((proposal) => {\n const key = nameKey(proposal.name)\n if (!key || held.has(key)) return false\n held.add(key)\n return true\n })\n if (!fresh.length) return []\n const room = roomFor(fresh.length)\n if (!room) throw new Error(PLAN_LOADING_COPY)\n if (!room.allowed) {\n throw new Error(\n `These products need ${fresh.length} product slots — your plan allows ` +\n `${room.limit}. See Billing to upgrade.`,\n )\n }\n // Each draft's slug is asked of the store: the base slug its name makes,\n // then `-2`, `-3`, … until one no product holds, live or deleted.\n const slugs = productSlugLedger(firestore, hostId)\n const created: string[] = []\n try {\n const drafts = fresh.map((proposal) =>\n CommerceModel.defaultPricedProductDraft(proposal, NO_SLUGS, Date.now()),\n )\n await slugs.ask(drafts.map((draft) => draft.slug))\n const smartCollections = await readSmartCollections(firestore, hostId)\n for (const draft of drafts) {\n const slug = await slugs.claim(draft.slug)\n const collectionIds = CommerceModel.productCollectionIds(draft, smartCollections)\n const { id } = await createHostResource({\n hostId,\n resource: 'product',\n data: { ...draft, slug, collectionIds },\n })\n created.push(id)\n }\n } finally {\n if (created.length) onCreated()\n }\n return created\n },\n [roomFor, createHostResource, firestore, hostId, onCreated],\n )\n\n const createCategories = useCallback(\n async (names: readonly string[]) => {\n const existing = await getDocs(\n query(collection(firestore, 'hosts', hostId, 'productCategories'), limit(CATEGORY_NAMES_READ)),\n )\n const held = new Set(existing.docs.map((category) => nameKey(String(category.get('name') ?? ''))))\n let created = 0\n for (const raw of names) {\n const name = raw.trim().slice(0, 80)\n if (!name || held.has(nameKey(name))) continue\n held.add(nameKey(name))\n await setDoc(doc(firestore, 'hosts', hostId, 'productCategories', createResourceUid()), {\n name,\n slug: CommerceModel.commerceSlug(name),\n parentId: null,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n const createDiscountDrafts = useCallback(\n async (discounts: readonly ConsoleProposedDiscount[]) => {\n const drafts = discounts\n .map((proposal) => CommerceModel.switchedOffDiscount(proposal))\n .filter((discount): discount is CommerceModel.StoredDiscount => discount !== null)\n if (!drafts.length) return 0\n const codes = [...new Set(drafts.map((discount) => discount.code).filter((code): code is string => Boolean(code)))]\n // A stored name is compared as typed, and the query matches it exactly.\n const names = [...new Set(drafts.filter((discount) => !discount.code).map((discount) => discount.name ?? ''))]\n const discountsRef = collection(firestore, 'hosts', hostId, 'discounts')\n const [byCode, byName] = await Promise.all([\n codes.length ? getDocs(query(discountsRef, where('code', 'in', codes.slice(0, 10)))) : null,\n names.length ? getDocs(query(discountsRef, where('name', 'in', names.slice(0, 10)))) : null,\n ])\n const heldCodes = new Set((byCode?.docs ?? []).map((existing) => String(existing.get('code'))))\n const heldNames = new Set((byName?.docs ?? []).map((existing) => nameKey(String(existing.get('name') ?? ''))))\n let created = 0\n for (const discount of drafts) {\n const name = nameKey(discount.name ?? '')\n if (discount.code ? heldCodes.has(discount.code) : heldNames.has(name)) continue\n if (discount.code) heldCodes.add(discount.code)\n else heldNames.add(name)\n await setDoc(doc(firestore, 'hosts', hostId, 'discounts', createResourceUid()), discount)\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n if (!Slot) return null\n return (\n <Slot\n slot={PRODUCTS_HUB_ZONE.id}\n hostId={hostId}\n orgId={undefined}\n products={summaries}\n lastImport={lastImport}\n applyProductCopy={applyProductCopy}\n createProductDrafts={createProductDrafts}\n createCategories={createCategories}\n createDiscountDrafts={createDiscountDrafts}\n />\n )\n}\nProductsHubZone.displayName = 'ProductsHubZone'\n\nexport default ProductsHubZone\n"],"names":["createResourceUid","useConsoleWidgetSlot","PRODUCTS_HUB_ZONE","Timestamp","useFirestore","useHostResourceApi","useUser","commitWithSiteWideEntry","settleSiteWideChange","collection","doc","getDocs","limit","query","runTransaction","setDoc","where","useCallback","useMemo","CommerceModel","productSlugLedger","readSmartCollections","PRODUCT_GONE_COPY","PLAN_LOADING_COPY","CATEGORY_NAMES_READ","nameKey","name","replace","trim","toLowerCase","IN_CLAUSE_MAX","NO_SLUGS","Set","ProductsHubZone","props","hostId","products","roomFor","lastImport","onCreated","Slot","firestore","data","user","createHostResource","summaries","map","product","id","$id","status","priceMissing","productPriceMissing","hasPhoto","Boolean","mediaUrls","imageUrl","applyProductCopy","productId","values","ref","smartCollections","entry","stage","transaction","stored","snapshot","get","exists","deletedAt","Error","lifted","liftLegacyProduct","patch","productCopyPatch","Object","keys","length","collectionIds","productCollectionIds","held","moved","some","at","update","updatedAtMs","Date","now","updatedAt","createProductDrafts","proposals","searchKeys","proposal","productSearchFields","nameLower","filter","productsRef","Promise","all","Array","from","Math","ceil","_","index","slice","read","existing","docs","add","String","fresh","key","has","room","allowed","slugs","created","drafts","defaultPricedProductDraft","ask","draft","slug","claim","resource","push","createCategories","names","category","raw","commerceSlug","parentId","createdAt","createDiscountDrafts","discounts","switchedOffDiscount","discount","codes","code","discountsRef","byCode","byName","heldCodes","heldNames","slot","orgId","undefined","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/products-hub-zone.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { useConsoleWidgetSlot } from '@aglyn/aglyn/app-utils/console-widget-slot-context'\nimport {\n PRODUCTS_HUB_ZONE,\n type ConsoleProductCopyValues,\n type ConsoleProductSummary,\n type ConsoleProductsHubZoneProps,\n type ConsoleProposedDiscount,\n type ConsoleProposedProduct,\n} from './product-zones'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n useFirestore,\n useHostResourceApi,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n commitWithSiteWideEntry,\n settleSiteWideChange,\n} from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport {\n collection,\n doc,\n getDocs,\n limit,\n query,\n runTransaction,\n setDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo } from 'react'\nimport * as CommerceModel from '../../model'\nimport { productSlugLedger } from './product-slugs'\nimport { readSmartCollections } from './smart-collections'\n\n/**\n * The products hub's zone (AGL-2916): the page of catalog rows the hub's table\n * shows, and the writes a widget there may ask the hub to make. A widget proposes; these are the\n * hub's writes, each through the door the hub's own controls use.\n *\n * - **Copy onto a saved product** is a transaction over the product as the\n * store holds it now, never a row the table was seeded with: it changes\n * only the fields the copy names, and renames options through\n * `renameProductOptions`, so a sale or a colleague's edit since the table\n * loaded is kept.\n * - **A proposed product** is created through the quota-enforcing resources\n * API, as Add product and Import create one, after the hub's own allowance\n * check for the batch. It is a draft at its suggested price, else the\n * default price (`proposedProductPriceUsd`, `defaultPricedProductDraft`,\n * AGL-3676), for the owner to change.\n * - **A category** is created as the catalog card creates one.\n * - **A discount** is created switched off (`switchedOffDiscount`), for a\n * person to switch on from the discounts card.\n *\n * Each create passes over what the site already has — a product of the same\n * name, a category of the same name, a discount with the same code or, with no\n * code, the same name — so a proposal applied twice, by anyone, creates\n * nothing twice. Each check is a read made when a person applies, never while\n * the hub is open, and reaches past the page the hub shows: a product is\n * looked up by its search key, and its slug by the slugs it could take\n * (`productSlugLedger`, AGL-3321).\n */\n\ntype ProductRow = CommerceModel.HostProduct & { $id: string }\n\n/** The hub's allowance check for `count` more products: `null` while the plan loads. */\nexport type ProductsRoomCheck = (count: number) => { allowed: boolean; limit: number } | null\n\nexport interface ProductsHubZoneProps {\n hostId: string\n /**\n * The page of catalog rows the hub's table shows, in its order — the\n * products its filters and search matched, one page at a time (AGL-3321).\n * Never the catalog: nothing here decides what exists from these rows.\n */\n products: readonly ProductRow[]\n roomFor: ProductsRoomCheck\n lastImport: ConsoleProductsHubZoneProps['lastImport']\n /** Called once a create has moved the catalog's count. */\n onCreated: () => void\n}\n\nexport const PRODUCT_GONE_COPY = 'This product is no longer in the catalog.'\nexport const PLAN_LOADING_COPY = 'Your plan is still loading. Try again in a moment.'\n\n/** How many of the site's categories the duplicate check reads. */\nconst CATEGORY_NAMES_READ = 250\n\n/** How a name is compared for a duplicate: case and spacing aside. */\nconst nameKey = (name: string) => name.replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/** The most values one Firestore `in` clause takes. */\nconst IN_CLAUSE_MAX = 10\n\n/** No slug taken: a draft's own base slug, before the store is asked. */\nconst NO_SLUGS: ReadonlySet<string> = new Set()\n\nexport function ProductsHubZone(props: ProductsHubZoneProps) {\n const { hostId, products, roomFor, lastImport, onCreated } = props\n const Slot = useConsoleWidgetSlot()\n const firestore = useFirestore()\n const { data: user } = useUser()\n const createHostResource = useHostResourceApi()\n\n const summaries = useMemo<ConsoleProductSummary[]>(\n () =>\n products.map((product) => ({\n id: product.$id,\n name: product.name,\n status: product.status,\n priceMissing: CommerceModel.productPriceMissing(product),\n hasPhoto: Boolean(product.mediaUrls?.[0] ?? product.imageUrl),\n })),\n [products],\n )\n\n const applyProductCopy = useCallback(\n async (productId: string, values: ConsoleProductCopyValues) => {\n const ref = doc(firestore, 'hosts', hostId, 'products', productId)\n const smartCollections = await readSmartCollections(firestore, hostId)\n // The product page renders the copy with no publish step, so the\n // transaction carries the site's cache drop with it (AGL-3386).\n const entry = await commitWithSiteWideEntry(hostId, (stage) =>\n runTransaction(firestore, async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = snapshot.exists() ? (snapshot.data() as CommerceModel.HostProduct) : null\n if (!stored || stored.deletedAt) throw new Error(PRODUCT_GONE_COPY)\n const lifted = CommerceModel.liftLegacyProduct(stored)\n const patch = CommerceModel.productCopyPatch(lifted, values)\n if (!Object.keys(patch).length) return\n // Tags and categories may move, and with them its smart collections\n // (AGL-3321).\n // Written only when it moved, so a copy that changes no rule input\n // writes only the copy.\n const collectionIds = CommerceModel.productCollectionIds(\n { ...lifted, ...patch },\n smartCollections,\n )\n const held = stored.collectionIds ?? []\n const moved =\n held.length !== collectionIds.length ||\n collectionIds.some((id, at) => held[at] !== id)\n transaction.update(ref, {\n ...patch,\n ...(moved ? { collectionIds } : {}),\n updatedAtMs: Date.now(),\n updatedAt: Timestamp.now(),\n })\n stage?.(transaction, firestore)\n }),\n )\n settleSiteWideChange({ user, hostId, entry })\n },\n [firestore, hostId, user],\n )\n\n const createProductDrafts = useCallback(\n async (proposals: readonly ConsoleProposedProduct[]) => {\n // What exists is asked of the store, never read off the page on screen.\n const held = new Set<string>()\n const searchKeys = [\n ...new Set(proposals.map((proposal) => CommerceModel.productSearchFields({ name: proposal.name.trim() }).nameLower)),\n ].filter(Boolean)\n const productsRef = collection(firestore, 'hosts', hostId, 'products')\n const stored = await Promise.all(\n Array.from({ length: Math.ceil(searchKeys.length / IN_CLAUSE_MAX) }, (_, index) =>\n getDocs(query(productsRef, where('nameLower', 'in', searchKeys.slice(index * IN_CLAUSE_MAX, (index + 1) * IN_CLAUSE_MAX)))),\n ),\n )\n for (const read of stored) {\n for (const existing of read.docs) {\n if (!existing.get('deletedAt')) held.add(nameKey(String(existing.get('name') ?? '')))\n }\n }\n const fresh = proposals.filter((proposal) => {\n const key = nameKey(proposal.name)\n if (!key || held.has(key)) return false\n held.add(key)\n return true\n })\n if (!fresh.length) return []\n const room = roomFor(fresh.length)\n if (!room) throw new Error(PLAN_LOADING_COPY)\n if (!room.allowed) {\n throw new Error(\n `These products need ${fresh.length} product slots — your plan allows ` +\n `${room.limit}. See Billing to upgrade.`,\n )\n }\n // Each draft's slug is asked of the store: the base slug its name makes,\n // then `-2`, `-3`, … until one no product holds, live or deleted.\n const slugs = productSlugLedger(firestore, hostId)\n const created: string[] = []\n try {\n // At its suggested price, held to range here, else the default\n // (Zach, 2026-10-12). Only the proposal's own fields reach the draft.\n const drafts = fresh.map(({ priceUsd, ...proposal }) =>\n CommerceModel.defaultPricedProductDraft(\n proposal,\n NO_SLUGS,\n Date.now(),\n CommerceModel.proposedProductPriceUsd(priceUsd),\n ),\n )\n await slugs.ask(drafts.map((draft) => draft.slug))\n const smartCollections = await readSmartCollections(firestore, hostId)\n for (const draft of drafts) {\n const slug = await slugs.claim(draft.slug)\n const collectionIds = CommerceModel.productCollectionIds(draft, smartCollections)\n const { id } = await createHostResource({\n hostId,\n resource: 'product',\n data: { ...draft, slug, collectionIds },\n })\n created.push(id)\n }\n } finally {\n if (created.length) onCreated()\n }\n return created\n },\n [roomFor, createHostResource, firestore, hostId, onCreated],\n )\n\n const createCategories = useCallback(\n async (names: readonly string[]) => {\n const existing = await getDocs(\n query(collection(firestore, 'hosts', hostId, 'productCategories'), limit(CATEGORY_NAMES_READ)),\n )\n const held = new Set(existing.docs.map((category) => nameKey(String(category.get('name') ?? ''))))\n let created = 0\n for (const raw of names) {\n const name = raw.trim().slice(0, 80)\n if (!name || held.has(nameKey(name))) continue\n held.add(nameKey(name))\n await setDoc(doc(firestore, 'hosts', hostId, 'productCategories', createResourceUid()), {\n name,\n slug: CommerceModel.commerceSlug(name),\n parentId: null,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n const createDiscountDrafts = useCallback(\n async (discounts: readonly ConsoleProposedDiscount[]) => {\n const drafts = discounts\n .map((proposal) => CommerceModel.switchedOffDiscount(proposal))\n .filter((discount): discount is CommerceModel.StoredDiscount => discount !== null)\n if (!drafts.length) return 0\n const codes = [...new Set(drafts.map((discount) => discount.code).filter((code): code is string => Boolean(code)))]\n // A stored name is compared as typed, and the query matches it exactly.\n const names = [...new Set(drafts.filter((discount) => !discount.code).map((discount) => discount.name ?? ''))]\n const discountsRef = collection(firestore, 'hosts', hostId, 'discounts')\n const [byCode, byName] = await Promise.all([\n codes.length ? getDocs(query(discountsRef, where('code', 'in', codes.slice(0, 10)))) : null,\n names.length ? getDocs(query(discountsRef, where('name', 'in', names.slice(0, 10)))) : null,\n ])\n const heldCodes = new Set((byCode?.docs ?? []).map((existing) => String(existing.get('code'))))\n const heldNames = new Set((byName?.docs ?? []).map((existing) => nameKey(String(existing.get('name') ?? ''))))\n let created = 0\n for (const discount of drafts) {\n const name = nameKey(discount.name ?? '')\n if (discount.code ? heldCodes.has(discount.code) : heldNames.has(name)) continue\n if (discount.code) heldCodes.add(discount.code)\n else heldNames.add(name)\n await setDoc(doc(firestore, 'hosts', hostId, 'discounts', createResourceUid()), discount)\n created += 1\n }\n return created\n },\n [firestore, hostId],\n )\n\n if (!Slot) return null\n return (\n <Slot\n slot={PRODUCTS_HUB_ZONE.id}\n hostId={hostId}\n orgId={undefined}\n products={summaries}\n lastImport={lastImport}\n applyProductCopy={applyProductCopy}\n createProductDrafts={createProductDrafts}\n createCategories={createCategories}\n createDiscountDrafts={createDiscountDrafts}\n />\n )\n}\nProductsHubZone.displayName = 'ProductsHubZone'\n\nexport default ProductsHubZone\n"],"names":["createResourceUid","useConsoleWidgetSlot","PRODUCTS_HUB_ZONE","Timestamp","useFirestore","useHostResourceApi","useUser","commitWithSiteWideEntry","settleSiteWideChange","collection","doc","getDocs","limit","query","runTransaction","setDoc","where","useCallback","useMemo","CommerceModel","productSlugLedger","readSmartCollections","PRODUCT_GONE_COPY","PLAN_LOADING_COPY","CATEGORY_NAMES_READ","nameKey","name","replace","trim","toLowerCase","IN_CLAUSE_MAX","NO_SLUGS","Set","ProductsHubZone","props","hostId","products","roomFor","lastImport","onCreated","Slot","firestore","data","user","createHostResource","summaries","map","product","id","$id","status","priceMissing","productPriceMissing","hasPhoto","Boolean","mediaUrls","imageUrl","applyProductCopy","productId","values","ref","smartCollections","entry","stage","transaction","stored","snapshot","get","exists","deletedAt","Error","lifted","liftLegacyProduct","patch","productCopyPatch","Object","keys","length","collectionIds","productCollectionIds","held","moved","some","at","update","updatedAtMs","Date","now","updatedAt","createProductDrafts","proposals","searchKeys","proposal","productSearchFields","nameLower","filter","productsRef","Promise","all","Array","from","Math","ceil","_","index","slice","read","existing","docs","add","String","fresh","key","has","room","allowed","slugs","created","drafts","priceUsd","defaultPricedProductDraft","proposedProductPriceUsd","ask","draft","slug","claim","resource","push","createCategories","names","category","raw","commerceSlug","parentId","createdAt","createDiscountDrafts","discounts","switchedOffDiscount","discount","codes","code","discountsRef","byCode","byName","heldCodes","heldNames","slot","orgId","undefined","displayName"],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ export interface ReviewSettingsCardProps {
2
+ hostId: string;
3
+ }
4
+ /**
5
+ * Who may review the store's products, and the review-request email
6
+ * (AGL-3736), on `hosts/{hostId}/settings/store.reviews`. Each control writes
7
+ * only its own key, so this card never overwrites the store settings card's
8
+ * fields beside it. Defaults: verified buyers only, the email on, 7 days.
9
+ */
10
+ export declare function ReviewSettingsCard(props: ReviewSettingsCardProps): import("react").JSX.Element;
11
+ export declare namespace ReviewSettingsCard {
12
+ var displayName: string;
13
+ }
14
+ export default ReviewSettingsCard;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
+ import { pluginDocsHelp } from "@aglyn/aglyn";
19
+ import { CardDisplay } from "@aglyn/shared-ui-jsx";
20
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
21
+ import { useFirestore, useFirestoreDoc, useUser } from "@aglyn/tenant-feature-instance";
22
+ import { writeSiteWideChange } from "@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change";
23
+ import { FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
24
+ import { doc } from "firebase/firestore";
25
+ import { useCallback, useEffect, useState } from "react";
26
+ import * as CommerceModel from "../../model/index.js";
27
+ /**
28
+ * Who may review the store's products, and the review-request email
29
+ * (AGL-3736), on `hosts/{hostId}/settings/store.reviews`. Each control writes
30
+ * only its own key, so this card never overwrites the store settings card's
31
+ * fields beside it. Defaults: verified buyers only, the email on, 7 days.
32
+ */ export function ReviewSettingsCard(props) {
33
+ const { hostId } = props;
34
+ const firestore = useFirestore();
35
+ const { data: user } = useUser();
36
+ const { enqueueSnackbar } = useSnackbar();
37
+ const { data: store } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'settings', 'store'), [
38
+ firestore,
39
+ hostId
40
+ ]);
41
+ const settings = CommerceModel.reviewSettingsFrom(store);
42
+ const [days, setDays] = useState(String(settings.requestDelayDays));
43
+ useEffect(()=>setDays(String(settings.requestDelayDays)), [
44
+ settings.requestDelayDays
45
+ ]);
46
+ const [saving, setSaving] = useState(null);
47
+ const save = useCallback(async (key, value)=>{
48
+ setSaving(key);
49
+ try {
50
+ // The policy decides what the product page's review form draws, so
51
+ // the write carries the site's cache drop (AGL-3386).
52
+ await writeSiteWideChange({
53
+ firestore,
54
+ user,
55
+ hostId,
56
+ write: (batch)=>batch.set(doc(firestore, 'hosts', hostId, 'settings', 'store'), {
57
+ reviews: {
58
+ [key]: value
59
+ }
60
+ }, {
61
+ merge: true
62
+ })
63
+ });
64
+ } catch (error) {
65
+ console.error(error);
66
+ enqueueSnackbar('That setting could not be saved. Try again.', {
67
+ variant: 'error'
68
+ });
69
+ } finally{
70
+ setSaving(null);
71
+ }
72
+ }, [
73
+ enqueueSnackbar,
74
+ firestore,
75
+ hostId,
76
+ user
77
+ ]);
78
+ const commitDays = ()=>{
79
+ const parsed = Math.round(Number(days));
80
+ const bounded = Number.isFinite(parsed) ? Math.min(CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS, Math.max(CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS, parsed)) : CommerceModel.REVIEW_REQUEST_DELAY_DEFAULT_DAYS;
81
+ setDays(String(bounded));
82
+ if (bounded !== settings.requestDelayDays) void save('requestDelayDays', bounded);
83
+ };
84
+ return /*#__PURE__*/ _jsx(CardDisplay, {
85
+ header: 'Reviews',
86
+ help: pluginDocsHelp('catalog', {
87
+ anchor: '#review-settings'
88
+ }),
89
+ contentGutterX: true,
90
+ contentGutterY: true,
91
+ children: /*#__PURE__*/ _jsxs(Stack, {
92
+ spacing: 1.5,
93
+ children: [
94
+ /*#__PURE__*/ _jsxs(TextField, {
95
+ label: "Who can review",
96
+ value: settings.policy,
97
+ onChange: (event)=>void save('policy', event.target.value),
98
+ disabled: saving === 'policy',
99
+ size: "small",
100
+ select: true,
101
+ sx: {
102
+ maxWidth: 360
103
+ },
104
+ helperText: settings.policy === 'verified' ? 'Customers who bought the product: signed in to their account, or ' + 'through the link in their review-request email. Their reviews ' + 'publish right away with a "Verified buyer" badge.' : 'Anyone may review. Customers who bought the product still get the ' + '"Verified buyer" badge and publish right away; everyone else gives a ' + 'name and email (never shown) and waits for your approval.',
105
+ children: [
106
+ /*#__PURE__*/ _jsx(MenuItem, {
107
+ value: "verified",
108
+ children: 'Verified buyers only (recommended)'
109
+ }),
110
+ /*#__PURE__*/ _jsx(MenuItem, {
111
+ value: "anyone",
112
+ children: 'Anyone'
113
+ })
114
+ ]
115
+ }),
116
+ /*#__PURE__*/ _jsx(FormControlLabel, {
117
+ control: /*#__PURE__*/ _jsx(Switch, {
118
+ size: "small",
119
+ checked: settings.requestEmail,
120
+ disabled: saving === 'requestEmail',
121
+ onChange: (change)=>void save('requestEmail', change.target.checked)
122
+ }),
123
+ label: /*#__PURE__*/ _jsxs(Stack, {
124
+ children: [
125
+ /*#__PURE__*/ _jsx(Typography, {
126
+ variant: "body2",
127
+ children: 'Ask customers for a review'
128
+ }),
129
+ /*#__PURE__*/ _jsx(Typography, {
130
+ variant: "caption",
131
+ color: "text.secondary",
132
+ children: 'One email per order, after it is fulfilled, with a link for each ' + 'product. Each link works once, for 60 days. Change its wording ' + 'under Emails → Review request.'
133
+ })
134
+ ]
135
+ })
136
+ }),
137
+ /*#__PURE__*/ _jsx(TextField, {
138
+ label: "Send the request (days after fulfillment)",
139
+ value: days,
140
+ onChange: (event)=>setDays(event.target.value),
141
+ onBlur: commitDays,
142
+ disabled: !settings.requestEmail || saving === 'requestDelayDays',
143
+ size: "small",
144
+ type: "number",
145
+ slotProps: {
146
+ htmlInput: {
147
+ min: CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS,
148
+ max: CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS
149
+ }
150
+ },
151
+ sx: {
152
+ maxWidth: 280
153
+ },
154
+ helperText: `Between ${CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS} and ${CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS} days. Give the order time to arrive.`
155
+ }),
156
+ /*#__PURE__*/ _jsx(Typography, {
157
+ variant: "caption",
158
+ color: "text.secondary",
159
+ children: 'Verified reviews are published as written, good or bad. You can hide ' + 'or reject one only for spam, abuse, being off-topic or sharing ' + 'private information.'
160
+ })
161
+ ]
162
+ })
163
+ });
164
+ }
165
+ ReviewSettingsCard.displayName = 'ReviewSettingsCard';
166
+ export default ReviewSettingsCard;
167
+
168
+ //# sourceMappingURL=review-settings-card.component.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/review-settings-card.component.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport {\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport * as CommerceModel from '../../model'\n\nexport interface ReviewSettingsCardProps {\n hostId: string\n}\n\n/**\n * Who may review the store's products, and the review-request email\n * (AGL-3736), on `hosts/{hostId}/settings/store.reviews`. Each control writes\n * only its own key, so this card never overwrites the store settings card's\n * fields beside it. Defaults: verified buyers only, the email on, 7 days.\n */\nexport function ReviewSettingsCard(props: ReviewSettingsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { data: store } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'settings', 'store'),\n [firestore, hostId],\n )\n const settings = CommerceModel.reviewSettingsFrom(store)\n const [days, setDays] = useState(String(settings.requestDelayDays))\n useEffect(() => setDays(String(settings.requestDelayDays)), [settings.requestDelayDays])\n const [saving, setSaving] = useState<string | null>(null)\n\n const save = useCallback(\n async (key: 'policy' | 'requestEmail' | 'requestDelayDays', value: unknown) => {\n setSaving(key)\n try {\n // The policy decides what the product page's review form draws, so\n // the write carries the site's cache drop (AGL-3386).\n await writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.set(\n doc(firestore, 'hosts', hostId, 'settings', 'store'),\n { reviews: { [key]: value } },\n { merge: true },\n ),\n })\n } catch (error) {\n console.error(error)\n enqueueSnackbar('That setting could not be saved. Try again.', { variant: 'error' })\n } finally {\n setSaving(null)\n }\n },\n [enqueueSnackbar, firestore, hostId, user],\n )\n\n const commitDays = () => {\n const parsed = Math.round(Number(days))\n const bounded = Number.isFinite(parsed)\n ? Math.min(\n CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS,\n Math.max(CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS, parsed),\n )\n : CommerceModel.REVIEW_REQUEST_DELAY_DEFAULT_DAYS\n setDays(String(bounded))\n if (bounded !== settings.requestDelayDays) void save('requestDelayDays', bounded)\n }\n\n return (\n <CardDisplay\n header={'Reviews'}\n help={pluginDocsHelp('catalog', { anchor: '#review-settings' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <TextField\n label=\"Who can review\"\n value={settings.policy}\n onChange={(event) => void save('policy', event.target.value)}\n disabled={saving === 'policy'}\n size=\"small\"\n select\n sx={{ maxWidth: 360 }}\n helperText={\n settings.policy === 'verified'\n ? 'Customers who bought the product: signed in to their account, or ' +\n 'through the link in their review-request email. Their reviews ' +\n 'publish right away with a \"Verified buyer\" badge.'\n : 'Anyone may review. Customers who bought the product still get the ' +\n '\"Verified buyer\" badge and publish right away; everyone else gives a ' +\n 'name and email (never shown) and waits for your approval.'\n }\n >\n <MenuItem value=\"verified\">{'Verified buyers only (recommended)'}</MenuItem>\n <MenuItem value=\"anyone\">{'Anyone'}</MenuItem>\n </TextField>\n <FormControlLabel\n control={\n <Switch\n size=\"small\"\n checked={settings.requestEmail}\n disabled={saving === 'requestEmail'}\n onChange={(change) => void save('requestEmail', change.target.checked)}\n />\n }\n label={\n <Stack>\n <Typography variant=\"body2\">{'Ask customers for a review'}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'One email per order, after it is fulfilled, with a link for each ' +\n 'product. Each link works once, for 60 days. Change its wording ' +\n 'under Emails → Review request.'}\n </Typography>\n </Stack>\n }\n />\n <TextField\n label=\"Send the request (days after fulfillment)\"\n value={days}\n onChange={(event) => setDays(event.target.value)}\n onBlur={commitDays}\n disabled={!settings.requestEmail || saving === 'requestDelayDays'}\n size=\"small\"\n type=\"number\"\n slotProps={{\n htmlInput: {\n min: CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS,\n max: CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS,\n },\n }}\n sx={{ maxWidth: 280 }}\n helperText={`Between ${CommerceModel.REVIEW_REQUEST_DELAY_MIN_DAYS} and ${CommerceModel.REVIEW_REQUEST_DELAY_MAX_DAYS} days. Give the order time to arrive.`}\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Verified reviews are published as written, good or bad. You can hide ' +\n 'or reject one only for spam, abuse, being off-topic or sharing ' +\n 'private information.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nReviewSettingsCard.displayName = 'ReviewSettingsCard'\n\nexport default ReviewSettingsCard\n"],"names":["pluginDocsHelp","CardDisplay","useSnackbar","useFirestore","useFirestoreDoc","useUser","writeSiteWideChange","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","doc","useCallback","useEffect","useState","CommerceModel","ReviewSettingsCard","props","hostId","firestore","data","user","enqueueSnackbar","store","settings","reviewSettingsFrom","days","setDays","String","requestDelayDays","saving","setSaving","save","key","value","write","batch","set","reviews","merge","error","console","variant","commitDays","parsed","Math","round","Number","bounded","isFinite","min","REVIEW_REQUEST_DELAY_MAX_DAYS","max","REVIEW_REQUEST_DELAY_MIN_DAYS","REVIEW_REQUEST_DELAY_DEFAULT_DAYS","header","help","anchor","contentGutterX","contentGutterY","spacing","label","policy","onChange","event","target","disabled","size","select","sx","maxWidth","helperText","control","checked","requestEmail","change","color","onBlur","type","slotProps","htmlInput","displayName"],"mappings":"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"}
@@ -1,6 +1,20 @@
1
+ import { type ReviewModerationReason } from '../../model/product-reviews';
1
2
  export interface ReviewsModerationCardProps {
2
3
  hostId: string;
3
4
  }
5
+ /**
6
+ * The status a "Hide" writes (AGL-3736): a published review is hidden, a
7
+ * pending one is rejected. Either way a content reason goes with it.
8
+ */
9
+ export declare function hiddenStatusFor(review: {
10
+ status?: string;
11
+ }): 'hidden' | 'rejected';
12
+ /**
13
+ * The fields a moderation decision writes. Exactly the keys the Firestore
14
+ * rules let a site editor change on a review: never `verified`, the rating
15
+ * or the text. Approving clears the reason; hiding records it.
16
+ */
17
+ export declare function moderationUpdate(status: 'approved' | 'hidden' | 'rejected', reason: ReviewModerationReason | null, nowMs: number): Record<string, unknown>;
4
18
  export declare function ReviewsModerationCard(props: ReviewsModerationCardProps): import("react").JSX.Element;
5
19
  export declare namespace ReviewsModerationCard {
6
20
  var displayName: string;
@@ -26,16 +26,39 @@ import { writeSiteWideChange } from "@aglyn/tenant-feature-instance/hooks/helper
26
26
  import { usePagedCollection } from "@aglyn/tenant-feature-instance";
27
27
  import { EntitlementGatedCard } from "./entitlement-gate.component.js";
28
28
  import { pluginDocsHelp } from "@aglyn/aglyn";
29
+ import { REVIEW_MODERATION_REASONS, REVIEW_MODERATION_REASON_LABELS } from "../../model/product-reviews.js";
29
30
  /**
30
- * Review moderation (AGL-324): pending queue with approve/reject and
31
- * inline seller replies; approved reviews render on the product-reviews
31
+ * The status a "Hide" writes (AGL-3736): a published review is hidden, a
32
+ * pending one is rejected. Either way a content reason goes with it.
33
+ */ export function hiddenStatusFor(review) {
34
+ return review.status === 'pending' ? 'rejected' : 'hidden';
35
+ }
36
+ /**
37
+ * The fields a moderation decision writes. Exactly the keys the Firestore
38
+ * rules let a site editor change on a review: never `verified`, the rating
39
+ * or the text. Approving clears the reason; hiding records it.
40
+ */ export function moderationUpdate(status, reason, nowMs) {
41
+ return {
42
+ status,
43
+ moderationReason: status === 'approved' ? null : reason,
44
+ moderatedAtMs: nowMs
45
+ };
46
+ }
47
+ /**
48
+ * Review moderation (AGL-324, AGL-3736): the queue of reviews waiting for
49
+ * approval (unverified ones, under the store's "anyone" policy), with
50
+ * approve, hide-for-a-reason and inline seller replies. Verified reviews
51
+ * publish on their own; the merchant may hide one only for a content reason
52
+ * (spam, abuse, off topic, private information) — the FTC's 2024 rule bars
53
+ * suppressing an honest review for being negative — and the reason is kept.
54
+ * Seller replies; approved reviews render on the product-reviews
32
55
  * block with the aggregate in JSON-LD.
33
56
  *
34
57
  * Gated on `productReviews` since AGL-2080. `server/reviews.ts:108` enforces
35
58
  * it, and this card rendered identically on Free — an operator could work a
36
59
  * moderation queue whose approvals the renderer would never honour.
37
60
  */ const reviewsHelp = pluginDocsHelp('catalog', {
38
- anchor: '#product-reviews'
61
+ anchor: '#review-moderation'
39
62
  });
40
63
  export function ReviewsModerationCard(props) {
41
64
  const { hostId } = props;
@@ -69,15 +92,15 @@ export function ReviewsModerationCard(props) {
69
92
  });
70
93
  const [replyFor, setReplyFor] = useState(null);
71
94
  const [reply, setReply] = useState('');
95
+ const [hideFor, setHideFor] = useState(null);
96
+ const [hideReason, setHideReason] = useState('spam');
72
97
  // An approved review is on the product page — its star aggregate and its
73
98
  // reply — so moderating one carries the site's cache drop (AGL-3386).
74
- const setStatus = (review, status)=>writeSiteWideChange({
99
+ const setStatus = (review, status, reason = null)=>writeSiteWideChange({
75
100
  firestore,
76
101
  user,
77
102
  hostId,
78
- write: (batch)=>batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {
79
- status
80
- })
103
+ write: (batch)=>batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), moderationUpdate(status, reason, Date.now()))
81
104
  });
82
105
  return /*#__PURE__*/ _jsx(EntitlementGatedCard, {
83
106
  help: reviewsHelp,
@@ -109,7 +132,11 @@ export function ReviewsModerationCard(props) {
109
132
  }),
110
133
  /*#__PURE__*/ _jsx(MenuItem, {
111
134
  value: "approved",
112
- children: 'Approved'
135
+ children: 'Published'
136
+ }),
137
+ /*#__PURE__*/ _jsx(MenuItem, {
138
+ value: "hidden",
139
+ children: 'Hidden'
113
140
  }),
114
141
  /*#__PURE__*/ _jsx(MenuItem, {
115
142
  value: "rejected",
@@ -160,6 +187,11 @@ export function ReviewsModerationCard(props) {
160
187
  })
161
188
  ]
162
189
  }),
190
+ review.moderationReason && review.moderationReason in REVIEW_MODERATION_REASON_LABELS ? /*#__PURE__*/ _jsx(Typography, {
191
+ variant: "caption",
192
+ color: "text.secondary",
193
+ children: `Hidden: ${REVIEW_MODERATION_REASON_LABELS[review.moderationReason]}`
194
+ }) : null,
163
195
  /*#__PURE__*/ _jsx(Typography, {
164
196
  variant: "body2",
165
197
  color: "text.secondary",
@@ -174,11 +206,14 @@ export function ReviewsModerationCard(props) {
174
206
  onClick: ()=>setStatus(review, 'approved'),
175
207
  children: 'Approve'
176
208
  }) : null,
177
- review.status !== 'rejected' ? /*#__PURE__*/ _jsx(Button, {
209
+ review.status !== 'rejected' && review.status !== 'hidden' ? /*#__PURE__*/ _jsx(Button, {
178
210
  size: "small",
179
211
  color: "error",
180
- onClick: ()=>setStatus(review, 'rejected'),
181
- children: 'Reject'
212
+ onClick: ()=>{
213
+ setHideFor(review.$id);
214
+ setHideReason('spam');
215
+ },
216
+ children: review.status === 'pending' ? 'Reject…' : 'Hide…'
182
217
  }) : null,
183
218
  /*#__PURE__*/ _jsx(Button, {
184
219
  size: "small",
@@ -191,6 +226,45 @@ export function ReviewsModerationCard(props) {
191
226
  })
192
227
  ]
193
228
  }),
229
+ hideFor === review.$id ? /*#__PURE__*/ _jsxs(Stack, {
230
+ direction: "row",
231
+ spacing: 1,
232
+ sx: {
233
+ alignItems: 'center'
234
+ },
235
+ children: [
236
+ /*#__PURE__*/ _jsx(TextField, {
237
+ label: "Reason",
238
+ value: hideReason,
239
+ onChange: (event)=>setHideReason(event.target.value),
240
+ size: "small",
241
+ select: true,
242
+ sx: {
243
+ minWidth: 220
244
+ },
245
+ helperText: "Reviews can't be hidden for being negative.",
246
+ children: REVIEW_MODERATION_REASONS.map((reason)=>/*#__PURE__*/ _jsx(MenuItem, {
247
+ value: reason,
248
+ children: REVIEW_MODERATION_REASON_LABELS[reason]
249
+ }, reason))
250
+ }),
251
+ /*#__PURE__*/ _jsx(Button, {
252
+ size: "small",
253
+ variant: "contained",
254
+ color: "error",
255
+ onClick: async ()=>{
256
+ await setStatus(review, hiddenStatusFor(review), hideReason);
257
+ setHideFor(null);
258
+ },
259
+ children: review.status === 'pending' ? 'Reject' : 'Hide'
260
+ }),
261
+ /*#__PURE__*/ _jsx(Button, {
262
+ size: "small",
263
+ onClick: ()=>setHideFor(null),
264
+ children: 'Cancel'
265
+ })
266
+ ]
267
+ }) : null,
194
268
  replyFor === review.$id ? /*#__PURE__*/ _jsxs(Stack, {
195
269
  direction: "row",
196
270
  spacing: 1,