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)}`;