@aglyn/plugins-commerce 1.0.0-beta.291 → 1.0.0-beta.292

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 (92) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/cart-fulfillment.d.ts +3 -0
  3. package/src/lib/components/cart-fulfillment.js.map +1 -1
  4. package/src/lib/components/cart.js +14 -4
  5. package/src/lib/components/cart.js.map +1 -1
  6. package/src/lib/components/console/invoice-send.component.js +1 -1
  7. package/src/lib/components/console/invoice-send.component.js.map +1 -1
  8. package/src/lib/components/console/pos/pos-item-dialog.component.js +3 -2
  9. package/src/lib/components/console/pos/pos-item-dialog.component.js.map +1 -1
  10. package/src/lib/components/console/pos-kiosk-page.component.js +2 -1
  11. package/src/lib/components/console/pos-kiosk-page.component.js.map +1 -1
  12. package/src/lib/components/console/pos-page.component.js +1 -1
  13. package/src/lib/components/console/pos-page.component.js.map +1 -1
  14. package/src/lib/components/console/product-add-ons-fields.component.d.ts +41 -0
  15. package/src/lib/components/console/product-add-ons-fields.component.js +356 -0
  16. package/src/lib/components/console/product-add-ons-fields.component.js.map +1 -0
  17. package/src/lib/components/console/product-editor-dialog.component.js +31 -5
  18. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  19. package/src/lib/components/console/product-register-fields.component.d.ts +4 -5
  20. package/src/lib/components/console/product-register-fields.component.js +8 -197
  21. package/src/lib/components/console/product-register-fields.component.js.map +1 -1
  22. package/src/lib/components/console/products-hub-zone.component.js +6 -0
  23. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  24. package/src/lib/components/console/quote-pay-link.component.d.ts +8 -1
  25. package/src/lib/components/console/quote-pay-link.component.js +29 -3
  26. package/src/lib/components/console/quote-pay-link.component.js.map +1 -1
  27. package/src/lib/components/console/quote-send.component.js +1 -4
  28. package/src/lib/components/console/quote-send.component.js.map +1 -1
  29. package/src/lib/components/product-add-ons.d.ts +28 -0
  30. package/src/lib/components/product-add-ons.js +136 -0
  31. package/src/lib/components/product-add-ons.js.map +1 -0
  32. package/src/lib/components/product-detail.d.ts +16 -0
  33. package/src/lib/components/product-detail.js +112 -19
  34. package/src/lib/components/product-detail.js.map +1 -1
  35. package/src/lib/components/product-grid.d.ts +8 -2
  36. package/src/lib/components/product-grid.js +6 -1
  37. package/src/lib/components/product-grid.js.map +1 -1
  38. package/src/lib/model/commerce-cart.d.ts +15 -3
  39. package/src/lib/model/commerce-cart.js +10 -3
  40. package/src/lib/model/commerce-cart.js.map +1 -1
  41. package/src/lib/model/commerce-invoices.d.ts +7 -0
  42. package/src/lib/model/commerce-invoices.js +11 -0
  43. package/src/lib/model/commerce-invoices.js.map +1 -1
  44. package/src/lib/model/commerce-orders.d.ts +6 -3
  45. package/src/lib/model/commerce-orders.js +3 -0
  46. package/src/lib/model/commerce-orders.js.map +1 -1
  47. package/src/lib/model/commerce-pos-kiosk.d.ts +1 -1
  48. package/src/lib/model/commerce-pos-kiosk.js +3 -2
  49. package/src/lib/model/commerce-pos-kiosk.js.map +1 -1
  50. package/src/lib/model/commerce-quote-email.d.ts +14 -0
  51. package/src/lib/model/commerce-quote-email.js +25 -9
  52. package/src/lib/model/commerce-quote-email.js.map +1 -1
  53. package/src/lib/model/commerce-quotes.js +1 -1
  54. package/src/lib/model/commerce-quotes.js.map +1 -1
  55. package/src/lib/model/commerce.d.ts +18 -0
  56. package/src/lib/model/commerce.js +26 -0
  57. package/src/lib/model/commerce.js.map +1 -1
  58. package/src/lib/model/product-modifiers.d.ts +74 -4
  59. package/src/lib/model/product-modifiers.js +131 -10
  60. package/src/lib/model/product-modifiers.js.map +1 -1
  61. package/src/lib/server/billing-webhook.js +64 -43
  62. package/src/lib/server/billing-webhook.js.map +1 -1
  63. package/src/lib/server/cart-checkout.js +14 -2
  64. package/src/lib/server/cart-checkout.js.map +1 -1
  65. package/src/lib/server/cart.d.ts +5 -0
  66. package/src/lib/server/cart.js +41 -4
  67. package/src/lib/server/cart.js.map +1 -1
  68. package/src/lib/server/catalog.d.ts +6 -0
  69. package/src/lib/server/catalog.js +2 -0
  70. package/src/lib/server/catalog.js.map +1 -1
  71. package/src/lib/server/checkout-extras.js +3 -1
  72. package/src/lib/server/checkout-extras.js.map +1 -1
  73. package/src/lib/server/checkout.js +28 -3
  74. package/src/lib/server/checkout.js.map +1 -1
  75. package/src/lib/server/local-fulfillment.js +5 -1
  76. package/src/lib/server/local-fulfillment.js.map +1 -1
  77. package/src/lib/server/pos-offline-sync.js +1 -1
  78. package/src/lib/server/pos-offline-sync.js.map +1 -1
  79. package/src/lib/server/pos-order.js +1 -1
  80. package/src/lib/server/pos-order.js.map +1 -1
  81. package/src/lib/server/product.d.ts +26 -0
  82. package/src/lib/server/product.js +18 -0
  83. package/src/lib/server/product.js.map +1 -1
  84. package/src/lib/server/products-write.js +5 -2
  85. package/src/lib/server/products-write.js.map +1 -1
  86. package/src/lib/server/quote-email.js.map +1 -1
  87. package/src/lib/utils/add-to-cart.d.ts +8 -0
  88. package/src/lib/utils/add-to-cart.js +10 -2
  89. package/src/lib/utils/add-to-cart.js.map +1 -1
  90. package/src/lib/utils/quote-email-content.d.ts +1 -0
  91. package/src/lib/utils/quote-email-content.js +7 -4
  92. package/src/lib/utils/quote-email-content.js.map +1 -1
@@ -83,14 +83,11 @@ const SEND_REFUSAL = commerceSitePermissionRefusal('commerce.quotes.send');
83
83
  message
84
84
  ]);
