diff --git a/starters/shopify-meilisearch/app/actions/cart.actions.ts b/starters/shopify-meilisearch/app/actions/cart.actions.ts index ca8a1e49..d47da21e 100644 --- a/starters/shopify-meilisearch/app/actions/cart.actions.ts +++ b/starters/shopify-meilisearch/app/actions/cart.actions.ts @@ -1,86 +1,117 @@ "use server" -import { revalidateTag, unstable_cache } from "next/cache" +import { revalidateTag } from "next/cache" import { cookies } from "next/headers" -import { storefrontClient } from "clients/storefrontClient" import { COOKIE_CART_ID, TAGS } from "constants/index" import { isDemoMode } from "utils/demo-utils" +import { createCart, createCartItem, deleteCartItem, getCart, getProduct, updateCartItem } from "lib/shopify" -export const getCart = unstable_cache(async (cartId: string) => storefrontClient.getCart(cartId), [TAGS.CART], { revalidate: 60 * 15, tags: [TAGS.CART] }) +export async function getOrCreateCart() { + const cartId = cookies().get(COOKIE_CART_ID)?.value + const cart = cartId ? await getCart(cartId) : await createCart() -export async function addCartItem(prevState: any, variantId: string) { - if (isDemoMode()) return { ok: false, message: "Demo mode active. Filtering, searching, and adding to cart disabled." } - if (!variantId) return { ok: false } + if (!cartId) { + const newCartId = cart?.id + if (newCartId) { + cookies().set(COOKIE_CART_ID, newCartId) + revalidateTag(TAGS.CART) + } + } - let cartId = cookies().get(COOKIE_CART_ID)?.value - let cart + return { cartId: cart?.id, cart } +} - if (cartId) cart = await storefrontClient.getCart(cartId) +export async function getItemAvailability({ + cartId, + variantId, + productId, +}: { + cartId: string | null | undefined + variantId: string | null | undefined + productId: string | null | undefined +}) { + if (!variantId) { + return { inCartQuantity: 0, inStockQuantity: 0 } + } - if (!cartId || !cart) { - cart = await storefrontClient.createCart([]) - cartId = cart?.id - cartId && cookies().set(COOKIE_CART_ID, cartId) + if (!cartId) { + const product = await getProduct(productId!) + const inStockQuantity = product?.variants?.find((variant) => variant.id === variantId)?.quantityAvailable ?? Infinity + return { + inCartQuantity: 0, + inStockQuantity, + } + } - revalidateTag(TAGS.CART) + const cart = await getCart(cartId) + const cartItem = cart?.items?.find((item) => item.merchandise.id === variantId) + + return { + inCartQuantity: cartItem?.quantity ?? 0, + inStockQuantity: cartItem?.merchandise.quantityAvailable ?? Infinity, } +} - const itemAvailability = await getItemAvailability(cartId, variantId) +export async function addCartItem(prevState: any, variantId: string, productId: string) { + if (isDemoMode()) { + return { + ok: false, + message: "Demo mode active. Filtering, searching, and adding to cart disabled.", + } + } - if (!itemAvailability || itemAvailability.inCartQuantity >= itemAvailability.inStockQuantity) + if (!variantId) return { ok: false } + + const { cartId } = await getOrCreateCart() + + if (!cartId) return { ok: false } + + const availability = await getItemAvailability({ cartId, variantId, productId }) + if (!availability || availability.inCartQuantity >= availability.inStockQuantity) { return { ok: false, message: "This product is out of stock", } + } - await storefrontClient.createCartItem(cartId!, [{ merchandiseId: variantId, quantity: 1 }]) + await createCartItem(cartId, [{ merchandiseId: variantId, quantity: 1 }]) revalidateTag(TAGS.CART) return { ok: true } } -export async function getItemAvailability(cartId: string | null | undefined, variantId: string | null | undefined) { - if (!cartId || !variantId) return { inCartQuantity: 0, inStockQuantity: Infinity } - - const cart = await storefrontClient.getCart(cartId) - const cartItem = cart?.items?.find((item) => item.merchandise.id === variantId) - - return { inCartQuantity: cartItem?.quantity ?? 0, inStockQuantity: cartItem?.merchandise.quantityAvailable ?? Infinity } -} - export async function removeCartItem(prevState: any, itemId: string) { const cartId = cookies().get(COOKIE_CART_ID)?.value - if (!cartId) return { ok: false } - await storefrontClient.deleteCartItem(cartId!, [itemId]) + await deleteCartItem(cartId, [itemId]) revalidateTag(TAGS.CART) return { ok: true } } -export async function updateItemQuantity(prevState: any, payload: { itemId: string; variantId: string; quantity: number }) { +export async function updateItemQuantity(prevState: any, payload: { itemId: string; variantId: string; quantity: number; productId: string }) { const cartId = cookies().get(COOKIE_CART_ID)?.value - if (!cartId) return { ok: false } - const { itemId, variantId, quantity } = payload + const { itemId, variantId, quantity, productId } = payload if (quantity === 0) { - await storefrontClient.deleteCartItem(cartId, [itemId]) + await deleteCartItem(cartId, [itemId]) revalidateTag(TAGS.CART) return { ok: true } } - const itemAvailability = await getItemAvailability(cartId, variantId) - if (!itemAvailability || quantity > itemAvailability.inStockQuantity) + const itemAvailability = await getItemAvailability({ cartId, variantId, productId }) + if (!itemAvailability || quantity > itemAvailability.inStockQuantity) { return { ok: false, message: "This product is out of stock", } + } - await storefrontClient.updateCartItem(cartId, [{ id: itemId, merchandiseId: variantId, quantity }]) - + await updateCartItem(cartId, [{ id: itemId, merchandiseId: variantId, quantity }]) revalidateTag(TAGS.CART) + return { ok: true } } diff --git a/starters/shopify-meilisearch/app/api/feed/sync/route.ts b/starters/shopify-meilisearch/app/api/feed/sync/route.ts index 04fba023..038de440 100644 --- a/starters/shopify-meilisearch/app/api/feed/sync/route.ts +++ b/starters/shopify-meilisearch/app/api/feed/sync/route.ts @@ -1,9 +1,9 @@ import type { PlatformProduct } from "lib/shopify/types" -import { storefrontClient } from "clients/storefrontClient" import { env } from "env.mjs" import { compareHmac } from "utils/compare-hmac" import { enrichProduct } from "utils/enrich-product" import { deleteCategories, deleteProducts, updateCategories, updateProducts } from "lib/meilisearch" +import { getCollection, getHierarchicalCollections, getProduct } from "lib/shopify" type SupportedTopic = "products/update" | "products/delete" | "products/create" | "collections/update" | "collections/delete" | "collections/create" @@ -49,7 +49,7 @@ async function handleCollectionTopics(topic: SupportedTopic, { id }: Record { diff --git a/starters/shopify-meilisearch/app/pages/[slug]/page.tsx b/starters/shopify-meilisearch/app/pages/[slug]/page.tsx index 7bf651c0..34421477 100644 --- a/starters/shopify-meilisearch/app/pages/[slug]/page.tsx +++ b/starters/shopify-meilisearch/app/pages/[slug]/page.tsx @@ -1,5 +1,5 @@ import { format } from "date-fns/format" -import { getAllPages, getPage } from "clients/storefrontClient" +import { getAllPages, getPage } from "lib/shopify" export const revalidate = 86400 export const dynamic = "force-static" diff --git a/starters/shopify-meilisearch/app/product/[slug]/draft/page.tsx b/starters/shopify-meilisearch/app/product/[slug]/draft/page.tsx index ce212998..38701421 100644 --- a/starters/shopify-meilisearch/app/product/[slug]/draft/page.tsx +++ b/starters/shopify-meilisearch/app/product/[slug]/draft/page.tsx @@ -1,13 +1,13 @@ -import { unstable_cache } from "next/cache" +import { Suspense } from "react" import { draftMode } from "next/headers" import { notFound } from "next/navigation" -import { Suspense } from "react" -import { storefrontClient } from "clients/storefrontClient" import type { CommerceProduct } from "types" import { Breadcrumbs } from "components/breadcrumbs" +import { getAdminProduct, getProductByHandle } from "lib/shopify" import type { PlatformProduct } from "lib/shopify/types" + import { getCombination, getOptionsFromUrl, hasValidOption, removeOptionsFromUrl } from "utils/product-options-utils" import { BackButton } from "views/product/back-button" import { SimilarProductsSection } from "views/product/similar-products-section" @@ -95,14 +95,12 @@ async function ProductView({ slug }: { slug: string }) { async function getDraftAwareProduct(slug: string) { const draft = draftMode() - let product = await storefrontClient.getProductByHandle(removeOptionsFromUrl(slug)) + let product = await getProductByHandle(removeOptionsFromUrl(slug)) if (draft.isEnabled && product) product = await getAdminProduct(product?.id) return product } -const getAdminProduct = unstable_cache(async (id: string) => storefrontClient.getAdminProduct(id), ["admin-product-by-handle"], { revalidate: 1 }) - function makeBreadcrumbs(product: PlatformProduct) { const lastCollection = product.collections?.findLast(Boolean) diff --git a/starters/shopify-meilisearch/clients/storefrontClient.ts b/starters/shopify-meilisearch/clients/storefrontClient.ts deleted file mode 100644 index f2194877..00000000 --- a/starters/shopify-meilisearch/clients/storefrontClient.ts +++ /dev/null @@ -1,15 +0,0 @@ -import "server-only" - -import { unstable_cache } from "next/cache" -import { createShopifyClient } from "lib/shopify" -import { env } from "../env.mjs" - -export const storefrontClient = createShopifyClient({ - storeDomain: env.SHOPIFY_STORE_DOMAIN || "", - storefrontAccessToken: env.SHOPIFY_STOREFRONT_ACCESS_TOKEN || "", - adminAccessToken: env.SHOPIFY_ADMIN_ACCESS_TOKEN || "", -}) - -export const getPage = unstable_cache(async (handle: string) => await storefrontClient.getPage(handle), ["page"], { revalidate: 3600 }) - -export const getAllPages = unstable_cache(async () => await storefrontClient.getAllPages(), ["page"], { revalidate: 3600 }) diff --git a/starters/shopify-meilisearch/lib/shopify/client.ts b/starters/shopify-meilisearch/lib/shopify/client.ts new file mode 100644 index 00000000..1a1028cf --- /dev/null +++ b/starters/shopify-meilisearch/lib/shopify/client.ts @@ -0,0 +1,273 @@ +import { AdminApiClient, createAdminApiClient } from "@shopify/admin-api-client" +import { createStorefrontApiClient, StorefrontApiClient } from "@shopify/storefront-api-client" + +import { createCartItemMutation, createCartMutation, deleteCartItemsMutation, updateCartItemsMutation } from "./mutations/cart.storefront" +import { createAccessTokenMutation, createCustomerMutation, updateCustomerMutation } from "./mutations/customer.storefront" +import { createProductFeedMutation, fullSyncProductFeedMutation } from "./mutations/product-feed.admin" +import { subscribeWebhookMutation } from "./mutations/webhook.admin" +import { normalizeCart, normalizeCollection, normalizeProduct } from "./normalize" +import { getCartQuery } from "./queries/cart.storefront" +import { getCollectionByIdQuery, getCollectionQuery, getCollectionsQuery } from "./queries/collection.storefront" +import { getCustomerQuery } from "./queries/customer.storefront" +import { getMenuQuery, type MenuQuery } from "./queries/menu.storefront" +import { getPageQuery, getPagesQuery } from "./queries/page.storefront" +import { getLatestProductFeedQuery } from "./queries/product-feed.admin" +import { getAdminProductQuery, getProductStatusQuery } from "./queries/product.admin" +import { getProductQuery, getProductsByHandleQuery } from "./queries/product.storefront" + +import type { + LatestProductFeedsQuery, + ProductFeedCreateMutation, + ProductFullSyncMutation, + ProductStatusQuery, + SingleAdminProductQuery, + WebhookSubscriptionCreateMutation, +} from "./types/admin/admin.generated" +import type { WebhookSubscriptionTopic } from "./types/admin/admin.types" +import type { + CollectionsQuery, + CreateAccessTokenMutation, + CreateCartItemMutation, + CreateCartMutation, + CreateCustomerMutation, + DeleteCartItemsMutation, + PagesQuery, + ProductsByHandleQuery, + SingleCartQuery, + SingleCollectionByIdQuery, + SingleCollectionQuery, + SingleCustomerQuery, + SinglePageQuery, + SingleProductQuery, + UpdateCartItemsMutation, + UpdateCustomerMutation, +} from "./types/storefront.generated" +import { CurrencyCode } from "./types/storefront.types" +import { + PlatformAccessToken, + PlatformCart, + PlatformCollection, + PlatformItemInput, + PlatformMenu, + PlatformPage, + PlatformProduct, + PlatformProductStatus, + PlatformUser, + PlatformUserCreateInput, +} from "./types" + +import { env } from "env.mjs" + +interface CreateShopifyClientProps { + storeDomain: string + storefrontAccessToken?: string + adminAccessToken?: string +} + +export function createShopifyClient({ storefrontAccessToken, adminAccessToken, storeDomain }: CreateShopifyClientProps) { + const client = createStorefrontApiClient({ + storeDomain, + privateAccessToken: storefrontAccessToken || "_BOGUS_TOKEN_", + apiVersion: "2024-01", + customFetchApi: (url, init) => fetch(url, init as never) as never, + }) + + const adminClient = createAdminApiClient({ + storeDomain, + accessToken: adminAccessToken || "", + apiVersion: "2024-01", + }) + + // To prevent prettier from wrapping pretty one liners and making them unreadable + // prettier-ignore + return { + getMenu: async (handle?: string, depth?: number) => getMenu(client!, handle, depth), + getProduct: async (id: string) => getProduct(client!, id), + getProductByHandle: async (handle: string) => getProductByHandle(client!, handle), + subscribeWebhook: async (topic: `${WebhookSubscriptionTopic}`, callbackUrl: string) => subscribeWebhook(adminClient, topic, callbackUrl), + createProductFeed: async () => createProductFeed(adminClient), + fullSyncProductFeed: async (id: string) => fullSyncProductFeed(adminClient, id), + getLatestProductFeed: async () => getLatestProductFeed(adminClient), + getPage: async (handle: string) => getPage(client!, handle), + getAllPages: async () => getAllPages(client!), + getProductStatus: async (id: string) => getProductStatus(adminClient!, id), + getAdminProduct: async (id: string) => getAdminProduct(adminClient, id), + createCart: async (items: PlatformItemInput[]) => createCart(client!, items), + createCartItem: async (cartId: string, items: PlatformItemInput[]) => createCartItem(client!,cartId, items), + updateCartItem: async (cartId: string, items: PlatformItemInput[]) => updateCartItem(client!,cartId, items), + deleteCartItem: async (cartId: string, itemIds: string[]) => deleteCartItem(client!, cartId, itemIds), + getCart: async (cartId: string) => getCart(client!, cartId), + getCollections: async (limit?: number) => getCollections(client!, limit), + getCollection: async (handle: string) => getCollection(client!, handle), + getCollectionById: async (id: string) => getCollectionById(client!, id), + createUser: async (input: PlatformUserCreateInput) => createUser(client!, input), + getUser: async (accessToken: string) => getUser(client!, accessToken), + updateUser: async (accessToken: string, input: Omit) => updateUser(client!, accessToken, input), + createUserAccessToken: async (input: Pick) => createUserAccessToken(client!, input), + getHierarchicalCollections: async (handle: string, depth?: number) => getHierarchicalCollections(client!, handle, depth), + } +} + +async function getMenu(client: StorefrontApiClient, handle: string = "main-menu", depth = 3): Promise { + const query = getMenuQuery(depth) + const response = await client.request(query, { variables: { handle } }) + const mappedItems = response.data?.menu?.items + + return { items: mappedItems || [] } +} + +async function getHierarchicalCollections(client: StorefrontApiClient, handle: string, depth = 3): Promise { + const query = getMenuQuery(depth) + const response = await client.request(query, { variables: { handle } }) + const mappedItems = response.data?.menu.items.filter((item) => item.resource?.__typename === "Collection") + + return { + items: mappedItems || [], + } +} + +async function getProduct(client: StorefrontApiClient, id: string): Promise { + const response = await client.request(getProductQuery, { variables: { id } }) + const product = response.data?.product + + return normalizeProduct(product) +} + +async function getProductByHandle(client: StorefrontApiClient, handle: string) { + const response = await client.request(getProductsByHandleQuery, { variables: { query: `'${handle}'` } }) + const product = response.data?.products?.edges?.find(Boolean)?.node + + return normalizeProduct(product) +} + +async function subscribeWebhook(client: AdminApiClient, topic: `${WebhookSubscriptionTopic}`, callbackUrl: string) { + return client.request(subscribeWebhookMutation, { + variables: { + topic: topic, + webhookSubscription: { + callbackUrl: callbackUrl, + format: "JSON", + }, + }, + }) +} + +async function createProductFeed(client: AdminApiClient) { + return client.request(createProductFeedMutation) +} + +async function fullSyncProductFeed(client: AdminApiClient, id: string) { + return client.request(fullSyncProductFeedMutation, { variables: { id } }) +} + +async function getLatestProductFeed(client: AdminApiClient) { + return client.request(getLatestProductFeedQuery) +} + +async function getPage(client: StorefrontApiClient, handle: string): Promise { + const page = await client.request(getPageQuery, { variables: { handle } }) + return page.data?.page +} + +async function getAllPages(client: StorefrontApiClient): Promise { + const pages = await client.request(getPagesQuery) + + return pages.data?.pages?.edges?.map((edge) => edge.node) || [] +} + +async function getProductStatus(client: AdminApiClient, id: string): Promise { + const status = await client.request(getProductStatusQuery, { variables: { id } }) + + return status.data?.product +} + +async function createCart(client: StorefrontApiClient, items: PlatformItemInput[]): Promise { + const cart = await client.request(createCartMutation, { variables: { items } }) + + return normalizeCart(cart.data?.cartCreate?.cart) +} + +async function createCartItem(client: StorefrontApiClient, cartId: string, items: PlatformItemInput[]): Promise { + const cart = await client.request(createCartItemMutation, { variables: { cartId, items } }) + + return normalizeCart(cart.data?.cartLinesAdd?.cart) +} + +async function updateCartItem(client: StorefrontApiClient, cartId: string, items: PlatformItemInput[]): Promise { + const cart = await client.request(updateCartItemsMutation, { variables: { cartId, items } }) + + return normalizeCart(cart.data?.cartLinesUpdate?.cart) +} + +async function deleteCartItem(client: StorefrontApiClient, cartId: string, itemIds: string[]): Promise { + const cart = await client.request(deleteCartItemsMutation, { variables: { itemIds, cartId } }) + + return normalizeCart(cart.data?.cartLinesRemove?.cart) +} + +async function getCart(client: StorefrontApiClient, cartId: string): Promise { + const cart = await client.request(getCartQuery, { variables: { cartId } }) + + return normalizeCart(cart.data?.cart) +} + +async function getCollections(client: StorefrontApiClient, limit?: number): Promise { + const collections = await client.request(getCollectionsQuery, { variables: { limit: limit || 250 } }) + + return collections.data?.collections.edges.map((collection) => normalizeCollection(collection.node)).filter(Boolean) as PlatformCollection[] +} + +async function getCollection(client: StorefrontApiClient, handle: string): Promise { + const collection = await client.request(getCollectionQuery, { variables: { handle } }) + + return normalizeCollection(collection.data?.collection) +} + +async function getCollectionById(client: StorefrontApiClient, id: string): Promise { + const collection = await client.request(getCollectionByIdQuery, { variables: { id } }) + + return normalizeCollection(collection.data?.collection) +} + +async function createUser(client: StorefrontApiClient, input: PlatformUserCreateInput): Promise | undefined | null> { + const user = await client.request(createCustomerMutation, { variables: { input } }) + + return user.data?.customerCreate?.customer +} + +async function createUserAccessToken(client: StorefrontApiClient, input: Pick): Promise { + const user = await client.request(createAccessTokenMutation, { variables: { input } }) + + return user.data?.customerAccessTokenCreate?.customerAccessToken +} + +async function getUser(client: StorefrontApiClient, customerAccessToken: string): Promise { + const user = await client.request(getCustomerQuery, { variables: { customerAccessToken } }) + + return user.data?.customer +} + +async function updateUser(client: StorefrontApiClient, customerAccessToken: string, input: Omit) { + const user = await client.request(updateCustomerMutation, { variables: { customer: input, customerAccessToken } }) + + return user.data?.customerUpdate?.customer +} + +async function getAdminProduct(client: AdminApiClient, id: string) { + const response = await client.request(getAdminProductQuery, { + variables: { id: id.startsWith("gid://shopify/Product/") ? id : `gid://shopify/Product/${id}` }, + }) + + if (!response.data?.product) return null + + const variants = { + edges: response.data?.product?.variants?.edges.map((edge) => ({ node: { ...edge.node, price: { amount: edge.node.price, currencyCode: "" as CurrencyCode } } })), + } + return normalizeProduct({ ...response.data?.product, variants }) +} + +export const storefrontClient = createShopifyClient({ + storeDomain: env.SHOPIFY_STORE_DOMAIN || "", + storefrontAccessToken: env.SHOPIFY_STOREFRONT_ACCESS_TOKEN || "", + adminAccessToken: env.SHOPIFY_ADMIN_ACCESS_TOKEN || "", +}) diff --git a/starters/shopify-meilisearch/lib/shopify/index.ts b/starters/shopify-meilisearch/lib/shopify/index.ts index 17c01fe7..f405f0bb 100644 --- a/starters/shopify-meilisearch/lib/shopify/index.ts +++ b/starters/shopify-meilisearch/lib/shopify/index.ts @@ -1,265 +1,31 @@ -import { AdminApiClient, createAdminApiClient } from "@shopify/admin-api-client" -import { createStorefrontApiClient, StorefrontApiClient } from "@shopify/storefront-api-client" +import { unstable_cache } from "next/cache" +import { storefrontClient } from "./client" +import type { PlatformItemInput } from "./types" +import { TAGS } from "constants/index" -import { createCartItemMutation, createCartMutation, deleteCartItemsMutation, updateCartItemsMutation } from "./mutations/cart.storefront" -import { createAccessTokenMutation, createCustomerMutation, updateCustomerMutation } from "./mutations/customer.storefront" -import { createProductFeedMutation, fullSyncProductFeedMutation } from "./mutations/product-feed.admin" -import { subscribeWebhookMutation } from "./mutations/webhook.admin" -import { normalizeCart, normalizeCollection, normalizeProduct } from "./normalize" -import { getCartQuery } from "./queries/cart.storefront" -import { getCollectionByIdQuery, getCollectionQuery, getCollectionsQuery } from "./queries/collection.storefront" -import { getCustomerQuery } from "./queries/customer.storefront" -import { getMenuQuery, type MenuQuery } from "./queries/menu.storefront" -import { getPageQuery, getPagesQuery } from "./queries/page.storefront" -import { getLatestProductFeedQuery } from "./queries/product-feed.admin" -import { getAdminProductQuery, getProductStatusQuery } from "./queries/product.admin" -import { getProductQuery, getProductsByHandleQuery } from "./queries/product.storefront" +export const getPage = unstable_cache(async (handle: string) => await storefrontClient.getPage(handle), ["page"], { revalidate: 86400 }) -import type { - LatestProductFeedsQuery, - ProductFeedCreateMutation, - ProductFullSyncMutation, - ProductStatusQuery, - SingleAdminProductQuery, - WebhookSubscriptionCreateMutation, -} from "./types/admin/admin.generated" -import type { WebhookSubscriptionTopic } from "./types/admin/admin.types" -import type { - CollectionsQuery, - CreateAccessTokenMutation, - CreateCartItemMutation, - CreateCartMutation, - CreateCustomerMutation, - DeleteCartItemsMutation, - PagesQuery, - ProductsByHandleQuery, - SingleCartQuery, - SingleCollectionByIdQuery, - SingleCollectionQuery, - SingleCustomerQuery, - SinglePageQuery, - SingleProductQuery, - UpdateCartItemsMutation, - UpdateCustomerMutation, -} from "./types/storefront.generated" -import { CurrencyCode } from "./types/storefront.types" -import { - PlatformAccessToken, - PlatformCart, - PlatformCollection, - PlatformItemInput, - PlatformMenu, - PlatformPage, - PlatformProduct, - PlatformProductStatus, - PlatformUser, - PlatformUserCreateInput, -} from "./types" +export const getProduct = unstable_cache(async (id: string) => await storefrontClient.getProduct(id), ["product"], { revalidate: 86400 }) -interface CreateShopifyClientProps { - storeDomain: string - storefrontAccessToken?: string - adminAccessToken?: string -} +export const getProductByHandle = unstable_cache(async (handle: string) => await storefrontClient.getProductByHandle(handle), ["product"], { revalidate: 86400 }) -export function createShopifyClient({ storefrontAccessToken, adminAccessToken, storeDomain }: CreateShopifyClientProps) { - const client = createStorefrontApiClient({ - storeDomain, - privateAccessToken: storefrontAccessToken || "_BOGUS_TOKEN_", - apiVersion: "2024-01", - customFetchApi: (url, init) => fetch(url, init as never) as never, - }) +export const getAdminProduct = async (id: string) => await storefrontClient.getAdminProduct(id) - const adminClient = createAdminApiClient({ - storeDomain, - accessToken: adminAccessToken || "", - apiVersion: "2024-01", - }) +export const getAllPages = unstable_cache(async () => await storefrontClient.getAllPages(), ["page"], { revalidate: 86400 }) - // To prevent prettier from wrapping pretty one liners and making them unreadable - // prettier-ignore - return { - getMenu: async (handle?: string, depth?: number) => getMenu(client!, handle, depth), - getProduct: async (id: string) => getProduct(client!, id), - getProductByHandle: async (handle: string) => getProductByHandle(client!, handle), - subscribeWebhook: async (topic: `${WebhookSubscriptionTopic}`, callbackUrl: string) => subscribeWebhook(adminClient, topic, callbackUrl), - createProductFeed: async () => createProductFeed(adminClient), - fullSyncProductFeed: async (id: string) => fullSyncProductFeed(adminClient, id), - getLatestProductFeed: async () => getLatestProductFeed(adminClient), - getPage: async (handle: string) => getPage(client!, handle), - getAllPages: async () => getAllPages(client!), - getProductStatus: async (id: string) => getProductStatus(adminClient!, id), - getAdminProduct: async (id: string) => getAdminProduct(adminClient, id), - createCart: async (items: PlatformItemInput[]) => createCart(client!, items), - createCartItem: async (cartId: string, items: PlatformItemInput[]) => createCartItem(client!,cartId, items), - updateCartItem: async (cartId: string, items: PlatformItemInput[]) => updateCartItem(client!,cartId, items), - deleteCartItem: async (cartId: string, itemIds: string[]) => deleteCartItem(client!, cartId, itemIds), - getCart: async (cartId: string) => getCart(client!, cartId), - getCollections: async (limit?: number) => getCollections(client!, limit), - getCollection: async (handle: string) => getCollection(client!, handle), - getCollectionById: async (id: string) => getCollectionById(client!, id), - createUser: async (input: PlatformUserCreateInput) => createUser(client!, input), - getUser: async (accessToken: string) => getUser(client!, accessToken), - updateUser: async (accessToken: string, input: Omit) => updateUser(client!, accessToken, input), - createUserAccessToken: async (input: Pick) => createUserAccessToken(client!, input), - getHierarchicalCollections: async (handle: string, depth?: number) => getHierarchicalCollections(client!, handle, depth), - } -} +export const getCart = unstable_cache(async (cartId: string) => await storefrontClient.getCart(cartId), [TAGS.CART], { + revalidate: 900, + tags: [TAGS.CART], +}) -async function getMenu(client: StorefrontApiClient, handle: string = "main-menu", depth = 3): Promise { - const query = getMenuQuery(depth) - const response = await client.request(query, { variables: { handle } }) - const mappedItems = response.data?.menu?.items +export const getCollection = unstable_cache(async (id: string) => await storefrontClient.getCollectionById(id), ["collection"], { revalidate: 86400 }) - return { items: mappedItems || [] } -} +export const getHierarchicalCollections = unstable_cache(async (handle: string) => await storefrontClient.getHierarchicalCollections(handle), ["collection"], { revalidate: 86400 }) -async function getHierarchicalCollections(client: StorefrontApiClient, handle: string, depth = 3): Promise { - const query = getMenuQuery(depth) - const response = await client.request(query, { variables: { handle } }) - const mappedItems = response.data?.menu.items.filter((item) => item.resource?.__typename === "Collection") +export const createCart = async () => await storefrontClient.createCart([]) - return { - items: mappedItems || [], - } -} +export const createCartItem = async (cartId: string, items: PlatformItemInput[]) => await storefrontClient.createCartItem(cartId, items) -async function getProduct(client: StorefrontApiClient, id: string): Promise { - const response = await client.request(getProductQuery, { variables: { id } }) - const product = response.data?.product +export const deleteCartItem = async (cartId: string, itemIds: string[]) => await storefrontClient.deleteCartItem(cartId, itemIds) - return normalizeProduct(product) -} - -async function getProductByHandle(client: StorefrontApiClient, handle: string) { - const response = await client.request(getProductsByHandleQuery, { variables: { query: `'${handle}'` } }) - const product = response.data?.products?.edges?.find(Boolean)?.node - - return normalizeProduct(product) -} - -async function subscribeWebhook(client: AdminApiClient, topic: `${WebhookSubscriptionTopic}`, callbackUrl: string) { - return client.request(subscribeWebhookMutation, { - variables: { - topic: topic, - webhookSubscription: { - callbackUrl: callbackUrl, - format: "JSON", - }, - }, - }) -} - -async function createProductFeed(client: AdminApiClient) { - return client.request(createProductFeedMutation) -} - -async function fullSyncProductFeed(client: AdminApiClient, id: string) { - return client.request(fullSyncProductFeedMutation, { variables: { id } }) -} - -async function getLatestProductFeed(client: AdminApiClient) { - return client.request(getLatestProductFeedQuery) -} - -async function getPage(client: StorefrontApiClient, handle: string): Promise { - const page = await client.request(getPageQuery, { variables: { handle } }) - return page.data?.page -} - -async function getAllPages(client: StorefrontApiClient): Promise { - const pages = await client.request(getPagesQuery) - - return pages.data?.pages?.edges?.map((edge) => edge.node) || [] -} - -async function getProductStatus(client: AdminApiClient, id: string): Promise { - const status = await client.request(getProductStatusQuery, { variables: { id } }) - - return status.data?.product -} - -async function createCart(client: StorefrontApiClient, items: PlatformItemInput[]): Promise { - const cart = await client.request(createCartMutation, { variables: { items } }) - - return normalizeCart(cart.data?.cartCreate?.cart) -} - -async function createCartItem(client: StorefrontApiClient, cartId: string, items: PlatformItemInput[]): Promise { - const cart = await client.request(createCartItemMutation, { variables: { cartId, items } }) - - return normalizeCart(cart.data?.cartLinesAdd?.cart) -} - -async function updateCartItem(client: StorefrontApiClient, cartId: string, items: PlatformItemInput[]): Promise { - const cart = await client.request(updateCartItemsMutation, { variables: { cartId, items } }) - - return normalizeCart(cart.data?.cartLinesUpdate?.cart) -} - -async function deleteCartItem(client: StorefrontApiClient, cartId: string, itemIds: string[]): Promise { - const cart = await client.request(deleteCartItemsMutation, { variables: { itemIds, cartId } }) - - return normalizeCart(cart.data?.cartLinesRemove?.cart) -} - -async function getCart(client: StorefrontApiClient, cartId: string): Promise { - const cart = await client.request(getCartQuery, { variables: { cartId } }) - - return normalizeCart(cart.data?.cart) -} - -async function getCollections(client: StorefrontApiClient, limit?: number): Promise { - const collections = await client.request(getCollectionsQuery, { variables: { limit: limit || 250 } }) - - return collections.data?.collections.edges.map((collection) => normalizeCollection(collection.node)).filter(Boolean) as PlatformCollection[] -} - -async function getCollection(client: StorefrontApiClient, handle: string): Promise { - const collection = await client.request(getCollectionQuery, { variables: { handle } }) - - return normalizeCollection(collection.data?.collection) -} - -async function getCollectionById(client: StorefrontApiClient, id: string): Promise { - const collection = await client.request(getCollectionByIdQuery, { variables: { id } }) - - return normalizeCollection(collection.data?.collection) -} - -async function createUser(client: StorefrontApiClient, input: PlatformUserCreateInput): Promise | undefined | null> { - const user = await client.request(createCustomerMutation, { variables: { input } }) - - return user.data?.customerCreate?.customer -} - -async function createUserAccessToken(client: StorefrontApiClient, input: Pick): Promise { - const user = await client.request(createAccessTokenMutation, { variables: { input } }) - - return user.data?.customerAccessTokenCreate?.customerAccessToken -} - -async function getUser(client: StorefrontApiClient, customerAccessToken: string): Promise { - const user = await client.request(getCustomerQuery, { variables: { customerAccessToken } }) - - return user.data?.customer -} - -async function updateUser(client: StorefrontApiClient, customerAccessToken: string, input: Omit) { - const user = await client.request(updateCustomerMutation, { variables: { customer: input, customerAccessToken } }) - - return user.data?.customerUpdate?.customer -} - -async function getAdminProduct(client: AdminApiClient, id: string) { - const response = await client.request(getAdminProductQuery, { - variables: { id: id.startsWith("gid://shopify/Product/") ? id : `gid://shopify/Product/${id}` }, - }) - - if (!response.data?.product) return null - - const variants = { - edges: response.data?.product?.variants?.edges.map((edge) => ({ node: { ...edge.node, price: { amount: edge.node.price, currencyCode: "" as CurrencyCode } } })), - } - return normalizeProduct({ ...response.data?.product, variants }) -} +export const updateCartItem = async (cartId: string, items: PlatformItemInput[]) => await storefrontClient.updateCartItem(cartId, items) diff --git a/starters/shopify-meilisearch/stores/cart-store.ts b/starters/shopify-meilisearch/stores/cart-store.ts index 97c065fb..3f9d3f98 100644 --- a/starters/shopify-meilisearch/stores/cart-store.ts +++ b/starters/shopify-meilisearch/stores/cart-store.ts @@ -6,12 +6,14 @@ interface CartStore { isSheetLoaded: boolean lastUpdatedAt: number cart: PlatformCart | null + checkoutReady: boolean openCart: () => void closeCart: () => void preloadSheet: () => void refresh: () => void setCart: (payload: PlatformCart | null) => void + setCheckoutReady: (payload: boolean) => void } export const useCartStore = create((set) => ({ @@ -19,10 +21,12 @@ export const useCartStore = create((set) => ({ lastUpdatedAt: 0, cart: null, isSheetLoaded: false, + checkoutReady: true, openCart: () => set(() => ({ isOpen: true, isSheetLoaded: true, lastUpdatedAt: Date.now() })), closeCart: () => set(() => ({ isOpen: false, isSheetLoaded: true, lastUpdatedAt: Date.now() })), preloadSheet: () => set(() => ({ isSheetLoaded: true })), refresh: () => set(() => ({ lastUpdatedAt: Date.now() })), + setCheckoutReady: (payload: boolean) => set(() => ({ checkoutReady: payload })), setCart: (payload: PlatformCart | null) => set(() => ({ cart: payload })), })) diff --git a/starters/shopify-meilisearch/views/cart/cart-item.tsx b/starters/shopify-meilisearch/views/cart/cart-item.tsx index dd88f60f..0ea21807 100644 --- a/starters/shopify-meilisearch/views/cart/cart-item.tsx +++ b/starters/shopify-meilisearch/views/cart/cart-item.tsx @@ -34,9 +34,9 @@ export function CartItem(props: CartItemProps) {
- +
{props.quantity}
- +
diff --git a/starters/shopify-meilisearch/views/cart/cart-view.tsx b/starters/shopify-meilisearch/views/cart/cart-view.tsx index 2fb8ab18..72c92303 100644 --- a/starters/shopify-meilisearch/views/cart/cart-view.tsx +++ b/starters/shopify-meilisearch/views/cart/cart-view.tsx @@ -1,11 +1,9 @@ "use client" -import { getCart } from "app/actions/cart.actions" -import { COOKIE_CART_ID } from "constants/index" -import dynamic from "next/dynamic" import { useEffect, useTransition } from "react" +import dynamic from "next/dynamic" +import { getOrCreateCart } from "app/actions/cart.actions" import { useCartStore } from "stores/cart-store" -import { getCookie } from "utils/get-cookie" const CartSheet = dynamic(() => import("views/cart/cart-sheet").then((mod) => mod.CartSheet)) @@ -17,12 +15,8 @@ export function CartView() { useEffect(() => { startTransition(async () => { - const cartId = getCookie(COOKIE_CART_ID) - - if (!cartId) return - - const newCart = await getCart(cartId) - newCart && setCart(newCart) + const { cart } = await getOrCreateCart() + cart && setCart(cart) }) }, [lastUpdatedAt, setCart]) diff --git a/starters/shopify-meilisearch/views/cart/change-quantity-button.tsx b/starters/shopify-meilisearch/views/cart/change-quantity-button.tsx index bbff38db..54da7c4e 100644 --- a/starters/shopify-meilisearch/views/cart/change-quantity-button.tsx +++ b/starters/shopify-meilisearch/views/cart/change-quantity-button.tsx @@ -8,16 +8,17 @@ interface ChangeQuantityButtonProps { id: string variantId: string quantity: number + productId: string children: React.ReactNode } -export function ChangeQuantityButton({ id, variantId, quantity, children }: ChangeQuantityButtonProps) { +export function ChangeQuantityButton({ id, variantId, quantity, productId, children }: ChangeQuantityButtonProps) { const refresh = useCartStore((prev) => prev.refresh) const [isPending, startTransition] = useTransition() const handleClick = () => { startTransition(async () => { - const { ok, message } = await updateItemQuantity(null, { itemId: id, variantId, quantity }) + const { ok, message } = await updateItemQuantity(null, { itemId: id, variantId, quantity, productId }) if (!ok && message) { toast.warning(message) diff --git a/starters/shopify-meilisearch/views/product/add-to-cart-button.tsx b/starters/shopify-meilisearch/views/product/add-to-cart-button.tsx index e643e5f6..d5a6735c 100644 --- a/starters/shopify-meilisearch/views/product/add-to-cart-button.tsx +++ b/starters/shopify-meilisearch/views/product/add-to-cart-button.tsx @@ -25,7 +25,7 @@ export function AddToCartButton({ className, product, combination }: { className const [isPending, setIsPending] = useState(false) const [hasAnyAvailable, setHasAnyAvailable] = useState(true) const { setProduct, clean } = useAddProductStore() - const { cart, refresh } = useCartStore((s) => s) + const { cart, refresh, setCheckoutReady } = useCartStore((s) => s) const disabled = !hasAnyAvailable || !combination?.availableForSale || isPending @@ -42,23 +42,29 @@ export function AddToCartButton({ className, product, combination }: { className setTimeout(() => clean(), 4500) - const res = await addCartItem(null, combination.id) + setCheckoutReady(false) + const res = await addCartItem(null, combination.id, product.id) if (!res.ok) toast.error("Out of stock") + setCheckoutReady(true) refresh() } useEffect(() => { const checkStock = async () => { const cartId = getCookie(COOKIE_CART_ID) - const itemAvailability = await getItemAvailability(cartId, combination?.id) + const itemAvailability = await getItemAvailability({ + cartId, + productId: product.id, + variantId: combination?.id, + }) itemAvailability && setHasAnyAvailable(itemAvailability.inCartQuantity < (combination?.quantityAvailable || 0)) } checkStock() - }, [combination?.id, isPending, combination?.quantityAvailable, cart?.items]) + }, [combination?.id, isPending, combination?.quantityAvailable, cart?.items, product.id]) return ( -