diff --git a/.github/workflows/pgk-pr-new.yaml b/.github/workflows/pgk-pr-new.yaml index 01973dd7..9a33dc85 100644 --- a/.github/workflows/pgk-pr-new.yaml +++ b/.github/workflows/pgk-pr-new.yaml @@ -37,7 +37,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build elements 🛠 - run: pnpm build + run: pnpm --filter @commercelayer/core --filter @commercelayer/hooks --filter @commercelayer/react-components build - name: Publish 🚀 pkg.pr.new run: | diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..e83ae717 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,36 @@ +# React Components — Payment + +This context covers the React components and state that let a storefront select a payment method and attach a payment source to an order during checkout. + +## Language + +**Payment Method**: +A selectable payment option on an order (a configured gateway option, e.g. "Stripe"). Backed by the `payment_method` resource. +_Avoid_: gateway (as a synonym), payment type + +**Payment Source**: +The concrete payment instrument record attached to a single order (e.g. `stripe_payment`, `adyen_payment`, `wire_transfer`). Created per order from the selected Payment Method. +_Avoid_: payment, card, token + +**Customer Payment Source**: +A Payment Source saved to a Customer for reuse across orders (a stored card). Selected via the order's `_customer_payment_source_id`. +_Avoid_: saved card (informal), wallet + +**Payment Gateway**: +In this codebase, the React component that wires a specific gateway's UI/SDK and drives Payment Source creation for that gateway (e.g. `StripeGateway`, `AdyenGateway`). +_Avoid_: using "gateway" to mean the Payment Method + +## Relationships + +- An **Order** has at most one **Payment Method** and one **Payment Source** +- A **Payment Source** is created from the selected **Payment Method** +- A **Customer Payment Source** belongs to a **Customer**; selecting one sets the **Order**'s Payment Source + +## Example dialogue + +> **Dev:** "When the shopper picks Stripe, do we create the **Payment Source** in the `StripePayment` component?" +> **Domain expert:** "No — Stripe has no dedicated creation component. The `StripeGateway`/`PaymentGateway` effect creates the **Payment Source** once the **Payment Method** is selected. Adyen and Braintree, by contrast, create it inside their own components." + +## Flagged ambiguities + +- "set payment source" was used to mean both the async operation that creates/attaches a Payment Source *and* the reducer action that stores it in state — resolved: the operation is `setPaymentSource(...)`, the reducer action is `dispatch({ type: "setPaymentSource" })`. diff --git a/docs/adr/0001-coalesce-payment-source-requests.md b/docs/adr/0001-coalesce-payment-source-requests.md new file mode 100644 index 00000000..8dcdc801 --- /dev/null +++ b/docs/adr/0001-coalesce-payment-source-requests.md @@ -0,0 +1,19 @@ +# Coalesce concurrent payment-source requests + +## Context + +`setPaymentSource` (in `PaymentMethodReducer.ts`) is a plain async function driven by React effects — notably the `PaymentGateway` effect, which fires it fire-and-forget from a 23-dependency effect. Before the first `create`/`update`/`updateOrder` request resolves, both `paymentSource` (context state) and `order.payment_source` are still null, so any dependency change re-runs the effect and fires a **second** request. On the Stripe / single-payment-method / new-source flow this produced two `stripe_payments.create` calls and two payment source records. + +## Decision + +De-duplicate genuinely-concurrent calls with a module-level `Map` keyed by `` `${order.id}:${paymentResource}:${customerPaymentSourceId ?? paymentSourceId ?? 'create'}` ``. The first call stores its in-flight promise; concurrent calls with the same key return that same promise; the entry is deleted in a `finally` once it settles. This coalesces duplicates within a burst without permanently memoizing, so a later legitimate re-create still runs. It backs up the narrower in-flight `useRef` guard in the `PaymentGateway` effect and protects every gateway, not just Stripe. + +## Considered options + +- **Effect-only `useRef` guard** — kept as the first line of defense, but too narrow: only protects the `PaymentGateway` caller, not other gateways or future callers. +- **Idempotency check against `order.payment_source`** — unreliable: the order is stale until `getOrder` refreshes after the first request. +- **Hashing `attributes` into the key** — rejected as fragile; attributes are effectively constant for a given order + resource + path within a burst. + +## Consequences + +The reducer module now holds mutable global state, which reads as surprising for an otherwise-stateless function — this ADR is the "why." The key deliberately separates the create, update/retrieve, and saved-source (`updateOrder`) paths so coalescing never merges semantically different operations. diff --git a/examples/_app.tsx b/examples/_app.tsx deleted file mode 100644 index 8898894f..00000000 --- a/examples/_app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import "../styles/styles.css" -import { AppProps } from "next/app" - -function MyApp({ Component, pageProps }: AppProps) { - return -} - -export default MyApp diff --git a/examples/cart.tsx b/examples/cart.tsx deleted file mode 100644 index 66aacc53..00000000 --- a/examples/cart.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useState, useEffect, Fragment } from "react" -import { getSalesChannelToken } from "@commercelayer/js-auth" -import { - CommerceLayer, - OrderContainer, - Price, - PricesContainer, - AddToCartButton, - LineItemsContainer, - LineItem, - LineItemImage, - LineItemName, - LineItemQuantity, - LineItemAmount, - LineItemRemoveLink, - LineItemsCount, - LineItemsEmpty, - CheckoutLink, - SubTotalAmount, - QuantitySelector, - TotalAmount, - DiscountAmount, - ShippingAmount, - TaxesAmount, - GiftCardAmount, - AvailabilityContainer, - AvailabilityTemplate, - ItemContainer, - Errors, - OrderStorage, - SkusContainer, - SkuField, - Skus, -} from "packages/react-components/src" - -const clientId = process.env.NEXT_PUBLIC_CLIENT_ID as string -const endpoint = process.env.NEXT_PUBLIC_ENDPOINT as string -const scope = process.env.NEXT_PUBLIC_MARKET_ID as string - -const skus = ["BABYONBU000000E63E7412MX", "CANVASAU000000FFFFFF1824", "BABYONBU000000E63E746MXX"] - -export default function Cart() { - const [token, setToken] = useState("") - useEffect(() => { - const getToken = async () => { - const token = await getSalesChannelToken({ - clientId, - endpoint, - scope, - }) - if (token) setToken(token.accessToken) - } - getToken() - }, []) - return ( - - -
- - - - - - - - - -
-
- -
-
- - - -
-
-
-
-
- -