85
85
  const openForm = useCallback(()=>{
86
- var _ref;
87
- var _section_customer;
88
- setTo(String((_ref = section == null ? void 0 : (_section_customer = section.customer) == null ? void 0 : _section_customer.email) != null ? _ref : ''));
86
+ setTo(CommerceModel.quoteSendPrefill(quote));
89
87
  setSubject(CommerceModel.quoteEmailDefaultSubject(quote, businessName, quoteId));
90
88
  setMessage('');
91
89
  setOpen(true);
92
90
  }, [
93
- section,
94
91
  quote,
95
92
  businessName,
96
93
  quoteId
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/quote-send.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\n'use client'\n\nimport * as CommerceModel from '../../model'\nimport { commerceSitePermissionRefusal } from '../../model/plugin-permissions'\nimport { siteContactEmail, resolveHostToken, type HostTokenSource } from '@aglyn/aglyn/app-utils/host-tokens'\nimport { BlockedControl } from '@aglyn/shared-ui-jsx/components/blocked-control.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { InvoiceSendDialog, invoiceSentLine } from './invoice-send.component'\n\nconst SEND_REFUSAL = commerceSitePermissionRefusal('commerce.quotes.send')\n\nexport interface QuoteSendSectionProps {\n hostId: string\n quoteId: string\n quote: CommerceModel.HostOrder\n /** Holds \"Send quotes and invoices to customers\" (`commerce.quotes.send`). */\n canSend: boolean\n /** The editor holds changes not saved yet, which the email would not show. */\n edited: boolean\n}\n\n/** `Oct 29, 2026, 3:04 PM`. */\nconst sentAt = (atMs: number) =>\n new Date(atMs).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })\n\n/**\n * Emailing the quote to the customer (AGL-4097): when it was last sent and to\n * whom, and Send quote, Send again or Send revision N, which opens a short\n * form (To, Subject, Message). The server writes the email in the site's\n * look with the proof, the PDF and the pay button, in the customer's\n * conversation with the business.\n *\n * Send invoice (AGL-4099) sits beside it: for an unpaid quote, an invoice for\n * a customer who needs one to pay (with the same pay link); for a paid one,\n * the invoice as their record. Both reply into the quote's conversation.\n */\nexport function QuoteSendSection(props: QuoteSendSectionProps) {\n const { hostId, quoteId, quote, canSend, edited } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { data: host } = useFirestoreDoc<Record<string, unknown>>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n )\n const businessName = String(resolveHostToken('businessName', (host ?? {}) as HostTokenSource) ?? '').trim()\n const section = quote.quote\n const unpaid = CommerceModel.isUnpaidQuote(quote)\n const last = CommerceModel.lastQuoteSend(section)\n const revision = Number(section?.revision ?? 1)\n const label = CommerceModel.quoteSendLabel(section)\n const replacesLink = CommerceModel.quoteSendReplacesLink(section)\n const approvalFirst = CommerceModel.quotePaymentBlockedByApproval(section)\n const proofCount = CommerceModel.currentQuoteProofs(section).length\n const listed = siteContactEmail(host as never)\n const contact = listed && /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(listed) ? listed : null\n\n const [open, setOpen] = useState(false)\n const [invoicing, setInvoicing] = useState(false)\n const invoiceLine = invoiceSentLine(quote)\n const closed = quote.status === 'cancelled' || quote.status === 'refunded'\n const [busy, setBusy] = useState(false)\n const [to, setTo] = useState('')\n const [subject, setSubject] = useState('')\n const [message, setMessage] = useState('')\n\n /*\n * One attempt per form (AGL-1697's rule): a retried Send presents the same\n * key and is answered from the first, so the customer never gets two; a\n * changed form is a new attempt.\n */\n const attemptKey = useRef('')\n useEffect(() => {\n attemptKey.current = ''\n }, [to, subject, message])\n\n const openForm = useCallback(() => {\n setTo(String(section?.customer?.email ?? ''))\n setSubject(CommerceModel.quoteEmailDefaultSubject(quote, businessName, quoteId))\n setMessage('')\n setOpen(true)\n }, [section, quote, businessName, quoteId])\n\n const send = useCallback(async () => {\n setBusy(true)\n try {\n if (!attemptKey.current) {\n attemptKey.current = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`\n }\n const response = await authorizedFetch(user, '/api/commerce/quote', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attemptKey.current },\n body: JSON.stringify({ action: 'send', hostId, quoteId, to, subject, message }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The quote could not be sent', { variant: 'error', allowDuplicate: true })\n }\n enqueueSnackbar(\n `${CommerceModel.formatQuoteNumber(quote, quoteId)} sent to ${String(payload.to ?? to)}` +\n (payload.linkReplaced ? '. The link in your earlier email no longer opens the quote.' : ''),\n { variant: 'success', persist: false },\n )\n setOpen(false)\n } finally {\n setBusy(false)\n }\n }, [user, hostId, quoteId, to, subject, message, quote, enqueueSnackbar])\n\n const status = last\n ? `Sent ${sentAt(Number(last.atMs))} to ${last.to || 'the customer'}` +\n (Number(last.revision) < revision ? ` (revision ${last.revision})` : '') +\n '.'\n : 'Not sent yet. Email it with your message, the proof and the PDF, or copy the pay link below and send it yourself.'\n\n return (\n <Stack spacing={1.5}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Email to customer'}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {unpaid ? status : last ? status : 'This quote was paid without being emailed.'}\n </Typography>\n {unpaid && replacesLink ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`Sending revision ${revision} gives your customer a new pay link. The link in your earlier email will say the quote was updated.`}\n </Typography>\n ) : null}\n {unpaid && edited ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Save your changes first: the email shows the quote as it was last saved.'}\n </Typography>\n ) : null}\n {invoiceLine ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {invoiceLine}\n </Typography>\n ) : null}\n </Stack>\n {!closed ? (\n <Stack direction=\"row\" spacing={1}>\n {unpaid ? (\n <BlockedControl blocked={!canSend} reason={SEND_REFUSAL}>\n <Button variant=\"contained\" size=\"small\" disabled={busy || edited} onClick={openForm}>\n {label}\n </Button>\n </BlockedControl>\n ) : null}\n <BlockedControl blocked={!canSend} reason={SEND_REFUSAL}>\n <Button\n variant={unpaid ? 'outlined' : 'contained'}\n size=\"small\"\n disabled={busy || (unpaid && edited)}\n onClick={() => setInvoicing(true)}\n >\n {'Send invoice'}\n </Button>\n </BlockedControl>\n </Stack>\n ) : null}\n <InvoiceSendDialog\n open={invoicing}\n onClose={() => setInvoicing(false)}\n hostId={hostId}\n orderId={quoteId}\n order={quote}\n />\n <Dialog open={open} onClose={busy ? undefined : () => setOpen(false)} maxWidth=\"sm\" fullWidth aria-labelledby=\"quote-send-title\">\n <DialogTitle id=\"quote-send-title\">{`${label}: ${CommerceModel.formatQuoteNumber(quote, quoteId)}`}</DialogTitle>\n <DialogContent>\n <Stack spacing={2} sx={{ pt: 1 }}>\n <TextField\n label=\"To\"\n type=\"email\"\n value={to}\n onChange={(event) => setTo(event.target.value)}\n required\n fullWidth\n disabled={busy}\n />\n <TextField\n label=\"Subject\"\n value={subject}\n onChange={(event) => setSubject(event.target.value)}\n fullWidth\n disabled={busy}\n slotProps={{ htmlInput: { maxLength: CommerceModel.QUOTE_MAX_SEND_SUBJECT } }}\n helperText={\n section?.emailThread\n ? 'Keep the subject the same to keep these emails in one conversation in your customer’s inbox.'\n : undefined\n }\n />\n <TextField\n label=\"Message\"\n value={message}\n onChange={(event) => setMessage(event.target.value)}\n multiline\n minRows={4}\n fullWidth\n disabled={busy}\n slotProps={{ htmlInput: { maxLength: CommerceModel.QUOTE_MAX_SEND_MESSAGE } }}\n helperText=\"Shown at the top of the email, in your words.\"\n />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`The email shows the quote number, revision ${revision} and total` +\n (proofCount ? ', the proof' : '') +\n `, with the quote attached as a PDF and a button to ${approvalFirst ? 'approve the proof' : 'pay'} on your site. ` +\n `Replies go to ${contact || 'your email'}.`}\n </Typography>\n {replacesLink ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This sends a new pay link. The link in your earlier email will say the quote was updated.'}\n </Typography>\n ) : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setOpen(false)} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button variant=\"contained\" disabled={busy || !to.trim()} onClick={() => void send()}>\n {busy ? 'Sending…' : 'Send'}\n </Button>\n </DialogActions>\n </Dialog>\n </Stack>\n )\n}\nQuoteSendSection.displayName = 'QuoteSendSection'\n\nexport default QuoteSendSection\n"],"names":["CommerceModel","commerceSitePermissionRefusal","siteContactEmail","resolveHostToken","BlockedControl","useSnackbar","authorizedFetch","useFirestore","useFirestoreDoc","useUser","Button","Dialog","DialogActions","DialogContent","DialogTitle","Stack","TextField","Typography","doc","useCallback","useEffect","useRef","useState","InvoiceSendDialog","invoiceSentLine","SEND_REFUSAL","sentAt","atMs","Date","toLocaleString","dateStyle","timeStyle","QuoteSendSection","props","hostId","quoteId","quote","canSend","edited","firestore","data","user","enqueueSnackbar","host","businessName","String","trim","section","unpaid","isUnpaidQuote","last","lastQuoteSend","revision","Number","label","quoteSendLabel","replacesLink","quoteSendReplacesLink","approvalFirst","quotePaymentBlockedByApproval","proofCount","currentQuoteProofs","length","listed","contact","test","open","setOpen","invoicing","setInvoicing","invoiceLine","closed","status","busy","setBusy","to","setTo","subject","setSubject","message","setMessage","attemptKey","current","openForm","customer","email","quoteEmailDefaultSubject","send","payload","globalThis","crypto","randomUUID","now","Math","random","toString","slice","response","method","headers","body","JSON","stringify","action","json","catch","ok","error","variant","allowDuplicate","formatQuoteNumber","linkReplaced","persist","spacing","color","direction","blocked","reason","size","disabled","onClick","onClose","orderId","order","undefined","maxWidth","fullWidth","aria-labelledby","id","sx","pt","type","value","onChange","event","target","required","slotProps","htmlInput","maxLength","QUOTE_MAX_SEND_SUBJECT","helperText","emailThread","multiline","minRows","QUOTE_MAX_SEND_MESSAGE","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/commerce/src/lib/components/console/quote-send.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\n'use client'\n\nimport * as CommerceModel from '../../model'\nimport { commerceSitePermissionRefusal } from '../../model/plugin-permissions'\nimport { siteContactEmail, resolveHostToken, type HostTokenSource } from '@aglyn/aglyn/app-utils/host-tokens'\nimport { BlockedControl } from '@aglyn/shared-ui-jsx/components/blocked-control.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { InvoiceSendDialog, invoiceSentLine } from './invoice-send.component'\n\nconst SEND_REFUSAL = commerceSitePermissionRefusal('commerce.quotes.send')\n\nexport interface QuoteSendSectionProps {\n hostId: string\n quoteId: string\n quote: CommerceModel.HostOrder\n /** Holds \"Send quotes and invoices to customers\" (`commerce.quotes.send`). */\n canSend: boolean\n /** The editor holds changes not saved yet, which the email would not show. */\n edited: boolean\n}\n\n/** `Oct 29, 2026, 3:04 PM`. */\nconst sentAt = (atMs: number) =>\n new Date(atMs).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })\n\n/**\n * Emailing the quote to the customer (AGL-4097): when it was last sent and to\n * whom, and Send quote, Send again or Send revision N, which opens a short\n * form (To, Subject, Message). The server writes the email in the site's\n * look with the proof, the PDF and the pay button, in the customer's\n * conversation with the business.\n *\n * Send invoice (AGL-4099) sits beside it: for an unpaid quote, an invoice for\n * a customer who needs one to pay (with the same pay link); for a paid one,\n * the invoice as their record. Both reply into the quote's conversation.\n */\nexport function QuoteSendSection(props: QuoteSendSectionProps) {\n const { hostId, quoteId, quote, canSend, edited } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { data: host } = useFirestoreDoc<Record<string, unknown>>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n )\n const businessName = String(resolveHostToken('businessName', (host ?? {}) as HostTokenSource) ?? '').trim()\n const section = quote.quote\n const unpaid = CommerceModel.isUnpaidQuote(quote)\n const last = CommerceModel.lastQuoteSend(section)\n const revision = Number(section?.revision ?? 1)\n const label = CommerceModel.quoteSendLabel(section)\n const replacesLink = CommerceModel.quoteSendReplacesLink(section)\n const approvalFirst = CommerceModel.quotePaymentBlockedByApproval(section)\n const proofCount = CommerceModel.currentQuoteProofs(section).length\n const listed = siteContactEmail(host as never)\n const contact = listed && /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(listed) ? listed : null\n\n const [open, setOpen] = useState(false)\n const [invoicing, setInvoicing] = useState(false)\n const invoiceLine = invoiceSentLine(quote)\n const closed = quote.status === 'cancelled' || quote.status === 'refunded'\n const [busy, setBusy] = useState(false)\n const [to, setTo] = useState('')\n const [subject, setSubject] = useState('')\n const [message, setMessage] = useState('')\n\n /*\n * One attempt per form (AGL-1697's rule): a retried Send presents the same\n * key and is answered from the first, so the customer never gets two; a\n * changed form is a new attempt.\n */\n const attemptKey = useRef('')\n useEffect(() => {\n attemptKey.current = ''\n }, [to, subject, message])\n\n const openForm = useCallback(() => {\n setTo(CommerceModel.quoteSendPrefill(quote))\n setSubject(CommerceModel.quoteEmailDefaultSubject(quote, businessName, quoteId))\n setMessage('')\n setOpen(true)\n }, [quote, businessName, quoteId])\n\n const send = useCallback(async () => {\n setBusy(true)\n try {\n if (!attemptKey.current) {\n attemptKey.current = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`\n }\n const response = await authorizedFetch(user, '/api/commerce/quote', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attemptKey.current },\n body: JSON.stringify({ action: 'send', hostId, quoteId, to, subject, message }),\n })\n const payload = await response.json().catch(() => ({}))\n if (!response.ok) {\n return void enqueueSnackbar(payload?.error ?? 'The quote could not be sent', { variant: 'error', allowDuplicate: true })\n }\n enqueueSnackbar(\n `${CommerceModel.formatQuoteNumber(quote, quoteId)} sent to ${String(payload.to ?? to)}` +\n (payload.linkReplaced ? '. The link in your earlier email no longer opens the quote.' : ''),\n { variant: 'success', persist: false },\n )\n setOpen(false)\n } finally {\n setBusy(false)\n }\n }, [user, hostId, quoteId, to, subject, message, quote, enqueueSnackbar])\n\n const status = last\n ? `Sent ${sentAt(Number(last.atMs))} to ${last.to || 'the customer'}` +\n (Number(last.revision) < revision ? ` (revision ${last.revision})` : '') +\n '.'\n : 'Not sent yet. Email it with your message, the proof and the PDF, or copy the pay link below and send it yourself.'\n\n return (\n <Stack spacing={1.5}>\n <Stack spacing={0.5}>\n <Typography variant=\"subtitle2\">{'Email to customer'}</Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {unpaid ? status : last ? status : 'This quote was paid without being emailed.'}\n </Typography>\n {unpaid && replacesLink ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`Sending revision ${revision} gives your customer a new pay link. The link in your earlier email will say the quote was updated.`}\n </Typography>\n ) : null}\n {unpaid && edited ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Save your changes first: the email shows the quote as it was last saved.'}\n </Typography>\n ) : null}\n {invoiceLine ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {invoiceLine}\n </Typography>\n ) : null}\n </Stack>\n {!closed ? (\n <Stack direction=\"row\" spacing={1}>\n {unpaid ? (\n <BlockedControl blocked={!canSend} reason={SEND_REFUSAL}>\n <Button variant=\"contained\" size=\"small\" disabled={busy || edited} onClick={openForm}>\n {label}\n </Button>\n </BlockedControl>\n ) : null}\n <BlockedControl blocked={!canSend} reason={SEND_REFUSAL}>\n <Button\n variant={unpaid ? 'outlined' : 'contained'}\n size=\"small\"\n disabled={busy || (unpaid && edited)}\n onClick={() => setInvoicing(true)}\n >\n {'Send invoice'}\n </Button>\n </BlockedControl>\n </Stack>\n ) : null}\n <InvoiceSendDialog\n open={invoicing}\n onClose={() => setInvoicing(false)}\n hostId={hostId}\n orderId={quoteId}\n order={quote}\n />\n <Dialog open={open} onClose={busy ? undefined : () => setOpen(false)} maxWidth=\"sm\" fullWidth aria-labelledby=\"quote-send-title\">\n <DialogTitle id=\"quote-send-title\">{`${label}: ${CommerceModel.formatQuoteNumber(quote, quoteId)}`}</DialogTitle>\n <DialogContent>\n <Stack spacing={2} sx={{ pt: 1 }}>\n <TextField\n label=\"To\"\n type=\"email\"\n value={to}\n onChange={(event) => setTo(event.target.value)}\n required\n fullWidth\n disabled={busy}\n />\n <TextField\n label=\"Subject\"\n value={subject}\n onChange={(event) => setSubject(event.target.value)}\n fullWidth\n disabled={busy}\n slotProps={{ htmlInput: { maxLength: CommerceModel.QUOTE_MAX_SEND_SUBJECT } }}\n helperText={\n section?.emailThread\n ? 'Keep the subject the same to keep these emails in one conversation in your customer’s inbox.'\n : undefined\n }\n />\n <TextField\n label=\"Message\"\n value={message}\n onChange={(event) => setMessage(event.target.value)}\n multiline\n minRows={4}\n fullWidth\n disabled={busy}\n slotProps={{ htmlInput: { maxLength: CommerceModel.QUOTE_MAX_SEND_MESSAGE } }}\n helperText=\"Shown at the top of the email, in your words.\"\n />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`The email shows the quote number, revision ${revision} and total` +\n (proofCount ? ', the proof' : '') +\n `, with the quote attached as a PDF and a button to ${approvalFirst ? 'approve the proof' : 'pay'} on your site. ` +\n `Replies go to ${contact || 'your email'}.`}\n </Typography>\n {replacesLink ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This sends a new pay link. The link in your earlier email will say the quote was updated.'}\n </Typography>\n ) : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setOpen(false)} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button variant=\"contained\" disabled={busy || !to.trim()} onClick={() => void send()}>\n {busy ? 'Sending…' : 'Send'}\n </Button>\n </DialogActions>\n </Dialog>\n </Stack>\n )\n}\nQuoteSendSection.displayName = 'QuoteSendSection'\n\nexport default QuoteSendSection\n"],"names":["CommerceModel","commerceSitePermissionRefusal","siteContactEmail","resolveHostToken","BlockedControl","useSnackbar","authorizedFetch","useFirestore","useFirestoreDoc","useUser","Button","Dialog","DialogActions","DialogContent","DialogTitle","Stack","TextField","Typography","doc","useCallback","useEffect","useRef","useState","InvoiceSendDialog","invoiceSentLine","SEND_REFUSAL","sentAt","atMs","Date","toLocaleString","dateStyle","timeStyle","QuoteSendSection","props","hostId","quoteId","quote","canSend","edited","firestore","data","user","enqueueSnackbar","host","businessName","String","trim","section","unpaid","isUnpaidQuote","last","lastQuoteSend","revision","Number","label","quoteSendLabel","replacesLink","quoteSendReplacesLink","approvalFirst","quotePaymentBlockedByApproval","proofCount","currentQuoteProofs","length","listed","contact","test","open","setOpen","invoicing","setInvoicing","invoiceLine","closed","status","busy","setBusy","to","setTo","subject","setSubject","message","setMessage","attemptKey","current","openForm","quoteSendPrefill","quoteEmailDefaultSubject","send","payload","globalThis","crypto","randomUUID","now","Math","random","toString","slice","response","method","headers","body","JSON","stringify","action","json","catch","ok","error","variant","allowDuplicate","formatQuoteNumber","linkReplaced","persist","spacing","color","direction","blocked","reason","size","disabled","onClick","onClose","orderId","order","undefined","maxWidth","fullWidth","aria-labelledby","id","sx","pt","type","value","onChange","event","target","required","slotProps","htmlInput","maxLength","QUOTE_MAX_SEND_SUBJECT","helperText","emailThread","multiline","minRows","QUOTE_MAX_SEND_MESSAGE","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;AAEA,YAAYA,mBAAmB,uBAAa;AAC5C,SAASC,6BAA6B,QAAQ,oCAAgC;AAC9E,SAASC,gBAAgB,EAAEC,gBAAgB,QAA8B,qCAAoC;AAC7G,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SAASC,YAAY,EAAEC,eAAe,EAAEC,OAAO,QAAQ,iCAAgC;AACvF,SACEC,MAAM,EACNC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,WAAW,EACXC,KAAK,EACLC,SAAS,EACTC,UAAU,QACL,gBAAe;AACtB,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SAASC,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAO;AAChE,SAASC,iBAAiB,EAAEC,eAAe,QAAQ,8BAA0B;AAE7E,MAAMC,eAAexB,8BAA8B;AAYnD,6BAA6B,GAC7B,MAAMyB,SAAS,CAACC,OACd,IAAIC,KAAKD,MAAME,cAAc,CAAC,SAAS;QAAEC,WAAW;QAAUC,WAAW;IAAQ;AAEnF;;;;;;;;;;CAUC,GACD,OAAO,SAASC,iBAAiBC,KAA4B;QAS/B9B;IAR5B,MAAM,EAAE+B,MAAM,EAAEC,OAAO,EAAEC,KAAK,EAAEC,OAAO,EAAEC,MAAM,EAAE,GAAGL;IACpD,MAAMM,YAAYhC;IAClB,MAAM,EAAEiC,MAAMC,IAAI,EAAE,GAAGhC;IACvB,MAAM,EAAEiC,eAAe,EAAE,GAAGrC;IAC5B,MAAM,EAAEmC,MAAMG,IAAI,EAAE,GAAGnC,gBACrB,IAAMU,IAAIqB,WAAW,SAASL,SAC9B;QAACK;QAAWL;KAAO;IAErB,MAAMU,eAAeC,QAAO1C,oBAAAA,iBAAiB,gBAAiBwC,eAAAA,OAAQ,CAAC,cAA3CxC,oBAAqE,IAAI2C,IAAI;IACzG,MAAMC,UAAUX,MAAMA,KAAK;IAC3B,MAAMY,SAAShD,cAAciD,aAAa,CAACb;IAC3C,MAAMc,OAAOlD,cAAcmD,aAAa,CAACJ;IACzC,MAAMK,WAAWC,eAAON,2BAAAA,QAASK,QAAQ,mBAAI;IAC7C,MAAME,QAAQtD,cAAcuD,cAAc,CAACR;IAC3C,MAAMS,eAAexD,cAAcyD,qBAAqB,CAACV;IACzD,MAAMW,gBAAgB1D,cAAc2D,6BAA6B,CAACZ;IAClE,MAAMa,aAAa5D,cAAc6D,kBAAkB,CAACd,SAASe,MAAM;IACnE,MAAMC,SAAS7D,iBAAiByC;IAChC,MAAMqB,UAAUD,UAAU,6BAA6BE,IAAI,CAACF,UAAUA,SAAS;IAE/E,MAAM,CAACG,MAAMC,QAAQ,GAAG7C,SAAS;IACjC,MAAM,CAAC8C,WAAWC,aAAa,GAAG/C,SAAS;IAC3C,MAAMgD,cAAc9C,gBAAgBY;IACpC,MAAMmC,SAASnC,MAAMoC,MAAM,KAAK,eAAepC,MAAMoC,MAAM,KAAK;IAChE,MAAM,CAACC,MAAMC,QAAQ,GAAGpD,SAAS;IACjC,MAAM,CAACqD,IAAIC,MAAM,GAAGtD,SAAS;IAC7B,MAAM,CAACuD,SAASC,WAAW,GAAGxD,SAAS;IACvC,MAAM,CAACyD,SAASC,WAAW,GAAG1D,SAAS;IAEvC;;;;GAIC,GACD,MAAM2D,aAAa5D,OAAO;IAC1BD,UAAU;QACR6D,WAAWC,OAAO,GAAG;IACvB,GAAG;QAACP;QAAIE;QAASE;KAAQ;IAEzB,MAAMI,WAAWhE,YAAY;QAC3ByD,MAAM5E,cAAcoF,gBAAgB,CAAChD;QACrC0C,WAAW9E,cAAcqF,wBAAwB,CAACjD,OAAOQ,cAAcT;QACvE6C,WAAW;QACXb,QAAQ;IACV,GAAG;QAAC/B;QAAOQ;QAAcT;KAAQ;IAEjC,MAAMmD,OAAOnE,YAAY;QACvBuD,QAAQ;QACR,IAAI;gBAcqEa;YAbvE,IAAI,CAACN,WAAWC,OAAO,EAAE;;oBACFM,+BAAAA;gBAArBP,WAAWC,OAAO,YAAGM,qBAAAA,WAAWC,MAAM,sBAAjBD,gCAAAA,mBAAmBE,UAAU,qBAA7BF,mCAAAA,sCAAqC,GAAG5D,KAAK+D,GAAG,GAAG,CAAC,EAAEC,KAAKC,MAAM,GAAGC,QAAQ,CAAC,IAAIC,KAAK,CAAC,IAAI;YAClH;YACA,MAAMC,WAAW,MAAM1F,gBAAgBmC,MAAM,uBAAuB;gBAClEwD,QAAQ;gBACRC,SAAS;oBAAE,gBAAgB;oBAAoB,mBAAmBjB,WAAWC,OAAO;gBAAC;gBACrFiB,MAAMC,KAAKC,SAAS,CAAC;oBAAEC,QAAQ;oBAAQpE;oBAAQC;oBAASwC;oBAAIE;oBAASE;gBAAQ;YAC/E;YACA,MAAMQ,UAAU,MAAMS,SAASO,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;YACpD,IAAI,CAACR,SAASS,EAAE,EAAE;;gBAChB,OAAO,KAAK/D,yBAAgB6C,2BAAAA,QAASmB,KAAK,oBAAI,+BAA+B;oBAAEC,SAAS;oBAASC,gBAAgB;gBAAK;YACxH;YACAlE,gBACE,GAAG1C,cAAc6G,iBAAiB,CAACzE,OAAOD,SAAS,SAAS,EAAEU,QAAO0C,cAAAA,QAAQZ,EAAE,YAAVY,cAAcZ,KAAK,GACrFY,CAAAA,QAAQuB,YAAY,GAAG,gEAAgE,EAAC,GAC3F;gBAAEH,SAAS;gBAAWI,SAAS;YAAM;YAEvC5C,QAAQ;QACV,SAAU;YACRO,QAAQ;QACV;IACF,GAAG;QAACjC;QAAMP;QAAQC;QAASwC;QAAIE;QAASE;QAAS3C;QAAOM;KAAgB;IAExE,MAAM8B,SAAStB,OACX,CAAC,KAAK,EAAExB,OAAO2B,OAAOH,KAAKvB,IAAI,GAAG,IAAI,EAAEuB,KAAKyB,EAAE,IAAI,gBAAgB,GAClEtB,CAAAA,OAAOH,KAAKE,QAAQ,IAAIA,WAAW,CAAC,WAAW,EAAEF,KAAKE,QAAQ,CAAC,CAAC,CAAC,GAAG,EAAC,IACtE,MACA;IAEJ,qBACE,MAACrC;QAAMiG,SAAS;;0BACd,MAACjG;gBAAMiG,SAAS;;kCACd,KAAC/F;wBAAW0F,SAAQ;kCAAa;;kCACjC,KAAC1F;wBAAW0F,SAAQ;wBAAQM,OAAM;kCAC/BjE,SAASwB,SAAStB,OAAOsB,SAAS;;oBAEpCxB,UAAUQ,6BACT,KAACvC;wBAAW0F,SAAQ;wBAAQM,OAAM;kCAC/B,CAAC,iBAAiB,EAAE7D,SAAS,mGAAmG,CAAC;yBAElI;oBACHJ,UAAUV,uBACT,KAACrB;wBAAW0F,SAAQ;wBAAQM,OAAM;kCAC/B;yBAED;oBACH3C,4BACC,KAACrD;wBAAW0F,SAAQ;wBAAQM,OAAM;kCAC/B3C;yBAED;;;YAEL,CAACC,uBACA,MAACxD;gBAAMmG,WAAU;gBAAMF,SAAS;;oBAC7BhE,uBACC,KAAC5C;wBAAe+G,SAAS,CAAC9E;wBAAS+E,QAAQ3F;kCACzC,cAAA,KAACf;4BAAOiG,SAAQ;4BAAYU,MAAK;4BAAQC,UAAU7C,QAAQnC;4BAAQiF,SAASpC;sCACzE7B;;yBAGH;kCACJ,KAAClD;wBAAe+G,SAAS,CAAC9E;wBAAS+E,QAAQ3F;kCACzC,cAAA,KAACf;4BACCiG,SAAS3D,SAAS,aAAa;4BAC/BqE,MAAK;4BACLC,UAAU7C,QAASzB,UAAUV;4BAC7BiF,SAAS,IAAMlD,aAAa;sCAE3B;;;;iBAIL;0BACJ,KAAC9C;gBACC2C,MAAME;gBACNoD,SAAS,IAAMnD,aAAa;gBAC5BnC,QAAQA;gBACRuF,SAAStF;gBACTuF,OAAOtF;;0BAET,MAACzB;gBAAOuD,MAAMA;gBAAMsD,SAAS/C,OAAOkD,YAAY,IAAMxD,QAAQ;gBAAQyD,UAAS;gBAAKC,SAAS;gBAACC,mBAAgB;;kCAC5G,KAAChH;wBAAYiH,IAAG;kCAAoB,GAAGzE,MAAM,EAAE,EAAEtD,cAAc6G,iBAAiB,CAACzE,OAAOD,UAAU;;kCAClG,KAACtB;kCACC,cAAA,MAACE;4BAAMiG,SAAS;4BAAGgB,IAAI;gCAAEC,IAAI;4BAAE;;8CAC7B,KAACjH;oCACCsC,OAAM;oCACN4E,MAAK;oCACLC,OAAOxD;oCACPyD,UAAU,CAACC,QAAUzD,MAAMyD,MAAMC,MAAM,CAACH,KAAK;oCAC7CI,QAAQ;oCACRV,SAAS;oCACTP,UAAU7C;;8CAEZ,KAACzD;oCACCsC,OAAM;oCACN6E,OAAOtD;oCACPuD,UAAU,CAACC,QAAUvD,WAAWuD,MAAMC,MAAM,CAACH,KAAK;oCAClDN,SAAS;oCACTP,UAAU7C;oCACV+D,WAAW;wCAAEC,WAAW;4CAAEC,WAAW1I,cAAc2I,sBAAsB;wCAAC;oCAAE;oCAC5EC,YACE7F,CAAAA,2BAAAA,QAAS8F,WAAW,IAChB,iGACAlB;;8CAGR,KAAC3G;oCACCsC,OAAM;oCACN6E,OAAOpD;oCACPqD,UAAU,CAACC,QAAUrD,WAAWqD,MAAMC,MAAM,CAACH,KAAK;oCAClDW,SAAS;oCACTC,SAAS;oCACTlB,SAAS;oCACTP,UAAU7C;oCACV+D,WAAW;wCAAEC,WAAW;4CAAEC,WAAW1I,cAAcgJ,sBAAsB;wCAAC;oCAAE;oCAC5EJ,YAAW;;8CAEb,KAAC3H;oCAAW0F,SAAQ;oCAAQM,OAAM;8CAC/B,CAAC,2CAA2C,EAAE7D,SAAS,UAAU,CAAC,GAChEQ,CAAAA,aAAa,gBAAgB,EAAC,IAC/B,CAAC,mDAAmD,EAAEF,gBAAgB,sBAAsB,MAAM,eAAe,CAAC,GAClH,CAAC,cAAc,EAAEM,WAAW,aAAa,CAAC,CAAC;;gCAE9CR,6BACC,KAACvC;oCAAW0F,SAAQ;oCAAQM,OAAM;8CAC/B;qCAED;;;;kCAGR,MAACrG;;0CACC,KAACF;gCAAO6G,SAAS,IAAMpD,QAAQ;gCAAQmD,UAAU7C;0CAC9C;;0CAEH,KAAC/D;gCAAOiG,SAAQ;gCAAYW,UAAU7C,QAAQ,CAACE,GAAG7B,IAAI;gCAAIyE,SAAS,IAAM,KAAKjC;0CAC3Eb,OAAO,aAAa;;;;;;;;AAMjC;AACAzC,iBAAiBiH,WAAW,GAAG;AAE/B,eAAejH,iBAAgB"}
@@ -0,0 +1,28 @@
1
+ import { type ModifierSelection, type ProductModifierGroup } from '../model/product-modifiers';
2
+ export interface ProductAddOnsProps {
3
+ /** The groups the website shows, as the product's page received them. */
4
+ groups: readonly ProductModifierGroup[];
5
+ /** The variant picked: a choice priced by variant shows its price on it. */
6
+ variantId?: string;
7
+ value: readonly ModifierSelection[];
8
+ onChange: (next: ModifierSelection[]) => void;
9
+ /** The store's money, e.g. `$1,250.00`. */
10
+ formatCents: (cents: number) => string;
11
+ disabled?: boolean;
12
+ }
13
+ /** The picks with one group's answer replaced, in the groups' own order. */
14
+ export declare function withGroupAnswer(groups: readonly ProductModifierGroup[], value: readonly ModifierSelection[], groupId: string, optionIds: readonly string[]): ModifierSelection[];
15
+ /** What a group asks for, said under it. */
16
+ export declare function addOnGroupHint(group: Pick<ProductModifierGroup, 'min' | 'max'>): string;
17
+ /**
18
+ * The add-ons a product's page offers (AGL-4100): a group that takes one
19
+ * choice is a select, one that takes several is a set of checkboxes, each
20
+ * choice with what it adds on the picked variant. A required group is marked
21
+ * as the form builder marks a required field. Its own chunk, loaded only by a
22
+ * product that has add-ons on the website.
23
+ */
24
+ export declare function ProductAddOns(props: ProductAddOnsProps): import("react").JSX.Element;
25
+ export declare namespace ProductAddOns {
26
+ var displayName: string;
27
+ }
28
+ export default ProductAddOns;
@@ -0,0 +1,136 @@
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 Checkbox from "@mui/material/Checkbox";
19
+ import FormControl from "@mui/material/FormControl";
20
+ import FormControlLabel from "@mui/material/FormControlLabel";
21
+ import FormGroup from "@mui/material/FormGroup";
22
+ import FormHelperText from "@mui/material/FormHelperText";
23
+ import FormLabel from "@mui/material/FormLabel";
24
+ import MenuItem from "@mui/material/MenuItem";
25
+ import Stack from "@mui/material/Stack";
26
+ import TextField from "@mui/material/TextField";
27
+ import { modifierGroupRequired, modifierOptionPriceCents } from "../model/product-modifiers.js";
28
+ /** The picks with one group's answer replaced, in the groups' own order. */ export function withGroupAnswer(groups, value, groupId, optionIds) {
29
+ const others = value.filter((pick)=>pick.groupId !== groupId);
30
+ const answer = optionIds.map((optionId)=>({
31
+ groupId,
32
+ optionId
33
+ }));
34
+ const order = new Map(groups.map((group, index)=>[
35
+ group.id,
36
+ index
37
+ ]));
38
+ return [
39
+ ...others,
40
+ ...answer
41
+ ].sort((a, b)=>{
42
+ var _order_get, _order_get1;
43
+ return ((_order_get = order.get(a.groupId)) != null ? _order_get : 0) - ((_order_get1 = order.get(b.groupId)) != null ? _order_get1 : 0);
44
+ });
45
+ }
46
+ /** What a group asks for, said under it. */ export function addOnGroupHint(group) {
47
+ if (group.max === 1) return group.min > 0 ? 'Choose one' : 'Optional';
48
+ if (group.min > 0 && group.min === group.max) return `Choose ${group.min}`;
49
+ if (group.min > 0) return `Choose ${group.min} to ${group.max}`;
50
+ return `Choose up to ${group.max}`;
51
+ }
52
+ /**
53
+ * The add-ons a product's page offers (AGL-4100): a group that takes one
54
+ * choice is a select, one that takes several is a set of checkboxes, each
55
+ * choice with what it adds on the picked variant. A required group is marked
56
+ * as the form builder marks a required field. Its own chunk, loaded only by a
57
+ * product that has add-ons on the website.
58
+ */ export function ProductAddOns(props) {
59
+ const { groups, variantId, value, onChange, formatCents, disabled } = props;
60
+ const priceLabel = (option)=>{
61
+ const cents = modifierOptionPriceCents(option, variantId);
62
+ return cents > 0 ? `${option.name} (+${formatCents(cents)})` : option.name;
63
+ };
64
+ return /*#__PURE__*/ _jsx(Stack, {
65
+ spacing: 1.5,
66
+ sx: {
67
+ mb: 1.5
68
+ },
69
+ children: groups.map((group)=>{
70
+ const chosen = value.filter((pick)=>pick.groupId === group.id).map((pick)=>pick.optionId);
71
+ const required = modifierGroupRequired(group);
72
+ if (group.max === 1) {
73
+ var _chosen_;
74
+ return /*#__PURE__*/ _jsxs(TextField, {
75
+ select: true,
76
+ size: "small",
77
+ fullWidth: true,
78
+ required: required,
79
+ disabled: disabled,
80
+ label: group.name,
81
+ value: (_chosen_ = chosen[0]) != null ? _chosen_ : '',
82
+ onChange: (event)=>onChange(withGroupAnswer(groups, value, group.id, event.target.value ? [
83
+ String(event.target.value)
84
+ ] : [])),
85
+ helperText: addOnGroupHint(group),
86
+ children: [
87
+ required ? null : /*#__PURE__*/ _jsx(MenuItem, {
88
+ value: "",
89
+ children: /*#__PURE__*/ _jsx("em", {
90
+ children: 'None'
91
+ })
92
+ }),
93
+ group.options.map((option)=>/*#__PURE__*/ _jsx(MenuItem, {
94
+ value: option.id,
95
+ children: priceLabel(option)
96
+ }, option.id))
97
+ ]
98
+ }, group.id);
99
+ }
100
+ const full = chosen.length >= group.max;
101
+ return /*#__PURE__*/ _jsxs(FormControl, {
102
+ component: "fieldset",
103
+ required: required,
104
+ disabled: disabled,
105
+ variant: "standard",
106
+ children: [
107
+ /*#__PURE__*/ _jsx(FormLabel, {
108
+ component: "legend",
109
+ children: group.name
110
+ }),
111
+ /*#__PURE__*/ _jsx(FormGroup, {
112
+ children: group.options.map((option)=>{
113
+ const checked = chosen.includes(option.id);
114
+ return /*#__PURE__*/ _jsx(FormControlLabel, {
115
+ control: /*#__PURE__*/ _jsx(Checkbox, {
116
+ size: "small",
117
+ checked: checked,
118
+ disabled: disabled || !checked && full,
119
+ onChange: (event)=>onChange(withGroupAnswer(groups, value, group.id, event.target.checked ? group.options.map((entry)=>entry.id).filter((id)=>id === option.id || chosen.includes(id)) : chosen.filter((id)=>id !== option.id)))
120
+ }),
121
+ label: priceLabel(option)
122
+ }, option.id);
123
+ })
124
+ }),
125
+ /*#__PURE__*/ _jsx(FormHelperText, {
126
+ children: addOnGroupHint(group)
127
+ })
128
+ ]
129
+ }, group.id);
130
+ })
131
+ });
132
+ }
133
+ ProductAddOns.displayName = 'ProductAddOns';
134
+ export default ProductAddOns;
135
+
136
+ //# sourceMappingURL=product-add-ons.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/components/product-add-ons.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 Checkbox from '@mui/material/Checkbox'\nimport FormControl from '@mui/material/FormControl'\nimport FormControlLabel from '@mui/material/FormControlLabel'\nimport FormGroup from '@mui/material/FormGroup'\nimport FormHelperText from '@mui/material/FormHelperText'\nimport FormLabel from '@mui/material/FormLabel'\nimport MenuItem from '@mui/material/MenuItem'\nimport Stack from '@mui/material/Stack'\nimport TextField from '@mui/material/TextField'\nimport {\n modifierGroupRequired,\n modifierOptionPriceCents,\n type ModifierSelection,\n type ProductModifierGroup,\n type ProductModifierOption,\n} from '../model/product-modifiers'\n\nexport interface ProductAddOnsProps {\n /** The groups the website shows, as the product's page received them. */\n groups: readonly ProductModifierGroup[]\n /** The variant picked: a choice priced by variant shows its price on it. */\n variantId?: string\n value: readonly ModifierSelection[]\n onChange: (next: ModifierSelection[]) => void\n /** The store's money, e.g. `$1,250.00`. */\n formatCents: (cents: number) => string\n disabled?: boolean\n}\n\n/** The picks with one group's answer replaced, in the groups' own order. */\nexport function withGroupAnswer(\n groups: readonly ProductModifierGroup[],\n value: readonly ModifierSelection[],\n groupId: string,\n optionIds: readonly string[],\n): ModifierSelection[] {\n const others = value.filter((pick) => pick.groupId !== groupId)\n const answer = optionIds.map((optionId) => ({ groupId, optionId }))\n const order = new Map(groups.map((group, index) => [group.id, index]))\n return [...others, ...answer].sort(\n (a, b) => (order.get(a.groupId) ?? 0) - (order.get(b.groupId) ?? 0),\n )\n}\n\n/** What a group asks for, said under it. */\nexport function addOnGroupHint(group: Pick<ProductModifierGroup, 'min' | 'max'>): string {\n if (group.max === 1) return group.min > 0 ? 'Choose one' : 'Optional'\n if (group.min > 0 && group.min === group.max) return `Choose ${group.min}`\n if (group.min > 0) return `Choose ${group.min} to ${group.max}`\n return `Choose up to ${group.max}`\n}\n\n/**\n * The add-ons a product's page offers (AGL-4100): a group that takes one\n * choice is a select, one that takes several is a set of checkboxes, each\n * choice with what it adds on the picked variant. A required group is marked\n * as the form builder marks a required field. Its own chunk, loaded only by a\n * product that has add-ons on the website.\n */\nexport function ProductAddOns(props: ProductAddOnsProps) {\n const { groups, variantId, value, onChange, formatCents, disabled } = props\n const priceLabel = (option: ProductModifierOption) => {\n const cents = modifierOptionPriceCents(option, variantId)\n return cents > 0 ? `${option.name} (+${formatCents(cents)})` : option.name\n }\n return (\n <Stack spacing={1.5} sx={{ mb: 1.5 }}>\n {groups.map((group) => {\n const chosen = value\n .filter((pick) => pick.groupId === group.id)\n .map((pick) => pick.optionId)\n const required = modifierGroupRequired(group)\n if (group.max === 1) {\n return (\n <TextField\n key={group.id}\n select\n size=\"small\"\n fullWidth\n required={required}\n disabled={disabled}\n label={group.name}\n value={chosen[0] ?? ''}\n onChange={(event) =>\n onChange(\n withGroupAnswer(\n groups,\n value,\n group.id,\n event.target.value ? [String(event.target.value)] : [],\n ),\n )\n }\n helperText={addOnGroupHint(group)}\n >\n {required ? null : (\n <MenuItem value=\"\">\n <em>{'None'}</em>\n </MenuItem>\n )}\n {group.options.map((option) => (\n <MenuItem key={option.id} value={option.id}>\n {priceLabel(option)}\n </MenuItem>\n ))}\n </TextField>\n )\n }\n const full = chosen.length >= group.max\n return (\n <FormControl\n key={group.id}\n component=\"fieldset\"\n required={required}\n disabled={disabled}\n variant=\"standard\"\n >\n <FormLabel component=\"legend\">{group.name}</FormLabel>\n <FormGroup>\n {group.options.map((option) => {\n const checked = chosen.includes(option.id)\n return (\n <FormControlLabel\n key={option.id}\n control={\n <Checkbox\n size=\"small\"\n checked={checked}\n disabled={disabled || (!checked && full)}\n onChange={(event) =>\n onChange(\n withGroupAnswer(\n groups,\n value,\n group.id,\n event.target.checked\n ? group.options\n .map((entry) => entry.id)\n .filter((id) => id === option.id || chosen.includes(id))\n : chosen.filter((id) => id !== option.id),\n ),\n )\n }\n />\n }\n label={priceLabel(option)}\n />\n )\n })}\n </FormGroup>\n <FormHelperText>{addOnGroupHint(group)}</FormHelperText>\n </FormControl>\n )\n })}\n </Stack>\n )\n}\n\nProductAddOns.displayName = 'ProductAddOns'\n\nexport default ProductAddOns\n"],"names":["Checkbox","FormControl","FormControlLabel","FormGroup","FormHelperText","FormLabel","MenuItem","Stack","TextField","modifierGroupRequired","modifierOptionPriceCents","withGroupAnswer","groups","value","groupId","optionIds","others","filter","pick","answer","map","optionId","order","Map","group","index","id","sort","a","b","get","addOnGroupHint","max","min","ProductAddOns","props","variantId","onChange","formatCents","disabled","priceLabel","option","cents","name","spacing","sx","mb","chosen","required","select","size","fullWidth","label","event","target","String","helperText","em","options","full","length","component","variant","checked","includes","control","entry","displayName"],"mappings":"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"}
@@ -70,7 +70,23 @@ interface Detail {
70
70
  subscriptionOptional?: boolean;
71
71
  /** The store's time zone, when a variant asks for a use day (AGL-4043). */
72
72
  useDayTimeZone?: string;
73
+ /**
74
+ * The add-ons the page offers (AGL-4100): only groups shown on the
75
+ * website ever arrive here.
76
+ */
77
+ modifierGroups?: CommerceModel.ProductModifierGroup[];
73
78
  }
