/**
* Storefront API operations (public/buyer-facing).
* Uses the Shopify Storefront API via GraphQL.
*/
import type { CatalogProduct, LineItem, CheckoutTotals } from '../types.js';
import type { ShopifyClient } from './client.js';
import type { ShopifyProduct, ShopifyCart, ShopifyCartLine } from './types.js';
import { toUCPProduct, toUCPLineItem } from './types.js';
// ─── GraphQL Queries ───
const PRODUCT_FIELDS_FRAGMENT = /* GraphQL */ `
fragment ProductFields on Product {
id
title
description
vendor
productType
tags
availableForSale
createdAt
updatedAt
variants(first: 50) {
edges {
node {
id
title
sku
price {
amount
currencyCode
}
availableForSale
quantityAvailable
barcode
}
}
}
images(first: 10) {
edges {
node {
url
altText
width
height
}
}
}
}
`;
const SEARCH_PRODUCTS_QUERY = /* GraphQL */ `
${PRODUCT_FIELDS_FRAGMENT}
query SearchProducts($query: String!, $first: Int!) {
products(query: $query, first: $first) {
edges {
node {
...ProductFields
}
}
}
}
`;
const GET_PRODUCT_QUERY = /* GraphQL */ `
${PRODUCT_FIELDS_FRAGMENT}
query GetProduct($id: ID!) {
product(id: $id) {
...ProductFields
}
}
`;
const CART_FIELDS_FRAGMENT = /* GraphQL */ `
fragment CartFields on Cart {
id
checkoutUrl
totalQuantity
lines(first: 100) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
sku
product {
id
title
}
image {
url
altText
width
height
}
price {
amount
currencyCode
}
}
}
cost {
totalAmount {
amount
currencyCode
}
amountPerQuantity {
amount
currencyCode
}
}
}
}
}
cost {
subtotalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
}
}
`;
const CREATE_CART_MUTATION = /* GraphQL */ `
mutation CreateCart {
cartCreate {
cart {
id
}
userErrors {
field
message
}
}
}
`;
const ADD_TO_CART_MUTATION = /* GraphQL */ `
mutation AddToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
}
userErrors {
field
message
}
}
}
`;
const GET_CART_QUERY = /* GraphQL */ `
${CART_FIELDS_FRAGMENT}
query GetCart($cartId: ID!) {
cart(id: $cartId) {
...CartFields
}
}
`;
// ─── Response Types ───
interface SearchProductsData {
products: {
edges: Array<{ node: ShopifyProduct }>;
};
}
interface GetProductData {
product: ShopifyProduct | null;
}
interface CreateCartData {
cartCreate: {
cart: { id: string } | null;
userErrors: Array<{ field: string[]; message: string }>;
};
}
interface AddToCartData {
cartLinesAdd: {
cart: { id: string } | null;
userErrors: Array<{ field: string[]; message: string }>;
};
}
interface GetCartData {
cart: ShopifyCart | null;
}
// ─── StorefrontAPI Class ───
export class StorefrontAPI {
private readonly client: ShopifyClient;
constructor(client: ShopifyClient) {
this.client = client;
}
/**
* Search products by query string.
* Uses the Storefront API products query with full-text search.
*/
async searchProducts(
query: string,
first: number = 10,
): Promise<CatalogProduct[]> {
const response = await this.client.storefrontQuery<SearchProductsData>(
SEARCH_PRODUCTS_QUERY,
{ query, first },
);
if (response.errors?.length) {
throw new Error(
`Storefront searchProducts failed: ${response.errors.map((e) => e.message).join(', ')}`,
);
}
if (!response.data) {
return [];
}
return response.data.products.edges.map(({ node }) =>
toUCPProduct(node),
);
}
/**
* Get a single product by its Shopify GID.
*/
async getProduct(id: string): Promise<CatalogProduct | null> {
const response = await this.client.storefrontQuery<GetProductData>(
GET_PRODUCT_QUERY,
{ id },
);
if (response.errors?.length) {
throw new Error(
`Storefront getProduct failed: ${response.errors.map((e) => e.message).join(', ')}`,
);
}
if (!response.data?.product) {
return null;
}
return toUCPProduct(response.data.product);
}
/**
* Create a new empty cart. Returns the cart ID.
*/
async createCart(): Promise<string> {
const response = await this.client.storefrontQuery<CreateCartData>(
CREATE_CART_MUTATION,
);
if (response.errors?.length) {
throw new Error(
`Storefront createCart failed: ${response.errors.map((e) => e.message).join(', ')}`,
);
}
const result = response.data?.cartCreate;
if (!result?.cart) {
const userErrors =
result?.userErrors.map((e) => e.message).join(', ') ?? 'Unknown error';
throw new Error(`Storefront createCart failed: ${userErrors}`);
}
return result.cart.id;
}
/**
* Add a product variant to an existing cart.
*/
async addToCart(
cartId: string,
variantId: string,
quantity: number,
): Promise<void> {
const response = await this.client.storefrontQuery<AddToCartData>(
ADD_TO_CART_MUTATION,
{
cartId,
lines: [{ merchandiseId: variantId, quantity }],
},
);
if (response.errors?.length) {
throw new Error(
`Storefront addToCart failed: ${response.errors.map((e) => e.message).join(', ')}`,
);
}
const result = response.data?.cartLinesAdd;
if (result?.userErrors.length) {
throw new Error(
`Storefront addToCart user errors: ${result.userErrors.map((e) => e.message).join(', ')}`,
);
}
}
/**
* Get cart contents including line items and totals.
*/
async getCart(
cartId: string,
): Promise<{ id: string; lines: LineItem[]; totals: CheckoutTotals }> {
const response = await this.client.storefrontQuery<GetCartData>(
GET_CART_QUERY,
{ cartId },
);
if (response.errors?.length) {
throw new Error(
`Storefront getCart failed: ${response.errors.map((e) => e.message).join(', ')}`,
);
}
const cart = response.data?.cart;
if (!cart) {
throw new Error(`Cart not found: ${cartId}`);
}
const lines: LineItem[] = cart.lines.edges.map(
({ node }: { node: ShopifyCartLine }) => toUCPLineItem(node),
);
const toMinor = (amount: string): number =>
Math.round(parseFloat(amount) * 100);
const currency = cart.cost.totalAmount.currencyCode;
const subtotal = toMinor(cart.cost.subtotalAmount.amount);
const tax = cart.cost.totalTaxAmount
? toMinor(cart.cost.totalTaxAmount.amount)
: 0;
const total = toMinor(cart.cost.totalAmount.amount);
const totals: CheckoutTotals = {
subtotal,
tax,
shipping: 0, // Shipping is calculated at checkout, not in the cart
discount: 0,
fee: 0,
total,
currency,
};
return { id: cart.id, lines, totals };
}
/**
* Get the checkout URL for a cart.
* This URL can be opened in a browser to complete purchase.
*/
async createCheckoutUrl(cartId: string): Promise<string> {
const response = await this.client.storefrontQuery<GetCartData>(
GET_CART_QUERY,
{ cartId },
);
if (response.errors?.length) {
throw new Error(
`Storefront createCheckoutUrl failed: ${response.errors.map((e) => e.message).join(', ')}`,
);
}
const cart = response.data?.cart;
if (!cart) {
throw new Error(`Cart not found: ${cartId}`);
}
return cart.checkoutUrl;
}
}