Shopping Bag

- -

- Your shopping bag contains{" "} - items -

-
- - -
- - - - - - -
-
- -
- - - - - -
-
- -
- - - - - -
-
- -
- - - - - -
-
-
-
-
-
-
-

Subtotal

-
-
- -
-
-
-
-

Discount

-
-
- -
-
-
-
-

Shipping

-
-
- -
-
-
-
-

- Taxes (included) -

-
-
- -
-
-
-
-

Gift card

-
-
- -
-
-
-
-

Total

-
-
- -
-
-
-
- -
-
-
-
-
-
- ) -} diff --git a/examples/categoryOrder.tsx b/examples/categoryOrder.tsx deleted file mode 100644 index d8a0c3c5..00000000 --- a/examples/categoryOrder.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import React, { useState, useEffect, Fragment } from "react" -import { getSalesChannelToken } from "@commercelayer/js-auth" -import CommerceLayer from "../#components/auth/CommerceLayer" -import OrderContainer from "../#components/OrderContainer" -import PriceContainer from "../#components/prices/PricesContainer" -import Price from "../#components/Price" -import AddToCart from "../#components/orders/AddToCartButton" -import LineItemsContainer from "../#components/LineItemsContainer" -import LineItem from "../#components/line_items/LineItem" -import LineItemImage from "../#components/LineItemImage" -import LineItemName from "../#components/LineItemName" -import LineItemQuantity from "../#components/LineItemQuantity" -import LineItemAmount from "../#components/LineItemAmount" -import LineItemRemoveLink from "../#components/LineItemRemoveLink" -import CheckoutLink from "../#components/orders/CheckoutLink" -import SubTotalAmount from "../#components/SubTotalAmount" -import QuantitySelector from "../#components/skus/QuantitySelector" -import LineItemsCount from "../#components/LineItemsCount" -import TotalAmount from "../#components/orders/TotalAmount" -import DiscountAmount from "../#components/orders/DiscountAmount" -import ShippingAmount from "../#components/orders/ShippingAmount" -import TaxesAmount from "../#components/TaxesAmount" -import GiftCardAmount from "../#components/orders/GiftCardAmount" -import AvailabilityContainer from "../#components/AvailabilityContainer" -import AvailabilityTemplate from "../#components/skus/AvailabilityTemplate" -import ItemContainer from "../#components/orders/ItemContainer" - -const clientId = process.env.NEXT_PUBLIC_CLIENT_ID as string -const endpoint = process.env.NEXT_PUBLIC_ENDPOINT as string -const scope = process.env.NEXT_PUBLIC_MARKET_ID as string -// const username = process.env.NEXT_PUBLIC_CUSTOMER_USERNAME as string -// const password = process.env.NEXT_PUBLIC_CUSTOMER_PASSWORD as string - -export default function Order() { - const [token, setToken] = useState("") - useEffect(() => { - const getToken = async () => { - const auth = await getSalesChannelToken({ - clientId, - endpoint, - scope, - }) - setToken(auth?.accessToken as string) - } - getToken() - }, []) - return ( - - -
- - - -
-
-
- -
-
-

