Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions platforms/web/sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ cd platforms/web
pnpm sample
```

Vite serves at `http://localhost:5173`.
Vite serves the single-checkout sample at `http://localhost:5173/` and the Universal Checkout sample at `http://localhost:5173/universal.html`. The topbar links the two pages.

## Universal Checkout sample

The Universal page uses the same three-panel layout and cart controls as the single-checkout page. Enter a storefront domain and choose **Add shop** to load its public `/products.json` catalog immediately. Each selected shop has its own cart, product loading status, Retry and Remove controls, and an individual cart permalink preview. You can add multiple shops, including products with the same variant ID in different shops; quantities remain separate.

Use a bare domain such as `store-one.myshopify.com` or its HTTPS homepage. The form rejects credentials, ports, other schemes, paths, and duplicate normalized domains. The selected domains, carts, and links stay in memory. Only presentation settings, panel collapse choices, and panel widths use Universal-specific local storage keys.

The public products endpoint does not supply a currency code through this sample's catalog helper, so prices are shown without a currency symbol and carts are counted by items rather than combined into a cross-shop monetary total. A failed or empty shop remains selected and must be retried or removed before a Universal Checkout URL can be created. The session creation and Open controls are added in the following sample increments; this page currently stops at cart previews.

## What the demo shows

Expand Down Expand Up @@ -55,12 +63,12 @@ The demo relies on the public `/products.json` endpoint. If product loading fail
- Confirm the storefront is reachable from your browser.
- Use **Use existing checkout source** if you already have a checkout URL or cart permalink and do not need product loading.

This sample does not currently call Storefront API `cartCreate`; it uses cart permalinks so the multi-item flow can be exercised without a Storefront access token.
The single-checkout page does not call Storefront API `cartCreate`; it uses cart permalinks so the multi-item flow can be exercised without a Storefront access token. Universal session creation will use shop carts in its next increment.

## Build

```bash
pnpm sample:build # outputs to sample/dist/
pnpm sample:build # outputs index.html and universal.html to sample/dist/
```

CI runs this on every PR (see `.github/workflows/web.yml`). The sample is not published to npm (`files` allowlist in `platforms/web/package.json`).
4 changes: 4 additions & 0 deletions platforms/web/sample/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<h1>Checkout Kit</h1>
<p class="subtitle">Web Component Playground</p>
</div>
<nav class="sample-nav" aria-label="Sample flows">
<a href="./index.html" aria-current="page">Single checkout</a>
<a href="./universal.html">Universal checkout</a>
</nav>
</header>

<main id="layout">
Expand Down
183 changes: 183 additions & 0 deletions platforms/web/sample/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1119,3 +1119,186 @@ button:disabled {
grid-template-columns: 1fr;
}
}

.sample-nav {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}

.sample-nav a {
padding: 7px 11px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 13px;
text-decoration: none;
}

.sample-nav a:hover,
.sample-nav a:focus-visible,
.sample-nav a[aria-current="page"] {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}

.universal-page .uc-add-row {
display: flex;
gap: 8px;
}

.universal-page .uc-add-row input {
flex: 1 1 auto;
}

.universal-page .uc-add-row button {
flex: none;
}

.universal-page .uc-summary-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding-left: 18px;
color: var(--text-muted);
font-size: 13px;
}

.universal-page .uc-summary-list:empty {
display: none;
}

.universal-page .uc-shop-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 4px 4px 0;
}

.universal-page .uc-shop {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
padding: 16px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
box-shadow: var(--shadow-md);
}

.universal-page .uc-shop-header {
display: flex;
align-items: center;
gap: 10px;
}

.universal-page .uc-shop-header h3 {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
font-size: 16px;
}

.universal-page .uc-shop-actions {
display: flex;
gap: 6px;
margin-left: auto;
}

.universal-page .uc-shop-cart {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-subdued);
}

.universal-page .uc-shop-cart h4 {
margin: 0;
font-size: 13px;
}

.universal-page .uc-shop-cart .cart-lines {
max-height: 230px;
overflow-y: auto;
}

.universal-page .uc-shop-cart .cart-line {
background: var(--surface);
}

.universal-page .uc-shop-catalog {
min-width: 0;
}

.universal-page #component-state p {
margin: 12px 0 0;
line-height: 1.45;
}

.universal-page .events-collapsed #uc-runtime-notice {
display: none;
}

@media (max-width: 1180px) {
html.universal-page {
height: auto;
min-height: 100%;
overflow: auto;
}

body.universal-page {
height: auto;
max-height: none;
min-height: 100dvh;
overflow: visible;
}

body.universal-page main,
body.universal-page main.settings-collapsed,
body.universal-page main.events-collapsed,
body.universal-page main.settings-collapsed.events-collapsed {
height: auto;
min-height: 0;
flex: none;
overflow: visible;
grid-template-columns: minmax(0, 1fr);
}

body.universal-page .panel,
body.universal-page .workspace {
height: auto;
max-height: none;
min-height: 0;
overflow: visible;
}

body.universal-page .product-scroll,
body.universal-page .runtime-scroll {
overflow: visible;
}

body.universal-page .panel-collapse-toggle,
body.universal-page .col-resizer {
display: none;
}
}

