TheBird

shop.ts

5.6 kB · typescript · 180 lines

1import { CDN, GIFT, SHOP } from "../config/shop.ts";23type Variant = { id: string; size: string; price: string; available: boolean };45type Picture = { url: string; alt: string; style: string };67export type Primary = "light" | "dark";89export const PRIMARIES: Primary[] = ["light", "dark"];1011export type ProductRow = {12  key: string;13  design: string;14  primary: Primary | "";15  type: string;16  vendor: string;17  created: string;18  released: string;19  available: boolean;20  variants: Variant[];21  images: Picture[];22  files: string[];23  group: string;24  secondary: string[];25};2627export type Facets = { files: string[]; design: string; group: string; secondary: string[] };2829export type Batch = { design: string; created: string; released: string };3031export const primaryOf = (key: string): Primary | "" => {32  const first = String(key ?? "").split("-")[0];33  return first === "light" || first === "dark" ? first : "";34};3536export const primaryName = (primary: Primary) => (primary === "dark" ? "Dark" : "Light");3738export const designOf = (key: string) => String(key ?? "").split("-").pop() ?? "";3940export const isDesign = (text: string) => /^[0-9a-f]{8}$/.test(text);4142export const handleOf = (key: string) => String(key ?? "").split("-").slice(1, -1).join("-");4344export const productUrl = (key: string) => `${SHOP}${handleOf(key)}/${designOf(key)}/`;4546export const collectionUrl = (handle: string) => `${SHOP}${handle}/`;4748export const GIFT_PREFIX = "gift-card-";4950export const giftUrl = (tier: string) => `${GIFT}${tier}/`;5152export const buyHref = (prefix: string, id: string) => (prefix && id ? `${prefix}${id}:1` : "/cart/");5354export const lineUrl = (key: string) => (key.startsWith(GIFT_PREFIX) ? giftUrl(key.slice(GIFT_PREFIX.length)) : productUrl(key));5556export function facetsOf(task: any): Facets {57  const variation = task?.variation ?? {};58  return {59    files: (task?.printfiles ?? []).map((one: { name: string }) => one.name).filter(Boolean),60    design: task?.design ?? designOf(task?.key ?? ""),61    group: variation.tile?.group ?? "",62    secondary: variation.paint?.secondary ?? [],63  };64}6566type Snapshot = { at: number; products: ProductRow[]; batches: Batch[] };6768const QUERY = `69query Feed($first: Int!, $after: String, $country: CountryCode) @inContext(country: $country) {70  products(first: $first, sortKey: CREATED_AT, reverse: true, after: $after) {71    pageInfo { hasNextPage endCursor }72    nodes {73      id74      handle75      createdAt76      availableForSale77      productType78      vendor79      variants(first: 100) {80        nodes {81          id82          price { amount }83          availableForSale84          selectedOptions { name value }85        }86      }87      media(first: 250) {88        nodes {89          mediaContentType90          ... on MediaImage { image { url altText } }91        }92      }93    }94  }95}96`;9798const TILE = " - Tile - ";99100const tail = (gid: string) => gid.split("/").pop() ?? gid;101102const styleOf = (alt: string) => alt.split(" - ")[0].trim();103104function variantOf(node: any): Variant {105  const size = (node.selectedOptions ?? []).find((o: any) => o.name === "Size")?.value ?? "One Size";106  return { id: tail(node.id), size, price: node.price.amount, available: node.availableForSale !== false };107}108109function picturesOf(nodes: any[]): Picture[] {110  const out: Picture[] = [];111  for (const node of nodes ?? []) {112    if (node.mediaContentType !== "IMAGE") continue;113    const alt = node.image?.altText ?? "";114    if (alt.includes(TILE)) continue;115    out.push({ url: node.image.url, alt, style: styleOf(alt) });116  }117  return out;118}119120function rowOf(node: any): ProductRow {121  return {122    key: node.handle,123    design: designOf(node.handle),124    primary: primaryOf(node.handle),125    type: String(node.productType ?? ""),126    vendor: String(node.vendor ?? ""),127    created: node.createdAt,128    released: "",129    available: node.availableForSale !== false,130    variants: (node.variants?.nodes ?? []).map(variantOf),131    images: picturesOf(node.media?.nodes ?? []),132    files: [],133    group: "",134    secondary: [],135  };136}137138/* FETCH */139140type Wire = { shop: string; token: string; api: string; country: string; live: number };141142export async function feed(wire: Wire): Promise<ProductRow[]> {143  const url = `https://${wire.shop}/api/${wire.api}/graphql.json`;144  const cutoff = Date.now() - wire.live * 24 * 60 * 60 * 1000;145  const rows: ProductRow[] = [];146  let after: string | null = null;147  for (;;) {148    const reply = await fetch(url, {149      method: "POST",150      headers: { "content-type": "application/json", "X-Shopify-Storefront-Access-Token": wire.token },151      body: JSON.stringify({ query: QUERY, variables: { first: 250, after, country: wire.country } }),152    });153    if (!reply.ok) throw new Error(`storefront: HTTP ${reply.status}`);154    const body: any = await reply.json();155    if (body.errors) throw new Error(`storefront: ${JSON.stringify(body.errors).slice(0, 300)}`);156    const page = body.data?.products;157    if (!page) throw new Error(`storefront: no products in the reply`);158    let stop = false;159    for (const node of page.nodes) {160      if (new Date(node.createdAt).getTime() < cutoff) {161        stop = true;162        break;163      }164      rows.push(rowOf(node));165    }166    if (stop || !page.pageInfo.hasNextPage) break;167    after = page.pageInfo.endCursor;168  }169  return rows;170}171172/* CDN */173174export const cdnUrl = (key: string, name: string) => (key ? `${CDN}/${key}/${name}.png` : "");175176export const tileUrl = (design: string, primary: Primary, n: number) => cdnUrl(design, `${primary}-tile-${n}`);177178export const grid = (url: string, width: number) => `${url}${url.includes("?") ? "&" : "?"}width=${width}&format=auto`;179180export const money = (amount: string | number) => `$${Number(amount).toFixed(2)}`;