@aglyn/plugins-commerce 1.0.0-beta.209 → 1.0.0-beta.212

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.
@@ -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 type {\n ConsoleProductCopyValues,\n ConsoleProductSummary,\n ConsoleProductsHubZoneProps,\n ConsoleProposedDiscount,\n ConsoleProposedProduct,\n} from '@aglyn/aglyn/plugin-manager/feature-plugins'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { useFirestore, useHostResourceApi } from '@aglyn/tenant-feature-instance'\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 with no price (`unpricedProductDraft`).\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 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 await 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 })\n },\n [firestore, hostId],\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.unpricedProductDraft(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=\"productsHub\"\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","Timestamp","useFirestore","useHostResourceApi","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","createHostResource","summaries","map","product","id","$id","status","priceMissing","productPriceMissing","hasPhoto","Boolean","mediaUrls","imageUrl","applyProductCopy","productId","values","ref","smartCollections","transaction","stored","snapshot","get","exists","data","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","unpricedProductDraft","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 type {\n ConsoleProductCopyValues,\n ConsoleProductSummary,\n ConsoleProductsHubZoneProps,\n ConsoleProposedDiscount,\n ConsoleProposedProduct,\n} from '@aglyn/aglyn/plugin-manager/feature-plugins'\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 with no price (`unpricedProductDraft`).\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.unpricedProductDraft(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=\"productsHub\"\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","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","unpricedProductDraft","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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"}
@@ -19,9 +19,10 @@ import { CardDisplay } from "@aglyn/shared-ui-jsx";
19
19
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
20
20
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
21
21
  import { Button, Chip, MenuItem, Rating, Stack, TextField, Typography } from "@mui/material";
22
- import { collection, doc, limit, orderBy, query, updateDoc, where } from "firebase/firestore";
22
+ import { collection, doc, limit, orderBy, query, where } from "firebase/firestore";
23
23
  import { useState } from "react";
24
- import { useFirestore } from "@aglyn/tenant-feature-instance";
24
+ import { useFirestore, useUser } from "@aglyn/tenant-feature-instance";
25
+ import { writeSiteWideChange } from "@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change";
25
26
  import { usePagedCollection } from "@aglyn/tenant-feature-instance";
26
27
  import { EntitlementGatedCard } from "./entitlement-gate.component.js";
27
28
  import { pluginDocsHelp } from "@aglyn/aglyn";
