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;