From b94e46d7962d76b109212fcda5a3b93476a914be Mon Sep 17 00:00:00 2001 From: Kristian Duda Date: Fri, 28 Jun 2024 07:42:33 +0200 Subject: [PATCH] wip --- .env.example | 8 +- lib/shopify/ajax.ts | 35 ++++++ lib/shopify/index.ts | 127 +++++++++++++------ lib/shopify/payload-types.ts | 228 +++++++++++++++++++++++++++++++++++ lib/shopify/payload.ts | 61 ++++++++++ lib/shopify/types.ts | 4 +- package.json | 2 + pnpm-lock.yaml | 39 +++--- 8 files changed, 443 insertions(+), 61 deletions(-) create mode 100644 lib/shopify/ajax.ts create mode 100644 lib/shopify/payload-types.ts create mode 100644 lib/shopify/payload.ts diff --git a/.env.example b/.env.example index 9ff0463db..f30e51a65 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ COMPANY_NAME="Vercel Inc." TWITTER_CREATOR="@vercel" TWITTER_SITE="https://nextjs.org/commerce" SITE_NAME="Next.js Commerce" -SHOPIFY_REVALIDATION_SECRET="" -SHOPIFY_STOREFRONT_ACCESS_TOKEN="" -SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" +# SHOPIFY_REVALIDATION_SECRET="" +# SHOPIFY_STOREFRONT_ACCESS_TOKEN="" +# SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com" + +CMS_URL="http://localhost:3000" diff --git a/lib/shopify/ajax.ts b/lib/shopify/ajax.ts new file mode 100644 index 000000000..9260684e4 --- /dev/null +++ b/lib/shopify/ajax.ts @@ -0,0 +1,35 @@ +interface ErrorMessage { + message: string; +} + +export class AjaxError extends Error { + statusCode: number; + errors: ErrorMessage[]; + + constructor(statusCode: number, message: string, errors: ErrorMessage[] = []) { + super(message); + this.errors = errors; + this.statusCode = statusCode; + } +} + +type AjaxMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +export const ajax = async (method: AjaxMethod, url: string, data?: object): Promise => { + const response = await fetch(url, { + method, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: data ? JSON.stringify(data) : undefined + }); + + const body = await response.json(); + if (response.ok) { + return body as T; + } + + throw new AjaxError(response.status, body.message ?? response.statusText, body.errors); +}; diff --git a/lib/shopify/index.ts b/lib/shopify/index.ts index ca5ddbdb6..d1793d651 100644 --- a/lib/shopify/index.ts +++ b/lib/shopify/index.ts @@ -1,4 +1,6 @@ import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants'; +import { find } from 'lib/shopify/payload'; +import { Media, Option, Product } from 'lib/shopify/payload-types'; import { isShopifyError } from 'lib/type-guards'; import { ensureStartsWith } from 'lib/utils'; import { revalidateTag } from 'next/cache'; @@ -11,12 +13,7 @@ import { removeFromCartMutation } from './mutations/cart'; import { getCartQuery } from './queries/cart'; -import { - getCollectionProductsQuery, - getCollectionQuery, - getCollectionsQuery -} from './queries/collection'; -import { getMenuQuery } from './queries/menu'; +import { getCollectionQuery, getCollectionsQuery } from './queries/collection'; import { getPageQuery, getPagesQuery } from './queries/page'; import { getProductQuery, @@ -27,19 +24,19 @@ import { Cart, Collection, Connection, + Product as ExProduct, Image, Menu, + Money, Page, - Product, + ProductOption, ShopifyAddToCartOperation, ShopifyCart, ShopifyCartOperation, ShopifyCollection, ShopifyCollectionOperation, - ShopifyCollectionProductsOperation, ShopifyCollectionsOperation, ShopifyCreateCartOperation, - ShopifyMenuOperation, ShopifyPageOperation, ShopifyPagesOperation, ShopifyProduct, @@ -282,6 +279,62 @@ export async function getCollection(handle: string): Promise { + return { + url: media.url!, + altText: media.alt + }; +}; + +type Price = { + amount: number; + currencyCode: string; +}; + +const reshapePrice = (price: Price): Money => { + return { + amount: (price.amount / 100).toString(), + currencyCode: price.currencyCode + }; +}; + +const reshapeP = (product: Product): ExProduct => { + const options: ProductOption[] = []; + const map = new Map(); + + product.variants.forEach((variant) => { + variant.selectedOptions?.forEach((selectedOption) => { + const option = selectedOption.option as Option; + map.set(option.id, option.values); + }); + }); + + // console.log(map); + + return { + id: product.id, + handle: product.id, + availableForSale: !product.disabled, + title: product.title, + description: product.description, + descriptionHtml: product.description, + options, + priceRange: { + maxVariantPrice: reshapePrice(product.variants[0]?.price!), + minVariantPrice: reshapePrice(product.variants[0]?.price!) + }, + featuredImage: {} as any, + images: [], + seo: { + title: product.title, + description: product.description + }, + // tags: product.tags ?? [], + updatedAt: product.updatedAt, + createdAt: product.createdAt + }; +}; + export async function getCollectionProducts({ collection, reverse, @@ -290,23 +343,20 @@ export async function getCollectionProducts({ collection: string; reverse?: boolean; sortKey?: string; -}): Promise { - const res = await shopifyFetch({ - query: getCollectionProductsQuery, - tags: [TAGS.collections, TAGS.products], - variables: { - handle: collection, - reverse, - sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey +}): Promise { + const m = await find('products', { + where: { + title: { + equals: 'test' + } } }); - if (!res.body.data.collection) { - console.log(`No collection found for \`${collection}\``); - return []; - } + const products: ExProduct[] = m.docs.map(reshapeP); - return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products)); + console.log(products); + + return products; } export async function getCollections(): Promise { @@ -338,20 +388,21 @@ export async function getCollections(): Promise { } export async function getMenu(handle: string): Promise { - const res = await shopifyFetch({ - query: getMenuQuery, - tags: [TAGS.collections], - variables: { - handle - } - }); + return []; + // const res = await shopifyFetch({ + // query: getMenuQuery, + // tags: [TAGS.collections], + // variables: { + // handle + // } + // }); - return ( - res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({ - title: item.title, - path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '') - })) || [] - ); + // return ( + // res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({ + // title: item.title, + // path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '') + // })) || [] + // ); } export async function getPage(handle: string): Promise { @@ -373,7 +424,7 @@ export async function getPages(): Promise { return removeEdgesAndNodes(res.body.data.pages); } -export async function getProduct(handle: string): Promise { +export async function getProduct(handle: string): Promise { const res = await shopifyFetch({ query: getProductQuery, tags: [TAGS.products], @@ -385,7 +436,7 @@ export async function getProduct(handle: string): Promise { return reshapeProduct(res.body.data.product, false); } -export async function getProductRecommendations(productId: string): Promise { +export async function getProductRecommendations(productId: string): Promise { const res = await shopifyFetch({ query: getProductRecommendationsQuery, tags: [TAGS.products], @@ -405,7 +456,7 @@ export async function getProducts({ query?: string; reverse?: boolean; sortKey?: string; -}): Promise { +}): Promise { const res = await shopifyFetch({ query: getProductsQuery, tags: [TAGS.products], diff --git a/lib/shopify/payload-types.ts b/lib/shopify/payload-types.ts new file mode 100644 index 000000000..fedcc2d6d --- /dev/null +++ b/lib/shopify/payload-types.ts @@ -0,0 +1,228 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config { + collections: { + posts: Post; + tags: Tag; + media: Media; + users: User; + options: Option; + products: Product; + carts: Cart; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + globals: {}; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + title: string; + summary?: string | null; + content?: { + root: { + type: string; + children: { + type: string; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + tags?: (string | Tag)[] | null; + publishedAt?: string | null; + authors?: (string | User)[] | null; + populatedAuthors?: + | { + id?: string | null; + name?: string | null; + }[] + | null; + slug?: string | null; + meta?: { + title?: string | null; + description?: string | null; + image?: string | Media | null; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tags". + */ +export interface Tag { + id: string; + name: string; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + name?: string | null; + roles?: ('admin' | 'user')[] | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: string; + alt: string; + caption?: { + root: { + type: string; + children: { + type: string; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + updatedAt: string; + createdAt: string; + url?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "options". + */ +export interface Option { + id: string; + name: string; + values: { + value: string; + label: string; + id?: string | null; + }[]; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "products". + */ +export interface Product { + id: string; + title: string; + disabled?: boolean | null; + description: string; + media: string | Media; + variants: { + price: { + amount: number; + currencyCode: 'eur'; + stripePriceID?: string | null; + }; + selectedOptions?: + | { + option: string | Option; + value: string; + id?: string | null; + }[] + | null; + id?: string | null; + }[]; + tags?: (string | Tag)[] | null; + stripeProductID?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "carts". + */ +export interface Cart { + id: string; + name: string; + totalAmount?: number | null; + user?: (string | null) | User; + lines?: + | { + product: string | Product; + variant: string; + quantity: number; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} + +declare module 'payload' { + export interface GeneratedTypes extends Config {} +} diff --git a/lib/shopify/payload.ts b/lib/shopify/payload.ts new file mode 100644 index 000000000..4c2f5cca2 --- /dev/null +++ b/lib/shopify/payload.ts @@ -0,0 +1,61 @@ +import { ajax } from 'lib/shopify/ajax'; +import { Config } from 'lib/shopify/payload-types'; +import qs from 'qs'; + +type Collection = keyof Config['collections']; + +const OPERATORS = [ + 'equals', + 'contains', + 'not_equals', + 'in', + 'all', + 'not_in', + 'exists', + 'greater_than', + 'greater_than_equal', + 'less_than', + 'less_than_equal', + 'like', + 'within', + 'intersects', + 'near' +] as const; + +type Operator = (typeof OPERATORS)[number]; +type WhereField = { + [key in Operator]?: unknown; +}; +type Where = { + [key: string]: Where[] | WhereField; + and?: Where[]; + or?: Where[]; +}; + +export type PaginatedDocs = { + docs: T[]; + hasNextPage: boolean; + hasPrevPage: boolean; + limit: number; + nextPage?: null | number; + page?: number; + pagingCounter: number; + prevPage?: null | number; + totalDocs: number; + totalPages: number; +}; + +type FindParams = { + where?: Where; + depth?: number; + sort?: string; + page?: number; + limit?: number; +}; + +export const find = (collection: string, params: FindParams) => { + const query = qs.stringify(params, { addQueryPrefix: true }); + + const url = `${process.env.CMS_URL}/api/${collection}${query}`; + return ajax>('GET', url); +}; diff --git a/lib/shopify/types.ts b/lib/shopify/types.ts index 23dc02d46..ce753bbf7 100644 --- a/lib/shopify/types.ts +++ b/lib/shopify/types.ts @@ -36,8 +36,8 @@ export type Collection = ShopifyCollection & { export type Image = { url: string; altText: string; - width: number; - height: number; + width?: number; + height?: number; }; export type Menu = { diff --git a/package.json b/package.json index 711a9a38b..7364eaae5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "clsx": "^2.1.0", "geist": "^1.3.0", "next": "14.2.2", + "qs": "^6.12.1", "react": "18.2.0", "react-dom": "18.2.0" }, @@ -34,6 +35,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.12", "@types/node": "20.12.7", + "@types/qs": "^6.9.15", "@types/react": "18.2.79", "@types/react-dom": "18.2.25", "@vercel/git-hooks": "^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36c1e0dc8..22095e812 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - dependencies: '@headlessui/react': specifier: ^1.7.19 @@ -20,6 +16,9 @@ dependencies: next: specifier: 14.2.2 version: 14.2.2(react-dom@18.2.0)(react@18.2.0) + qs: + specifier: ^6.12.1 + version: 6.12.1 react: specifier: 18.2.0 version: 18.2.0 @@ -37,6 +36,9 @@ devDependencies: '@types/node': specifier: 20.12.7 version: 20.12.7 + '@types/qs': + specifier: ^6.9.15 + version: 6.9.15 '@types/react': specifier: 18.2.79 version: 18.2.79 @@ -429,6 +431,10 @@ packages: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} dev: true + /@types/qs@6.9.15: + resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} + dev: true + /@types/react-dom@18.2.25: resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==} dependencies: @@ -799,7 +805,6 @@ packages: function-bind: 1.1.2 get-intrinsic: 1.2.4 set-function-length: 1.2.2 - dev: true /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -1016,7 +1021,6 @@ packages: es-define-property: 1.0.0 es-errors: 1.3.0 gopd: 1.0.1 - dev: true /define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} @@ -1152,12 +1156,10 @@ packages: engines: {node: '>= 0.4'} dependencies: get-intrinsic: 1.2.4 - dev: true /es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - dev: true /es-iterator-helpers@1.0.18: resolution: {integrity: sha512-scxAJaewsahbqTYrGKJihhViaM6DDZDDoucfvzNbK0pOren1g/daDQ3IAhzn+1G14rBG7w+i5N+qul60++zlKA==} @@ -1661,7 +1663,6 @@ packages: /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - dev: true /function.prototype.name@1.1.6: resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} @@ -1699,7 +1700,6 @@ packages: has-proto: 1.0.3 has-symbols: 1.0.3 hasown: 2.0.2 - dev: true /get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} @@ -1800,7 +1800,6 @@ packages: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: get-intrinsic: 1.2.4 - dev: true /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1827,17 +1826,14 @@ packages: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} dependencies: es-define-property: 1.0.0 - dev: true /has-proto@1.0.3: resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} engines: {node: '>= 0.4'} - dev: true /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} - dev: true /has-tostringtag@1.0.2: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} @@ -1851,7 +1847,6 @@ packages: engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 - dev: true /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -2497,7 +2492,6 @@ packages: /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - dev: true /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} @@ -2879,6 +2873,13 @@ packages: engines: {node: '>=6'} dev: true + /qs@6.12.1: + resolution: {integrity: sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.6 + dev: false + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true @@ -3085,7 +3086,6 @@ packages: get-intrinsic: 1.2.4 gopd: 1.0.1 has-property-descriptors: 1.0.2 - dev: true /set-function-name@2.0.2: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} @@ -3117,7 +3117,6 @@ packages: es-errors: 1.3.0 get-intrinsic: 1.2.4 object-inspect: 1.13.1 - dev: true /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3649,3 +3648,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false