shop.ts

3.9 kB · typescript · 126 lines

1export type Variant = { id: string; size: string; price: string; available: boolean };23export type Picture = { url: string; alt: string; style: string };45export type ProductRow = {6  key: string;7  type: string;8  created: string;9  available: boolean;10  variants: Variant[];11  images: Picture[];12  files: string[];13};1415export type Snapshot = { at: number; products: ProductRow[] };1617export const QUERY = `18query Feed($first: Int!, $after: String, $country: CountryCode) @inContext(country: $country) {19  products(first: $first, sortKey: CREATED_AT, reverse: true, after: $after) {20    pageInfo { hasNextPage endCursor }21    nodes {22      id23      handle24      createdAt25      availableForSale26      productType27      variants(first: 100) {28        nodes {29          id30          price { amount }31          availableForSale32          selectedOptions { name value }33        }34      }35      media(first: 50) {36        nodes {37          mediaContentType38          ... on MediaImage { image { url altText } }39        }40      }41    }42  }43}44`;4546const TILE = " - Tile - ";4748export const tail = (gid: string) => gid.split("/").pop() ?? gid;4950export const styleOf = (alt: string) => alt.split(" - ")[0].trim();5152export function variantOf(node: any): Variant {53  const size = (node.selectedOptions ?? []).find((o: any) => o.name === "Size")?.value ?? "One Size";54  return { id: tail(node.id), size, price: node.price.amount, available: node.availableForSale !== false };55}5657export function picturesOf(nodes: any[]): Picture[] {58  const out: Picture[] = [];59  for (const node of nodes ?? []) {60    if (node.mediaContentType !== "IMAGE") continue;61    const alt = node.image?.altText ?? "";62    if (alt.includes(TILE)) continue;63    out.push({ url: node.image.url, alt, style: styleOf(alt) });64  }65  return out;66}6768export function rowOf(node: any): ProductRow {69  return {70    key: node.handle,71    type: String(node.productType ?? ""),72    created: node.createdAt,73    available: node.availableForSale !== false,74    variants: (node.variants?.nodes ?? []).map(variantOf),75    images: picturesOf(node.media?.nodes ?? []),76    files: [],77  };78}7980/* FETCH */8182export type Wire = { shop: string; token: string; api: string; country: string; live: number };8384export async function feed(wire: Wire): Promise<ProductRow[]> {85  const url = `https://${wire.shop}/api/${wire.api}/graphql.json`;86  const cutoff = Date.now() - wire.live * 24 * 60 * 60 * 1000;87  const rows: ProductRow[] = [];88  let after: string | null = null;89  for (;;) {90    const reply = await fetch(url, {91      method: "POST",92      headers: { "content-type": "application/json", "X-Shopify-Storefront-Access-Token": wire.token },93      body: JSON.stringify({ query: QUERY, variables: { first: 250, after, country: wire.country } }),94    });95    if (!reply.ok) throw new Error(`storefront: HTTP ${reply.status}`);96    const body: any = await reply.json();97    if (body.errors) throw new Error(`storefront: ${JSON.stringify(body.errors).slice(0, 300)}`);98    const page = body.data?.products;99    if (!page) throw new Error(`storefront: no products in the reply`);100    let stop = false;101    for (const node of page.nodes) {102      if (new Date(node.createdAt).getTime() < cutoff) {103        stop = true;104        break;105      }106      rows.push(rowOf(node));107    }108    if (stop || !page.pageInfo.hasNextPage) break;109    after = page.pageInfo.endCursor;110  }111  return rows;112}113114/* CDN */115116export const cdnUrl = (key: string, name: string) => (key ? `/cdn/printful/${key}/${name}.png` : "");117118export const tileUrl = (key: string, n: number) => cdnUrl(key, `tile-${n}`);119120export const ogUrl = (key: string) => cdnUrl(key, "og");121122export const grid = (url: string, width: number) => `${url}${url.includes("?") ? "&" : "?"}width=${width}&format=auto`;123124export const money = (amount: string) => `$${Number(amount).toFixed(2)}`;125126export const expiry = (created: string, live: number) => new Date(created).getTime() + live * 24 * 60 * 60 * 1000;