sanity-plugin-stripe 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +355 -2
- package/dist/cart-CSjxtjq-.js +111 -0
- package/dist/cart-CSjxtjq-.js.map +1 -0
- package/dist/cart-D2KxuwQz.d.ts +74 -0
- package/dist/countries-BSdN9FCR.d.ts +3 -0
- package/dist/countries-BuqQF1AW.js +46 -0
- package/dist/countries-BuqQF1AW.js.map +1 -0
- package/dist/guards-CAcETsf7.js +67 -0
- package/dist/guards-CAcETsf7.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +513 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +107 -0
- package/dist/react.js +199 -0
- package/dist/react.js.map +1 -0
- package/dist/regions-DbCIM0I1.d.ts +32 -0
- package/dist/server.d.ts +292 -0
- package/dist/server.js +680 -0
- package/dist/server.js.map +1 -0
- package/package.json +91 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Confederation Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,356 @@
|
|
|
1
|
-
#
|
|
1
|
+
# sanity-plugin-stripe
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sell from a Sanity site with Stripe Checkout, without a shop platform in between. Editors set price, stock and variants on their own documents in the Studio; the site keeps a cart in the browser and sends buyers to Stripe's payment page; a webhook lowers stock and hands the paid order to your code. Made by Eurico Sá Fernandes at [Confederation Studio](https://github.com/ConfederationStudio). It isn't affiliated with or endorsed by Stripe or Sanity.
|
|
4
|
+
|
|
5
|
+
> **Status:** 0.1, tried in a Stripe sandbox against a real, public Sanity dataset, from the cart to Stripe's page and back through the webhook, and in the Studio: stock went down once, also when the same event was delivered twice at the same moment; a resent event changed nothing; a declined card fulfilled nothing; a payment made outside the shop was left alone; a query without a token could read no stock, no draft and nothing about an order; and the Studio's stock boxes followed a count lowered from outside the Studio, as an order lowers it. Not tried yet: a delayed payment method (a bank debit, for one), a Sanity token that may only read, and three-decimal currencies. Until 1.0, a minor version may change the API; the [changelog](https://github.com/ConfederationStudio/sanity-plugin-stripe/blob/main/CHANGELOG.md) says how. It targets Sanity Studio 6, React 19.2 and stripe-node 23.
|
|
6
|
+
|
|
7
|
+
- **In the Studio.** A `stripeCommerce` field for any document type: on sale or sold out, price, tax class, variants that each sell out on their own, and optional stock counts. A Shop settings document holds the shipping regions with one flat rate each, and whether stock sells out by itself at 0.
|
|
8
|
+
- **In the browser.** A cart in localStorage that holds product references and quantities, never prices, with hooks for the buy button and the checkout. The markup is yours.
|
|
9
|
+
- **On the server.** A checkout route that reads prices, stock and shipping fresh from Sanity and creates the Checkout Session, and a webhook route that lowers stock and calls your `onOrder`, each once per payment however often Stripe delivers the event.
|
|
10
|
+
- **Made for a public dataset.** Most Sanity datasets can be read by anyone. Buyers, addresses and orders are never written to Sanity; stock counts are kept where only a token can read them; keys stay in your site's environment. See [On a public dataset](#on-a-public-dataset).
|
|
11
|
+
|
|
12
|
+
## How it fits together
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Studio ── prices, on sale / sold out, shop settings ──▶ Sanity (public)
|
|
16
|
+
└──── stock counts ────────────────────────────────▶ Sanity (private documents)
|
|
17
|
+
▲ │ read fresh at checkout
|
|
18
|
+
browser cart ──(product ids, quantities, region)──▶ your site's /api/checkout ──▶ Stripe Checkout
|
|
19
|
+
│ │ buyer pays
|
|
20
|
+
your onOrder (send the email) ◀── your site's /api/stripe/webhook ◀──(signed event)────┘
|
|
21
|
+
└─ lowers stock, once per payment
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i sanity-plugin-stripe stripe
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The Studio and the site install the same package. The site imports `sanity-plugin-stripe/react` and `sanity-plugin-stripe/server`, which never import Sanity; the Studio's entry never imports Stripe.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
### 1. The Studio
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// sanity.config.ts
|
|
38
|
+
import {defineConfig} from 'sanity'
|
|
39
|
+
import {EU_COUNTRIES, shopSettingsListItem, stripeCommerce} from 'sanity-plugin-stripe'
|
|
40
|
+
import {structureTool} from 'sanity/structure'
|
|
41
|
+
|
|
42
|
+
export default defineConfig({
|
|
43
|
+
// …
|
|
44
|
+
plugins: [
|
|
45
|
+
structureTool({
|
|
46
|
+
structure: (S) =>
|
|
47
|
+
S.list()
|
|
48
|
+
.title('Content')
|
|
49
|
+
.items([S.documentTypeListItem('product'), shopSettingsListItem(S)]),
|
|
50
|
+
}),
|
|
51
|
+
stripeCommerce({
|
|
52
|
+
currency: 'eur',
|
|
53
|
+
tax: {
|
|
54
|
+
label: 'VAT',
|
|
55
|
+
classes: [
|
|
56
|
+
{title: 'Printed matter (9%)', value: 'reduced'},
|
|
57
|
+
{title: 'Standard (21%)', value: 'standard'},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
shop: {
|
|
61
|
+
shippingRegions: [
|
|
62
|
+
{title: 'Netherlands', countries: ['NL'], rate: 5},
|
|
63
|
+
{title: 'European Union', countries: EU_COUNTRIES.filter((c) => c !== 'NL'), rate: 12},
|
|
64
|
+
{title: 'Rest of the world', everywhereElse: true, rate: 18},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Give the documents you sell the field:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
defineField({name: 'commerce', title: 'Price and stock', type: 'stripeCommerce'})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Under the field's own inputs are the "In stock" boxes. A count is saved as you leave its box, outside the product document, and is never published with it.
|
|
79
|
+
|
|
80
|
+
There is one Shop settings document. `shopSettingsListItem(S)` opens it from the structure, and the plugin keeps it out of the "new document" menu and takes away duplicate and delete, so you don't list it among your own single documents. While it has no shipping regions it offers the ones from the plugin's options. Open it and publish it: the checkout answers 503 until it has at least one region.
|
|
81
|
+
|
|
82
|
+
### 2. The routes
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// lib/shop.ts
|
|
86
|
+
import {createClient} from '@sanity/client'
|
|
87
|
+
import {createStripe} from 'sanity-plugin-stripe/server'
|
|
88
|
+
|
|
89
|
+
// No token: it sees what anyone sees, so a draft can never be priced or sold.
|
|
90
|
+
// No CDN: the checkout must read today's price, not a cached one.
|
|
91
|
+
export const client = createClient({projectId, dataset, apiVersion: '2025-02-19', useCdn: false})
|
|
92
|
+
|
|
93
|
+
// A token, for stock alone. Only ever on the server.
|
|
94
|
+
export const stockClient = process.env.SANITY_API_WRITE_TOKEN
|
|
95
|
+
? client.withConfig({token: process.env.SANITY_API_WRITE_TOKEN})
|
|
96
|
+
: null
|
|
97
|
+
|
|
98
|
+
export const stripe = process.env.STRIPE_SECRET_KEY
|
|
99
|
+
? createStripe(process.env.STRIPE_SECRET_KEY)
|
|
100
|
+
: null
|
|
101
|
+
|
|
102
|
+
// Marks this shop's Checkout Sessions. End it with 8 random letters.
|
|
103
|
+
export const integrationIdentifier = 'myshop_qhzvmkpt'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// app/api/checkout/route.ts
|
|
108
|
+
import {checkoutRoute} from 'sanity-plugin-stripe/server'
|
|
109
|
+
|
|
110
|
+
export const POST = checkoutRoute({
|
|
111
|
+
stripe,
|
|
112
|
+
client,
|
|
113
|
+
stockClient,
|
|
114
|
+
currency: 'eur',
|
|
115
|
+
successUrl: '/shop/thanks?session_id={CHECKOUT_SESSION_ID}',
|
|
116
|
+
cancelUrl: '/cart',
|
|
117
|
+
integrationIdentifier,
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// app/api/stripe/webhook/route.ts
|
|
123
|
+
import {orderEmail, webhookRoute} from 'sanity-plugin-stripe/server'
|
|
124
|
+
|
|
125
|
+
export const POST = webhookRoute({
|
|
126
|
+
stripe,
|
|
127
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
|
128
|
+
integrationIdentifier,
|
|
129
|
+
client,
|
|
130
|
+
stockClient,
|
|
131
|
+
async onOrder(order, {stock, stockError}) {
|
|
132
|
+
const email = orderEmail(order, {shopName: 'My shop', stock, stockError})
|
|
133
|
+
await sendEmail({to: process.env.ORDER_EMAIL_TO, replyTo: order.customer.email, ...email})
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Both take a web `Request` and return a `Response`, so the same handlers fit Remix, Astro, SvelteKit or Hono. `createStripe` pins the Stripe API version the plugin is tested against. The plugin never reads the environment itself: you pass the values in.
|
|
139
|
+
|
|
140
|
+
Give your email service `order.sessionId` as an idempotency key. Stock is lowered once per payment whatever happens, but when Stripe delivers one event twice in the same moment, `onOrder` can run for both.
|
|
141
|
+
|
|
142
|
+
The webhook is not optional. It is the only place an order is fulfilled, because a buyer who pays and closes the tab never reaches the thank-you page. It fulfils only sessions that carry your `integrationIdentifier`: a Payment Link or another site on the same Stripe account is left alone.
|
|
143
|
+
|
|
144
|
+
Without `stockClient` nothing is counted: the shop sells by the on-sale switch alone, and the routes say so once in the log.
|
|
145
|
+
|
|
146
|
+
### 3. Stripe
|
|
147
|
+
|
|
148
|
+
Use a [sandbox](https://docs.stripe.com/sandboxes) while building, and a restricted key (`rk_…`) rather than a secret key. The key needs Checkout Sessions: Write. Reading back which products were bought may also need Products: Read. The smallest set that works has not been confirmed yet, so look at the webhook's log after your first test payment: a missing permission shows there, in Stripe's own words.
|
|
149
|
+
|
|
150
|
+
Locally, forward events to the site and copy the `whsec_…` secret it prints into `STRIPE_WEBHOOK_SECRET`:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
stripe listen --forward-to localhost:3000/api/stripe/webhook --events checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
In production, add a webhook endpoint for `https://<your domain>/api/stripe/webhook` in the Stripe Dashboard with the same three events (`WEBHOOK_EVENTS` lists them).
|
|
157
|
+
|
|
158
|
+
Payment methods are chosen in the Stripe Dashboard, not in code: the plugin never sends `payment_method_types`.
|
|
159
|
+
|
|
160
|
+
### 4. The cart
|
|
161
|
+
|
|
162
|
+
Wrap the site in the provider, once:
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
import {CartProvider} from 'sanity-plugin-stripe/react'
|
|
166
|
+
|
|
167
|
+
;<CartProvider storageKey="myshop-cart">{children}</CartProvider>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Read products with the plugin's projection, so the cart and the checkout get what they need:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import {cartProductProjection} from 'sanity-plugin-stripe/server'
|
|
174
|
+
|
|
175
|
+
const query = `*[_type == "product" && slug.current == $slug][0]{${cartProductProjection()}, description}`
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The buy button, in your markup:
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
'use client'
|
|
182
|
+
import {useBuyBox} from 'sanity-plugin-stripe/react'
|
|
183
|
+
|
|
184
|
+
export function BuyBox({product}) {
|
|
185
|
+
const box = useBuyBox(product)
|
|
186
|
+
if (box.soldOut) return <p>Sold out</p>
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
{box.variants.map((variant) => (
|
|
190
|
+
<label key={variant._key}>
|
|
191
|
+
<input
|
|
192
|
+
type="radio"
|
|
193
|
+
checked={box.variantKey === variant._key}
|
|
194
|
+
disabled={!variant.available}
|
|
195
|
+
onChange={() => box.chooseVariant(variant._key)}
|
|
196
|
+
/>
|
|
197
|
+
{variant.label}
|
|
198
|
+
</label>
|
|
199
|
+
))}
|
|
200
|
+
<button disabled={!box.status.available || box.full} onClick={() => box.add()}>
|
|
201
|
+
Add to cart
|
|
202
|
+
</button>
|
|
203
|
+
</>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The cart page reads every product and the shop settings on the server (`cartProductsQuery()`, `SHOP_SETTINGS_QUERY`, `regionOptions(settings)`), and a client component draws it with `useCart()`, `cartRows(lines, products)`, `cartSubtotal(rows)` and `useCheckout()`, whose `start(regionId)` posts the cart to the checkout route and goes to Stripe. On the page Stripe returns to, `<ClearCartOnReturn />` empties the cart.
|
|
209
|
+
|
|
210
|
+
The browser knows whether something is on sale, not how many are left. When a cart asks for more than there are, the checkout turns it down and says how many can be ordered (`problems[].maxQuantity`), or just that there aren't that many with `hideRemaining`.
|
|
211
|
+
|
|
212
|
+
`dev/site` in this repository is a complete, small example of all of it.
|
|
213
|
+
|
|
214
|
+
## On a public dataset
|
|
215
|
+
|
|
216
|
+
Sanity serves a public dataset to anyone, without a token, with one fixed exception: a document whose `_id` contains a dot (a draft, for one) is only served to signed-in project members and to API tokens. That is the only line Sanity draws below its Enterprise plan, so the plugin puts each thing on the right side of it, or outside Sanity altogether.
|
|
217
|
+
|
|
218
|
+
| What | Where it lives | Who can read it |
|
|
219
|
+
| ---------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
220
|
+
| Prices, on sale or sold out, variants | Your product documents | Anyone. The shop shows them. |
|
|
221
|
+
| Shipping regions and rates, "sell out at 0" | The Shop settings document, id `stripeShopSettings` | Anyone. The cart shows them. |
|
|
222
|
+
| Stock counts | `stripe.stock.<product id>` | Project members and tokens. |
|
|
223
|
+
| What each payment did to stock | `stripe.stockChange.<session id>`, deleted after 30 days | Project members and tokens. It holds product ids and counts only. |
|
|
224
|
+
| Buyers, addresses, orders, payments | Stripe | Whoever has the Stripe account. Never written to Sanity. |
|
|
225
|
+
| The address order emails go to | Your site's environment | Your server. |
|
|
226
|
+
| The Stripe key, the webhook secret, the Sanity token | Your site's environment | Your server. Never in Sanity, never in the browser. |
|
|
227
|
+
|
|
228
|
+
Why not keep orders in private documents too: a dot hides a document from the public, not from the rest of the project. Every member, including free Viewer seats, every API token that can read (the one many sites use for previews, for one), and every dataset export gets them, and Sanity keeps deleted content in history for 3 to 365 days depending on the plan. That is fine for stock. It is not fine for people's names and addresses.
|
|
229
|
+
|
|
230
|
+
What this asks of your site:
|
|
231
|
+
|
|
232
|
+
- **Never ship a Sanity token to the browser.** `stockClient` is for the two routes and for server components. Anything with `NEXT_PUBLIC_` in its name is public.
|
|
233
|
+
- **Read products without a token** in the checkout (`client`), as above. The plugin also refuses any product id with a dot, so a draft can't be sold even if a site gets this wrong.
|
|
234
|
+
- **Don't publish the order email address.** Forged "new order" emails are easy to write when the format is open source and the mailbox is known. Before shipping anything, open the payment in Stripe: the email links to it.
|
|
235
|
+
|
|
236
|
+
Counts can be shown by a server component with `readStock(stockClient, [id])`, for "only 2 left". Don't send all of them to the browser.
|
|
237
|
+
|
|
238
|
+
## Data structure
|
|
239
|
+
|
|
240
|
+
The field saves this on your document:
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"_type": "stripeCommerce",
|
|
245
|
+
"availability": "onSale",
|
|
246
|
+
"price": 29.5,
|
|
247
|
+
"taxClass": "standard",
|
|
248
|
+
"variants": [
|
|
249
|
+
{"_key": "a1", "_type": "stripeVariant", "label": "M"},
|
|
250
|
+
{"_key": "b2", "_type": "stripeVariant", "label": "L", "availability": "soldOut"}
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Prices are in currency units, as editors type them (29.5 is €29.50), and are converted to Stripe's minor units at checkout. Stripe's sandbox has accepted the plugin's amounts in euros and in zero-decimal currencies (yen, won); three-decimal currencies such as the Kuwaiti dinar follow Stripe's rule but have not been tried.
|
|
256
|
+
|
|
257
|
+
Stock is a document of its own per product, written by the stock boxes and the webhook:
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"_id": "stripe.stock.4f6c1c2e",
|
|
262
|
+
"_type": "stripe.stock",
|
|
263
|
+
"variants": [{"_key": "a1", "stockCount": 2}]
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A product without variants has `stockCount` at the top. No count means it isn't counted. When a count reaches 0 and "sell out automatically" is on, the webhook switches the product or variant to sold out in every version of the product document: published, draft, and copies inside a Content Release. A count of 0 is refused at checkout whatever the switch says.
|
|
268
|
+
|
|
269
|
+
## Options
|
|
270
|
+
|
|
271
|
+
### `stripeCommerce()` in the Studio
|
|
272
|
+
|
|
273
|
+
| Option | |
|
|
274
|
+
| ---------------------- | --------------------------------------------------------------------------------------- |
|
|
275
|
+
| `currency` | ISO 4217 code every price is in, such as `eur`. Required. |
|
|
276
|
+
| `tax.label` | What tax is called where the shop is: `VAT`, `GST`, `sales tax`. Default `tax`. |
|
|
277
|
+
| `tax.included` | Whether entered prices include tax. It only changes the labels. Default true. |
|
|
278
|
+
| `tax.classes` | `{title, value}` choices for a product's tax class. Without any, the field is left out. |
|
|
279
|
+
| `shop.shippingRegions` | Starting regions: `{title, countries, rate}` or `{title, everywhereElse: true, rate}`. |
|
|
280
|
+
|
|
281
|
+
### `checkoutRoute()`
|
|
282
|
+
|
|
283
|
+
| Option | |
|
|
284
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------- |
|
|
285
|
+
| `stripe` | From `createStripe`. Without one the route answers 503. Required. |
|
|
286
|
+
| `client` | A Sanity client without a token that skips the CDN. Required. |
|
|
287
|
+
| `stockClient` | A Sanity client with a token, to read stock. Without it nothing is counted. |
|
|
288
|
+
| `currency` | The same code the Studio plugin has. Required. |
|
|
289
|
+
| `successUrl` | A path or address, with `{CHECKOUT_SESSION_ID}` in it. Required. |
|
|
290
|
+
| `cancelUrl` | Where Stripe's back link goes. Required. |
|
|
291
|
+
| `integrationIdentifier` | Marks the shop's sessions for the webhook and the Stripe Dashboard. Required. |
|
|
292
|
+
| `products` | Where products live: `{types, field, title, slug, image}`. Default `product` with `commerce`. |
|
|
293
|
+
| `taxRates` | Stripe tax rate ids (`txr_…`) by tax class, so receipts show the tax. |
|
|
294
|
+
| `maxQuantity` | Most of one item per order. Default 10. |
|
|
295
|
+
| `hideRemaining` | Don't say how many are left when a cart asks for too many. |
|
|
296
|
+
| `expiresInMinutes` | Minutes until an unpaid session expires, about 30 at least. Stripe's default is 24 hours. |
|
|
297
|
+
| `extend` | Changes the session's parameters before it is created: promotion codes, phone numbers, consent. |
|
|
298
|
+
| `messages` | What buyers read when the checkout can't start. |
|
|
299
|
+
| `debug` | Adds the reason to error replies. For development. |
|
|
300
|
+
|
|
301
|
+
### `webhookRoute()`
|
|
302
|
+
|
|
303
|
+
| Option | |
|
|
304
|
+
| ----------------------- | -------------------------------------------------------------------------------------------- |
|
|
305
|
+
| `stripe` | From `createStripe`. Required. |
|
|
306
|
+
| `webhookSecret` | The endpoint's signing secret (`whsec_…`). Required. |
|
|
307
|
+
| `integrationIdentifier` | The same value the checkout route has. Required. |
|
|
308
|
+
| `client` | A Sanity client without a token that skips the CDN. Required. |
|
|
309
|
+
| `stockClient` | A Sanity client with a token that can write, to lower stock. Without it stock is left alone. |
|
|
310
|
+
| `products` | The same as the checkout route's. |
|
|
311
|
+
| `onOrder` | Called once per paid order with the order, the shop settings and what happened to stock. |
|
|
312
|
+
| `onPaymentFailed` | Called when a delayed payment of the shop fails. |
|
|
313
|
+
|
|
314
|
+
If stock can't be lowered (a token that may only read), `onOrder` still runs, with `stockError` saying why, and Stripe is asked to deliver the event again; only the stock is tried again.
|
|
315
|
+
|
|
316
|
+
## What it doesn't do
|
|
317
|
+
|
|
318
|
+
- **Tax compliance.** Prices are what editors enter. A tax class can attach a Stripe tax rate you created, so the receipt shows the tax, and that is all. It does not use Stripe Tax and does not work out the VAT of the buyer's country for cross-border sales. Ask your accountant what your shop needs.
|
|
319
|
+
- **Hold stock during checkout.** Stock is checked when Checkout starts and lowered when the payment lands. Two buyers can both pay for the last copy; the order email then says so. `expiresInMinutes` narrows the window.
|
|
320
|
+
- **Show orders in the Studio.** Orders are in the Stripe Dashboard and in the email your `onOrder` sends. An Orders tab that reads them live from Stripe is planned.
|
|
321
|
+
- **Send email.** `orderEmail()` writes the order email; sending it is your `onOrder`, with your email service.
|
|
322
|
+
- **Subscriptions, discount logic, shipping by weight.** One-off payments with a flat rate per region. `extend` can switch on Stripe's promotion codes.
|
|
323
|
+
|
|
324
|
+
## Security
|
|
325
|
+
|
|
326
|
+
- The browser sends product ids, quantities and a region id. Prices, names and shipping rates are read from Sanity on the server, and anything else in the request is dropped.
|
|
327
|
+
- Every webhook delivery's signature is checked against the raw body before it is parsed. The route then reads the session again from Stripe's API, checks that it is this shop's, and goes by that copy's payment status.
|
|
328
|
+
- Keys come from your environment and are passed in. Use a restricted key, and a separate one for each environment.
|
|
329
|
+
- Names inside the plugin's GROQ (document types, the field name) are checked when the routes are made. Ids travel as query parameters.
|
|
330
|
+
- The order email escapes everything a buyer typed.
|
|
331
|
+
|
|
332
|
+
## Developing
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
pnpm install
|
|
336
|
+
pnpm build
|
|
337
|
+
pnpm check
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`pnpm check` typechecks, lints and runs the tests. The tests use the real stripe-node client with its network replaced, so requests are built and webhook signatures are checked by the SDK itself, and run the plugin's real GROQ with groq-js against a stand-in dataset that hides dotted ids from a tokenless reader, as Sanity does. One test fails if `/react` or `/server` reaches Sanity, or the Studio's entry reaches Stripe. Build first: the lint reads `dev/site`, which imports the built package.
|
|
341
|
+
|
|
342
|
+
### The test site
|
|
343
|
+
|
|
344
|
+
`dev/site` is a Next.js shop with the Studio inside it at `/studio`. Without a Sanity project it sells sample products kept in memory, so the cart, the checkout and the webhook can be tried with a Stripe key alone; stock goes back to the sample values when the server restarts. Copy `dev/site/.env.example` to `dev/site/.env.local` and fill it in, then:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
pnpm watch # rebuilds dist as you edit
|
|
348
|
+
pnpm --dir dev/site dev
|
|
349
|
+
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Without a Stripe key the routes answer 503 and the rest of the site works.
|
|
353
|
+
|
|
354
|
+
## License
|
|
355
|
+
|
|
356
|
+
MIT © Confederation Studio
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { i as isObject, t as asArray } from "./guards-CAcETsf7.js";
|
|
2
|
+
/**
|
|
3
|
+
* Products as a query with `cartProductProjection` returns them. Anything that isn't a document
|
|
4
|
+
* is dropped; the fields are checked where they are used, in `lineStatus`.
|
|
5
|
+
*/
|
|
6
|
+
function readCartProducts(value) {
|
|
7
|
+
return asArray(value).filter((item) => isObject(item) && typeof item._id == "string");
|
|
8
|
+
}
|
|
9
|
+
/** One cart line per product and variant. */
|
|
10
|
+
function lineKey(line) {
|
|
11
|
+
return `${line.productId}:${line.variantKey ?? ""}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Turns anything (stored JSON, a request body) into valid cart lines: drops what doesn't parse,
|
|
15
|
+
* merges duplicates and keeps quantities between 1 and `max`.
|
|
16
|
+
*
|
|
17
|
+
* A product id with a dot is dropped: a published document's id has none, so a draft or a copy
|
|
18
|
+
* inside a release can never be put in a cart, whatever the site's Sanity client is able to read.
|
|
19
|
+
*/
|
|
20
|
+
function sanitizeLines(input, max = 10) {
|
|
21
|
+
let merged = /* @__PURE__ */ new Map();
|
|
22
|
+
for (let item of asArray(input).slice(0, 100)) {
|
|
23
|
+
if (!isObject(item)) continue;
|
|
24
|
+
let { productId, variantKey, quantity } = item;
|
|
25
|
+
if (typeof productId != "string" || !productId || productId.length > 200 || productId.includes(".") || variantKey !== void 0 && (typeof variantKey != "string" || variantKey.length > 200) || typeof quantity != "number" || !Number.isFinite(quantity)) continue;
|
|
26
|
+
let line = {
|
|
27
|
+
productId,
|
|
28
|
+
quantity: 0,
|
|
29
|
+
...variantKey ? { variantKey } : {}
|
|
30
|
+
}, key = lineKey(line), total = (merged.get(key)?.quantity ?? 0) + Math.floor(quantity);
|
|
31
|
+
total < 1 || merged.set(key, {
|
|
32
|
+
...line,
|
|
33
|
+
quantity: Math.min(total, max)
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return [...merged.values()];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether a product (and variant) is on sale, as far as anyone can see. Stock counts are private,
|
|
40
|
+
* so they are checked by the checkout route, on the server.
|
|
41
|
+
*/
|
|
42
|
+
function lineStatus(product, variantKey, max = 10) {
|
|
43
|
+
let commerce = product?.commerce;
|
|
44
|
+
if (!product || !commerce) return {
|
|
45
|
+
available: !1,
|
|
46
|
+
reason: "missing"
|
|
47
|
+
};
|
|
48
|
+
if (commerce.availability === "soldOut") return {
|
|
49
|
+
available: !1,
|
|
50
|
+
reason: "soldOut"
|
|
51
|
+
};
|
|
52
|
+
if (typeof commerce.price != "number") return {
|
|
53
|
+
available: !1,
|
|
54
|
+
reason: "noPrice"
|
|
55
|
+
};
|
|
56
|
+
let variants = commerce.variants ?? [], variant;
|
|
57
|
+
if (variants.length) {
|
|
58
|
+
if (!variantKey) return {
|
|
59
|
+
available: !1,
|
|
60
|
+
reason: "chooseVariant"
|
|
61
|
+
};
|
|
62
|
+
if (variant = variants.find((candidate) => candidate._key === variantKey), !variant) return {
|
|
63
|
+
available: !1,
|
|
64
|
+
reason: "variantMissing"
|
|
65
|
+
};
|
|
66
|
+
if (variant.availability === "soldOut") return {
|
|
67
|
+
available: !1,
|
|
68
|
+
reason: "soldOut"
|
|
69
|
+
};
|
|
70
|
+
} else if (variantKey) return {
|
|
71
|
+
available: !1,
|
|
72
|
+
reason: "variantMissing"
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
available: !0,
|
|
76
|
+
maxQuantity: max,
|
|
77
|
+
price: commerce.price,
|
|
78
|
+
variant
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Display name for a line: the product title, plus the variant label when there is one. */
|
|
82
|
+
function lineName(product, variant) {
|
|
83
|
+
let title = product.title ?? "Product";
|
|
84
|
+
return variant?.label ? `${title} – ${variant.label}` : title;
|
|
85
|
+
}
|
|
86
|
+
/** The problems in a checkout route's reply, as the browser reads them. */
|
|
87
|
+
function readCheckoutProblems(value) {
|
|
88
|
+
return asArray(value).filter((item) => isObject(item) && typeof item.productId == "string" && typeof item.reason == "string");
|
|
89
|
+
}
|
|
90
|
+
/** Joins the cart's lines with their products, as read from Sanity for the cart page. */
|
|
91
|
+
function cartRows(lines, products, max = 10) {
|
|
92
|
+
let byId = new Map(products.map((product) => [product._id, product]));
|
|
93
|
+
return lines.map((line) => {
|
|
94
|
+
let product = byId.get(line.productId), status = lineStatus(product, line.variantKey, max), name = product ? lineName(product, status.available ? status.variant : void 0) : "Item", quantity = status.available ? Math.min(line.quantity, status.maxQuantity) : 0;
|
|
95
|
+
return {
|
|
96
|
+
line,
|
|
97
|
+
product,
|
|
98
|
+
status,
|
|
99
|
+
name,
|
|
100
|
+
quantity,
|
|
101
|
+
total: status.available ? status.price * quantity : 0
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** What the cart's lines add up to, in currency units, before shipping. */
|
|
106
|
+
function cartSubtotal(rows) {
|
|
107
|
+
return rows.reduce((sum, row) => sum + row.total, 0);
|
|
108
|
+
}
|
|
109
|
+
export { lineStatus as a, sanitizeLines as c, lineName as i, cartSubtotal as n, readCartProducts as o, lineKey as r, readCheckoutProblems as s, cartRows as t };
|
|
110
|
+
|
|
111
|
+
//# sourceMappingURL=cart-CSjxtjq-.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cart-CSjxtjq-.js","names":[],"sources":["../src/core/cart.ts"],"sourcesContent":["import {asArray, isObject} from './guards'\n\n/** The cart's types and rules. Used by the Studio, the browser and the server alike. */\n\nexport type Availability = 'onSale' | 'soldOut'\n\n/** What the browser keeps in the cart: references only, never prices. */\nexport interface CartLine {\n /** Sanity document id of the product. */\n productId: string\n /** `_key` of the chosen variant, when the product has variants. */\n variantKey?: string\n quantity: number\n}\n\nexport interface CartVariant {\n _key: string\n label?: string | null\n availability?: Availability | null\n}\n\n/**\n * A product as the cart and the checkout read it from Sanity: see `cartProductProjection`. It\n * says whether the product is on sale, never how many are left: counts are private.\n */\nexport interface CartProduct {\n _id: string\n title?: string | null\n slug?: string | null\n imageUrl?: string | null\n commerce?: {\n availability?: Availability | null\n /** In currency units (25 means €25). */\n price?: number | null\n taxClass?: string | null\n variants?: CartVariant[] | null\n } | null\n}\n\n/**\n * Products as a query with `cartProductProjection` returns them. Anything that isn't a document\n * is dropped; the fields are checked where they are used, in `lineStatus`.\n */\nexport function readCartProducts(value: unknown): CartProduct[] {\n return asArray(value).filter(\n (item): item is CartProduct => isObject(item) && typeof item._id === 'string',\n )\n}\n\n/** Most of one item a buyer can order at once. */\nexport const MAX_QUANTITY = 10\n\n/** One cart line per product and variant. */\nexport function lineKey(line: Pick<CartLine, 'productId' | 'variantKey'>): string {\n return `${line.productId}:${line.variantKey ?? ''}`\n}\n\n/**\n * Turns anything (stored JSON, a request body) into valid cart lines: drops what doesn't parse,\n * merges duplicates and keeps quantities between 1 and `max`.\n *\n * A product id with a dot is dropped: a published document's id has none, so a draft or a copy\n * inside a release can never be put in a cart, whatever the site's Sanity client is able to read.\n */\nexport function sanitizeLines(input: unknown, max = MAX_QUANTITY): CartLine[] {\n const merged = new Map<string, CartLine>()\n for (const item of asArray(input).slice(0, 100)) {\n if (!isObject(item)) continue\n const {productId, variantKey, quantity} = item\n if (typeof productId !== 'string' || !productId || productId.length > 200) continue\n if (productId.includes('.')) continue\n if (variantKey !== undefined && (typeof variantKey !== 'string' || variantKey.length > 200))\n continue\n if (typeof quantity !== 'number' || !Number.isFinite(quantity)) continue\n const line: CartLine = {productId, quantity: 0, ...(variantKey ? {variantKey} : {})}\n const key = lineKey(line)\n const existing = merged.get(key)\n const total = (existing?.quantity ?? 0) + Math.floor(quantity)\n if (total < 1) continue\n merged.set(key, {...line, quantity: Math.min(total, max)})\n }\n return [...merged.values()]\n}\n\nexport type Unavailable = 'missing' | 'soldOut' | 'noPrice' | 'chooseVariant' | 'variantMissing'\n\nexport type LineStatus =\n | {available: true; maxQuantity: number; price: number; variant?: CartVariant}\n | {available: false; reason: Unavailable}\n\n/**\n * Whether a product (and variant) is on sale, as far as anyone can see. Stock counts are private,\n * so they are checked by the checkout route, on the server.\n */\nexport function lineStatus(\n product: CartProduct | undefined,\n variantKey?: string,\n max = MAX_QUANTITY,\n): LineStatus {\n const commerce = product?.commerce\n if (!product || !commerce) return {available: false, reason: 'missing'}\n if (commerce.availability === 'soldOut') return {available: false, reason: 'soldOut'}\n if (typeof commerce.price !== 'number') return {available: false, reason: 'noPrice'}\n\n const variants = commerce.variants ?? []\n let variant: CartVariant | undefined\n if (variants.length) {\n if (!variantKey) return {available: false, reason: 'chooseVariant'}\n variant = variants.find((candidate) => candidate._key === variantKey)\n if (!variant) return {available: false, reason: 'variantMissing'}\n if (variant.availability === 'soldOut') return {available: false, reason: 'soldOut'}\n } else if (variantKey) {\n return {available: false, reason: 'variantMissing'}\n }\n\n return {available: true, maxQuantity: max, price: commerce.price, variant}\n}\n\n/** Display name for a line: the product title, plus the variant label when there is one. */\nexport function lineName(\n product: Pick<CartProduct, 'title'>,\n variant?: Pick<CartVariant, 'label'>,\n): string {\n const title = product.title ?? 'Product'\n return variant?.label ? `${title} – ${variant.label}` : title\n}\n\n/** Why the checkout turned a cart line down. */\nexport interface CheckoutProblem {\n productId: string\n variantKey?: string\n name?: string\n reason: Unavailable | 'tooMany'\n /** For `tooMany`: how many can be ordered. Left out when the shop hides what is left. */\n maxQuantity?: number\n}\n\n/** The problems in a checkout route's reply, as the browser reads them. */\nexport function readCheckoutProblems(value: unknown): CheckoutProblem[] {\n return asArray(value).filter(\n (item): item is CheckoutProblem =>\n isObject(item) && typeof item.productId === 'string' && typeof item.reason === 'string',\n )\n}\n\n/** A cart line with everything a cart page shows for it. */\nexport interface CartRow {\n line: CartLine\n product: CartProduct | undefined\n status: LineStatus\n name: string\n /** The line's quantity, capped at what can be ordered. 0 when the line can't be bought. */\n quantity: number\n /** Price times quantity, in currency units. 0 when the line can't be bought. */\n total: number\n}\n\n/** Joins the cart's lines with their products, as read from Sanity for the cart page. */\nexport function cartRows(\n lines: readonly CartLine[],\n products: readonly CartProduct[],\n max = MAX_QUANTITY,\n): CartRow[] {\n const byId = new Map(products.map((product) => [product._id, product]))\n return lines.map((line) => {\n const product = byId.get(line.productId)\n const status = lineStatus(product, line.variantKey, max)\n const name = product ? lineName(product, status.available ? status.variant : undefined) : 'Item'\n const quantity = status.available ? Math.min(line.quantity, status.maxQuantity) : 0\n const total = status.available ? status.price * quantity : 0\n return {line, product, status, name, quantity, total}\n })\n}\n\n/** What the cart's lines add up to, in currency units, before shipping. */\nexport function cartSubtotal(rows: readonly CartRow[]): number {\n return rows.reduce((sum, row) => sum + row.total, 0)\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** The cart's types and rules. Used by the Studio, the browser and the server alike. */
|
|
2
|
+
type Availability = 'onSale' | 'soldOut';
|
|
3
|
+
/** What the browser keeps in the cart: references only, never prices. */
|
|
4
|
+
interface CartLine {
|
|
5
|
+
/** Sanity document id of the product. */
|
|
6
|
+
productId: string;
|
|
7
|
+
/** `_key` of the chosen variant, when the product has variants. */
|
|
8
|
+
variantKey?: string;
|
|
9
|
+
quantity: number;
|
|
10
|
+
}
|
|
11
|
+
interface CartVariant {
|
|
12
|
+
_key: string;
|
|
13
|
+
label?: string | null;
|
|
14
|
+
availability?: Availability | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A product as the cart and the checkout read it from Sanity: see `cartProductProjection`. It
|
|
18
|
+
* says whether the product is on sale, never how many are left: counts are private.
|
|
19
|
+
*/
|
|
20
|
+
interface CartProduct {
|
|
21
|
+
_id: string;
|
|
22
|
+
title?: string | null;
|
|
23
|
+
slug?: string | null;
|
|
24
|
+
imageUrl?: string | null;
|
|
25
|
+
commerce?: {
|
|
26
|
+
availability?: Availability | null;
|
|
27
|
+
/** In currency units (25 means €25). */
|
|
28
|
+
price?: number | null;
|
|
29
|
+
taxClass?: string | null;
|
|
30
|
+
variants?: CartVariant[] | null;
|
|
31
|
+
} | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Products as a query with `cartProductProjection` returns them. Anything that isn't a document
|
|
35
|
+
* is dropped; the fields are checked where they are used, in `lineStatus`.
|
|
36
|
+
*/
|
|
37
|
+
declare function readCartProducts(value: unknown): CartProduct[];
|
|
38
|
+
/** One cart line per product and variant. */
|
|
39
|
+
declare function lineKey(line: Pick<CartLine, 'productId' | 'variantKey'>): string;
|
|
40
|
+
type Unavailable = 'missing' | 'soldOut' | 'noPrice' | 'chooseVariant' | 'variantMissing';
|
|
41
|
+
type LineStatus = {
|
|
42
|
+
available: true;
|
|
43
|
+
maxQuantity: number;
|
|
44
|
+
price: number;
|
|
45
|
+
variant?: CartVariant;
|
|
46
|
+
} | {
|
|
47
|
+
available: false;
|
|
48
|
+
reason: Unavailable;
|
|
49
|
+
};
|
|
50
|
+
/** Why the checkout turned a cart line down. */
|
|
51
|
+
interface CheckoutProblem {
|
|
52
|
+
productId: string;
|
|
53
|
+
variantKey?: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
reason: Unavailable | 'tooMany';
|
|
56
|
+
/** For `tooMany`: how many can be ordered. Left out when the shop hides what is left. */
|
|
57
|
+
maxQuantity?: number;
|
|
58
|
+
}
|
|
59
|
+
/** A cart line with everything a cart page shows for it. */
|
|
60
|
+
interface CartRow {
|
|
61
|
+
line: CartLine;
|
|
62
|
+
product: CartProduct | undefined;
|
|
63
|
+
status: LineStatus;
|
|
64
|
+
name: string;
|
|
65
|
+
/** The line's quantity, capped at what can be ordered. 0 when the line can't be bought. */
|
|
66
|
+
quantity: number;
|
|
67
|
+
/** Price times quantity, in currency units. 0 when the line can't be bought. */
|
|
68
|
+
total: number;
|
|
69
|
+
}
|
|
70
|
+
/** Joins the cart's lines with their products, as read from Sanity for the cart page. */
|
|
71
|
+
declare function cartRows(lines: readonly CartLine[], products: readonly CartProduct[], max?: number): CartRow[];
|
|
72
|
+
/** What the cart's lines add up to, in currency units, before shipping. */
|
|
73
|
+
declare function cartSubtotal(rows: readonly CartRow[]): number;
|
|
74
|
+
export { CartVariant as a, Unavailable as c, lineKey as d, readCartProducts as f, CartRow as i, cartRows as l, CartLine as n, CheckoutProblem as o, CartProduct as r, LineStatus as s, Availability as t, cartSubtotal as u };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** The European Union's member states, for a shipping region: `countries: [...EU_COUNTRIES]`. */
|
|
2
|
+
declare const EU_COUNTRIES: readonly ["AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK"];
|
|
3
|
+
export { EU_COUNTRIES as t };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { i as isObject, n as asNumber, t as asArray } from "./guards-CAcETsf7.js";
|
|
2
|
+
/** Schema type names. They are saved in content, so they never change. */
|
|
3
|
+
const COMMERCE_TYPE = "stripeCommerce", VARIANT_TYPE = "stripeVariant", SHIPPING_REGION_TYPE = "stripeShippingRegion", SHOP_SETTINGS_TYPE = "stripeShopSettings", SHOP_SETTINGS_ID = "stripeShopSettings", STOCK_TYPE = "stripe.stock";
|
|
4
|
+
function stockId(productId) {
|
|
5
|
+
return `stripe.stock.${productId}`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What one paid Checkout Session did to stock, written in the same transaction as the change, so
|
|
9
|
+
* the same payment never lowers stock twice. It holds product ids and counts, nothing about the
|
|
10
|
+
* buyer, and is deleted after a month.
|
|
11
|
+
*/
|
|
12
|
+
const STOCK_CHANGE_TYPE = "stripe.stockChange";
|
|
13
|
+
function stockChangeId(sessionId) {
|
|
14
|
+
return `stripe.stockChange.${sessionId.replaceAll(/[^\w-]/g, "-")}`;
|
|
15
|
+
}
|
|
16
|
+
/** The id a product is published under, whichever version of it a form is editing. */
|
|
17
|
+
function publishedId(documentId) {
|
|
18
|
+
return documentId.startsWith("drafts.") ? documentId.slice(7) : documentId.startsWith("versions.") ? documentId.split(".").slice(2).join(".") : documentId;
|
|
19
|
+
}
|
|
20
|
+
/** The stock documents with the ids in `$ids`. Only a client with a token gets an answer. */
|
|
21
|
+
const STOCK_QUERY = `*[_type == "${STOCK_TYPE}" && _id in $ids]{_id, stockCount, variants[]{_key, stockCount}}`;
|
|
22
|
+
/** Stock documents as Sanity returned them, by product id. */
|
|
23
|
+
function readStockDocuments(value) {
|
|
24
|
+
let prefix = stockId(""), stock = /* @__PURE__ */ new Map();
|
|
25
|
+
for (let item of asArray(value)) isObject(item) && typeof item._id == "string" && item._id.startsWith(prefix) && stock.set(item._id.slice(prefix.length), {
|
|
26
|
+
stockCount: asNumber(item.stockCount),
|
|
27
|
+
variants: asArray(item.variants).flatMap((variant) => isObject(variant) && typeof variant._key == "string" ? [{
|
|
28
|
+
_key: variant._key,
|
|
29
|
+
stockCount: asNumber(variant.stockCount)
|
|
30
|
+
}] : [])
|
|
31
|
+
});
|
|
32
|
+
return stock;
|
|
33
|
+
}
|
|
34
|
+
/** How many are left of a product or one of its variants. Null when it isn't counted. */
|
|
35
|
+
function stockLeft(stock, variantKey) {
|
|
36
|
+
return stock ? variantKey ? stock.variants.find((variant) => variant._key === variantKey)?.stockCount ?? null : stock.stockCount : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every country Stripe Checkout accepts in `shipping_address_collection.allowed_countries`
|
|
40
|
+
* (stripe-node 23, API version 2026-10-28.endive). A region set to "everywhere else" ships to
|
|
41
|
+
* these, minus the countries the other regions list.
|
|
42
|
+
*/
|
|
43
|
+
const STRIPE_SHIPPING_COUNTRIES = /* @__PURE__ */ "AC.AD.AE.AF.AG.AI.AL.AM.AO.AQ.AR.AT.AU.AW.AX.AZ.BA.BB.BD.BE.BF.BG.BH.BI.BJ.BL.BM.BN.BO.BQ.BR.BS.BT.BV.BW.BY.BZ.CA.CD.CF.CG.CH.CI.CK.CL.CM.CN.CO.CR.CV.CW.CY.CZ.DE.DJ.DK.DM.DO.DZ.EC.EE.EG.EH.ER.ES.ET.FI.FJ.FK.FO.FR.GA.GB.GD.GE.GF.GG.GH.GI.GL.GM.GN.GP.GQ.GR.GS.GT.GU.GW.GY.HK.HN.HR.HT.HU.ID.IE.IL.IM.IN.IO.IQ.IS.IT.JE.JM.JO.JP.KE.KG.KH.KI.KM.KN.KR.KW.KY.KZ.LA.LB.LC.LI.LK.LR.LS.LT.LU.LV.LY.MA.MC.MD.ME.MF.MG.MK.ML.MM.MN.MO.MQ.MR.MS.MT.MU.MV.MW.MX.MY.MZ.NA.NC.NE.NG.NI.NL.NO.NP.NR.NU.NZ.OM.PA.PE.PF.PG.PH.PK.PL.PM.PN.PR.PS.PT.PY.QA.RE.RO.RS.RU.RW.SA.SB.SC.SD.SE.SG.SH.SI.SJ.SK.SL.SM.SN.SO.SR.SS.ST.SV.SX.SZ.TA.TC.TD.TF.TG.TH.TJ.TK.TL.TM.TN.TO.TR.TT.TV.TW.TZ.UA.UG.US.UY.UZ.VA.VC.VE.VG.VN.VU.WF.WS.XK.YE.YT.ZA.ZM.ZW".split("."), EU_COUNTRIES = /* @__PURE__ */ "AT.BE.BG.CY.CZ.DE.DK.EE.ES.FI.FR.GR.HR.HU.IE.IT.LT.LU.LV.MT.NL.PL.PT.RO.SE.SI.SK".split(".");
|
|
44
|
+
export { stockLeft as a, SHOP_SETTINGS_ID as c, STOCK_TYPE as d, VARIANT_TYPE as f, stockId as h, readStockDocuments as i, SHOP_SETTINGS_TYPE as l, stockChangeId as m, STRIPE_SHIPPING_COUNTRIES as n, COMMERCE_TYPE as o, publishedId as p, STOCK_QUERY as r, SHIPPING_REGION_TYPE as s, EU_COUNTRIES as t, STOCK_CHANGE_TYPE as u };
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=countries-BuqQF1AW.js.map
|