@amboras-dev/meta-shops-checkout 0.2.0
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/README.md +39 -0
- package/dist/index.d.mts +79 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +161 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @amboras-dev/meta-shops-checkout
|
|
2
|
+
|
|
3
|
+
Storefront half of the **Facebook & Instagram Shop** plugin (`facebook-instagram-shop`).
|
|
4
|
+
|
|
5
|
+
When a shopper checks out from a Meta Shop configured with **"Checkout on another
|
|
6
|
+
website"**, Meta sends them to a deep-link on the merchant's storefront:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
https://store.example.com/checkout?products=variant_x:4,variant_y:2&coupon=SAVE10&cart_origin=meta_shops
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
This package exports a single `rootProviders` slot component,
|
|
13
|
+
`MetaShopsCheckoutProvider`, that:
|
|
14
|
+
|
|
15
|
+
1. Detects the deep-link (only on the checkout path, only when
|
|
16
|
+
`cart_origin=meta_shops`).
|
|
17
|
+
2. Builds a **fresh cart** with exactly the items Meta sent (`variantId:qty`).
|
|
18
|
+
The retailer_id Meta returns is the Medusa variant id, so it maps straight to
|
|
19
|
+
`createLineItem`.
|
|
20
|
+
3. Applies the `coupon` as a cart promotion (a bad coupon is skipped, not fatal).
|
|
21
|
+
4. Makes it the storefront's active cart (`localStorage.medusa_cart_id` +
|
|
22
|
+
invalidates the shared `['cart']` query) and strips the URL params.
|
|
23
|
+
|
|
24
|
+
It is **idempotency-guarded** (a refresh won't re-add items) and fully inert on
|
|
25
|
+
every other page/URL.
|
|
26
|
+
|
|
27
|
+
### What it does NOT do
|
|
28
|
+
|
|
29
|
+
Payment-session initialization is left to the storefront's `useCheckout`, which
|
|
30
|
+
auto-initializes it once the shopper provides a shipping address + method —
|
|
31
|
+
Medusa ties the payment amount to shipping, and Meta does not send an address,
|
|
32
|
+
so payment cannot (and should not) be pre-initialized.
|
|
33
|
+
|
|
34
|
+
### Installation
|
|
35
|
+
|
|
36
|
+
Installed automatically by the Amboras plugin pipeline as part of the
|
|
37
|
+
`facebook-instagram-shop` plugin (it is the manifest's `npm_package`). It is a
|
|
38
|
+
Class A integration — declarative `rootProviders` binding, no storefront source
|
|
39
|
+
edits.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
interface MetaShopsCheckoutProviderProps {
|
|
4
|
+
/**
|
|
5
|
+
* Medusa backend base URL. Defaults to `NEXT_PUBLIC_MEDUSA_BACKEND_URL`
|
|
6
|
+
* (falling back to `http://localhost:9000`). The `/store/*` API resolves the
|
|
7
|
+
* tenant from the publishable key, so no store-environment header is needed.
|
|
8
|
+
*/
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Publishable API key sent as `x-publishable-api-key`. Defaults to
|
|
12
|
+
* `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY`. Routes the `/store/*` calls to the
|
|
13
|
+
* correct tenant.
|
|
14
|
+
*/
|
|
15
|
+
publishableApiKey?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The `cart_origin` query value that marks a Meta Shops deep-link. The
|
|
18
|
+
* provider stays completely inert unless this exact value is present, so it
|
|
19
|
+
* is safe to mount on every page. Defaults to `'meta_shops'`.
|
|
20
|
+
*/
|
|
21
|
+
cartOrigin?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Path the deep-link lands on. The provider only seeds when the current
|
|
24
|
+
* pathname matches this (so a stray `?cart_origin=meta_shops` elsewhere does
|
|
25
|
+
* nothing). Defaults to `'/checkout'`.
|
|
26
|
+
*/
|
|
27
|
+
checkoutPath?: string;
|
|
28
|
+
/** Master switch. When false the provider renders null and does nothing. */
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** A single `variantId:quantity` pair parsed out of the `products` param. */
|
|
32
|
+
interface ParsedItem {
|
|
33
|
+
variant_id: string;
|
|
34
|
+
quantity: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Null-rendering side-effect component for the `rootProviders` slot.
|
|
39
|
+
*
|
|
40
|
+
* When a Meta Shops "checkout on another website" deep-link lands on the
|
|
41
|
+
* checkout page:
|
|
42
|
+
*
|
|
43
|
+
* /checkout?products=variant_x:4,variant_y:2&coupon=SAVE10&cart_origin=meta_shops
|
|
44
|
+
*
|
|
45
|
+
* it builds a FRESH cart containing exactly the items Meta sent, applies the
|
|
46
|
+
* coupon, makes that cart the storefront's active cart, and strips the params.
|
|
47
|
+
* Payment is intentionally NOT initialized here — the storefront's useCheckout
|
|
48
|
+
* auto-initializes the payment session once the shopper provides shipping
|
|
49
|
+
* (Medusa ties the pay amount to shipping, which Meta does not send).
|
|
50
|
+
*
|
|
51
|
+
* Why a fresh cart: the buyer agreed to a specific set of items on Meta, so we
|
|
52
|
+
* never merge into a stale browse-cart. Writing our cart id to localStorage
|
|
53
|
+
* overrides any empty cart the header's useCart may have just auto-created; the
|
|
54
|
+
* orphaned empty cart is abandoned harmlessly.
|
|
55
|
+
*
|
|
56
|
+
* Safe to mount globally: it is fully inert unless pathname === checkoutPath
|
|
57
|
+
* AND cart_origin === cartOrigin AND a non-empty products param is present.
|
|
58
|
+
*/
|
|
59
|
+
declare function MetaShopsCheckoutProvider({ baseUrl, publishableApiKey, cartOrigin, checkoutPath, enabled, }: MetaShopsCheckoutProviderProps): ReactElement | null;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse Meta's `products` deep-link param into line items.
|
|
63
|
+
*
|
|
64
|
+
* Format (already percent-decoded by URLSearchParams):
|
|
65
|
+
* `variant_123:4,variant_456:2`
|
|
66
|
+
*
|
|
67
|
+
* The retailer_id Meta sends back is the Medusa variant id (the Meta Shop sync
|
|
68
|
+
* mapper sets each catalog item's `id` to `variant.id`), so these map straight
|
|
69
|
+
* to `createLineItem({ variant_id })` — no translation needed.
|
|
70
|
+
*
|
|
71
|
+
* Defensive rules:
|
|
72
|
+
* - missing/!positive qty → 1
|
|
73
|
+
* - a bare `variant_x` (no `:qty`) → qty 1
|
|
74
|
+
* - duplicate variants are merged (quantities summed)
|
|
75
|
+
* - blank/garbage segments are skipped, never thrown
|
|
76
|
+
*/
|
|
77
|
+
declare function parseProducts(raw: string): ParsedItem[];
|
|
78
|
+
|
|
79
|
+
export { MetaShopsCheckoutProvider, type MetaShopsCheckoutProviderProps, type ParsedItem, parseProducts };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
interface MetaShopsCheckoutProviderProps {
|
|
4
|
+
/**
|
|
5
|
+
* Medusa backend base URL. Defaults to `NEXT_PUBLIC_MEDUSA_BACKEND_URL`
|
|
6
|
+
* (falling back to `http://localhost:9000`). The `/store/*` API resolves the
|
|
7
|
+
* tenant from the publishable key, so no store-environment header is needed.
|
|
8
|
+
*/
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Publishable API key sent as `x-publishable-api-key`. Defaults to
|
|
12
|
+
* `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY`. Routes the `/store/*` calls to the
|
|
13
|
+
* correct tenant.
|
|
14
|
+
*/
|
|
15
|
+
publishableApiKey?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The `cart_origin` query value that marks a Meta Shops deep-link. The
|
|
18
|
+
* provider stays completely inert unless this exact value is present, so it
|
|
19
|
+
* is safe to mount on every page. Defaults to `'meta_shops'`.
|
|
20
|
+
*/
|
|
21
|
+
cartOrigin?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Path the deep-link lands on. The provider only seeds when the current
|
|
24
|
+
* pathname matches this (so a stray `?cart_origin=meta_shops` elsewhere does
|
|
25
|
+
* nothing). Defaults to `'/checkout'`.
|
|
26
|
+
*/
|
|
27
|
+
checkoutPath?: string;
|
|
28
|
+
/** Master switch. When false the provider renders null and does nothing. */
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** A single `variantId:quantity` pair parsed out of the `products` param. */
|
|
32
|
+
interface ParsedItem {
|
|
33
|
+
variant_id: string;
|
|
34
|
+
quantity: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Null-rendering side-effect component for the `rootProviders` slot.
|
|
39
|
+
*
|
|
40
|
+
* When a Meta Shops "checkout on another website" deep-link lands on the
|
|
41
|
+
* checkout page:
|
|
42
|
+
*
|
|
43
|
+
* /checkout?products=variant_x:4,variant_y:2&coupon=SAVE10&cart_origin=meta_shops
|
|
44
|
+
*
|
|
45
|
+
* it builds a FRESH cart containing exactly the items Meta sent, applies the
|
|
46
|
+
* coupon, makes that cart the storefront's active cart, and strips the params.
|
|
47
|
+
* Payment is intentionally NOT initialized here — the storefront's useCheckout
|
|
48
|
+
* auto-initializes the payment session once the shopper provides shipping
|
|
49
|
+
* (Medusa ties the pay amount to shipping, which Meta does not send).
|
|
50
|
+
*
|
|
51
|
+
* Why a fresh cart: the buyer agreed to a specific set of items on Meta, so we
|
|
52
|
+
* never merge into a stale browse-cart. Writing our cart id to localStorage
|
|
53
|
+
* overrides any empty cart the header's useCart may have just auto-created; the
|
|
54
|
+
* orphaned empty cart is abandoned harmlessly.
|
|
55
|
+
*
|
|
56
|
+
* Safe to mount globally: it is fully inert unless pathname === checkoutPath
|
|
57
|
+
* AND cart_origin === cartOrigin AND a non-empty products param is present.
|
|
58
|
+
*/
|
|
59
|
+
declare function MetaShopsCheckoutProvider({ baseUrl, publishableApiKey, cartOrigin, checkoutPath, enabled, }: MetaShopsCheckoutProviderProps): ReactElement | null;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse Meta's `products` deep-link param into line items.
|
|
63
|
+
*
|
|
64
|
+
* Format (already percent-decoded by URLSearchParams):
|
|
65
|
+
* `variant_123:4,variant_456:2`
|
|
66
|
+
*
|
|
67
|
+
* The retailer_id Meta sends back is the Medusa variant id (the Meta Shop sync
|
|
68
|
+
* mapper sets each catalog item's `id` to `variant.id`), so these map straight
|
|
69
|
+
* to `createLineItem({ variant_id })` — no translation needed.
|
|
70
|
+
*
|
|
71
|
+
* Defensive rules:
|
|
72
|
+
* - missing/!positive qty → 1
|
|
73
|
+
* - a bare `variant_x` (no `:qty`) → qty 1
|
|
74
|
+
* - duplicate variants are merged (quantities summed)
|
|
75
|
+
* - blank/garbage segments are skipped, never thrown
|
|
76
|
+
*/
|
|
77
|
+
declare function parseProducts(raw: string): ParsedItem[];
|
|
78
|
+
|
|
79
|
+
export { MetaShopsCheckoutProvider, type MetaShopsCheckoutProviderProps, type ParsedItem, parseProducts };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var src_exports = {};
|
|
23
|
+
__export(src_exports, {
|
|
24
|
+
MetaShopsCheckoutProvider: () => MetaShopsCheckoutProvider,
|
|
25
|
+
parseProducts: () => parseProducts
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(src_exports);
|
|
28
|
+
|
|
29
|
+
// src/provider.tsx
|
|
30
|
+
var import_react = require("react");
|
|
31
|
+
var import_react_query = require("@tanstack/react-query");
|
|
32
|
+
|
|
33
|
+
// src/lib/fetch.ts
|
|
34
|
+
function resolveEnv(key) {
|
|
35
|
+
if (key === "NEXT_PUBLIC_MEDUSA_BACKEND_URL") return process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL;
|
|
36
|
+
if (key === "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY") return process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY;
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
async function storeFetch(path, options = {}) {
|
|
40
|
+
const {
|
|
41
|
+
baseUrl = resolveEnv("NEXT_PUBLIC_MEDUSA_BACKEND_URL") ?? "http://localhost:9000",
|
|
42
|
+
publishableApiKey = resolveEnv("NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY"),
|
|
43
|
+
method = "GET",
|
|
44
|
+
body
|
|
45
|
+
} = options;
|
|
46
|
+
const headers = { Accept: "application/json" };
|
|
47
|
+
if (publishableApiKey) headers["x-publishable-api-key"] = publishableApiKey;
|
|
48
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
49
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
50
|
+
method,
|
|
51
|
+
headers,
|
|
52
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const err = await res.json().catch(() => ({}));
|
|
56
|
+
throw new Error(err.message ?? err.error ?? `Store request failed (${res.status})`);
|
|
57
|
+
}
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/lib/parse.ts
|
|
62
|
+
function parseProducts(raw) {
|
|
63
|
+
const merged = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const segment of raw.split(",")) {
|
|
65
|
+
const pair = segment.trim();
|
|
66
|
+
if (!pair) continue;
|
|
67
|
+
const sep = pair.lastIndexOf(":");
|
|
68
|
+
const rawId = sep === -1 ? pair : pair.slice(0, sep);
|
|
69
|
+
const rawQty = sep === -1 ? "" : pair.slice(sep + 1);
|
|
70
|
+
let variantId = rawId.trim();
|
|
71
|
+
try {
|
|
72
|
+
variantId = decodeURIComponent(variantId).trim();
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
if (!variantId) continue;
|
|
76
|
+
const parsed = parseInt(rawQty, 10);
|
|
77
|
+
const quantity = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
|
78
|
+
merged.set(variantId, (merged.get(variantId) ?? 0) + quantity);
|
|
79
|
+
}
|
|
80
|
+
return Array.from(merged, ([variant_id, quantity]) => ({ variant_id, quantity }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/provider.tsx
|
|
84
|
+
var CART_ID_KEY = "medusa_cart_id";
|
|
85
|
+
var CART_QUERY_KEY = ["cart"];
|
|
86
|
+
var SEEDED_PREFIX = "meta_shops_seeded:";
|
|
87
|
+
function MetaShopsCheckoutProvider({
|
|
88
|
+
baseUrl,
|
|
89
|
+
publishableApiKey,
|
|
90
|
+
cartOrigin = "meta_shops",
|
|
91
|
+
checkoutPath = "/checkout",
|
|
92
|
+
enabled = true
|
|
93
|
+
}) {
|
|
94
|
+
const queryClient = (0, import_react_query.useQueryClient)();
|
|
95
|
+
const ranRef = (0, import_react.useRef)(false);
|
|
96
|
+
(0, import_react.useEffect)(() => {
|
|
97
|
+
if (!enabled || typeof window === "undefined" || ranRef.current) return;
|
|
98
|
+
let params;
|
|
99
|
+
try {
|
|
100
|
+
params = new URLSearchParams(window.location.search);
|
|
101
|
+
} catch {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const norm = (p) => p.replace(/\/+$/, "");
|
|
105
|
+
if (norm(window.location.pathname) !== norm(checkoutPath)) return;
|
|
106
|
+
if (params.get("cart_origin") !== cartOrigin) return;
|
|
107
|
+
const productsRaw = params.get("products");
|
|
108
|
+
if (!productsRaw) return;
|
|
109
|
+
const items = parseProducts(productsRaw);
|
|
110
|
+
if (items.length === 0) return;
|
|
111
|
+
const couponRaw = params.get("coupon");
|
|
112
|
+
const coupon = couponRaw && couponRaw.trim() ? couponRaw.trim().toUpperCase() : null;
|
|
113
|
+
const cleanUrl = () => {
|
|
114
|
+
try {
|
|
115
|
+
window.history.replaceState(window.history.state, "", checkoutPath);
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const seedKey = `${SEEDED_PREFIX}${productsRaw}|${coupon ?? ""}`;
|
|
120
|
+
try {
|
|
121
|
+
if (sessionStorage.getItem(seedKey)) {
|
|
122
|
+
cleanUrl();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
ranRef.current = true;
|
|
128
|
+
const opts = { baseUrl, publishableApiKey };
|
|
129
|
+
const seed = async () => {
|
|
130
|
+
try {
|
|
131
|
+
const { regions } = await storeFetch(
|
|
132
|
+
"/store/regions",
|
|
133
|
+
opts
|
|
134
|
+
);
|
|
135
|
+
const regionId = regions?.[0]?.id;
|
|
136
|
+
if (!regionId) throw new Error("no region available");
|
|
137
|
+
const { cart: created } = await storeFetch("/store/carts", {
|
|
138
|
+
...opts,
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: { region_id: regionId }
|
|
141
|
+
});
|
|
142
|
+
const cartId = created.id;
|
|
143
|
+
let latest = created;
|
|
144
|
+
for (const item of items) {
|
|
145
|
+
const { cart } = await storeFetch(
|
|
146
|
+
`/store/carts/${cartId}/line-items`,
|
|
147
|
+
{ ...opts, method: "POST", body: { variant_id: item.variant_id, quantity: item.quantity } }
|
|
148
|
+
);
|
|
149
|
+
latest = cart;
|
|
150
|
+
}
|
|
151
|
+
if (coupon) {
|
|
152
|
+
try {
|
|
153
|
+
const { cart } = await storeFetch(`/store/carts/${cartId}`, {
|
|
154
|
+
...opts,
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: { promo_codes: [coupon] }
|
|
157
|
+
});
|
|
158
|
+
latest = cart;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.warn(`[meta-shops-checkout] could not apply coupon "${coupon}":`, e);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
localStorage.setItem(CART_ID_KEY, cartId);
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
sessionStorage.setItem(seedKey, "1");
|
|
169
|
+
} catch {
|
|
170
|
+
}
|
|
171
|
+
queryClient.setQueryData(CART_QUERY_KEY, latest);
|
|
172
|
+
void queryClient.invalidateQueries({ queryKey: CART_QUERY_KEY });
|
|
173
|
+
cleanUrl();
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error("[meta-shops-checkout] failed to seed cart from Meta Shops deep-link:", e);
|
|
176
|
+
ranRef.current = false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
void seed();
|
|
180
|
+
}, [enabled, baseUrl, publishableApiKey, cartOrigin, checkoutPath]);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
184
|
+
0 && (module.exports = {
|
|
185
|
+
MetaShopsCheckoutProvider,
|
|
186
|
+
parseProducts
|
|
187
|
+
});
|
|
188
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/provider.tsx","../src/lib/fetch.ts","../src/lib/parse.ts"],"sourcesContent":["// Provider (client component — rootProviders slot): seeds a fresh cart from a\n// Meta Shops \"checkout on another website\" deep-link, then hands off to the\n// storefront's checkout flow.\nexport { MetaShopsCheckoutProvider } from './provider'\n\n// Public types\nexport type { MetaShopsCheckoutProviderProps, ParsedItem } from './types'\n\n// Exposed for reuse / testing.\nexport { parseProducts } from './lib/parse'\n","'use client'\n\nimport { useEffect, useRef, type ReactElement } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { storeFetch } from './lib/fetch'\nimport { parseProducts } from './lib/parse'\nimport type { MetaShopsCheckoutProviderProps } from './types'\n\n// Same key + query the storefront's useCart owns, so the seeded cart becomes\n// THE active cart and the checkout page renders it.\nconst CART_ID_KEY = 'medusa_cart_id'\nconst CART_QUERY_KEY = ['cart'] as const\nconst SEEDED_PREFIX = 'meta_shops_seeded:'\n\n/**\n * Null-rendering side-effect component for the `rootProviders` slot.\n *\n * When a Meta Shops \"checkout on another website\" deep-link lands on the\n * checkout page:\n *\n * /checkout?products=variant_x:4,variant_y:2&coupon=SAVE10&cart_origin=meta_shops\n *\n * it builds a FRESH cart containing exactly the items Meta sent, applies the\n * coupon, makes that cart the storefront's active cart, and strips the params.\n * Payment is intentionally NOT initialized here — the storefront's useCheckout\n * auto-initializes the payment session once the shopper provides shipping\n * (Medusa ties the pay amount to shipping, which Meta does not send).\n *\n * Why a fresh cart: the buyer agreed to a specific set of items on Meta, so we\n * never merge into a stale browse-cart. Writing our cart id to localStorage\n * overrides any empty cart the header's useCart may have just auto-created; the\n * orphaned empty cart is abandoned harmlessly.\n *\n * Safe to mount globally: it is fully inert unless pathname === checkoutPath\n * AND cart_origin === cartOrigin AND a non-empty products param is present.\n */\nexport function MetaShopsCheckoutProvider({\n baseUrl,\n publishableApiKey,\n cartOrigin = 'meta_shops',\n checkoutPath = '/checkout',\n enabled = true,\n}: MetaShopsCheckoutProviderProps): ReactElement | null {\n const queryClient = useQueryClient()\n // Guards a single mount (incl. React StrictMode's double-invoke in dev).\n const ranRef = useRef(false)\n\n useEffect(() => {\n if (!enabled || typeof window === 'undefined' || ranRef.current) return\n\n let params: URLSearchParams\n try {\n params = new URLSearchParams(window.location.search)\n } catch {\n return\n }\n\n const norm = (p: string) => p.replace(/\\/+$/, '')\n if (norm(window.location.pathname) !== norm(checkoutPath)) return\n if (params.get('cart_origin') !== cartOrigin) return\n\n const productsRaw = params.get('products')\n if (!productsRaw) return\n\n const items = parseProducts(productsRaw)\n if (items.length === 0) return\n\n const couponRaw = params.get('coupon')\n const coupon = couponRaw && couponRaw.trim() ? couponRaw.trim().toUpperCase() : null\n\n const cleanUrl = () => {\n try {\n window.history.replaceState(window.history.state, '', checkoutPath)\n } catch {\n /* ignore */\n }\n }\n\n // Idempotency: never re-seed the same deep-link (refresh, back-nav, re-mount).\n const seedKey = `${SEEDED_PREFIX}${productsRaw}|${coupon ?? ''}`\n try {\n if (sessionStorage.getItem(seedKey)) {\n cleanUrl()\n return\n }\n } catch {\n /* sessionStorage blocked → ranRef + cleanUrl still prevent most repeats */\n }\n\n ranRef.current = true\n const opts = { baseUrl, publishableApiKey }\n const seed = async () => {\n try {\n // 1. Region — mirror the storefront's useCart (first region).\n const { regions } = await storeFetch<{ regions: Array<{ id: string }> }>(\n '/store/regions',\n opts,\n )\n const regionId = regions?.[0]?.id\n if (!regionId) throw new Error('no region available')\n\n // 2. Fresh cart for this Meta order.\n const { cart: created } = await storeFetch<{ cart: { id: string } }>('/store/carts', {\n ...opts,\n method: 'POST',\n body: { region_id: regionId },\n })\n const cartId = created.id\n\n // 3. Line items — sequential to keep the cart's optimistic version sane.\n let latest: unknown = created\n for (const item of items) {\n const { cart } = await storeFetch<{ cart: unknown }>(\n `/store/carts/${cartId}/line-items`,\n { ...opts, method: 'POST', body: { variant_id: item.variant_id, quantity: item.quantity } },\n )\n latest = cart\n }\n\n // 4. Discount — an invalid/expired coupon must NOT abort the cart.\n if (coupon) {\n try {\n const { cart } = await storeFetch<{ cart: unknown }>(`/store/carts/${cartId}`, {\n ...opts,\n method: 'POST',\n body: { promo_codes: [coupon] },\n })\n latest = cart\n } catch (e) {\n console.warn(`[meta-shops-checkout] could not apply coupon \"${coupon}\":`, e)\n }\n }\n\n // 5. Activate: make this the storefront's cart and reveal it on checkout.\n try {\n localStorage.setItem(CART_ID_KEY, cartId)\n } catch {\n /* ignore */\n }\n try {\n sessionStorage.setItem(seedKey, '1')\n } catch {\n /* ignore */\n }\n queryClient.setQueryData(CART_QUERY_KEY, latest)\n // Force a refetch so useCart re-reads localStorage (our cart) and wins\n // any race with a concurrently auto-created empty cart.\n void queryClient.invalidateQueries({ queryKey: CART_QUERY_KEY })\n\n cleanUrl()\n } catch (e) {\n // Non-fatal: leave the shopper on /checkout with whatever cart exists.\n console.error('[meta-shops-checkout] failed to seed cart from Meta Shops deep-link:', e)\n ranRef.current = false // allow a retry if the component re-mounts\n }\n }\n\n void seed()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, baseUrl, publishableApiKey, cartOrigin, checkoutPath])\n\n return null\n}\n","'use client'\n\nfunction resolveEnv(key: string): string | undefined {\n if (key === 'NEXT_PUBLIC_MEDUSA_BACKEND_URL') return process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL\n if (key === 'NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY') return process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY\n return undefined\n}\n\nexport interface StoreFetchOptions {\n baseUrl?: string\n publishableApiKey?: string\n method?: string\n body?: unknown\n}\n\n/**\n * Minimal `/store/*` fetch helper, mirroring the storefront's medusa-client\n * (publishable key in the header, JSON in/out). Intentionally raw fetch rather\n * than the JS SDK: this package must NOT bundle a second `@medusajs/js-sdk`\n * copy, and the three cart endpoints we touch are stable.\n */\nexport async function storeFetch<T>(path: string, options: StoreFetchOptions = {}): Promise<T> {\n const {\n baseUrl = resolveEnv('NEXT_PUBLIC_MEDUSA_BACKEND_URL') ?? 'http://localhost:9000',\n publishableApiKey = resolveEnv('NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY'),\n method = 'GET',\n body,\n } = options\n\n const headers: Record<string, string> = { Accept: 'application/json' }\n if (publishableApiKey) headers['x-publishable-api-key'] = publishableApiKey\n if (body !== undefined) headers['Content-Type'] = 'application/json'\n\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n ...(body !== undefined ? { body: JSON.stringify(body) } : {}),\n })\n\n if (!res.ok) {\n const err = (await res.json().catch(() => ({}))) as { message?: string; error?: string }\n throw new Error(err.message ?? err.error ?? `Store request failed (${res.status})`)\n }\n\n return res.json() as Promise<T>\n}\n","import type { ParsedItem } from '../types'\n\n/**\n * Parse Meta's `products` deep-link param into line items.\n *\n * Format (already percent-decoded by URLSearchParams):\n * `variant_123:4,variant_456:2`\n *\n * The retailer_id Meta sends back is the Medusa variant id (the Meta Shop sync\n * mapper sets each catalog item's `id` to `variant.id`), so these map straight\n * to `createLineItem({ variant_id })` — no translation needed.\n *\n * Defensive rules:\n * - missing/!positive qty → 1\n * - a bare `variant_x` (no `:qty`) → qty 1\n * - duplicate variants are merged (quantities summed)\n * - blank/garbage segments are skipped, never thrown\n */\nexport function parseProducts(raw: string): ParsedItem[] {\n const merged = new Map<string, number>()\n\n for (const segment of raw.split(',')) {\n const pair = segment.trim()\n if (!pair) continue\n\n // lastIndexOf(':') so a variant id that itself contained a colon survives.\n const sep = pair.lastIndexOf(':')\n const rawId = sep === -1 ? pair : pair.slice(0, sep)\n const rawQty = sep === -1 ? '' : pair.slice(sep + 1)\n\n let variantId = rawId.trim()\n try {\n variantId = decodeURIComponent(variantId).trim()\n } catch {\n // already-decoded / malformed escape → keep as-is\n }\n if (!variantId) continue\n\n const parsed = parseInt(rawQty, 10)\n const quantity = Number.isFinite(parsed) && parsed > 0 ? parsed : 1\n\n merged.set(variantId, (merged.get(variantId) ?? 0) + quantity)\n }\n\n return Array.from(merged, ([variant_id, quantity]) => ({ variant_id, quantity }))\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/provider.tsx
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
|
|
7
|
+
// src/lib/fetch.ts
|
|
8
|
+
function resolveEnv(key) {
|
|
9
|
+
if (key === "NEXT_PUBLIC_MEDUSA_BACKEND_URL") return process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL;
|
|
10
|
+
if (key === "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY") return process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY;
|
|
11
|
+
return void 0;
|
|
12
|
+
}
|
|
13
|
+
async function storeFetch(path, options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
baseUrl = resolveEnv("NEXT_PUBLIC_MEDUSA_BACKEND_URL") ?? "http://localhost:9000",
|
|
16
|
+
publishableApiKey = resolveEnv("NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY"),
|
|
17
|
+
method = "GET",
|
|
18
|
+
body
|
|
19
|
+
} = options;
|
|
20
|
+
const headers = { Accept: "application/json" };
|
|
21
|
+
if (publishableApiKey) headers["x-publishable-api-key"] = publishableApiKey;
|
|
22
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
23
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
24
|
+
method,
|
|
25
|
+
headers,
|
|
26
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const err = await res.json().catch(() => ({}));
|
|
30
|
+
throw new Error(err.message ?? err.error ?? `Store request failed (${res.status})`);
|
|
31
|
+
}
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/lib/parse.ts
|
|
36
|
+
function parseProducts(raw) {
|
|
37
|
+
const merged = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const segment of raw.split(",")) {
|
|
39
|
+
const pair = segment.trim();
|
|
40
|
+
if (!pair) continue;
|
|
41
|
+
const sep = pair.lastIndexOf(":");
|
|
42
|
+
const rawId = sep === -1 ? pair : pair.slice(0, sep);
|
|
43
|
+
const rawQty = sep === -1 ? "" : pair.slice(sep + 1);
|
|
44
|
+
let variantId = rawId.trim();
|
|
45
|
+
try {
|
|
46
|
+
variantId = decodeURIComponent(variantId).trim();
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
if (!variantId) continue;
|
|
50
|
+
const parsed = parseInt(rawQty, 10);
|
|
51
|
+
const quantity = Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
|
|
52
|
+
merged.set(variantId, (merged.get(variantId) ?? 0) + quantity);
|
|
53
|
+
}
|
|
54
|
+
return Array.from(merged, ([variant_id, quantity]) => ({ variant_id, quantity }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/provider.tsx
|
|
58
|
+
var CART_ID_KEY = "medusa_cart_id";
|
|
59
|
+
var CART_QUERY_KEY = ["cart"];
|
|
60
|
+
var SEEDED_PREFIX = "meta_shops_seeded:";
|
|
61
|
+
function MetaShopsCheckoutProvider({
|
|
62
|
+
baseUrl,
|
|
63
|
+
publishableApiKey,
|
|
64
|
+
cartOrigin = "meta_shops",
|
|
65
|
+
checkoutPath = "/checkout",
|
|
66
|
+
enabled = true
|
|
67
|
+
}) {
|
|
68
|
+
const queryClient = useQueryClient();
|
|
69
|
+
const ranRef = useRef(false);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!enabled || typeof window === "undefined" || ranRef.current) return;
|
|
72
|
+
let params;
|
|
73
|
+
try {
|
|
74
|
+
params = new URLSearchParams(window.location.search);
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const norm = (p) => p.replace(/\/+$/, "");
|
|
79
|
+
if (norm(window.location.pathname) !== norm(checkoutPath)) return;
|
|
80
|
+
if (params.get("cart_origin") !== cartOrigin) return;
|
|
81
|
+
const productsRaw = params.get("products");
|
|
82
|
+
if (!productsRaw) return;
|
|
83
|
+
const items = parseProducts(productsRaw);
|
|
84
|
+
if (items.length === 0) return;
|
|
85
|
+
const couponRaw = params.get("coupon");
|
|
86
|
+
const coupon = couponRaw && couponRaw.trim() ? couponRaw.trim().toUpperCase() : null;
|
|
87
|
+
const cleanUrl = () => {
|
|
88
|
+
try {
|
|
89
|
+
window.history.replaceState(window.history.state, "", checkoutPath);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const seedKey = `${SEEDED_PREFIX}${productsRaw}|${coupon ?? ""}`;
|
|
94
|
+
try {
|
|
95
|
+
if (sessionStorage.getItem(seedKey)) {
|
|
96
|
+
cleanUrl();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
ranRef.current = true;
|
|
102
|
+
const opts = { baseUrl, publishableApiKey };
|
|
103
|
+
const seed = async () => {
|
|
104
|
+
try {
|
|
105
|
+
const { regions } = await storeFetch(
|
|
106
|
+
"/store/regions",
|
|
107
|
+
opts
|
|
108
|
+
);
|
|
109
|
+
const regionId = regions?.[0]?.id;
|
|
110
|
+
if (!regionId) throw new Error("no region available");
|
|
111
|
+
const { cart: created } = await storeFetch("/store/carts", {
|
|
112
|
+
...opts,
|
|
113
|
+
method: "POST",
|
|
114
|
+
body: { region_id: regionId }
|
|
115
|
+
});
|
|
116
|
+
const cartId = created.id;
|
|
117
|
+
let latest = created;
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const { cart } = await storeFetch(
|
|
120
|
+
`/store/carts/${cartId}/line-items`,
|
|
121
|
+
{ ...opts, method: "POST", body: { variant_id: item.variant_id, quantity: item.quantity } }
|
|
122
|
+
);
|
|
123
|
+
latest = cart;
|
|
124
|
+
}
|
|
125
|
+
if (coupon) {
|
|
126
|
+
try {
|
|
127
|
+
const { cart } = await storeFetch(`/store/carts/${cartId}`, {
|
|
128
|
+
...opts,
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: { promo_codes: [coupon] }
|
|
131
|
+
});
|
|
132
|
+
latest = cart;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.warn(`[meta-shops-checkout] could not apply coupon "${coupon}":`, e);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
localStorage.setItem(CART_ID_KEY, cartId);
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
sessionStorage.setItem(seedKey, "1");
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
queryClient.setQueryData(CART_QUERY_KEY, latest);
|
|
146
|
+
void queryClient.invalidateQueries({ queryKey: CART_QUERY_KEY });
|
|
147
|
+
cleanUrl();
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.error("[meta-shops-checkout] failed to seed cart from Meta Shops deep-link:", e);
|
|
150
|
+
ranRef.current = false;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
void seed();
|
|
154
|
+
}, [enabled, baseUrl, publishableApiKey, cartOrigin, checkoutPath]);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
export {
|
|
158
|
+
MetaShopsCheckoutProvider,
|
|
159
|
+
parseProducts
|
|
160
|
+
};
|
|
161
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/provider.tsx","../src/lib/fetch.ts","../src/lib/parse.ts"],"sourcesContent":["'use client'\n\nimport { useEffect, useRef, type ReactElement } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { storeFetch } from './lib/fetch'\nimport { parseProducts } from './lib/parse'\nimport type { MetaShopsCheckoutProviderProps } from './types'\n\n// Same key + query the storefront's useCart owns, so the seeded cart becomes\n// THE active cart and the checkout page renders it.\nconst CART_ID_KEY = 'medusa_cart_id'\nconst CART_QUERY_KEY = ['cart'] as const\nconst SEEDED_PREFIX = 'meta_shops_seeded:'\n\n/**\n * Null-rendering side-effect component for the `rootProviders` slot.\n *\n * When a Meta Shops \"checkout on another website\" deep-link lands on the\n * checkout page:\n *\n * /checkout?products=variant_x:4,variant_y:2&coupon=SAVE10&cart_origin=meta_shops\n *\n * it builds a FRESH cart containing exactly the items Meta sent, applies the\n * coupon, makes that cart the storefront's active cart, and strips the params.\n * Payment is intentionally NOT initialized here — the storefront's useCheckout\n * auto-initializes the payment session once the shopper provides shipping\n * (Medusa ties the pay amount to shipping, which Meta does not send).\n *\n * Why a fresh cart: the buyer agreed to a specific set of items on Meta, so we\n * never merge into a stale browse-cart. Writing our cart id to localStorage\n * overrides any empty cart the header's useCart may have just auto-created; the\n * orphaned empty cart is abandoned harmlessly.\n *\n * Safe to mount globally: it is fully inert unless pathname === checkoutPath\n * AND cart_origin === cartOrigin AND a non-empty products param is present.\n */\nexport function MetaShopsCheckoutProvider({\n baseUrl,\n publishableApiKey,\n cartOrigin = 'meta_shops',\n checkoutPath = '/checkout',\n enabled = true,\n}: MetaShopsCheckoutProviderProps): ReactElement | null {\n const queryClient = useQueryClient()\n // Guards a single mount (incl. React StrictMode's double-invoke in dev).\n const ranRef = useRef(false)\n\n useEffect(() => {\n if (!enabled || typeof window === 'undefined' || ranRef.current) return\n\n let params: URLSearchParams\n try {\n params = new URLSearchParams(window.location.search)\n } catch {\n return\n }\n\n const norm = (p: string) => p.replace(/\\/+$/, '')\n if (norm(window.location.pathname) !== norm(checkoutPath)) return\n if (params.get('cart_origin') !== cartOrigin) return\n\n const productsRaw = params.get('products')\n if (!productsRaw) return\n\n const items = parseProducts(productsRaw)\n if (items.length === 0) return\n\n const couponRaw = params.get('coupon')\n const coupon = couponRaw && couponRaw.trim() ? couponRaw.trim().toUpperCase() : null\n\n const cleanUrl = () => {\n try {\n window.history.replaceState(window.history.state, '', checkoutPath)\n } catch {\n /* ignore */\n }\n }\n\n // Idempotency: never re-seed the same deep-link (refresh, back-nav, re-mount).\n const seedKey = `${SEEDED_PREFIX}${productsRaw}|${coupon ?? ''}`\n try {\n if (sessionStorage.getItem(seedKey)) {\n cleanUrl()\n return\n }\n } catch {\n /* sessionStorage blocked → ranRef + cleanUrl still prevent most repeats */\n }\n\n ranRef.current = true\n const opts = { baseUrl, publishableApiKey }\n const seed = async () => {\n try {\n // 1. Region — mirror the storefront's useCart (first region).\n const { regions } = await storeFetch<{ regions: Array<{ id: string }> }>(\n '/store/regions',\n opts,\n )\n const regionId = regions?.[0]?.id\n if (!regionId) throw new Error('no region available')\n\n // 2. Fresh cart for this Meta order.\n const { cart: created } = await storeFetch<{ cart: { id: string } }>('/store/carts', {\n ...opts,\n method: 'POST',\n body: { region_id: regionId },\n })\n const cartId = created.id\n\n // 3. Line items — sequential to keep the cart's optimistic version sane.\n let latest: unknown = created\n for (const item of items) {\n const { cart } = await storeFetch<{ cart: unknown }>(\n `/store/carts/${cartId}/line-items`,\n { ...opts, method: 'POST', body: { variant_id: item.variant_id, quantity: item.quantity } },\n )\n latest = cart\n }\n\n // 4. Discount — an invalid/expired coupon must NOT abort the cart.\n if (coupon) {\n try {\n const { cart } = await storeFetch<{ cart: unknown }>(`/store/carts/${cartId}`, {\n ...opts,\n method: 'POST',\n body: { promo_codes: [coupon] },\n })\n latest = cart\n } catch (e) {\n console.warn(`[meta-shops-checkout] could not apply coupon \"${coupon}\":`, e)\n }\n }\n\n // 5. Activate: make this the storefront's cart and reveal it on checkout.\n try {\n localStorage.setItem(CART_ID_KEY, cartId)\n } catch {\n /* ignore */\n }\n try {\n sessionStorage.setItem(seedKey, '1')\n } catch {\n /* ignore */\n }\n queryClient.setQueryData(CART_QUERY_KEY, latest)\n // Force a refetch so useCart re-reads localStorage (our cart) and wins\n // any race with a concurrently auto-created empty cart.\n void queryClient.invalidateQueries({ queryKey: CART_QUERY_KEY })\n\n cleanUrl()\n } catch (e) {\n // Non-fatal: leave the shopper on /checkout with whatever cart exists.\n console.error('[meta-shops-checkout] failed to seed cart from Meta Shops deep-link:', e)\n ranRef.current = false // allow a retry if the component re-mounts\n }\n }\n\n void seed()\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, baseUrl, publishableApiKey, cartOrigin, checkoutPath])\n\n return null\n}\n","'use client'\n\nfunction resolveEnv(key: string): string | undefined {\n if (key === 'NEXT_PUBLIC_MEDUSA_BACKEND_URL') return process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL\n if (key === 'NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY') return process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY\n return undefined\n}\n\nexport interface StoreFetchOptions {\n baseUrl?: string\n publishableApiKey?: string\n method?: string\n body?: unknown\n}\n\n/**\n * Minimal `/store/*` fetch helper, mirroring the storefront's medusa-client\n * (publishable key in the header, JSON in/out). Intentionally raw fetch rather\n * than the JS SDK: this package must NOT bundle a second `@medusajs/js-sdk`\n * copy, and the three cart endpoints we touch are stable.\n */\nexport async function storeFetch<T>(path: string, options: StoreFetchOptions = {}): Promise<T> {\n const {\n baseUrl = resolveEnv('NEXT_PUBLIC_MEDUSA_BACKEND_URL') ?? 'http://localhost:9000',\n publishableApiKey = resolveEnv('NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY'),\n method = 'GET',\n body,\n } = options\n\n const headers: Record<string, string> = { Accept: 'application/json' }\n if (publishableApiKey) headers['x-publishable-api-key'] = publishableApiKey\n if (body !== undefined) headers['Content-Type'] = 'application/json'\n\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n ...(body !== undefined ? { body: JSON.stringify(body) } : {}),\n })\n\n if (!res.ok) {\n const err = (await res.json().catch(() => ({}))) as { message?: string; error?: string }\n throw new Error(err.message ?? err.error ?? `Store request failed (${res.status})`)\n }\n\n return res.json() as Promise<T>\n}\n","import type { ParsedItem } from '../types'\n\n/**\n * Parse Meta's `products` deep-link param into line items.\n *\n * Format (already percent-decoded by URLSearchParams):\n * `variant_123:4,variant_456:2`\n *\n * The retailer_id Meta sends back is the Medusa variant id (the Meta Shop sync\n * mapper sets each catalog item's `id` to `variant.id`), so these map straight\n * to `createLineItem({ variant_id })` — no translation needed.\n *\n * Defensive rules:\n * - missing/!positive qty → 1\n * - a bare `variant_x` (no `:qty`) → qty 1\n * - duplicate variants are merged (quantities summed)\n * - blank/garbage segments are skipped, never thrown\n */\nexport function parseProducts(raw: string): ParsedItem[] {\n const merged = new Map<string, number>()\n\n for (const segment of raw.split(',')) {\n const pair = segment.trim()\n if (!pair) continue\n\n // lastIndexOf(':') so a variant id that itself contained a colon survives.\n const sep = pair.lastIndexOf(':')\n const rawId = sep === -1 ? pair : pair.slice(0, sep)\n const rawQty = sep === -1 ? '' : pair.slice(sep + 1)\n\n let variantId = rawId.trim()\n try {\n variantId = decodeURIComponent(variantId).trim()\n } catch {\n // already-decoded / malformed escape → keep as-is\n }\n if (!variantId) continue\n\n const parsed = parseInt(rawQty, 10)\n const quantity = Number.isFinite(parsed) && parsed > 0 ? parsed : 1\n\n merged.set(variantId, (merged.get(variantId) ?? 0) + quantity)\n }\n\n return Array.from(merged, ([variant_id, quantity]) => ({ variant_id, quantity }))\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amboras-dev/meta-shops-checkout",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Meta Shops 'checkout on another website' handoff for Amboras storefronts — builds a fresh cart (line items + discount) from the deep-link URL params and activates it for checkout",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"react": ">=18",
|
|
21
|
+
"@tanstack/react-query": ">=5"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"typescript": "^5.0.0",
|
|
26
|
+
"@tanstack/react-query": "^5.0.0",
|
|
27
|
+
"react": "^19.0.0",
|
|
28
|
+
"@types/react": "^19.0.0",
|
|
29
|
+
"@types/node": "^20.0.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
}
|
|
38
|
+
}
|