@@ -40,6 +41,7 @@ import { pluginDocsHelp } from "@aglyn/aglyn";
40
41
  export function ReviewsModerationCard(props) {
41
42
  const { hostId } = props;
42
43
  const firestore = useFirestore();
44
+ const { data: user } = useUser();
43
45
  const { enqueueSnackbar } = useSnackbar();
44
46
  const [filter, setFilter] = useState('pending');
45
47
  /*
@@ -68,8 +70,15 @@ export function ReviewsModerationCard(props) {
68
70
  });
69
71
  const [replyFor, setReplyFor] = useState(null);
70
72
  const [reply, setReply] = useState('');
71
- const setStatus = (review, status)=>updateDoc(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {
72
- status
73
+ // An approved review is on the product page — its star aggregate and its
74
+ // reply — so moderating one carries the site's cache drop (AGL-3386).
75
+ const setStatus = (review, status)=>writeSiteWideChange({
76
+ firestore,
77
+ user,
78
+ hostId,
79
+ write: (batch)=>batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {
80
+ status
81
+ })
73
82
  });
74
83
  return /*#__PURE__*/ _jsx(EntitlementGatedCard, {
75
84
  help: reviewsHelp,
@@ -201,8 +210,13 @@ export function ReviewsModerationCard(props) {
201
210
  variant: "contained",
202
211
  color: "primary",
203
212
  onClick: async ()=>{
204
- await updateDoc(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {
205
- reply: reply.trim().slice(0, 500) || null
213
+ await writeSiteWideChange({
214
+ firestore,
215
+ user,
216
+ hostId,
217
+ write: (batch)=>batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {
218
+ reply: reply.trim().slice(0, 500) || null
219
+ })
206
220
  });
207
221
  setReplyFor(null);
208
222
  enqueueSnackbar('Reply saved', {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/reviews-moderation-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 { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n MenuItem,\n Rating,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n limit,\n orderBy,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useMemo, useState } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { usePagedCollection } from '@aglyn/tenant-feature-instance'\nimport { EntitlementGatedCard } from './entitlement-gate.component'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\n\nexport interface ReviewsModerationCardProps {\n hostId: string\n}\n\n/**\n * Review moderation (AGL-324): pending queue with approve/reject and\n * inline seller replies; approved reviews render on the product-reviews\n * block with the aggregate in JSON-LD.\n *\n * Gated on `productReviews` since AGL-2080. `server/reviews.ts:108` enforces\n * it, and this card rendered identically on Free — an operator could work a\n * moderation queue whose approvals the renderer would never honour.\n */\n\nconst reviewsHelp = pluginDocsHelp('catalog', {\n anchor: '#products-options-and-variants',\n excerpt:\n 'Customer reviews left on your products, waiting for you to ' +\n 'publish or reject them.',\n})\n\nexport function ReviewsModerationCard(props: ReviewsModerationCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const [filter, setFilter] = useState('pending')\n /*\n * The status filter is the QUERY, and the order is the server's.\n *\n * This read `limit(200)` with no `orderBy` and then filtered and sorted in\n * the browser. A limited Firestore query with no ordering returns\n * DOCUMENT-ID order, and reviews are written with `.add()`, so those two\n * hundred were a pseudo-random sample — \"Pending\" showed whichever pending\n * reviews happened to fall in it, which on a busy shop is not the queue and\n * cannot be worked through.\n *\n * Filtering here rather than in the browser is what lets the window shrink:\n * a page of 25 mixed reviews might hold three pending ones, and a card that\n * rendered those three would call it a page while the queue sat behind it.\n * `reviews(status ASC, createdAtMs DESC)` is in\n * cloud/firebase-firestore.indexes.json for exactly this.\n */\n const {\n rows: reviews,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'reviews'),\n ...(filter === 'all' ? [] : [where('status', '==', filter)]),\n orderBy('createdAtMs', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId, filter],\n { idField: '$id' },\n )\n const [replyFor, setReplyFor] = useState<string | null>(null)\n const [reply, setReply] = useState('')\n\n const setStatus = (review: any, status: string) =>\n updateDoc(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {\n status,\n })\n\n return (\n <EntitlementGatedCard\n help={reviewsHelp}\n hostId={hostId}\n feature=\"productReviews\"\n header={'Review moderation'}\n upsell={\n 'Verified-buyer reviews let customers rate what they bought and ' +\n 'show the star aggregate on your product pages and in search ' +\n 'results.'\n }\n >\n <CardDisplay\n header={'Review moderation'}\n help={reviewsHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n <TextField\n label=\"Show\"\n value={filter}\n onChange={(event) => setFilter(event.target.value)}\n size=\"small\"\n select\n sx={{ maxWidth: 180 }}\n >\n <MenuItem value=\"pending\">{'Pending'}</MenuItem>\n <MenuItem value=\"approved\">{'Approved'}</MenuItem>\n <MenuItem value=\"rejected\">{'Rejected'}</MenuItem>\n <MenuItem value=\"all\">{'All'}</MenuItem>\n </TextField>\n {reviews.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing to moderate.'}\n </Typography>\n ) : (\n reviews.map((review: any) => (\n <Stack key={review.$id} spacing={0.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Rating value={review.rating} size=\"small\" readOnly />\n <Typography variant=\"body2\" sx={{ fontWeight: 600 }}>\n {review.authorName}\n </Typography>\n {review.verified ? (\n <Chip label=\"Verified\" size=\"small\" color=\"success\" variant=\"outlined\" />\n ) : null}\n <Chip label={review.status} size=\"small\" variant=\"outlined\" />\n </Stack>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {review.body}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n {review.status !== 'approved' ? (\n <Button size=\"small\" onClick={() => setStatus(review, 'approved')}>\n {'Approve'}\n </Button>\n ) : null}\n {review.status !== 'rejected' ? (\n <Button\n size=\"small\"\n color=\"error\"\n onClick={() => setStatus(review, 'rejected')}\n >\n {'Reject'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n onClick={() => {\n setReplyFor(review.$id)\n setReply(review.reply ?? '')\n }}\n >\n {review.reply ? 'Edit reply' : 'Reply'}\n </Button>\n </Stack>\n {replyFor === review.$id ? (\n <Stack direction=\"row\" spacing={1}>\n <TextField\n value={reply}\n onChange={(event) => setReply(event.target.value)}\n size=\"small\"\n sx={{ flex: 1 }}\n placeholder=\"Public seller reply\"\n />\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={async () => {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'reviews', review.$id),\n { reply: reply.trim().slice(0, 500) || null },\n )\n setReplyFor(null)\n enqueueSnackbar('Reply saved', {\n variant: 'success',\n persist: false,\n })\n }}\n >\n {'Save'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n ))\n )}\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={reviews.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </Stack>\n </CardDisplay>\n </EntitlementGatedCard>\n )\n}\nReviewsModerationCard.displayName = 'ReviewsModerationCard'\n\nexport default ReviewsModerationCard\n"],"names":["CardDisplay","ListPagination","useSnackbar","Button","Chip","MenuItem","Rating","Stack","TextField","Typography","collection","doc","limit","orderBy","query","updateDoc","where","useState","useFirestore","usePagedCollection","EntitlementGatedCard","pluginDocsHelp","reviewsHelp","anchor","excerpt","ReviewsModerationCard","props","hostId","firestore","enqueueSnackbar","filter","setFilter","rows","reviews","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","replyFor","setReplyFor","reply","setReply","setStatus","review","status","$id","help","feature","header","upsell","contentGutterX","contentGutterY","spacing","label","value","onChange","event","target","size","select","sx","maxWidth","length","variant","color","map","direction","alignItems","rating","readOnly","fontWeight","authorName","verified","body","onClick","flex","placeholder","trim","slice","persist","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/reviews-moderation-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 { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n Chip,\n MenuItem,\n Rating,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { collection, doc, limit, orderBy, query, where } from 'firebase/firestore'\nimport { useMemo, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\nimport { usePagedCollection } from '@aglyn/tenant-feature-instance'\nimport { EntitlementGatedCard } from './entitlement-gate.component'\nimport { pluginDocsHelp } from '@aglyn/aglyn'\n\nexport interface ReviewsModerationCardProps {\n hostId: string\n}\n\n/**\n * Review moderation (AGL-324): pending queue with approve/reject and\n * inline seller replies; approved reviews render on the product-reviews\n * block with the aggregate in JSON-LD.\n *\n * Gated on `productReviews` since AGL-2080. `server/reviews.ts:108` enforces\n * it, and this card rendered identically on Free — an operator could work a\n * moderation queue whose approvals the renderer would never honour.\n */\n\nconst reviewsHelp = pluginDocsHelp('catalog', {\n anchor: '#products-options-and-variants',\n excerpt:\n 'Customer reviews left on your products, waiting for you to ' +\n 'publish or reject them.',\n})\n\nexport function ReviewsModerationCard(props: ReviewsModerationCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const [filter, setFilter] = useState('pending')\n /*\n * The status filter is the QUERY, and the order is the server's.\n *\n * This read `limit(200)` with no `orderBy` and then filtered and sorted in\n * the browser. A limited Firestore query with no ordering returns\n * DOCUMENT-ID order, and reviews are written with `.add()`, so those two\n * hundred were a pseudo-random sample — \"Pending\" showed whichever pending\n * reviews happened to fall in it, which on a busy shop is not the queue and\n * cannot be worked through.\n *\n * Filtering here rather than in the browser is what lets the window shrink:\n * a page of 25 mixed reviews might hold three pending ones, and a card that\n * rendered those three would call it a page while the queue sat behind it.\n * `reviews(status ASC, createdAtMs DESC)` is in\n * cloud/firebase-firestore.indexes.json for exactly this.\n */\n const {\n rows: reviews,\n hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n } = usePagedCollection<any>(\n (pageLimit) =>\n query(\n collection(firestore, 'hosts', hostId, 'reviews'),\n ...(filter === 'all' ? [] : [where('status', '==', filter)]),\n orderBy('createdAtMs', 'desc'),\n limit(pageLimit),\n ),\n [firestore, hostId, filter],\n { idField: '$id' },\n )\n const [replyFor, setReplyFor] = useState<string | null>(null)\n const [reply, setReply] = useState('')\n\n // An approved review is on the product page — its star aggregate and its\n // reply — so moderating one carries the site's cache drop (AGL-3386).\n const setStatus = (review: any, status: string) =>\n writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.update(doc(firestore, 'hosts', hostId, 'reviews', review.$id), {\n status,\n }),\n })\n\n return (\n <EntitlementGatedCard\n help={reviewsHelp}\n hostId={hostId}\n feature=\"productReviews\"\n header={'Review moderation'}\n upsell={\n 'Verified-buyer reviews let customers rate what they bought and ' +\n 'show the star aggregate on your product pages and in search ' +\n 'results.'\n }\n >\n <CardDisplay\n header={'Review moderation'}\n help={reviewsHelp}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n <TextField\n label=\"Show\"\n value={filter}\n onChange={(event) => setFilter(event.target.value)}\n size=\"small\"\n select\n sx={{ maxWidth: 180 }}\n >\n <MenuItem value=\"pending\">{'Pending'}</MenuItem>\n <MenuItem value=\"approved\">{'Approved'}</MenuItem>\n <MenuItem value=\"rejected\">{'Rejected'}</MenuItem>\n <MenuItem value=\"all\">{'All'}</MenuItem>\n </TextField>\n {reviews.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Nothing to moderate.'}\n </Typography>\n ) : (\n reviews.map((review: any) => (\n <Stack key={review.$id} spacing={0.5}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Rating value={review.rating} size=\"small\" readOnly />\n <Typography variant=\"body2\" sx={{ fontWeight: 600 }}>\n {review.authorName}\n </Typography>\n {review.verified ? (\n <Chip label=\"Verified\" size=\"small\" color=\"success\" variant=\"outlined\" />\n ) : null}\n <Chip label={review.status} size=\"small\" variant=\"outlined\" />\n </Stack>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {review.body}\n </Typography>\n <Stack direction=\"row\" spacing={1}>\n {review.status !== 'approved' ? (\n <Button size=\"small\" onClick={() => setStatus(review, 'approved')}>\n {'Approve'}\n </Button>\n ) : null}\n {review.status !== 'rejected' ? (\n <Button\n size=\"small\"\n color=\"error\"\n onClick={() => setStatus(review, 'rejected')}\n >\n {'Reject'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n onClick={() => {\n setReplyFor(review.$id)\n setReply(review.reply ?? '')\n }}\n >\n {review.reply ? 'Edit reply' : 'Reply'}\n </Button>\n </Stack>\n {replyFor === review.$id ? (\n <Stack direction=\"row\" spacing={1}>\n <TextField\n value={reply}\n onChange={(event) => setReply(event.target.value)}\n size=\"small\"\n sx={{ flex: 1 }}\n placeholder=\"Public seller reply\"\n />\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={async () => {\n await writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.update(\n doc(firestore, 'hosts', hostId, 'reviews', review.$id),\n { reply: reply.trim().slice(0, 500) || null },\n ),\n })\n setReplyFor(null)\n enqueueSnackbar('Reply saved', {\n variant: 'success',\n persist: false,\n })\n }}\n >\n {'Save'}\n </Button>\n </Stack>\n ) : null}\n </Stack>\n ))\n )}\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={reviews.length}\n hasMore={hasMore}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n </Stack>\n </CardDisplay>\n </EntitlementGatedCard>\n )\n}\nReviewsModerationCard.displayName = 'ReviewsModerationCard'\n\nexport default ReviewsModerationCard\n"],"names":["CardDisplay","ListPagination","useSnackbar","Button","Chip","MenuItem","Rating","Stack","TextField","Typography","collection","doc","limit","orderBy","query","where","useState","useFirestore","useUser","writeSiteWideChange","usePagedCollection","EntitlementGatedCard","pluginDocsHelp","reviewsHelp","anchor","excerpt","ReviewsModerationCard","props","hostId","firestore","data","user","enqueueSnackbar","filter","setFilter","rows","reviews","hasMore","page","setPage","pageSize","setPageSize","pageLimit","idField","replyFor","setReplyFor","reply","setReply","setStatus","review","status","write","batch","update","$id","help","feature","header","upsell","contentGutterX","contentGutterY","spacing","label","value","onChange","event","target","size","select","sx","maxWidth","length","variant","color","map","direction","alignItems","rating","readOnly","fontWeight","authorName","verified","body","onClick","flex","placeholder","trim","slice","persist","rowCount","onPageChange","onPageSizeChange","displayName"],"mappings":"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"}
@@ -19,12 +19,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
19
19
  import { CardDisplay } from "@aglyn/shared-ui-jsx";
20
20
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
21
21
  import { Button, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
22
- import { doc, setDoc } from "firebase/firestore";
22
+ import { doc } from "firebase/firestore";
23
23
  import { useCallback, useState } from "react";
24
24
  import { useFirestore, useUser } from "@aglyn/tenant-feature-instance";
25
25
  import { useFirestoreDoc, writeGuardedBySeed } from "@aglyn/tenant-feature-instance";
26
26
  import { hostPublicOrigin, pluginDocsHelp } from "@aglyn/aglyn";
27
- import { announceSiteWideChange } from "@aglyn/aglyn/app-utils/announce-site-wide-change";
27
+ import { writeSiteWideChange } from "@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change";
28
28
  /**
29
29
  * Store settings (AGL-295) on `hosts/{hostId}/settings/store`: the PDP
30
30
  * template screen (drives /products/{slug}), currency display, checkout
@@ -103,16 +103,26 @@ import { announceSiteWideChange } from "@aglyn/aglyn/app-utils/announce-site-wid
103
103
  unreadable: storeStatus === 'error',
104
104
  fromCache: storeFromCache
105
105
  }, async ()=>{
106
- var _current_currency;
107
- await setDoc(doc(firestore, 'hosts', hostId, 'settings', 'store'), {
108
- pdpScreenId: current.pdpScreenId || null,
109
- collectionScreenId: current.collectionScreenId || null,
110
- currency: (_current_currency = current.currency) != null ? _current_currency : 'USD',
111
- guestCheckout: current.guestCheckout !== false,
112
- termsUrl: current.termsUrl || null,
113
- receiptFooter: current.receiptFooter || null
114
- }, {
115
- merge: true
106
+ // The product and collection templates, the currency and the
107
+ // checkout rules render on the live store with no publish step, so
108
+ // the save carries the site's cache drop with it.
109
+ await writeSiteWideChange({
110
+ firestore,
111
+ user,
112
+ hostId,
113
+ write: (batch)=>{
114
+ var _current_currency;
115
+ return batch.set(doc(firestore, 'hosts', hostId, 'settings', 'store'), {
116
+ pdpScreenId: current.pdpScreenId || null,
117
+ collectionScreenId: current.collectionScreenId || null,
118
+ currency: (_current_currency = current.currency) != null ? _current_currency : 'USD',
119
+ guestCheckout: current.guestCheckout !== false,
120
+ termsUrl: current.termsUrl || null,
121
+ receiptFooter: current.receiptFooter || null
122
+ }, {
123
+ merge: true
124
+ });
125
+ }
116
126
  });
117
127
  });
118
128
  // Before `setDraft(null)`, so a refusal keeps every typed value on
@@ -125,13 +135,6 @@ import { announceSiteWideChange } from "@aglyn/aglyn/app-utils/announce-site-wid
125
135
  });
126
136
  }
127
137
  setDraft(null);
128
- // The product and collection templates, the currency and the checkout
129
- // rules render on the live store with no publish step: drop its cached
130
- // pages so they show now rather than within the hour.
131
- void announceSiteWideChange({
132
- user,
133
- hostId
134
- });
135
138
  enqueueSnackbar('Store settings saved', {
136
139
  variant: 'success',
137
140
  persist: false
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/store-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 { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc, setDoc } from 'firebase/firestore'\nimport { useCallback, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n useFirestoreDoc,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { hostPublicOrigin, pluginDocsHelp } from '@aglyn/aglyn'\nimport { announceSiteWideChange } from '@aglyn/aglyn/app-utils/announce-site-wide-change'\n\nexport interface StoreSettingsCardProps {\n hostId: string\n}\n\ninterface StoreSettings {\n /** Screen rendered for /products/{slug} with product tokens (AGL-292). */\n pdpScreenId?: string\n /** Screen rendered for /collections/{slug} (AGL-298). */\n collectionScreenId?: string\n currency?: string\n guestCheckout?: boolean\n termsUrl?: string\n receiptFooter?: string\n}\n\n/**\n * Store settings (AGL-295) on `hosts/{hostId}/settings/store`: the PDP\n * template screen (drives /products/{slug}), currency display, checkout\n * preferences, and receipt branding. Tax and shipping have their own\n * cards; store password/maintenance live in Site Protection.\n */\nexport function StoreSettingsCard(props: StoreSettingsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const {\n data: store,\n status: storeStatus,\n /**\n * The settings doc this form is seeded from is unconfirmed by the server\n * (AGL-1358). `current` is `draft ?? {…store}` and the save writes all\n * six keys, so `merge: true` protects only the tax and shipping maps that\n * belong to other cards — never this card's own fields. Against a cached\n * read, changing the currency rewrites `guestCheckout`, and the two\n * template ids fall back to `|| null`, which is how a storefront's\n * product and collection URLs start returning 404.\n */\n fromCache: storeFromCache,\n } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'settings', 'store'),\n [firestore, hostId],\n )\n const { data: host } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n )\n const [draft, setDraft] = useState<StoreSettings | null>(null)\n const current: StoreSettings = draft ?? {\n pdpScreenId: store?.pdpScreenId ?? '',\n collectionScreenId: store?.collectionScreenId ?? '',\n currency: store?.currency ?? 'USD',\n guestCheckout: store?.guestCheckout !== false,\n termsUrl: store?.termsUrl ?? '',\n receiptFooter: store?.receiptFooter ?? '',\n }\n const update = (patch: Partial<StoreSettings>) =>\n setDraft({ ...current, ...patch })\n\n /**\n * Google Merchant Center feed address (AGL-2249).\n *\n * `server/feed.ts` has served this since AGL-299, and the only place the\n * URL was ever written down was that file's own docblock — so the feature\n * was built, deployed, and undiscoverable. A feed nobody can find is a\n * shopping channel nobody switches on.\n *\n * Through `hostPublicOrigin` rather than a hand-built string, because a\n * store on a custom domain must be told ITS name: the feed's `<link>`\n * elements are built from the request host, so submitting the `.aglyn.app`\n * address for a site that serves on `shop.example.com` would put the wrong\n * domain on every item in Merchant Center.\n *\n * Undefined until the host doc lands, and undefined forever for a host with\n * neither a cname nor a subdomain — `hostPublicOrigin` returns undefined\n * rather than a half-built URL, and a copyable half-URL is worse than none.\n */\n const feedUrl = host\n ? (() => {\n const origin = hostPublicOrigin(host)\n return origin\n ? `${origin}/api/commerce/feed?hostId=${hostId}`\n : undefined\n })()\n : undefined\n\n // Host screen directory: {screenId: path} (AGL-292 template pick).\n const screenEntries = Object.entries(\n (host?.screens ?? {}) as Record<string, string>,\n ).sort(([, a], [, b]) => String(a).localeCompare(String(b)))\n\n const handleSave = useCallback(async () => {\n /**\n * Refuse a save whose seed the server never confirmed (AGL-1358).\n *\n * There is no create path to exempt, and that is deliberate rather than\n * an oversight: unlike a new row at a fresh uid, this writes a FIXED\n * document path, so the all-defaults `current` produced when the cache\n * has never seen this doc is not the harmless blank a fresh uid would\n * be. It would put `USD`, guest checkout on and two null template ids\n * over whatever the server really holds.\n *\n * The guard WRAPS the write — an early return is a shape you can keep\n * while losing the protection.\n */\n const verdict = await writeGuardedBySeed(\n {\n subject: 'store settings',\n unreadable: storeStatus === 'error',\n fromCache: storeFromCache,\n },\n async () => {\n await setDoc(\n doc(firestore, 'hosts', hostId, 'settings', 'store'),\n {\n pdpScreenId: current.pdpScreenId || null,\n collectionScreenId: current.collectionScreenId || null,\n currency: current.currency ?? 'USD',\n guestCheckout: current.guestCheckout !== false,\n termsUrl: current.termsUrl || null,\n receiptFooter: current.receiptFooter || null,\n },\n { merge: true },\n )\n },\n )\n // Before `setDraft(null)`, so a refusal keeps every typed value on\n // screen. A save that silently does nothing sends the user back to\n // retype a form that will be refused again just as quietly.\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n setDraft(null)\n // The product and collection templates, the currency and the checkout\n // rules render on the live store with no publish step: drop its cached\n // pages so they show now rather than within the hour.\n void announceSiteWideChange({ user, hostId })\n enqueueSnackbar('Store settings saved', {\n variant: 'success',\n persist: false,\n })\n }, [\n current,\n firestore,\n hostId,\n user,\n enqueueSnackbar,\n storeStatus,\n storeFromCache,\n ])\n\n return (\n <CardDisplay\n header={'Store settings'}\n help={pluginDocsHelp('commerce')}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <TextField\n label=\"Product page template\"\n value={current.pdpScreenId ?? ''}\n onChange={(event) => update({ pdpScreenId: event.target.value })}\n size=\"small\"\n select\n helperText={\n 'Screen rendered at /products/{slug} — design it in the ' +\n 'besigner with the Product detail block and {{product.*}} tokens.'\n }\n >\n <MenuItem value=\"\">{'None (product URLs 404)'}</MenuItem>\n {screenEntries.map(([screenId, path]) => (\n <MenuItem key={screenId} value={screenId}>\n {`/${path === '/' ? '' : path}`}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n label=\"Collection page template\"\n value={current.collectionScreenId ?? ''}\n onChange={(event) =>\n update({ collectionScreenId: event.target.value })\n }\n size=\"small\"\n select\n helperText={\n 'Screen rendered at /collections/{slug} — drop a Product grid ' +\n 'with source \"A collection\" and {{collection.*}} tokens.'\n }\n >\n <MenuItem value=\"\">{'None (collection URLs 404)'}</MenuItem>\n {screenEntries.map(([screenId, path]) => (\n <MenuItem key={screenId} value={screenId}>\n {`/${path === '/' ? '' : path}`}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n label=\"Currency\"\n value={current.currency ?? 'USD'}\n onChange={(event) => update({ currency: event.target.value })}\n size=\"small\"\n select\n sx={{ maxWidth: 200 }}\n helperText=\"Charges settle in USD; more currencies are coming\"\n >\n <MenuItem value=\"USD\">{'USD ($)'}</MenuItem>\n </TextField>\n <FormControlLabel\n control={\n <Switch\n size=\"small\"\n checked={current.guestCheckout !== false}\n onChange={(event) =>\n update({ guestCheckout: event.target.checked })\n }\n />\n }\n label=\"Allow guest checkout (no account required)\"\n />\n <TextField\n label=\"Terms URL\"\n value={current.termsUrl ?? ''}\n onChange={(event) => update({ termsUrl: event.target.value })}\n size=\"small\"\n placeholder=\"/terms\"\n helperText=\"Linked from checkout when set\"\n />\n <TextField\n label=\"Receipt footer\"\n value={current.receiptFooter ?? ''}\n onChange={(event) => update({ receiptFooter: event.target.value })}\n size=\"small\"\n multiline\n minRows={2}\n placeholder=\"Thanks for supporting our shop!\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Password protection and maintenance mode live under Site ' +\n 'Protection.'}\n </Typography>\n <Button\n variant=\"contained\"\n color=\"primary\"\n size=\"small\"\n disabled={!draft}\n onClick={handleSave}\n sx={{ alignSelf: 'flex-start' }}\n >\n {'Save store settings'}\n </Button>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Product feed'}</Typography>\n {feedUrl ? (\n <>\n <TextField\n label=\"Google Merchant Center feed URL\"\n value={feedUrl}\n size=\"small\"\n slotProps={{ input: { readOnly: true } }}\n onFocus={(event) => event.target.select()}\n helperText={\n 'Paste this into Merchant Center → Products → Feeds as a ' +\n 'scheduled fetch. It lists every active product with price ' +\n 'and availability, and refreshes itself.'\n }\n />\n <Button\n size=\"small\"\n sx={{ alignSelf: 'flex-start' }}\n onClick={() => {\n void navigator.clipboard?.writeText(feedUrl).then(() =>\n enqueueSnackbar('Feed URL copied', {\n variant: 'success',\n persist: false,\n }),\n )\n }}\n >\n {'Copy feed URL'}\n </Button>\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Publish this site to a subdomain or custom domain to get its ' +\n 'Google Merchant Center feed URL.'}\n </Typography>\n )}\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nStoreSettingsCard.displayName = 'StoreSettingsCard'\n\nexport default StoreSettingsCard\n"],"names":["CardDisplay","useSnackbar","Button","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","doc","setDoc","useCallback","useState","useFirestore","useUser","useFirestoreDoc","writeGuardedBySeed","hostPublicOrigin","pluginDocsHelp","announceSiteWideChange","StoreSettingsCard","props","current","hostId","firestore","data","user","enqueueSnackbar","store","status","storeStatus","fromCache","storeFromCache","host","draft","setDraft","pdpScreenId","collectionScreenId","currency","guestCheckout","termsUrl","receiptFooter","update","patch","feedUrl","origin","undefined","screenEntries","Object","entries","screens","sort","a","b","String","localeCompare","handleSave","verdict","subject","unreadable","merge","ok","message","variant","persist","header","help","contentGutterX","contentGutterY","spacing","label","value","onChange","event","target","size","select","helperText","map","screenId","path","sx","maxWidth","control","checked","placeholder","multiline","minRows","color","disabled","onClick","alignSelf","slotProps","input","readOnly","onFocus","navigator","clipboard","writeText","then","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/store-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 { CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n Button,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useState } from 'react'\nimport { useFirestore, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n useFirestoreDoc,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport { hostPublicOrigin, pluginDocsHelp } from '@aglyn/aglyn'\nimport { writeSiteWideChange } from '@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change'\n\nexport interface StoreSettingsCardProps {\n hostId: string\n}\n\ninterface StoreSettings {\n /** Screen rendered for /products/{slug} with product tokens (AGL-292). */\n pdpScreenId?: string\n /** Screen rendered for /collections/{slug} (AGL-298). */\n collectionScreenId?: string\n currency?: string\n guestCheckout?: boolean\n termsUrl?: string\n receiptFooter?: string\n}\n\n/**\n * Store settings (AGL-295) on `hosts/{hostId}/settings/store`: the PDP\n * template screen (drives /products/{slug}), currency display, checkout\n * preferences, and receipt branding. Tax and shipping have their own\n * cards; store password/maintenance live in Site Protection.\n */\nexport function StoreSettingsCard(props: StoreSettingsCardProps) {\n const { hostId } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const {\n data: store,\n status: storeStatus,\n /**\n * The settings doc this form is seeded from is unconfirmed by the server\n * (AGL-1358). `current` is `draft ?? {…store}` and the save writes all\n * six keys, so `merge: true` protects only the tax and shipping maps that\n * belong to other cards — never this card's own fields. Against a cached\n * read, changing the currency rewrites `guestCheckout`, and the two\n * template ids fall back to `|| null`, which is how a storefront's\n * product and collection URLs start returning 404.\n */\n fromCache: storeFromCache,\n } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId, 'settings', 'store'),\n [firestore, hostId],\n )\n const { data: host } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n )\n const [draft, setDraft] = useState<StoreSettings | null>(null)\n const current: StoreSettings = draft ?? {\n pdpScreenId: store?.pdpScreenId ?? '',\n collectionScreenId: store?.collectionScreenId ?? '',\n currency: store?.currency ?? 'USD',\n guestCheckout: store?.guestCheckout !== false,\n termsUrl: store?.termsUrl ?? '',\n receiptFooter: store?.receiptFooter ?? '',\n }\n const update = (patch: Partial<StoreSettings>) =>\n setDraft({ ...current, ...patch })\n\n /**\n * Google Merchant Center feed address (AGL-2249).\n *\n * `server/feed.ts` has served this since AGL-299, and the only place the\n * URL was ever written down was that file's own docblock — so the feature\n * was built, deployed, and undiscoverable. A feed nobody can find is a\n * shopping channel nobody switches on.\n *\n * Through `hostPublicOrigin` rather than a hand-built string, because a\n * store on a custom domain must be told ITS name: the feed's `<link>`\n * elements are built from the request host, so submitting the `.aglyn.app`\n * address for a site that serves on `shop.example.com` would put the wrong\n * domain on every item in Merchant Center.\n *\n * Undefined until the host doc lands, and undefined forever for a host with\n * neither a cname nor a subdomain — `hostPublicOrigin` returns undefined\n * rather than a half-built URL, and a copyable half-URL is worse than none.\n */\n const feedUrl = host\n ? (() => {\n const origin = hostPublicOrigin(host)\n return origin\n ? `${origin}/api/commerce/feed?hostId=${hostId}`\n : undefined\n })()\n : undefined\n\n // Host screen directory: {screenId: path} (AGL-292 template pick).\n const screenEntries = Object.entries(\n (host?.screens ?? {}) as Record<string, string>,\n ).sort(([, a], [, b]) => String(a).localeCompare(String(b)))\n\n const handleSave = useCallback(async () => {\n /**\n * Refuse a save whose seed the server never confirmed (AGL-1358).\n *\n * There is no create path to exempt, and that is deliberate rather than\n * an oversight: unlike a new row at a fresh uid, this writes a FIXED\n * document path, so the all-defaults `current` produced when the cache\n * has never seen this doc is not the harmless blank a fresh uid would\n * be. It would put `USD`, guest checkout on and two null template ids\n * over whatever the server really holds.\n *\n * The guard WRAPS the write — an early return is a shape you can keep\n * while losing the protection.\n */\n const verdict = await writeGuardedBySeed(\n {\n subject: 'store settings',\n unreadable: storeStatus === 'error',\n fromCache: storeFromCache,\n },\n async () => {\n // The product and collection templates, the currency and the\n // checkout rules render on the live store with no publish step, so\n // the save carries the site's cache drop with it.\n await writeSiteWideChange({\n firestore,\n user,\n hostId,\n write: (batch) =>\n batch.set(\n doc(firestore, 'hosts', hostId, 'settings', 'store'),\n {\n pdpScreenId: current.pdpScreenId || null,\n collectionScreenId: current.collectionScreenId || null,\n currency: current.currency ?? 'USD',\n guestCheckout: current.guestCheckout !== false,\n termsUrl: current.termsUrl || null,\n receiptFooter: current.receiptFooter || null,\n },\n { merge: true },\n ),\n })\n },\n )\n // Before `setDraft(null)`, so a refusal keeps every typed value on\n // screen. A save that silently does nothing sends the user back to\n // retype a form that will be refused again just as quietly.\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n setDraft(null)\n enqueueSnackbar('Store settings saved', {\n variant: 'success',\n persist: false,\n })\n }, [\n current,\n firestore,\n hostId,\n user,\n enqueueSnackbar,\n storeStatus,\n storeFromCache,\n ])\n\n return (\n <CardDisplay\n header={'Store settings'}\n help={pluginDocsHelp('commerce')}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1.5}>\n <TextField\n label=\"Product page template\"\n value={current.pdpScreenId ?? ''}\n onChange={(event) => update({ pdpScreenId: event.target.value })}\n size=\"small\"\n select\n helperText={\n 'Screen rendered at /products/{slug} — design it in the ' +\n 'besigner with the Product detail block and {{product.*}} tokens.'\n }\n >\n <MenuItem value=\"\">{'None (product URLs 404)'}</MenuItem>\n {screenEntries.map(([screenId, path]) => (\n <MenuItem key={screenId} value={screenId}>\n {`/${path === '/' ? '' : path}`}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n label=\"Collection page template\"\n value={current.collectionScreenId ?? ''}\n onChange={(event) =>\n update({ collectionScreenId: event.target.value })\n }\n size=\"small\"\n select\n helperText={\n 'Screen rendered at /collections/{slug} — drop a Product grid ' +\n 'with source \"A collection\" and {{collection.*}} tokens.'\n }\n >\n <MenuItem value=\"\">{'None (collection URLs 404)'}</MenuItem>\n {screenEntries.map(([screenId, path]) => (\n <MenuItem key={screenId} value={screenId}>\n {`/${path === '/' ? '' : path}`}\n </MenuItem>\n ))}\n </TextField>\n <TextField\n label=\"Currency\"\n value={current.currency ?? 'USD'}\n onChange={(event) => update({ currency: event.target.value })}\n size=\"small\"\n select\n sx={{ maxWidth: 200 }}\n helperText=\"Charges settle in USD; more currencies are coming\"\n >\n <MenuItem value=\"USD\">{'USD ($)'}</MenuItem>\n </TextField>\n <FormControlLabel\n control={\n <Switch\n size=\"small\"\n checked={current.guestCheckout !== false}\n onChange={(event) =>\n update({ guestCheckout: event.target.checked })\n }\n />\n }\n label=\"Allow guest checkout (no account required)\"\n />\n <TextField\n label=\"Terms URL\"\n value={current.termsUrl ?? ''}\n onChange={(event) => update({ termsUrl: event.target.value })}\n size=\"small\"\n placeholder=\"/terms\"\n helperText=\"Linked from checkout when set\"\n />\n <TextField\n label=\"Receipt footer\"\n value={current.receiptFooter ?? ''}\n onChange={(event) => update({ receiptFooter: event.target.value })}\n size=\"small\"\n multiline\n minRows={2}\n placeholder=\"Thanks for supporting our shop!\"\n />\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Password protection and maintenance mode live under Site ' +\n 'Protection.'}\n </Typography>\n <Button\n variant=\"contained\"\n color=\"primary\"\n size=\"small\"\n disabled={!draft}\n onClick={handleSave}\n sx={{ alignSelf: 'flex-start' }}\n >\n {'Save store settings'}\n </Button>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Product feed'}</Typography>\n {feedUrl ? (\n <>\n <TextField\n label=\"Google Merchant Center feed URL\"\n value={feedUrl}\n size=\"small\"\n slotProps={{ input: { readOnly: true } }}\n onFocus={(event) => event.target.select()}\n helperText={\n 'Paste this into Merchant Center → Products → Feeds as a ' +\n 'scheduled fetch. It lists every active product with price ' +\n 'and availability, and refreshes itself.'\n }\n />\n <Button\n size=\"small\"\n sx={{ alignSelf: 'flex-start' }}\n onClick={() => {\n void navigator.clipboard?.writeText(feedUrl).then(() =>\n enqueueSnackbar('Feed URL copied', {\n variant: 'success',\n persist: false,\n }),\n )\n }}\n >\n {'Copy feed URL'}\n </Button>\n </>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Publish this site to a subdomain or custom domain to get its ' +\n 'Google Merchant Center feed URL.'}\n </Typography>\n )}\n </Stack>\n </Stack>\n </CardDisplay>\n )\n}\nStoreSettingsCard.displayName = 'StoreSettingsCard'\n\nexport default StoreSettingsCard\n"],"names":["CardDisplay","useSnackbar","Button","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","doc","useCallback","useState","useFirestore","useUser","useFirestoreDoc","writeGuardedBySeed","hostPublicOrigin","pluginDocsHelp","writeSiteWideChange","StoreSettingsCard","props","current","hostId","firestore","data","user","enqueueSnackbar","store","status","storeStatus","fromCache","storeFromCache","host","draft","setDraft","pdpScreenId","collectionScreenId","currency","guestCheckout","termsUrl","receiptFooter","update","patch","feedUrl","origin","undefined","screenEntries","Object","entries","screens","sort","a","b","String","localeCompare","handleSave","verdict","subject","unreadable","write","batch","set","merge","ok","message","variant","persist","header","help","contentGutterX","contentGutterY","spacing","label","value","onChange","event","target","size","select","helperText","map","screenId","path","sx","maxWidth","control","checked","placeholder","multiline","minRows","color","disabled","onClick","alignSelf","slotProps","input","readOnly","onFocus","navigator","clipboard","writeText","then","displayName"],"mappings":"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"}