- Black Baby Onesie Short Sleeve with Pink Logo (6 Months) -

-
-
-

- BABYONBU000000E63E746MXX -

-
-
- -
-
- -
-
- -
-
- - - -
-
-
-
- -
-
-

- White Long Sleeve T-shirt with Black Logo (L) -

-
-
-

- LSLEEVMMFFFFFF000000LXXX -

-
-
- -
-
- -
-
- -
-
- - - -
-
-
-
- -
-
-

- Pink Canvas with White Logo (18x24) -

-
-
-

- CANVASAUE63E74FFFFFF1824FAKE -

-
-
- -
-
- -
-
- -
-
- - - -
-
-
-
-
-

Shopping Bag

- -

- Your shopping bag contains {" "} - items -

- -
- - - - - -
-
-
-
-
-
-

Subtotal

-
-
- -
-
-
-
-

Discount

-
-
- -
-
-
-
-

Shipping

-
-
- -
-
-
-
-

- Taxes (included) -

-
-
- -
-
-
-
-

Gift card

-
-
- -
-
-
-
-

Total

-
-
- -
-
-
-
- -
-
-
-
-
- ) -} diff --git a/examples/checkout/addresses.tsx b/examples/checkout/addresses.tsx deleted file mode 100644 index ff074b29..00000000 --- a/examples/checkout/addresses.tsx +++ /dev/null @@ -1,804 +0,0 @@ -import React, { useState, useEffect, Fragment } from "react" -import { getSalesChannelToken } from "@commercelayer/js-auth" -import { Nav } from ".." -import Head from "next/head" -import { - CommerceLayer, - OrderContainer, - Errors, - AddressesContainer, - BillingAddressForm, - AddressInput, - AddressCountrySelector, - SaveAddressesButton, - ShippingAddressForm, - CustomerContainer, - CustomerInput, - SaveCustomerButton, - AddressStateSelector, -} from "packages/react-components/src" -import { useRouter } from "next/router" -import getSdk from "#utils/getSdk" - -const clientId = process.env.NEXT_PUBLIC_CLIENT_ID as string -const endpoint = process.env.NEXT_PUBLIC_ENDPOINT as string -const scope = process.env.NEXT_PUBLIC_MARKET_ID as string - -let orderId = "" - -export default function Main() { - const [token, setToken] = useState("") - const [customerEmail, setCustomerEmail] = useState("") - const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false) - const [saveOnBlur, setSaveOnBlur] = useState(false) - const [isBusiness, setIsBusiness] = useState(false) - const [hideAddress, setHideAddress] = useState(false) - const [billingAddress, setBillingAddress] = useState({}) - const [shippingAddress, setShippingAddress] = useState({}) - const { query } = useRouter() - if (query.orderId) { - orderId = query.orderId as string - } - const getOrder = async () => { - if (token && orderId) { - const config = { accessToken: token, endpoint } - const sdk = getSdk(config) - try { - const order = await sdk.orders.retrieve(orderId, { - include: ["billing_address", "shipping_address"], - }) - if (order?.billing_address) { - setBillingAddress(order.billing_address) - } - if (order.shipping_address) { - setShippingAddress(order.shipping_address) - } - if (order.customer_email) { - setCustomerEmail(order.customer_email) - } - } catch (error) { - console.error(error) - } - } - } - useEffect(() => { - const getToken = async () => { - // @ts-ignore - const token = await getSalesChannelToken({ - clientId, - endpoint, - scope, - }) - if (token) setToken(token.accessToken) - } - if (!token) { - getToken() - } else { - getOrder() - } - }, [token]) - const messages: any = [ - { - code: "EMPTY_ERROR", - resource: "billingAddress", - field: "firstName", - message: `Can't be blank`, - }, - { - code: "VALIDATION_ERROR", - resource: "billingAddress", - field: "email", - message: `Must be valid email`, - }, - ] - const handleOnSave = async () => { - if (token) { - try { - await getOrder() - } catch (error) { - console.error(error) - } - } - } - const handleClick = async () => { - await getOrder() - } - return ( - - - - -