This commit is contained in:
Kristian Duda 2024-06-28 07:42:33 +02:00
parent 7fd9ad8a8c
commit b94e46d796
8 changed files with 443 additions and 61 deletions

View File

@ -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"

35
lib/shopify/ajax.ts Normal file
View File

@ -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 <T>(method: AjaxMethod, url: string, data?: object): Promise<T> => {
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);
};

View File

@ -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<Collection | undefi
return reshapeCollection(res.body.data.collection);
}
const reshapeImage = (media: Media): Image => {
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<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
tags: [TAGS.collections, TAGS.products],
variables: {
handle: collection,
reverse,
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : sortKey
}): Promise<ExProduct[]> {
const m = await find<Product>('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<Collection[]> {
@ -338,20 +388,21 @@ export async function getCollections(): Promise<Collection[]> {
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
tags: [TAGS.collections],
variables: {
handle
}
});
return [];
// const res = await shopifyFetch<ShopifyMenuOperation>({
// 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<Page> {
@ -373,7 +424,7 @@ export async function getPages(): Promise<Page[]> {
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
export async function getProduct(handle: string): Promise<ExProduct | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
tags: [TAGS.products],
@ -385,7 +436,7 @@ export async function getProduct(handle: string): Promise<Product | undefined> {
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
export async function getProductRecommendations(productId: string): Promise<ExProduct[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
tags: [TAGS.products],
@ -405,7 +456,7 @@ export async function getProducts({
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
}): Promise<ExProduct[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
tags: [TAGS.products],

View File

@ -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 {}
}

61
lib/shopify/payload.ts Normal file
View File

@ -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<T> = {
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 = <T>(collection: string, params: FindParams) => {
const query = qs.stringify(params, { addQueryPrefix: true });
const url = `${process.env.CMS_URL}/api/${collection}${query}`;
return ajax<PaginatedDocs<T>>('GET', url);
};

View File

@ -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 = {

View File

@ -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",

39
pnpm-lock.yaml generated
View File

@ -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