@media (max-width: 640px) {
.universal-page .uc-add-row,
.universal-page .uc-shop-header {
align-items: stretch;
flex-direction: column;
}

.universal-page .uc-shop-actions {
margin-left: 0;
}
}
3 changes: 2 additions & 1 deletion platforms/web/sample/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"noEmit": true,
"types": ["vite/client"],
"paths": {
"@shopify/checkout-kit": ["../src/index.ts"]
"@shopify/checkout-kit": ["../src/index.ts"],
"@shopify/checkout-kit/universal": ["../src/universal.ts"]
}
},
"include": ["./**/*.ts", "./**/*.d.ts"]
Expand Down
147 changes: 147 additions & 0 deletions platforms/web/sample/universal-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { normalizeQuantity } from "./cart";
import { createColumnResizer } from "./column-resizer";
import { createUniversalController } from "./universal/controller";
import {
createInitialState,
createUniversalStore,
selectQuantity,
selectShop,
type DisplayState,
} from "./universal/state";
import {
loadUniversalDisplay,
persistUniversalDisplay,
persistUniversalWidth,
readUniversalWidth,
} from "./universal/storage";
import { queryUniversalRefs, renderUniversalApp, renderUniversalChange } from "./universal/views";
import "./styles.css";

const refs = queryUniversalRefs();
const store = createUniversalStore(createInitialState(loadUniversalDisplay()));
const controller = createUniversalController({ store });
const resizer = createColumnResizer({
layout: refs.layout,
leftPanel: refs.settingsPanel,
rightPanel: refs.runtimePanel,
leftHandle: refs.resizeLeft,
rightHandle: refs.resizeRight,
readPersisted: readUniversalWidth,
persist: persistUniversalWidth,
});

const unsubscribe = store.subscribe((state, previous) => {
renderUniversalChange(refs, state, previous);
resizer.reposition();
});

function updateDisplay(partial: Partial<DisplayState>): void {
const display = { ...store.getState().display, ...partial };
store.update((state) => ({ ...state, display }));
persistUniversalDisplay(display);
}

refs.form.addEventListener("submit", (event) => {
event.preventDefault();
if (controller.addShop(refs.domainInput.value)) {
refs.domainInput.value = "";
refs.domainInput.focus();
}
});

refs.domainInput.addEventListener("input", controller.clearAddError);

refs.form.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLSelectElement)) return;
if (target === refs.targetInput) {
updateDisplay({ target: target.value === "auto" ? "auto" : "popup" });
} else if (target === refs.appearanceInput) {
updateDisplay({ appearance: target.value });
} else if (target === refs.logLevelInput) {
const logLevel = target.value;
if (
logLevel === "debug" ||
logLevel === "warn" ||
logLevel === "error" ||
logLevel === "none"
) {
updateDisplay({ logLevel });
}
}
});

refs.settingsToggle.addEventListener("click", () => {
updateDisplay({ settingsCollapsed: !store.getState().display.settingsCollapsed });
});

refs.eventsToggle.addEventListener("click", () => {
updateDisplay({ eventsCollapsed: !store.getState().display.eventsCollapsed });
});

refs.shopList.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;

const shopButton = target.closest<HTMLButtonElement>("button[data-shop-action]");
if (shopButton) {
const shopKey = shopButton.closest<HTMLElement>("[data-shop-key]")?.dataset["shopKey"];
if (!shopKey) return;
if (shopButton.dataset["shopAction"] === "remove") controller.removeShop(shopKey);
if (shopButton.dataset["shopAction"] === "retry") controller.retryShop(shopKey);
return;
}

const cartButton = target.closest<HTMLButtonElement>("button[data-cart-action]");
const item = cartButton?.closest<HTMLElement>("[data-shop-key][data-variant-id]");
const shopKey = item?.dataset["shopKey"];
const variantId = item?.dataset["variantId"];
if (!cartButton || !shopKey || !variantId) return;
const shop = selectShop(store.getState(), shopKey);
if (!shop) return;
const quantity = selectQuantity(shop, variantId);
switch (cartButton.dataset["cartAction"]) {
case "add":
controller.setQuantity(shopKey, variantId, 1);
break;
case "increment":
controller.setQuantity(shopKey, variantId, quantity + 1);
break;
case "decrement":
controller.setQuantity(shopKey, variantId, quantity - 1);
break;
case "remove":
controller.setQuantity(shopKey, variantId, 0);
break;
default:
break;
}
});

refs.shopList.addEventListener("change", (event) => {
const target = event.target;
if (
!(target instanceof HTMLInputElement) ||
(!target.classList.contains("cart-line-quantity") &&
!target.classList.contains("cart-line-summary-quantity"))
) {
return;
}
const item = target.closest<HTMLElement>("[data-shop-key][data-variant-id]");
const shopKey = item?.dataset["shopKey"];
const variantId = item?.dataset["variantId"];
if (!shopKey || !variantId) return;
const quantity = normalizeQuantity(target.value);
target.value = String(quantity);
controller.setQuantity(shopKey, variantId, quantity);
});

renderUniversalApp(refs, store.getState());
resizer.applyWidths();
window.addEventListener("resize", resizer.reposition);
window.addEventListener("pagehide", (event) => {
if (event.persisted) return;
unsubscribe();
controller.dispose();
window.removeEventListener("resize", resizer.reposition);
});
Loading
Loading