79
+ /**
80
+ * The page's add-ons for the variant picked (AGL-4100): whether every
81
+ * required group is answered — the server's own check, run on the groups the
82
+ * page was sent — and what the picks so far add to one unit, for the live
83
+ * price. Display only; every sale door prices them again from the product.
84
+ */
85
+ export declare function productPageAddOns(product: Pick<Detail, 'name' | 'modifierGroups'>, picks: readonly CommerceModel.ModifierSelection[], variantId: string | undefined): {
86
+ ready: boolean;
87
+ extraCents: number;
88
+ error?: string;
89
+ };
74
90
  /** How one option is offered on the page. */
75
91
  export interface OptionPresentation {
76
92
  name: string;
@@ -65,6 +65,12 @@ export const ID = 'product-detail';
65
65
  * The use-day picker (AGL-4043), in its own chunk: only a product whose
66
66
  * variant asks for a day downloads the date picker.
67
67
  */ const UseDayPicker = /*#__PURE__*/ lazy(()=>import("./use-day-picker.js"));
68
+ /**
69
+ * The add-on pickers (AGL-4100), in their own chunk: only a product with
70
+ * add-ons on the website downloads them.
71
+ */ const ProductAddOns = /*#__PURE__*/ lazy(()=>import("./product-add-ons.js"));
72
+ /** Money on the product page, in the store's currency: `$1,250.00`, never `$1250`. */ const money = (cents)=>CommerceModel.formatOrderMoney(cents);
73
+ /** A price in dollars as the page prints it. */ const moneyFromUsd = (priceUsd)=>money(Math.round(priceUsd * 100));
68
74
  const SAMPLE = {
69
75
  id: 'sample',
70
76
  name: 'Sample product',
@@ -119,15 +125,43 @@ const SAMPLE = {
119
125
  * `price` is the resolved variant's, which arrived from the server's product
120
126
  * payload — the same figure `checkout` prices the charge from, so the reported
121
127
  * value and the amount Stripe collects come from one source.
122
- */ function buyItem(product, variant, quantity) {
128
+ */ function buyItem(product, variant, quantity, /** What one unit costs with its add-ons (AGL-4100), when the page has some. */ unitCents) {
123
129
  var _ref;
124
130
  return {
125
131
  item_id: product.id,
126
132
  item_name: product.name,
127
- price: (_ref = variant == null ? void 0 : variant.priceUsd) != null ? _ref : undefined,
133
+ price: unitCents !== undefined && typeof (variant == null ? void 0 : variant.priceUsd) === 'number' ? unitCents / 100 : (_ref = variant == null ? void 0 : variant.priceUsd) != null ? _ref : undefined,
128
134
  quantity
129
135
  };
130
136
  }
137
+ /**
138
+ * The page's add-ons for the variant picked (AGL-4100): whether every
139
+ * required group is answered — the server's own check, run on the groups the
140
+ * page was sent — and what the picks so far add to one unit, for the live
141
+ * price. Display only; every sale door prices them again from the product.
142
+ */ export function productPageAddOns(product, picks, variantId) {
143
+ var _product_modifierGroups;
144
+ const groups = (_product_modifierGroups = product.modifierGroups) != null ? _product_modifierGroups : [];
145
+ if (!groups.length) return {
146
+ ready: true,
147
+ extraCents: 0
148
+ };
149
+ const source = {
150
+ name: product.name,
151
+ modifierGroups: groups
152
+ };
153
+ const checked = CommerceModel.resolveLineModifiers(source, picks, variantId);
154
+ const extraCents = CommerceModel.soldLineModifiers(source, picks, variantId).reduce((sum, modifier)=>sum + modifier.priceCents, 0);
155
+ return checked.ok ? {
156
+ ready: true,
157
+ extraCents
158
+ } : _extends({
159
+ ready: false,
160
+ extraCents
161
+ }, checked.error ? {
162
+ error: checked.error
163
+ } : {});
164
+ }
131
165
  /**
132
166
  * Which options are a CHOICE (AGL-3676 follow-up). A select with one entry —
133
167
  * or with entries no variant carries — is not a choice, and on a live store
@@ -218,7 +252,7 @@ function slugFromLocation() {
218
252
  * variant. Resolves its product from the /products/{slug} URL unless a
219
253
  * slug prop pins it; sample data on the inert canvas.
220
254
  */ const ProductDetail = /*#__PURE__*/ forwardRef((props, ref)=>{
221
- var _ref, _ref1, _ref2;
255
+ var _ref, _ref1, _ref2, _ref3;
222
256
  var _site_pageData;
223
257
  const { slug: slugProp, buyLabel, hideDescription, showCoupon, showDetails, showShipping } = props, rest = _object_without_properties_loose(props, [
224
258
  "slug",
@@ -255,6 +289,8 @@ function slugFromLocation() {
255
289
  // used to set this, so it opened with nothing selected — an empty select.
256
290
  const [selections, setSelections] = useState(()=>initialSelections(seededProduct));
257
291
  const [quantity, setQuantity] = useState(1);
292
+ /** The add-ons picked (AGL-4100), ids only. */ const [addOns, setAddOns] = useState([]);
293
+ /** Why the cart refused the line, when it did. */ const [addRefusal, setAddRefusal] = useState('');
258
294
  /**
259
295
  * The day the buyer chose to use a pass (AGL-4043), `YYYY-MM-DD` on the
260
296
  * store's calendar; '' is the day they buy, which is also the default.
@@ -326,6 +362,7 @@ function slugFromLocation() {
326
362
  ]
327
363
  });
328
364
  setSelections(initialSelections(product));
365
+ setAddOns([]);
329
366
  }
330
367
  }).catch(()=>{
331
368
  if (active) setDetail('missing');
@@ -349,6 +386,14 @@ function slugFromLocation() {
349
386
  resolved,
350
387
  selections
351
388
  ]);
389
+ // The add-ons picked, checked and priced on this variant (AGL-4100).
390
+ const addOnGroups = (_ref = resolved == null ? void 0 : resolved.modifierGroups) != null ? _ref : [];
391
+ const pageAddOns = resolved ? productPageAddOns(resolved, addOns, variant == null ? void 0 : variant.id) : {
392
+ ready: true,
393
+ extraCents: 0
394
+ };
395
+ const unitCents = typeof (variant == null ? void 0 : variant.priceUsd) === 'number' ? Math.round(variant.priceUsd * 100) + pageAddOns.extraCents : 0;
396
+ const addOnsKey = CommerceModel.modifierSelectionKey(addOns);
352
397
  // The day to send with this variant: only when it asks for one and the
353
398
  // buyer chose a day (AGL-4043).
354
399
  const chosenUseDay = (variant == null ? void 0 : variant.asksUseDate) && useDay ? useDay : '';
@@ -356,8 +401,11 @@ function slugFromLocation() {
356
401
  const storeTodayKey = (resolved == null ? void 0 : resolved.useDayTimeZone) ? zonedDayKey(Date.now(), resolved.useDayTimeZone) : '';
357
402
  // Add to cart (AGL-293) beside instant buy; badge refresh via event.
358
403
  const handleAddToCart = async ()=>{
359
- if (!hostId || !resolved || !variant) return;
404
+ // A required add-on unanswered is refused here, before any request
405
+ // (AGL-4100); the button says so too.
406
+ if (!hostId || !resolved || !variant || !pageAddOns.ready) return;
360
407
  setAdded(false);
408
+ setAddRefusal('');
361
409
  // Priced (AGL-1591's shape, completed): the resolved variant's price
362
410
  // and the chosen quantity both come from the server's product payload,
363
411
  // so GA4's "value added to cart" is a real number. The funnel step
@@ -371,8 +419,11 @@ function slugFromLocation() {
371
419
  quantity
372
420
  }, chosenUseDay ? {
373
421
  useDayKey: chosenUseDay
422
+ } : {}, addOns.length ? {
423
+ modifiers: addOns
374
424
  } : {}, {
375
- item: buyItem(resolved, variant, quantity)
425
+ item: buyItem(resolved, variant, quantity, addOnGroups.length ? unitCents : undefined),
426
+ onRefused: setAddRefusal
376
427
  }));
377
428
  if (ok) setAdded(true);
378
429
  };
@@ -387,11 +438,13 @@ function slugFromLocation() {
387
438
  * subscription).
388
439
  */ // Pickup or the store's own delivery (AGL-3624), for a one-time sale; the
389
440
  // server offers nothing for a product with nothing physical to collect.
390
- const fulfillment = useCartFulfillment(hostId, (_ref = resolved == null ? void 0 : resolved.id) != null ? _ref : '[]', resolved && variant && !(resolved.subscription && (!resolved.subscriptionOptional || billing === 'subscribe')) ? {
441
+ const fulfillment = useCartFulfillment(hostId, (_ref1 = resolved == null ? void 0 : resolved.id) != null ? _ref1 : '[]', resolved && variant && !(resolved.subscription && (!resolved.subscriptionOptional || billing === 'subscribe')) ? _extends({
391
442
  productId: resolved.id,
392
443
  variantId: variant.id,
393
444
  quantity
394
- } : null);
445
+ }, addOns.length ? {
446
+ modifiers: addOns
447
+ } : {}) : null);
395
448
  const attemptKey = useRef('');
396
449
  useEffect(()=>{
397
450
  attemptKey.current = '';
@@ -400,6 +453,7 @@ function slugFromLocation() {
400
453
  // key, and the server replays the original full-price session — a quoted
401
454
  // number that is not the number charged, which is the whole defect class
402
455
  // this path has been cleared of.
456
+ // `addOnsKey` too: different add-ons are a different price (AGL-4100).
403
457
  }, [
404
458
  resolved == null ? void 0 : resolved.id,
405
459
  variant == null ? void 0 : variant.id,
@@ -409,10 +463,11 @@ function slugFromLocation() {
409
463
  shipPostal,
410
464
  coupon,
411
465
  fulfillment.signature,
412
- chosenUseDay
466
+ chosenUseDay,
467
+ addOnsKey
413
468
  ]);
414
469
  const handleBuy = async ()=>{
415
- if (!hostId || !resolved || !variant || status === 'sending') return;
470
+ if (!hostId || !resolved || !variant || status === 'sending' || !pageAddOns.ready) return;
416
471
  setStatus('sending');
417
472
  setMessage('');
418
473
  if (!attemptKey.current) {
@@ -435,6 +490,8 @@ function slugFromLocation() {
435
490
  quantity
436
491
  }, chosenUseDay ? {
437
492
  useDayKey: chosenUseDay
493
+ } : {}, addOns.length ? {
494
+ modifiers: addOns
438
495
  } : {}, resolved.subscriptionOptional ? {
439
496
  billing
440
497
  } : {}, shipTo && !fulfillment.request ? {
@@ -466,7 +523,7 @@ function slugFromLocation() {
466
523
  // flag flipped would look like a conversion collapse.
467
524
  trackEvent('begin_checkout', buildBeginCheckoutParams({
468
525
  items: [
469
- buyItem(resolved, variant, quantity)
526
+ buyItem(resolved, variant, quantity, addOnGroups.length ? unitCents : undefined)
470
527
  ]
471
528
  }));
472
529
  setNativeCheckout({
@@ -484,7 +541,7 @@ function slugFromLocation() {
484
541
  // property that makes a bare call safe is invisible from this file.
485
542
  await trackEventBeforeNavigation('begin_checkout', buildBeginCheckoutParams({
486
543
  items: [
487
- buyItem(resolved, variant, quantity)
544
+ buyItem(resolved, variant, quantity, addOnGroups.length ? unitCents : undefined)
488
545
  ]
489
546
  }));
490
547
  window.location.assign(payload.url);
@@ -593,7 +650,7 @@ function slugFromLocation() {
593
650
  // schema.org `image` array below is read out of band by a crawler, where
594
651
  // an absolute URL is the point. That one wants `absoluteMediaSrc` with
595
652
  // the site's own origin, which is the tenant page's job (AGL-1725).
596
- const galleryImage = Aglyn.siteRelativeMediaSrc((_ref1 = (_ref2 = activeImage !== null ? resolved.mediaUrls[activeImage] : undefined) != null ? _ref2 : variant == null ? void 0 : variant.imageUrl) != null ? _ref1 : resolved.mediaUrls[0], {
653
+ const galleryImage = Aglyn.siteRelativeMediaSrc((_ref2 = (_ref3 = activeImage !== null ? resolved.mediaUrls[activeImage] : undefined) != null ? _ref3 : variant == null ? void 0 : variant.imageUrl) != null ? _ref2 : resolved.mediaUrls[0], {
597
654
  hostId
598
655
  });
599
656
  const presentedOptions = optionPresentation(resolved.options, resolved.variants);
@@ -743,7 +800,7 @@ function slugFromLocation() {
743
800
  children: [
744
801
  /*#__PURE__*/ _jsx(Typography, {
745
802
  variant: "h6",
746
- children: priced ? `$${variant == null ? void 0 : variant.priceUsd}${subscribing ? intervalSuffix : ''}` : PRICE_COMING_SOON_LABEL
803
+ children: priced ? `${money(unitCents)}${subscribing ? intervalSuffix : ''}` : PRICE_COMING_SOON_LABEL
747
804
  }),
748
805
  subscribing && (subscription == null ? void 0 : subscription.trialDays) ? /*#__PURE__*/ _jsx(Typography, {
749
806
  variant: "caption",
@@ -758,7 +815,7 @@ function slugFromLocation() {
758
815
  sx: {
759
816
  textDecoration: 'line-through'
760
817
  },
761
- children: `$${variant.compareAtPriceUsd}`
818
+ children: moneyFromUsd(variant.compareAtPriceUsd)
762
819
  }),
763
820
  /*#__PURE__*/ _jsx(Chip, {
764
821
  label: "Sale",
@@ -798,11 +855,11 @@ function slugFromLocation() {
798
855
  children: [
799
856
  /*#__PURE__*/ _jsx(ToggleButton, {
800
857
  value: "once",
801
- children: priced ? `One-time $${variant == null ? void 0 : variant.priceUsd}` : 'One-time'
858
+ children: priced ? `One-time ${money(unitCents)}` : 'One-time'
802
859
  }),
803
860
  /*#__PURE__*/ _jsx(ToggleButton, {
804
861
  value: "subscribe",
805
- children: priced ? `Subscribe $${variant == null ? void 0 : variant.priceUsd}${intervalSuffix}` : 'Subscribe'
862
+ children: priced ? `Subscribe ${money(unitCents)}${intervalSuffix}` : 'Subscribe'
806
863
  })
807
864
  ]
808
865
  }) : null,
@@ -837,6 +894,17 @@ function slugFromLocation() {
837
894
  }, value))
838
895
  }, option.name);
839
896
  }),
897
+ addOnGroups.length && priced ? /*#__PURE__*/ _jsx(Suspense, {
898
+ fallback: null,
899
+ children: /*#__PURE__*/ _jsx(ProductAddOns, {
900
+ groups: addOnGroups,
901
+ variantId: variant == null ? void 0 : variant.id,
902
+ value: addOns,
903
+ onChange: setAddOns,
904
+ formatCents: money,
905
+ disabled: Boolean(variant == null ? void 0 : variant.soldOut)
906
+ })
907
+ }) : null,
840
908
  showCoupon ? /*#__PURE__*/ _jsx(TextField, {
841
909
  label: "Discount code",
842
910
  value: coupon,
@@ -896,7 +964,7 @@ function slugFromLocation() {
896
964
  variant: "outlined",
897
965
  color: "primary",
898
966
  size: "large",
899
- disabled: !hostId || !priced || (variant == null ? void 0 : variant.soldOut),
967
+ disabled: !hostId || !priced || (variant == null ? void 0 : variant.soldOut) || !pageAddOns.ready,
900
968
  onClick: handleAddToCart,
901
969
  children: added ? 'Added ✓' : 'Add to cart'
902
970
  }),
@@ -904,7 +972,8 @@ function slugFromLocation() {
904
972
  variant: "contained",
905
973
  color: "primary",
906
974
  size: "large",
907
- disabled: !hostId || !priced || status === 'sending' || (variant == null ? void 0 : variant.soldOut) || // No payments on this deployment: the same 501 every time, so
975
+ disabled: !hostId || !priced || status === 'sending' || (variant == null ? void 0 : variant.soldOut) || // A required add-on unanswered (AGL-4100).
976
+ !pageAddOns.ready || // No payments on this deployment: the same 501 every time, so
908
977
  // the control latches off rather than inviting a retry that
909
978
  // cannot succeed (AGL-2019).
910
979
  status === 'unconfigured' || // Asked but unanswered: the server would only refuse again.
@@ -919,6 +988,30 @@ function slugFromLocation() {
919
988
  })
920
989
  ]
921
990
  }),
991
+ addOnGroups.length && priced ? /*#__PURE__*/ _jsxs(Box, {
992
+ sx: {
993
+ mb: 2
994
+ },
995
+ children: [
996
+ /*#__PURE__*/ _jsx(Typography, {
997
+ variant: "subtitle1",
998
+ component: "p",
999
+ children: quantity > 1 ? `Total for ${quantity}: ${money(unitCents * quantity)}` : `Total: ${money(unitCents)}`
1000
+ }),
1001
+ !pageAddOns.ready && pageAddOns.error && !(variant == null ? void 0 : variant.soldOut) ? /*#__PURE__*/ _jsx(Typography, {
1002
+ variant: "body2",
1003
+ color: "text.secondary",
1004
+ children: pageAddOns.error
1005
+ }) : null
1006
+ ]
1007
+ }) : null,
1008
+ addRefusal ? /*#__PURE__*/ _jsx(Alert, {
1009
+ severity: "warning",
1010
+ sx: {
1011
+ mb: 2
1012
+ },
1013
+ children: addRefusal
1014
+ }) : null,
922
1015
  nativeCheckout ? /*#__PURE__*/ _jsx(Suspense, {
923
1016
  fallback: /*#__PURE__*/ _jsx(StorefrontPaymentElementFallback, {}),
924
1017
  children: /*#__PURE__*/ _jsx(StorefrontPaymentElement, {
@@ -947,7 +1040,7 @@ function slugFromLocation() {
947
1040
  },
948
1041
  children: /*#__PURE__*/ _jsx(CartFulfillmentChoice, {
949
1042
  state: fulfillment,
950
- formatCents: (cents)=>`$${(cents / 100).toFixed(2)}`
1043
+ formatCents: money
951
1044
  })
952
1045
  }) : null,
953
1046
  shipCountries && fulfillment.method === 'shipping' && priced ? /*#__PURE__*/ _jsx(TextField, {