feat: project initial setup

This commit is contained in:
alex.saiannyi 2023-05-22 15:22:52 +02:00 committed by Volodymyr Krasnoshapka
parent f5dade74fb
commit b17652b26b
54 changed files with 3140 additions and 1590 deletions

View File

@ -1,5 +1,10 @@
TWITTER_CREATOR="@vercel" TWITTER_CREATOR="@BigCommerce"
TWITTER_SITE="https://nextjs.org/commerce" TWITTER_SITE="https://nextjs.org/commerce"
SITE_NAME="Next.js Commerce" SITE_NAME="Next.js Commerce by BigCommerce"
SHOPIFY_STOREFRONT_ACCESS_TOKEN= BIGCOMMERCE_ACCESS_TOKEN=
SHOPIFY_STORE_DOMAIN= BIGCOMMERCE_CHANNEL_ID=
BIGCOMMERCE_STORE_HASH=
# Optional
BIGCOMMERCE_CANONICAL_STORE_DOMAIN="mybigcommerce.com"
BIGCOMMERCE_API_URL="https://api.bigcommerce.com"
BIGCOMMERCE_CDN_HOSTNAME="*.bigcommerce.com"

View File

@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Prose from 'components/prose'; import Prose from 'components/prose';
import { getPage } from 'lib/shopify'; import { getPage } from 'lib/bigcommerce';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -1,8 +1,8 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { addToCart, removeFromCart, updateCart } from 'lib/shopify'; import { addToCart, removeFromCart, updateCart } from 'lib/bigcommerce';
import { isShopifyError } from 'lib/type-guards'; import { isVercelCommerceError } from 'lib/type-guards';
function formatErrorMessage(err: Error): string { function formatErrorMessage(err: Error): string {
return JSON.stringify(err, Object.getOwnPropertyNames(err)); return JSON.stringify(err, Object.getOwnPropertyNames(err));
@ -10,16 +10,18 @@ function formatErrorMessage(err: Error): string {
export async function POST(req: NextRequest): Promise<Response> { export async function POST(req: NextRequest): Promise<Response> {
const cartId = cookies().get('cartId')?.value; const cartId = cookies().get('cartId')?.value;
const { merchandiseId } = await req.json(); const { merchandiseId, isBigCommerceAPI } = await req.json();
if (!cartId?.length || !merchandiseId?.length) { if ((!isBigCommerceAPI && !cartId?.length) || !merchandiseId?.length) {
return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 }); return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 });
} else if (isBigCommerceAPI && !merchandiseId?.length) {
return NextResponse.json({ error: 'Missing variantId' }, { status: 400 });
} }
try { try {
await addToCart(cartId, [{ merchandiseId, quantity: 1 }]); await addToCart(cartId || '', [{ merchandiseId, quantity: 1 }]);
return NextResponse.json({ status: 204 }); return NextResponse.json({ status: 204 });
} catch (e) { } catch (e) {
if (isShopifyError(e)) { if (isVercelCommerceError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status }); return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
} }
@ -47,7 +49,7 @@ export async function PUT(req: NextRequest): Promise<Response> {
]); ]);
return NextResponse.json({ status: 204 }); return NextResponse.json({ status: 204 });
} catch (e) { } catch (e) {
if (isShopifyError(e)) { if (isVercelCommerceError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status }); return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
} }
@ -66,7 +68,7 @@ export async function DELETE(req: NextRequest): Promise<Response> {
await removeFromCart(cartId, [lineId]); await removeFromCart(cartId, [lineId]);
return NextResponse.json({ status: 204 }); return NextResponse.json({ status: 204 });
} catch (e) { } catch (e) {
if (isShopifyError(e)) { if (isVercelCommerceError(e)) {
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status }); return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
} }

View File

@ -6,7 +6,7 @@ import { Suspense } from 'react';
export const runtime = 'edge'; export const runtime = 'edge';
export const metadata = { export const metadata = {
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.', description: 'High-performance ecommerce store built with Next.js, Vercel, and BigCommerce.',
openGraph: { openGraph: {
images: [ images: [
{ {

View File

@ -10,8 +10,8 @@ import { Gallery } from 'components/product/gallery';
import { VariantSelector } from 'components/product/variant-selector'; import { VariantSelector } from 'components/product/variant-selector';
import Prose from 'components/prose'; import Prose from 'components/prose';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/shopify'; import { getProduct, getProductRecommendations } from 'lib/bigcommerce';
import { Image } from 'lib/shopify/types'; import { Image } from 'lib/bigcommerce/types';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -1,4 +1,4 @@
import { getCollection, getCollectionProducts } from 'lib/shopify'; import { getCollection, getCollectionProducts } from 'lib/bigcommerce';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';

View File

@ -1,7 +1,7 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import ProductGridItems from 'components/layout/product-grid-items'; import ProductGridItems from 'components/layout/product-grid-items';
import { getProducts } from 'lib/bigcommerce';
import { defaultSort, sorting } from 'lib/constants'; import { defaultSort, sorting } from 'lib/constants';
import { getProducts } from 'lib/shopify';
export const runtime = 'edge'; export const runtime = 'edge';

View File

@ -1,4 +1,4 @@
import { getCollections, getPages, getProducts } from 'lib/shopify'; import { getCollections, getPages, getProducts } from 'lib/bigcommerce';
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL

View File

@ -1,4 +1,4 @@
import { getCollectionProducts } from 'lib/shopify'; import { getCollectionProducts } from 'lib/bigcommerce';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';

View File

@ -6,7 +6,7 @@ import { useCookies } from 'react-cookie';
import CartIcon from 'components/icons/cart'; import CartIcon from 'components/icons/cart';
import CartModal from './modal'; import CartModal from './modal';
import type { Cart } from 'lib/shopify/types'; import type { VercelCart as Cart } from 'lib/bigcommerce/types';
export default function CartButton({ export default function CartButton({
cart, cart,

View File

@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { startTransition, useState } from 'react'; import { startTransition, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { CartItem } from 'lib/shopify/types'; import type { VercelCartItem as CartItem } from 'lib/bigcommerce/types';
export default function DeleteItemButton({ item }: { item: CartItem }) { export default function DeleteItemButton({ item }: { item: CartItem }) {
const router = useRouter(); const router = useRouter();

View File

@ -4,7 +4,7 @@ import { startTransition, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import MinusIcon from 'components/icons/minus'; import MinusIcon from 'components/icons/minus';
import PlusIcon from 'components/icons/plus'; import PlusIcon from 'components/icons/plus';
import type { CartItem } from 'lib/shopify/types'; import type { VercelCartItem as CartItem } from 'lib/bigcommerce/types';
import LoadingDots from '../loading-dots'; import LoadingDots from '../loading-dots';
export default function EditItemQuantityButton({ export default function EditItemQuantityButton({

View File

@ -1,4 +1,4 @@
import { createCart, getCart } from 'lib/shopify'; import { createCart, getCart } from 'lib/bigcommerce';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import CartButton from './button'; import CartButton from './button';

View File

@ -7,7 +7,7 @@ import CloseIcon from 'components/icons/close';
import ShoppingBagIcon from 'components/icons/shopping-bag'; import ShoppingBagIcon from 'components/icons/shopping-bag';
import Price from 'components/price'; import Price from 'components/price';
import { DEFAULT_OPTION } from 'lib/constants'; import { DEFAULT_OPTION } from 'lib/constants';
import type { Cart } from 'lib/shopify/types'; import type { VercelCart as Cart } from 'lib/bigcommerce/types';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import DeleteItemButton from './delete-item-button'; import DeleteItemButton from './delete-item-button';
import EditItemQuantityButton from './edit-item-quantity-button'; import EditItemQuantityButton from './edit-item-quantity-button';

View File

@ -1,6 +1,6 @@
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import { getCollectionProducts } from 'lib/shopify'; import { getCollectionProducts } from 'lib/bigcommerce';
import type { Product } from 'lib/shopify/types'; import type { VercelProduct as Product } from 'lib/bigcommerce/types';
import Link from 'next/link'; import Link from 'next/link';
function ThreeItemGridItem({ function ThreeItemGridItem({

View File

@ -3,8 +3,8 @@ import Link from 'next/link';
import GitHubIcon from 'components/icons/github'; import GitHubIcon from 'components/icons/github';
import LogoIcon from 'components/icons/logo'; import LogoIcon from 'components/icons/logo';
import VercelIcon from 'components/icons/vercel'; import VercelIcon from 'components/icons/vercel';
import { getMenu } from 'lib/shopify'; import { getMenu } from 'lib/bigcommerce';
import { Menu } from 'lib/shopify/types'; import { VercelMenu as Menu } from 'lib/bigcommerce/types';
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;

View File

@ -4,18 +4,19 @@ import { Suspense } from 'react';
import Cart from 'components/cart'; import Cart from 'components/cart';
import CartIcon from 'components/icons/cart'; import CartIcon from 'components/icons/cart';
import LogoIcon from 'components/icons/logo'; import LogoIcon from 'components/icons/logo';
import { getMenu } from 'lib/shopify'; import { getMenu } from 'lib/bigcommerce';
import { Menu } from 'lib/shopify/types'; import { VercelMenu as Menu } from 'lib/bigcommerce/types';
import MobileMenu from './mobile-menu'; import MobileMenu from './mobile-menu';
import Search from './search'; import Search from './search';
export default async function Navbar() { export default async function Navbar() {
const menu = await getMenu('next-js-frontend-header-menu'); const menu = await getMenu('next-js-frontend-header-menu');
const demoMenu = menu.slice(0, 4);
return ( return (
<nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6"> <nav className="relative flex items-center justify-between bg-white p-4 dark:bg-black lg:px-6">
<div className="block w-1/3 md:hidden"> <div className="block w-1/3 md:hidden">
<MobileMenu menu={menu} /> <MobileMenu menu={demoMenu} />
</div> </div>
<div className="flex justify-self-center md:w-1/3 md:justify-self-start"> <div className="flex justify-self-center md:w-1/3 md:justify-self-start">
<div className="md:mr-4"> <div className="md:mr-4">
@ -23,9 +24,9 @@ export default async function Navbar() {
<LogoIcon className="h-8 transition-transform hover:scale-110" /> <LogoIcon className="h-8 transition-transform hover:scale-110" />
</Link> </Link>
</div> </div>
{menu.length ? ( {demoMenu.length ? (
<ul className="hidden md:flex"> <ul className="hidden md:flex">
{menu.map((item: Menu) => ( {demoMenu.map((item: Menu) => (
<li key={item.title}> <li key={item.title}>
<Link <Link
href={item.path} href={item.path}

View File

@ -8,7 +8,7 @@ import { useEffect, useState } from 'react';
import CloseIcon from 'components/icons/close'; import CloseIcon from 'components/icons/close';
import MenuIcon from 'components/icons/menu'; import MenuIcon from 'components/icons/menu';
import { Menu } from 'lib/shopify/types'; import { VercelMenu as Menu } from 'lib/bigcommerce/types';
import Search from './search'; import Search from './search';
export default function MobileMenu({ menu }: { menu: Menu[] }) { export default function MobileMenu({ menu }: { menu: Menu[] }) {

View File

@ -1,6 +1,6 @@
import Grid from 'components/grid'; import Grid from 'components/grid';
import { GridTileImage } from 'components/grid/tile'; import { GridTileImage } from 'components/grid/tile';
import { Product } from 'lib/shopify/types'; import { VercelProduct as Product } from 'lib/bigcommerce/types';
import Link from 'next/link'; import Link from 'next/link';
export default function ProductGridItems({ products }: { products: Product[] }) { export default function ProductGridItems({ products }: { products: Product[] }) {

View File

@ -1,7 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { getCollections } from 'lib/shopify'; import { getCollections } from 'lib/bigcommerce';
import FilterList from './filter'; import FilterList from './filter';
async function CollectionList() { async function CollectionList() {

View File

@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react'; import { useEffect, useState, useTransition } from 'react';
import LoadingDots from 'components/loading-dots'; import LoadingDots from 'components/loading-dots';
import { ProductVariant } from 'lib/shopify/types'; import { VercelProductVariant as ProductVariant } from 'lib/bigcommerce/types';
export function AddToCart({ export function AddToCart({
variants, variants,
@ -14,7 +14,8 @@ export function AddToCart({
variants: ProductVariant[]; variants: ProductVariant[];
availableForSale: boolean; availableForSale: boolean;
}) { }) {
const [selectedVariantId, setSelectedVariantId] = useState(variants[0]?.id); const productEntityId = variants[0]?.parentId || variants[0]?.id;
const [selectedVariantId, setSelectedVariantId] = useState(productEntityId);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -28,7 +29,7 @@ export function AddToCart({
); );
if (variant) { if (variant) {
setSelectedVariantId(variant.id); setSelectedVariantId(variant.parentId || variant.id);
} }
}, [searchParams, variants, setSelectedVariantId]); }, [searchParams, variants, setSelectedVariantId]);
@ -42,7 +43,8 @@ export function AddToCart({
const response = await fetch(`/api/cart`, { const response = await fetch(`/api/cart`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
merchandiseId: selectedVariantId merchandiseId: selectedVariantId,
isBigCommerceAPI: true
}) })
}); });

View File

@ -1,7 +1,10 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { ProductOption, ProductVariant } from 'lib/shopify/types'; import {
VercelProductOption as ProductOption,
VercelProductVariant as ProductVariant
} from 'lib/bigcommerce/types';
import { createUrl } from 'lib/utils'; import { createUrl } from 'lib/utils';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';

View File

@ -0,0 +1,4 @@
export const BIGCOMMERCE_API_URL = process.env.BIGCOMMERCE_API_URL ?? 'https://api.bigcommerce.com';
export const BIGCOMMERCE_CANONICAL_STORE_DOMAIN =
process.env.BIGCOMMERCE_CANONICAL_STORE_DOMAIN ?? 'mybigcommerce.com';
export const BIGCOMMERCE_GRAPHQL_API_ENDPOINT = `${BIGCOMMERCE_CANONICAL_STORE_DOMAIN}/graphql`;

View File

@ -0,0 +1,187 @@
const physicalItemFragment = /* GraphQL */ `
fragment physicalItem on CartPhysicalItem {
entityId
parentEntityId
variantEntityId
productEntityId
sku
name
url
imageUrl
brand
quantity
isTaxable
discounts {
entityId
discountedAmount {
currencyCode
value
}
}
discountedAmount {
currencyCode
value
}
couponAmount {
currencyCode
value
}
listPrice {
currencyCode
value
}
originalPrice {
currencyCode
value
}
salePrice {
currencyCode
value
}
extendedListPrice {
currencyCode
value
}
extendedSalePrice {
currencyCode
value
}
isShippingRequired
selectedOptions {
entityId
name
... on CartSelectedCheckboxOption {
value
valueEntityId
}
... on CartSelectedDateFieldOption {
date {
utc
}
}
... on CartSelectedFileUploadOption {
fileName
}
... on CartSelectedMultiLineTextFieldOption {
text
}
... on CartSelectedMultipleChoiceOption {
value
valueEntityId
}
... on CartSelectedNumberFieldOption {
number
}
... on CartSelectedTextFieldOption {
text
}
}
giftWrapping {
name
amount {
currencyCode
value
}
message
}
}
`;
const digitalItemFragment = /* GraphQL */ `
fragment digitalItem on CartDigitalItem {
entityId
parentEntityId
variantEntityId
productEntityId
sku
name
url
imageUrl
brand
quantity
isTaxable
discounts {
entityId
discountedAmount {
currencyCode
value
}
}
discountedAmount {
currencyCode
value
}
couponAmount {
currencyCode
value
}
listPrice {
currencyCode
value
}
originalPrice {
currencyCode
value
}
salePrice {
currencyCode
value
}
extendedListPrice {
currencyCode
value
}
extendedSalePrice {
currencyCode
value
}
selectedOptions {
entityId
name
... on CartSelectedCheckboxOption {
value
valueEntityId
}
... on CartSelectedDateFieldOption {
date {
utc
}
}
... on CartSelectedFileUploadOption {
fileName
}
... on CartSelectedMultiLineTextFieldOption {
text
}
... on CartSelectedMultipleChoiceOption {
value
valueEntityId
}
... on CartSelectedNumberFieldOption {
number
}
... on CartSelectedTextFieldOption {
text
}
}
}
`;
const customItemFragment = /* GraphQL */ `
fragment customItem on CartCustomItem {
entityId
sku
name
quantity
extendedListPrice {
currencyCode
value
}
listPrice {
value
currencyCode
}
}
`;
export { customItemFragment, digitalItemFragment, physicalItemFragment };

View File

@ -0,0 +1,12 @@
export const pageContentFragment = /* GraphQL */ `
fragment pageContent on WebPage {
__typename
entityId
name
seo {
metaKeywords
metaDescription
pageTitle
}
}
`;

View File

@ -0,0 +1,177 @@
const productOptionFragment = /* GraphQL */ `
fragment productOption on CatalogProductOption {
__typename
entityId
displayName
isRequired
... on MultipleChoiceOption {
displayStyle
values(first: 5) {
edges {
node {
entityId
isDefault
... on SwatchOptionValue {
hexColors
imageUrl(width: 200)
label
isSelected
}
... on MultipleChoiceOptionValue {
entityId
label
isSelected
}
... on ProductPickListOptionValue {
entityId
label
isSelected
}
}
}
}
}
... on NumberFieldOption {
entityId
displayName
}
... on TextFieldOption {
entityId
displayName
}
... on MultiLineTextFieldOption {
entityId
displayName
}
... on FileUploadFieldOption {
entityId
displayName
}
... on DateFieldOption {
entityId
displayName
}
... on CheckboxOption {
entityId
displayName
}
}
`;
const productVariantFragment = /* GraphQL */ `
fragment productVariant on Variant {
id
entityId
sku
upc
isPurchasable
prices {
price {
value
currencyCode
}
priceRange {
min {
value
currencyCode
}
max {
value
currencyCode
}
}
}
options(first: 5) {
edges {
node {
entityId
displayName
values(first: 5) {
edges {
node {
entityId
label
}
}
}
}
}
}
}
`;
const productFragment = /* GraphQL */ `
fragment product on Product {
id
entityId
sku
upc
name
brand {
name
}
plainTextDescription
description
availabilityV2 {
status
description
}
defaultImage {
...ImageFields
}
images {
edges {
node {
...ImageFields
}
}
}
seo {
pageTitle
metaDescription
metaKeywords
}
prices {
price {
...MoneyFields
}
priceRange {
min {
...MoneyFields
}
max {
...MoneyFields
}
}
}
createdAt {
utc
}
variants(first: 5) {
edges {
node {
...productVariant
}
}
}
productOptions(first: 3) {
edges {
node {
...productOption
}
}
}
}
fragment ImageFields on Image {
url: url(width: 1080)
altText
}
fragment MoneyFields on Money {
value
currencyCode
}
${productOptionFragment}
${productVariantFragment}
`;
export { productOptionFragment, productVariantFragment, productFragment };

580
lib/bigcommerce/index.ts Normal file
View File

@ -0,0 +1,580 @@
import { isVercelCommerceError } from 'lib/type-guards';
import { BIGCOMMERCE_GRAPHQL_API_ENDPOINT } from './constants';
import {
bigCommerceToVercelCollection,
bigCommerceToVercelPageContent,
bigcommerceToVercelCart,
bigcommerceToVercelProduct,
bigcommerceToVercelProducts,
vercelFromBigCommerceLineItems,
vercelToBigCommerceSorting
} from './mappers';
import {
addCartLineItemMutation,
createCartMutation,
deleteCartLineItemMutation,
updateCartLineItemMutation
} from './mutations/cart';
import { getCartQuery } from './queries/cart';
import { getCategoryQuery, getStoreCategoriesQuery } from './queries/category';
import { getCheckoutQuery } from './queries/checkout';
import { getMenuQuery } from './queries/menu';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getNewestProductsQuery,
getPopularProductsQuery,
getProductQuery,
getProductsCollectionQuery,
getProductsRecommedationsQuery,
searchProductsQuery
} from './queries/product';
import { getEntityIdByRouteQuery } from './queries/route';
import { fetchStorefrontToken } from './storefront-config';
import {
BigCommerceAddToCartOperation,
BigCommerceCart,
BigCommerceCartOperation,
BigCommerceCategoryTreeItem,
BigCommerceCheckoutOperation,
BigCommerceCollectionOperation,
BigCommerceCollectionsOperation,
BigCommerceCreateCartOperation,
BigCommerceDeleteCartItemOperation,
BigCommerceEntityIdOperation,
BigCommerceMenuOperation,
BigCommerceNewestProductsOperation,
BigCommercePageOperation,
BigCommercePagesOperation,
BigCommercePopularProductsOperation,
BigCommerceProductOperation,
BigCommerceProductsCollectionOperation,
BigCommerceRecommendationsOperation,
BigCommerceSearchProductsOperation,
BigCommerceUpdateCartItemOperation,
VercelCart,
VercelCollection,
VercelMenu,
VercelPage,
VercelProduct
} from './types';
const channelIdSegment =
parseInt(process.env.BIGCOMMERCE_CHANNEL_ID!) !== 1
? `-${process.env.BIGCOMMERCE_CHANNEL_ID}`
: '';
const domain = `https://store-${process.env.BIGCOMMERCE_STORE_HASH!}${channelIdSegment}`;
const endpoint = `${domain}.${BIGCOMMERCE_GRAPHQL_API_ENDPOINT}`;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
const getEntityIdByHandle = async (entityHandle: string) => {
const res = await bigcommerceFetch<BigCommerceEntityIdOperation>({
query: getEntityIdByRouteQuery,
variables: {
path: `/${entityHandle}`
}
});
return res.body.data.site.route.node.entityId;
};
export async function bigcommerceFetch<T>({
query,
variables,
headers,
cache = 'force-cache'
}: {
query: string;
variables?: ExtractVariables<T>;
headers?: HeadersInit;
cache?: RequestCache;
}): Promise<{ status: number; body: T } | never> {
try {
const {
data: { token }
} = await fetchStorefrontToken();
const result = await fetch(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
next: { revalidate: 900 } // 15 minutes
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isVercelCommerceError(e)) {
throw {
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const getCategoryEntityIdbyHandle = async (handle: string) => {
const resp = await bigcommerceFetch<BigCommerceMenuOperation>({
query: getMenuQuery
});
const recursiveFindCollectionId = (list: BigCommerceCategoryTreeItem[], slug: string): number => {
const collectionId = list
.flatMap((item): number | null => {
if (item.path.includes(slug!)) {
return item.entityId;
}
if (item.children && item.children.length) {
return recursiveFindCollectionId(item.children!, slug);
}
return null;
})
.filter((id) => typeof id === 'number')[0];
return collectionId!;
};
return recursiveFindCollectionId(resp.body.data.site.categoryTree, handle);
};
const getBigCommerceProductsWithCheckout = async (
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
) => {
const bigCommerceProducts = await Promise.all(
lines.map(async ({ merchandiseId }) => {
const productId = Number(merchandiseId);
const resp = await bigcommerceFetch<BigCommerceProductOperation>({
query: getProductQuery,
variables: {
productId
},
cache: 'no-store'
});
return {
productId,
productData: resp.body.data.site.product
};
})
);
const resCheckout = await bigcommerceFetch<BigCommerceCheckoutOperation>({
query: getCheckoutQuery,
variables: {
entityId: cartId
},
cache: 'no-store'
});
return {
productsByIdList: bigCommerceProducts,
checkout: resCheckout.body.data.site.checkout
};
};
export async function createCart(): Promise<VercelCart> {
// NOTE: on BigCommerce side we can't create cart
// w/t item params as quantity, productEntityId
return {
id: '',
checkoutUrl: '',
cost: {
subtotalAmount: {
amount: '',
currencyCode: ''
},
totalAmount: {
amount: '',
currencyCode: ''
},
totalTaxAmount: {
amount: '',
currencyCode: ''
}
},
lines: [],
totalQuantity: 0
};
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<VercelCart> {
let bigCommerceCart: BigCommerceCart;
if (cartId) {
const res = await bigcommerceFetch<BigCommerceAddToCartOperation>({
query: addCartLineItemMutation,
variables: {
addCartLineItemsInput: {
cartEntityId: cartId,
data: {
lineItems: lines.map(({ merchandiseId, quantity }) => ({
productEntityId: parseInt(merchandiseId, 10),
quantity
}))
}
}
},
cache: 'no-store'
});
bigCommerceCart = res.body.data.cart.addCartLineItems.cart;
} else {
const res = await bigcommerceFetch<BigCommerceCreateCartOperation>({
query: createCartMutation,
variables: {
createCartInput: {
lineItems: lines.map(({ merchandiseId, quantity }) => ({
productEntityId: parseInt(merchandiseId, 10),
quantity
}))
}
},
cache: 'no-store'
});
bigCommerceCart = res.body.data.cart.createCart.cart;
}
const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(
bigCommerceCart.entityId,
lines
);
return bigcommerceToVercelCart(bigCommerceCart, productsByIdList, checkout);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<VercelCart> {
let cartState: { status: number; body: BigCommerceDeleteCartItemOperation };
for (let removals = lineIds.length; removals > 0; removals--) {
const lineId = lineIds[removals - 1]!;
const res = await bigcommerceFetch<BigCommerceDeleteCartItemOperation>({
query: deleteCartLineItemMutation,
variables: {
deleteCartLineItemInput: {
cartEntityId: cartId,
lineItemEntityId: lineId
}
},
cache: 'no-store'
});
cartState = res;
}
const cart = cartState!.body.data.cart.deleteCartLineItem.cart;
const lines = vercelFromBigCommerceLineItems(cart.lineItems);
const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(cartId, lines);
return bigcommerceToVercelCart(cart, productsByIdList, checkout);
}
// NOTE: looks like we can update only product-level update.
// Update on selected options requires variantEntityId, optionEntityId
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<VercelCart> {
let cartState: { status: number; body: BigCommerceUpdateCartItemOperation } | undefined;
for (let updates = lines.length; updates > 0; updates--) {
const { id, merchandiseId, quantity } = lines[updates - 1]!;
const res = await bigcommerceFetch<BigCommerceUpdateCartItemOperation>({
query: updateCartLineItemMutation,
variables: {
updateCartLineItemInput: {
cartEntityId: cartId,
lineItemEntityId: id,
data: {
lineItem: {
quantity,
productEntityId: Number(merchandiseId)
}
}
}
},
cache: 'no-store'
});
cartState = res;
}
const updatedCart = cartState!.body.data.cart.updateCartLineItem.cart;
const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(cartId, lines);
return bigcommerceToVercelCart(updatedCart, productsByIdList, checkout);
}
// NOTE: DONE & review if it works
export async function getCart(cartId: string): Promise<VercelCart | null> {
const res = await bigcommerceFetch<BigCommerceCartOperation>({
query: getCartQuery,
variables: { entityId: cartId },
cache: 'no-store'
});
if (!res.body.data.site.cart) {
return null;
}
const cart = res.body.data.site.cart;
const lines = vercelFromBigCommerceLineItems(cart.lineItems);
const { productsByIdList, checkout } = await getBigCommerceProductsWithCheckout(cartId, lines);
return bigcommerceToVercelCart(cart, productsByIdList, checkout);
}
export async function getCollection(handle: string): Promise<VercelCollection> {
const entityId = await getCategoryEntityIdbyHandle(handle); // NOTE: check if this approach suits us
const res = await bigcommerceFetch<BigCommerceCollectionOperation>({
query: getCategoryQuery,
variables: {
entityId
}
});
return bigCommerceToVercelCollection(res.body.data.site.category);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<VercelProduct[]> {
const expectedCollectionBreakpoints: Record<string, string> = {
'hidden-homepage-carousel': 'carousel_collection',
'hidden-homepage-featured-items': 'featured_collection'
};
if (expectedCollectionBreakpoints[collection] === 'carousel_collection') {
const res = await bigcommerceFetch<BigCommerceNewestProductsOperation>({
query: getNewestProductsQuery,
variables: {
first: 10
}
});
if (!res.body.data.site.newestProducts) {
console.log(`No collection found for \`${collection}\``);
return [];
}
const productList = res.body.data.site.newestProducts.edges.map((item) => item.node);
return bigcommerceToVercelProducts(productList);
}
if (expectedCollectionBreakpoints[collection] === 'featured_collection') {
const res = await bigcommerceFetch<BigCommercePopularProductsOperation>({
query: getPopularProductsQuery,
variables: {
first: 10
}
});
if (!res.body.data.site.bestSellingProducts) {
console.log(`No collection found for \`${collection}\``);
return [];
}
const productList = res.body.data.site.bestSellingProducts.edges.map((item) => item.node);
return bigcommerceToVercelProducts(productList);
}
const entityId = await getCategoryEntityIdbyHandle(collection);
const sortBy = vercelToBigCommerceSorting(reverse ?? false, sortKey);
const res = await bigcommerceFetch<BigCommerceProductsCollectionOperation>({
query: getProductsCollectionQuery,
variables: {
entityId,
first: 10,
hideOutOfStock: false,
sortBy: sortBy === 'RELEVANCE' ? 'DEFAULT' : sortBy
}
});
if (!res.body.data.site.category) {
console.log(`No collection found for \`${collection}\``);
return [];
}
const productList = res.body.data.site.category.products.edges.map((item) => item.node);
return bigcommerceToVercelProducts(productList);
}
export async function getCollections(): Promise<VercelCollection[]> {
const res = await bigcommerceFetch<BigCommerceCollectionsOperation>({
query: getStoreCategoriesQuery
});
const collectionIdList = res.body.data.site.categoryTree.map(({ entityId }) => entityId);
const collections = await Promise.all(
collectionIdList.map(async (entityId) => {
const res = await bigcommerceFetch<BigCommerceCollectionOperation>({
query: getCategoryQuery,
variables: {
entityId
}
});
return bigCommerceToVercelCollection(res.body.data.site.category);
})
);
return collections;
}
export async function getMenu(handle: string): Promise<VercelMenu[]> {
const expectedMenyType = 'footerOrHeader';
const handleToSlug: Record<string, string> = {
'next-js-frontend-footer-menu': expectedMenyType,
'next-js-frontend-header-menu': expectedMenyType
};
const configureMenuPath = (path: string) =>
path
.split('/')
.filter((item) => item.length)
.pop();
const createVercelCollectionPath = (title: string) => `/search/${title}`;
const configureVercelMenu = (
menuData: BigCommerceCategoryTreeItem[],
isMenuData: boolean
): VercelMenu[] => {
if (isMenuData) {
return menuData.flatMap((item) => {
const { name, path, hasChildren, children } = item;
const verceLTitle = configureMenuPath(path);
const vercelMenuItem = {
title: name,
path: createVercelCollectionPath(verceLTitle!)
};
// NOTE: for NavBar we probably should keep it only high level categories
// if (hasChildren && children) {
// return configureVercelMenu(children, hasChildren);
// }
return [vercelMenuItem];
});
}
return [];
};
if (handleToSlug[handle] === expectedMenyType) {
const res = await bigcommerceFetch<BigCommerceMenuOperation>({
query: getMenuQuery
});
return configureVercelMenu(res.body.data.site.categoryTree, true);
}
return [];
}
// TODO: replace with BC API next Page(s) Methods
export async function getPage(handle: string): Promise<VercelPage> {
const entityId = await getEntityIdByHandle(handle);
const res = await bigcommerceFetch<BigCommercePageOperation>({
query: getPageQuery,
variables: {
entityId
}
});
return bigCommerceToVercelPageContent(res.body.data.site.content.page);
}
export async function getPages(): Promise<VercelPage[]> {
const res = await bigcommerceFetch<BigCommercePagesOperation>({
query: getPagesQuery
});
const pagesList = res.body.data.site.content.pages.edges.map((item) => item.node);
return pagesList.map((page) => bigCommerceToVercelPageContent(page));
}
export async function getProduct(handle: string): Promise<VercelProduct | undefined> {
// const productId = await getEntityIdByHandle(handle); // NOTE: check of this approach work
const res = await bigcommerceFetch<BigCommerceProductOperation>({
query: getProductQuery,
variables: {
productId: parseInt(handle, 10)
}
});
return bigcommerceToVercelProduct(res.body.data.site.product);
}
export async function getProductRecommendations(productId: string): Promise<VercelProduct[]> {
const res = await bigcommerceFetch<BigCommerceRecommendationsOperation>({
query: getProductsRecommedationsQuery,
variables: {
productId: productId
}
});
const productList = res.body.data.site.product.relatedProducts.edges.map((item) => item.node);
return bigcommerceToVercelProducts(productList);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<VercelProduct[]> {
const sort = vercelToBigCommerceSorting(reverse ?? false, sortKey);
const res = await bigcommerceFetch<BigCommerceSearchProductsOperation>({
query: searchProductsQuery,
variables: {
filters: {
searchTerm: query || ''
},
sort
}
});
const productList = res.body.data.site.search.searchProducts.products.edges.map(
(item) => item.node
);
return bigcommerceToVercelProducts(productList);
}

336
lib/bigcommerce/mappers.ts Normal file
View File

@ -0,0 +1,336 @@
import { BigCommerceSortKeys, VercelSortKeys, vercelToBigCommerceSortKeys } from 'lib/constants';
import {
BigCommerceCart,
BigCommerceCheckout,
BigCommerceCollection,
BigCommercePage,
BigCommerceProduct,
BigCommerceProductOption,
BigCommerceProductVariant,
CartCustomItem,
DigitalOrPhysicalItem,
VercelCart,
VercelCartItem,
VercelCollection,
VercelPage,
VercelProduct,
VercelProductOption,
VercelProductVariant
} from './types';
type ProductsList = { productId: number; productData: BigCommerceProduct }[];
const vercelFromBigCommerceLineItems = (lineItems: BigCommerceCart['lineItems']) => {
const { physicalItems, digitalItems, customItems } = lineItems;
const cartItemMapper = ({ entityId, quantity }: DigitalOrPhysicalItem | CartCustomItem) => ({
merchandiseId: entityId.toString(),
quantity
});
return [physicalItems, digitalItems, customItems].flatMap((list) => list.map(cartItemMapper));
};
const bigcommerceToVercelOptions = (options: BigCommerceProductOption[]): VercelProductOption[] => {
return options.map((option) => {
return {
id: option.entityId.toString(),
name: option.displayName.toString(),
values: option.values ? option.values.edges.map(({ node: value }) => value.label) : []
};
});
};
const bigcommerceToVercelVariants = (
variants: BigCommerceProductVariant[],
productId: number
): VercelProductVariant[] => {
return variants.map((variant) => {
return {
parentId: productId.toString(),
id: variant.entityId.toString(),
title: '',
availableForSale: variant.isPurchasable,
selectedOptions: variant.options?.edges.map(({ node: option }) => ({
name: option.displayName ?? '',
value: option.values.edges.map(({ node }) => node.label)[0] ?? ''
})) || [
{
name: '',
value: ''
}
],
price: {
amount:
variant.prices?.price.value.toString() ||
variant.prices?.priceRange.max.value.toString() ||
'0',
currencyCode:
variant.prices?.price.currencyCode || variant.prices?.priceRange.max.currencyCode || ''
}
};
});
};
const bigcommerceToVercelProduct = (product: BigCommerceProduct): VercelProduct => {
const createVercelProductImage = (img: { url: string; altText: string }) => {
return {
url: img.url,
altText: img.altText,
width: 2048,
height: 2048
};
};
const options = product.productOptions.edges.length
? bigcommerceToVercelOptions(product.productOptions.edges.map((item) => item.node))
: [];
const variants = product.variants.edges.length
? bigcommerceToVercelVariants(
product.variants.edges.map((item) => item.node),
product.entityId
)
: [];
return {
id: product.id.toString(),
handle: product.entityId.toString(),
availableForSale: product.availabilityV2.status === 'Available' ? true : false,
title: product.name,
description: product.plainTextDescription || '',
descriptionHtml: product.description ?? '',
options,
priceRange: {
maxVariantPrice: {
amount:
product.prices.priceRange.max.value.toString() ||
product.prices.price.value.toString() ||
'0',
currencyCode:
product.prices.priceRange.max.currencyCode || product.prices.price.currencyCode || ''
},
minVariantPrice: {
amount:
product.prices.priceRange.min.value.toString() ||
product.prices.price.value.toString() ||
'0',
currencyCode:
product.prices.priceRange.min.currencyCode || product.prices.price.currencyCode || ''
}
},
variants,
images: product.images
? product.images.edges.map(({ node: img }) => createVercelProductImage(img))
: [],
featuredImage: createVercelProductImage(product.defaultImage),
seo: {
title: product.seo.pageTitle || product.name,
description: product.seo.metaDescription || ''
},
tags: [product.seo.metaKeywords] || [],
updatedAt: product.createdAt.utc.toString()
};
};
const bigcommerceToVercelProducts = (products: BigCommerceProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = bigcommerceToVercelProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
const bigcommerceToVercelCartItems = (
lineItems: BigCommerceCart['lineItems'],
products: ProductsList
) => {
const getItemMapper = (products: ProductsList, isCustomItem: boolean = false) => {
return (item: CartCustomItem | DigitalOrPhysicalItem): VercelCartItem => {
const vercelProductFallback = {
id: '',
handle: '',
availableForSale: false,
title: '',
description: '',
descriptionHtml: '',
options: [],
priceRange: {
maxVariantPrice: { amount: '', currencyCode: '' },
minVariantPrice: { amount: '', currencyCode: '' }
},
variants: [],
featuredImage: {
url: '',
altText: '',
width: 0,
height: 0
},
images: [
{
url: '',
altText: '',
width: 0,
height: 0
}
],
seo: { title: '', description: '' },
tags: [],
updatedAt: ''
};
let product;
let selectedOptions;
if (isCustomItem) {
product = vercelProductFallback;
selectedOptions = [{ name: '', value: '' }];
} else {
const productData = products.filter(
({ productId }) => productId === (item as DigitalOrPhysicalItem).productEntityId
)[0]?.productData;
product = productData ? bigcommerceToVercelProduct(productData) : vercelProductFallback;
selectedOptions = (item as DigitalOrPhysicalItem).selectedOptions.map((option) => ({
name: option.name,
value: option.value || option.text || option.number?.toString() || option.fileName || ''
}));
}
return {
id: item.entityId.toString(),
quantity: item.quantity,
cost: {
totalAmount: {
amount:
item.extendedListPrice.value.toString() || item.listPrice.value.toString() || '0',
currencyCode: item.extendedListPrice.currencyCode || item.listPrice.currencyCode || ''
}
},
merchandise: {
id: item.entityId.toString(),
title: `${item.name}`,
selectedOptions,
product
}
};
};
};
const { physicalItems, digitalItems, customItems } = lineItems;
const areCustomItemsInCart = customItems.length > 0;
const line1 = physicalItems.map((item) => getItemMapper(products)(item));
const line2 = digitalItems.map((item) => getItemMapper(products)(item));
const line3 = areCustomItemsInCart
? customItems.map((item) => getItemMapper(products, areCustomItemsInCart)(item))
: [];
return [...line1, ...line2, ...line3];
};
const bigcommerceToVercelCart = (
cart: BigCommerceCart,
products: ProductsList,
checkout: BigCommerceCheckout
): VercelCart => {
return {
id: cart.entityId,
checkoutUrl: '', // NOTE: where to get checkoutUrl??
cost: {
// NOTE: these props lay down in checkout not cart
subtotalAmount: {
amount: checkout.subtotal.value.toString(),
currencyCode: checkout.subtotal.currencyCode
},
totalAmount: {
amount: checkout.grandTotal.value.toString(),
currencyCode: checkout.grandTotal.currencyCode
},
totalTaxAmount: {
amount: checkout.taxTotal.value.toString(),
currencyCode: checkout.taxTotal.currencyCode
}
},
lines: bigcommerceToVercelCartItems(cart.lineItems, products),
totalQuantity: cart.lineItems.totalQuantity
};
};
const bigCommerceToVercelCollection = (collection: BigCommerceCollection): VercelCollection => {
if (!collection) {
return {
handle: '',
title: '',
description: '',
seo: {
title: '',
description: ''
},
updatedAt: '',
path: ''
};
}
return {
handle: collection.entityId.toString() || collection.name,
title: collection.name,
description: collection.description,
seo: {
title: collection.seo.pageTitle,
description: collection.seo.metaDescription
},
updatedAt: new Date().toISOString(),
path: `/search${collection.path}`
};
};
export {
bigcommerceToVercelCart,
bigcommerceToVercelProduct,
bigcommerceToVercelProducts,
bigCommerceToVercelCollection,
vercelFromBigCommerceLineItems
};
export const vercelToBigCommerceSorting = (
isReversed: boolean,
sortKey?: string
): keyof typeof BigCommerceSortKeys | null => {
const VercelSorting: Record<string, string> = {
RELEVANCE: 'RELEVANCE',
BEST_SELLING: 'BEST_SELLING',
CREATED_AT: 'CREATED_AT',
PRICE: 'PRICE'
};
if (!sortKey || VercelSorting[sortKey] === undefined) {
return null;
}
if (sortKey === VercelSortKeys.PRICE) {
return isReversed
? vercelToBigCommerceSortKeys.PRICE_ON_REVERSE
: vercelToBigCommerceSortKeys.PRICE;
}
return vercelToBigCommerceSortKeys[sortKey as keyof typeof VercelSortKeys];
};
export const bigCommerceToVercelPageContent = (page: BigCommercePage): VercelPage => {
return {
id: page.entityId.toString(),
title: page.name,
handle: page.path,
body: page.htmlBody ?? '',
bodySummary: page.plainTextSummary ?? '',
seo: {
title: page.seo.pageTitle,
description: page.seo.metaDescription
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
};

View File

@ -0,0 +1,152 @@
import { customItemFragment, digitalItemFragment, physicalItemFragment } from '../fragments/cart';
const addCartLineItemMutation = /* GraphQL */ `
mutation addCartLineItems($addCartLineItemsInput: AddCartLineItemsInput!) {
cart {
addCartLineItems(input: $addCartLineItemsInput) {
cart {
entityId
amount {
currencyCode
value
}
lineItems {
totalQuantity
physicalItems {
...physicalItem
}
digitalItems {
...digitalItem
}
customItems {
...customItem
}
}
}
}
}
}
${physicalItemFragment}
${digitalItemFragment}
${customItemFragment}
`;
const createCartMutation = /* GraphQL */ `
mutation createCart($createCartInput: CreateCartInput!) {
cart {
createCart(input: $createCartInput) {
cart {
entityId
amount {
currencyCode
value
}
lineItems {
totalQuantity
physicalItems {
...physicalItem
}
digitalItems {
...digitalItem
}
customItems {
...customItem
}
giftCertificates {
name
}
}
}
}
}
}
${physicalItemFragment}
${digitalItemFragment}
${customItemFragment}
`;
const deleteCartLineItemMutation = /* GraphQL */ `
mutation deleteCartLineItem($deleteCartLineItemInput: DeleteCartLineItemInput!) {
cart {
deleteCartLineItem(input: $deleteCartLineItemInput) {
deletedLineItemEntityId
deletedCartEntityId
cart {
entityId
amount {
currencyCode
value
}
lineItems {
physicalItems {
...physicalItem
}
digitalItems {
...digitalItem
}
customItems {
...customItem
}
totalQuantity
}
}
}
}
}
${physicalItemFragment}
${digitalItemFragment}
${customItemFragment}
`;
const updateCartLineItemMutation = /* GraphQL */ `
mutation updateCartLineItem($updateCartLineItemInput: UpdateCartLineItemInput!) {
cart {
updateCartLineItem(input: $updateCartLineItemInput) {
cart {
entityId
amount {
currencyCode
value
}
updatedAt {
utc
}
totalQuantity
lineItems {
totalQuantity
physicalItems {
...physicalItem
}
digitalItems {
...digitalItem
}
customItems {
...customItem
}
}
}
}
}
}
${physicalItemFragment}
${digitalItemFragment}
${customItemFragment}
`;
const deleteCartMutation = /* GraphQL */ `
mutation deleteCart($deleteCartInput: DeleteCartInput!) {
cart {
deleteCart(input: $deleteCartInput) {
deletedCartEntityId
}
}
}
`;
export {
createCartMutation,
addCartLineItemMutation,
updateCartLineItemMutation,
deleteCartLineItemMutation,
deleteCartMutation
};

View File

@ -0,0 +1,94 @@
import { customItemFragment, digitalItemFragment, physicalItemFragment } from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($entityId: String!) {
site {
cart(entityId: $entityId) {
entityId
currencyCode
isTaxIncluded
amount {
currencyCode
value
}
lineItems {
physicalItems {
...physicalItem
}
digitalItems {
...digitalItem
}
customItems {
...customItem
}
giftCertificates {
entityId
name
theme
amount {
currencyCode
value
}
isTaxable
sender {
name
email
}
recipient {
name
email
}
message
}
customItems {
entityId
sku
name
quantity
listPrice {
currencyCode
value
}
extendedListPrice {
currencyCode
value
}
}
totalQuantity
}
createdAt {
utc
}
updatedAt {
utc
}
locale
}
}
}
${physicalItemFragment}
${digitalItemFragment}
${customItemFragment}
`;
export const getCheckoutNodeQuery = /* GraphQL */ `
query getCheckoutNode($nodeId: ID!) {
node(id: $nodeId) {
... on Checkout {
entityId
subtotal {
currencyCode
value
}
taxTotal {
currencyCode
value
}
grandTotal {
currencyCode
value
}
}
}
}
`;

View File

@ -0,0 +1,28 @@
export const getCategoryQuery = /* GraphQL */ `
query getCategory($entityId: Int!) {
site {
category(entityId: $entityId) {
entityId
name
path
description
seo {
metaDescription
metaKeywords
pageTitle
}
}
}
}
`;
export const getStoreCategoriesQuery = /* GraphQL */ `
query getStoreCategories {
site {
categoryTree {
entityId
name
}
}
}
`;

View File

@ -0,0 +1,20 @@
export const getCheckoutQuery = /* GraphQL */ `
query getCheckout($entityId: String) {
site {
checkout(entityId: $entityId) {
subtotal {
currencyCode
value
}
taxTotal {
currencyCode
value
}
grandTotal {
currencyCode
value
}
}
}
}
`;

View File

@ -0,0 +1,21 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu {
site {
categoryTree {
...CategoryFields
children {
...CategoryFields
children {
...CategoryFields
}
}
}
}
}
fragment CategoryFields on CategoryTreeItem {
hasChildren
entityId
name
path
}
`;

View File

@ -0,0 +1,67 @@
import { pageContentFragment } from '../fragments/page';
export const getPageQuery = /* GraphQL */ `
query getPage($entityId: Int!) {
site {
content {
page(entityId: $entityId) {
...pageContent
... on NormalPage {
plainTextSummary(characterLimit: 100)
htmlBody
path
}
... on ContactPage {
plainTextSummary(characterLimit: 100)
htmlBody
path
}
... on BlogIndexPage {
path
}
... on RawHtmlPage {
plainTextSummary(characterLimit: 100)
htmlBody
path
}
}
}
}
}
${pageContentFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
site {
content {
pages {
edges {
node {
...pageContent
... on NormalPage {
plainTextSummary(characterLimit: 100)
htmlBody
path
}
... on ContactPage {
plainTextSummary(characterLimit: 100)
htmlBody
path
}
... on BlogIndexPage {
path
}
... on RawHtmlPage {
plainTextSummary(characterLimit: 100)
htmlBody
path
}
}
}
}
}
}
}
${pageContentFragment}
`;

View File

@ -0,0 +1,130 @@
import { productFragment } from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query productById($productId: Int!) {
site {
product(entityId: $productId) {
...product
}
}
}
${productFragment}
`;
export const getProductsCollectionQuery = /* GraphQL */ `
query getProductsCollection(
$entityId: Int!
$sortBy: CategoryProductSort
$hideOutOfStock: Boolean
$first: Int
) {
site {
category(entityId: $entityId) {
products(sortBy: $sortBy, hideOutOfStock: $hideOutOfStock, first: $first) {
edges {
node {
...product
}
}
}
}
}
}
${productFragment}
`;
export const getStoreProductsQuery = /* GraphQL */ `
query getStoreProducts($first: Int, $entityIds: [number!]) {
site {
products(first: $first, entityIds: $entityIds) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;
export const searchProductsQuery = /* GraphQL */ `
query searchProducts($filters: SearchProductsFiltersInput!, $sort: SearchProductsSortInput) {
site {
search {
searchProducts(filters: $filters, sort: $sort) {
products {
edges {
node {
...product
}
}
}
}
}
}
}
${productFragment}
`;
export const getProductsRecommedationsQuery = /* GraphQL */ `
query getProductsRecommedations($productId: ID) {
site {
product(id: $productId) {
relatedProducts {
edges {
node {
...product
}
}
}
}
}
}
${productFragment}
`;
export const getNewestProductsQuery = /* GraphQL */ `
query getNewestProducts($first: Int) {
site {
newestProducts(first: $first) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;
export const getFeaturedProductsQuery = /* GraphQL */ `
query getFeaturedProducts($first: Int) {
site {
featuredProducts(first: $first) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;
export const getPopularProductsQuery = /* GraphQL */ `
query bestSellingProducts($first: Int) {
site {
bestSellingProducts(first: $first) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -0,0 +1,30 @@
export const getEntityIdByRouteQuery = /* GraphQL */ `
query getEntityIdByRoute($path: String!) {
site {
route(path: $path) {
node {
__typename
... on Product {
entityId
}
... on Category {
entityId
}
... on Brand {
entityId
}
# NOTE: this API is still not public
# ... on NormalPage {
# entityId
# }
# ... on ContactPage {
# entityId
# }
# ... on RawHtmlPage {
# entityId
# }
}
}
}
}
`;

View File

@ -0,0 +1,29 @@
import { BIGCOMMERCE_API_URL } from './constants';
interface StorefrontTokenResponse {
data: {
token: string;
};
meta: unknown;
}
export const fetchStorefrontToken = async () => {
const response = await fetch(
`${BIGCOMMERCE_API_URL}/stores/${process.env.BIGCOMMERCE_STORE_HASH}/v3/storefront/api-token-customer-impersonation`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
'x-auth-token': process.env.BIGCOMMERCE_ACCESS_TOKEN!,
'x-bc-customer-id': ''
},
body: JSON.stringify({
channel_id: parseInt(process.env.BIGCOMMERCE_CHANNEL_ID!),
expires_at: Math.floor(new Date().getTime() / 1000) + 1 * 24 * 60 * 60 // 1 day
})
}
);
return (await response.json()) as StorefrontTokenResponse;
};

602
lib/bigcommerce/types.ts Normal file
View File

@ -0,0 +1,602 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type VercelPage = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: VercelSEO;
createdAt: string;
updatedAt: string;
};
export type VercelMenu = {
title: string;
path: string;
};
export type VercelCollection = {
handle: string;
title: string;
description: string;
seo: VercelSEO;
updatedAt: string;
path: string;
};
type VercelMoney = {
amount: string;
currencyCode: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type VercelProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: VercelProductOption[];
priceRange: {
maxVariantPrice: VercelMoney;
minVariantPrice: VercelMoney;
};
variants: VercelProductVariant[];
featuredImage: Image;
images: Image[];
seo: VercelSEO;
tags: string[];
updatedAt: string;
};
export type VercelProductOption = {
id: string;
name: string;
values: string[];
};
export type VercelProductVariant = {
parentId?: string;
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: VercelMoney;
};
export type VercelSEO = {
title: string;
description: string;
};
export type VercelCartItem = {
id: string;
quantity: number;
cost: {
totalAmount: VercelMoney;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: VercelProduct;
};
};
export type VercelCart = {
id: string;
checkoutUrl: string;
cost: {
subtotalAmount: VercelMoney;
totalAmount: VercelMoney;
totalTaxAmount: VercelMoney;
};
lines: VercelCartItem[];
totalQuantity: number;
};
export type BigCommerceCartOperation = {
data: {
site: {
cart: BigCommerceCart;
};
};
variables: {
entityId: string;
};
};
export type BigCommerceCreateCartOperation = {
data: {
cart: {
createCart: {
cart: BigCommerceCart;
};
};
};
variables: {
createCartInput: {
lineItems: CartItem[];
};
};
};
export type BigCommerceAddToCartOperation = {
data: {
cart: {
addCartLineItems: {
cart: BigCommerceCart;
};
};
};
variables: {
addCartLineItemsInput: {
cartEntityId: string;
data: {
lineItems: CartItem[];
};
};
};
};
export type BigCommerceDeleteCartItemOperation = {
data: {
cart: {
deleteCartLineItem: {
cart: BigCommerceCart;
};
};
};
variables: {
deleteCartLineItemInput: {
cartEntityId: string;
lineItemEntityId: string;
};
};
};
export type BigCommerceUpdateCartItemOperation = {
data: {
cart: {
updateCartLineItem: {
cart: BigCommerceCart;
};
};
};
variables: {
updateCartLineItemInput: {
cartEntityId: string;
lineItemEntityId: string;
data: {
lineItem: CartItem;
};
};
};
};
export type BigCommerceCheckoutOperation = {
data: {
site: {
checkout: BigCommerceCheckout;
};
};
variables: {
entityId: string;
};
};
export type BigCommerceProductOperation = {
data: {
site: {
product: BigCommerceProduct;
};
};
variables: {
productId: number;
};
};
export type BigCommerceProductsOperation = {
data: {
site: {
products: Connection<BigCommerceProduct>;
};
};
variables: {
entityIds: number[] | [];
};
};
export type BigCommerceEntityIdOperation = {
data: {
site: {
route: {
node: {
__typename:
| 'Product'
| 'Category'
| 'Brand'
| 'NormalPage'
| 'ContactPage'
| 'RawHtmlPage'
| 'BlogIndexPage';
entityId: number;
};
};
};
};
variables: {
path: string;
};
};
export type BigCommerceRecommendationsOperation = {
data: {
site: {
product: {
relatedProducts: Connection<BigCommerceProduct>;
};
};
};
variables: {
productId: number | string;
};
};
export type BigCommerceSearchProductsOperation = {
data: {
site: {
search: {
searchProducts: {
products: Connection<BigCommerceProduct>;
};
};
};
};
variables: {
filters: {
searchTerm: string;
};
sort: string | null;
};
};
export type BigCommerceMenuOperation = {
data: {
site: {
categoryTree: BigCommerceCategoryTreeItem[];
};
};
};
export type BigCommerceCollectionOperation = {
data: {
site: {
category: BigCommerceCollection;
};
};
variables: {
entityId: number;
};
};
export type BigCommerceProductsCollectionOperation = {
data: {
site: {
category: {
products: Connection<BigCommerceProduct>;
};
};
};
variables: {
entityId: number;
sortBy: string | null;
hideOutOfStock: boolean;
first: number;
};
};
export type BigCommerceNewestProductsOperation = {
data: {
site: {
newestProducts: Connection<BigCommerceProduct>;
};
};
variables: {
first: number;
};
};
export type BigCommerceFeaturedProductsOperation = {
data: {
site: {
featuredProducts: Connection<BigCommerceProduct>;
};
};
variables: {
first: number;
};
};
export type BigCommercePopularProductsOperation = {
data: {
site: {
bestSellingProducts: Connection<BigCommerceProduct>;
};
};
variables: {
first: number;
};
};
export type BigCommerceCollectionsOperation = {
data: {
site: {
categoryTree: BigCommerceCategoryWithId[];
};
};
};
export type BigCommercePageOperation = {
data: {
site: {
content: {
page: BigCommercePage;
};
};
};
variables: { entityId: number };
};
export type BigCommercePagesOperation = {
data: {
site: {
content: {
pages: Connection<BigCommercePage>;
};
};
};
};
export type BigCommerceCheckout = {
subtotal: BigCommerceMoney;
grandTotal: BigCommerceMoney;
taxTotal: BigCommerceMoney;
};
export type BigCommerceCategoryWithId = Omit<BigCommerceCollection, 'description' | 'seo' | 'path'>;
export type BigCommerceSEO = {
pageTitle: string;
metaDescription: string;
metaKeywords: string;
};
export type BigCommerceCollection = {
entityId: number;
name: string;
path: string;
description: string;
seo: BigCommerceSEO;
};
export type BigCommerceCart = {
entityId: string;
currencyCode: string;
isTaxIncluded: boolean;
baseAmount: BigCommerceMoney;
discountedAmount: BigCommerceMoney;
amount: BigCommerceMoney;
discounts: CartDiscount[];
lineItems: CartLineItems;
createdAt: { utc: Date };
updatedAt: { utc: Date };
locale: string;
};
type CartLineItems = {
physicalItems: DigitalOrPhysicalItem[];
digitalItems: DigitalOrPhysicalItem[];
customItems: CartCustomItem[];
giftCertificates: CartGiftCertificate[];
totalQuantity: number;
};
type CartItem = {
quantity: number;
productEntityId: number;
variantEntityId?: number;
};
export type BigCommerceCategoryTreeItem = {
name: string;
path: string;
hasChildren: boolean;
entityId: number;
children?: BigCommerceCategoryTreeItem[];
};
export type BigCommercePage = {
__typename: 'NormalPage' | 'ContactPage' | 'RawHtmlPage' | 'BlogIndexPage';
entityId: number;
name: string;
seo: BigCommerceSEO;
path: string;
plainTextSummary?: string;
htmlBody?: string;
};
export type BigCommerceMoney = {
value: number;
currencyCode: string;
};
type CartDiscount = {
entityId: string;
discountedAmount: BigCommerceMoney;
};
type CartGiftCertificatePersonDetails = {
name: string;
email: string;
};
export type DigitalOrPhysicalItem = {
entityId: number;
parentEntityId: number | null;
productEntityId: number;
variantEntityId: number | null;
sku: string;
name: string;
url: string;
imageUrl: string | null;
brand: string | null;
quantity: number;
isTaxable: boolean;
listPrice: BigCommerceMoney;
extendedListPrice: BigCommerceMoney;
selectedOptions: {
entityId: number;
name: string;
value?: string;
date?: { utc: Date };
text?: string;
number?: string;
fileName?: ScrollSetting;
}[];
isShippingRequired: boolean;
};
export type CartCustomItem = {
entityId: string;
sku: string;
name: string;
quantity: number;
listPrice: BigCommerceMoney;
extendedListPrice: BigCommerceMoney;
};
type CartGiftCertificate = {
entityId: number;
name: string;
amount: BigCommerceMoney;
isTaxable: boolean;
message: string;
sender: CartGiftCertificatePersonDetails;
recipient: CartGiftCertificatePersonDetails;
};
export type BigCommerceProductVariant = {
id: number;
entityId: number;
sku: string;
upc: string | null;
isPurchasable: boolean;
prices: {
price: BigCommerceMoney;
priceRange: {
min: BigCommerceMoney;
max: BigCommerceMoney;
};
};
options: {
edges: Array<{
node: {
entityId: number;
displayName: string;
values: {
edges: Array<{
node: {
entityId: number;
label: string;
};
}>;
};
};
}>;
};
};
export type BigCommerceProductOption = {
__typename: string;
entityId: number;
displayName: string;
isRequired: boolean;
displayStyle: string;
values: {
edges: Array<{
node: {
entityId: number;
label: string;
isDefault: boolean;
hexColors: string[];
imageUrl: string | null;
isSelected: boolean;
};
}>;
};
};
export type BigCommerceProduct = {
id: number;
entityId: number;
sku: string;
upc: string | null;
name: string;
brand: {
name: string;
} | null;
plainTextDescription: string;
description: string;
availabilityV2: {
status: string;
description: string;
};
defaultImage: {
url: string;
altText: string;
};
images: {
edges: Array<{
node: {
url: string;
altText: string;
};
}>;
};
seo: BigCommerceSEO;
prices: {
price: BigCommerceMoney;
priceRange: {
min: BigCommerceMoney;
max: BigCommerceMoney;
};
};
createdAt: {
utc: Date;
};
variants: Connection<BigCommerceProductVariant>;
productOptions: Connection<BigCommerceProductOption>;
};

View File

@ -1,7 +1,34 @@
export enum BigCommerceSortKeys {
A_TO_Z = 'A_TO_Z',
BEST_REVIEWED = 'BEST_REVIEWED',
BEST_SELLING = 'BEST_SELLING',
RELEVANCE = 'RELEVANCE',
FEATURED = 'FEATURED',
HIGHEST_PRICE = 'HIGHEST_PRICE',
LOWEST_PRICE = 'LOWEST_PRICE',
NEWEST = 'NEWEST',
Z_TO_A = 'Z_TO_A'
}
export enum VercelSortKeys {
RELEVANCE = 'RELEVANCE',
BEST_SELLING = 'BEST_SELLING',
CREATED_AT = 'CREATED_AT',
PRICE = 'PRICE'
}
export enum vercelToBigCommerceSortKeys {
RELEVANCE = 'RELEVANCE',
BEST_SELLING = 'BEST_SELLING',
CREATED_AT = 'NEWEST',
PRICE = 'LOWEST_PRICE',
PRICE_ON_REVERSE = 'HIGHEST_PRICE'
}
export type SortFilterItem = { export type SortFilterItem = {
title: string; title: string;
slug: string | null; slug: string | null;
sortKey: 'RELEVANCE' | 'BEST_SELLING' | 'CREATED_AT' | 'PRICE'; sortKey: keyof typeof VercelSortKeys;
reverse: boolean; reverse: boolean;
}; };
@ -22,4 +49,3 @@ export const sorting: SortFilterItem[] = [
export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden'; export const HIDDEN_PRODUCT_TAG = 'nextjs-frontend-hidden';
export const DEFAULT_OPTION = 'Default Title'; export const DEFAULT_OPTION = 'Default Title';
export const SHOPIFY_GRAPHQL_API_ENDPOINT = '/api/2023-01/graphql.json';

View File

@ -1,53 +0,0 @@
import productFragment from './product';
const cartFragment = /* GraphQL */ `
fragment cart on Cart {
id
checkoutUrl
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
totalTaxAmount {
amount
currencyCode
}
}
lines(first: 100) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
selectedOptions {
name
value
}
product {
...product
}
}
}
}
}
}
totalQuantity
}
${productFragment}
`;
export default cartFragment;

View File

@ -1,10 +0,0 @@
const imageFragment = /* GraphQL */ `
fragment image on Image {
url
altText
width
height
}
`;
export default imageFragment;

View File

@ -1,64 +0,0 @@
import imageFragment from './image';
import seoFragment from './seo';
const productFragment = /* GraphQL */ `
fragment product on Product {
id
handle
availableForSale
title
description
descriptionHtml
options {
id
name
values
}
priceRange {
maxVariantPrice {
amount
currencyCode
}
minVariantPrice {
amount
currencyCode
}
}
variants(first: 250) {
edges {
node {
id
title
availableForSale
selectedOptions {
name
value
}
price {
amount
currencyCode
}
}
}
}
featuredImage {
...image
}
images(first: 20) {
edges {
node {
...image
}
}
}
seo {
...seo
}
tags
updatedAt
}
${imageFragment}
${seoFragment}
`;
export default productFragment;

View File

@ -1,8 +0,0 @@
const seoFragment = /* GraphQL */ `
fragment seo on SEO {
description
title
}
`;
export default seoFragment;

View File

@ -1,385 +0,0 @@
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT } from 'lib/constants';
import { isShopifyError } from 'lib/type-guards';
import {
addToCartMutation,
createCartMutation,
editCartItemsMutation,
removeFromCartMutation
} from './mutations/cart';
import { getCartQuery } from './queries/cart';
import {
getCollectionProductsQuery,
getCollectionQuery,
getCollectionsQuery
} from './queries/collection';
import { getMenuQuery } from './queries/menu';
import { getPageQuery, getPagesQuery } from './queries/page';
import {
getProductQuery,
getProductRecommendationsQuery,
getProductsQuery
} from './queries/product';
import {
Cart,
Collection,
Connection,
Menu,
Page,
Product,
ShopifyAddToCartOperation,
ShopifyCart,
ShopifyCartOperation,
ShopifyCollection,
ShopifyCollectionOperation,
ShopifyCollectionProductsOperation,
ShopifyCollectionsOperation,
ShopifyCreateCartOperation,
ShopifyMenuOperation,
ShopifyPageOperation,
ShopifyPagesOperation,
ShopifyProduct,
ShopifyProductOperation,
ShopifyProductRecommendationsOperation,
ShopifyProductsOperation,
ShopifyRemoveFromCartOperation,
ShopifyUpdateCartOperation
} from './types';
const domain = `https://${process.env.SHOPIFY_STORE_DOMAIN!}`;
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
export async function shopifyFetch<T>({
query,
variables,
headers,
cache = 'force-cache'
}: {
query: string;
variables?: ExtractVariables<T>;
headers?: HeadersInit;
cache?: RequestCache;
}): Promise<{ status: number; body: T } | never> {
try {
const result = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': key,
...headers
},
body: JSON.stringify({
...(query && { query }),
...(variables && { variables })
}),
cache,
next: { revalidate: 900 } // 15 minutes
});
const body = await result.json();
if (body.errors) {
throw body.errors[0];
}
return {
status: result.status,
body
};
} catch (e) {
if (isShopifyError(e)) {
throw {
status: e.status || 500,
message: e.message,
query
};
}
throw {
error: e,
query
};
}
}
const removeEdgesAndNodes = (array: Connection<any>) => {
return array.edges.map((edge) => edge?.node);
};
const reshapeCart = (cart: ShopifyCart): Cart => {
if (!cart.cost?.totalTaxAmount) {
cart.cost.totalTaxAmount = {
amount: '0.0',
currencyCode: 'USD'
};
}
return {
...cart,
lines: removeEdgesAndNodes(cart.lines)
};
};
const reshapeCollection = (collection: ShopifyCollection): Collection | undefined => {
if (!collection) {
return undefined;
}
return {
...collection,
path: `/search/${collection.handle}`
};
};
const reshapeCollections = (collections: ShopifyCollection[]) => {
const reshapedCollections = [];
for (const collection of collections) {
if (collection) {
const reshapedCollection = reshapeCollection(collection);
if (reshapedCollection) {
reshapedCollections.push(reshapedCollection);
}
}
}
return reshapedCollections;
};
const reshapeProduct = (product: ShopifyProduct, filterHiddenProducts: boolean = true) => {
if (!product || (filterHiddenProducts && product.tags.includes(HIDDEN_PRODUCT_TAG))) {
return undefined;
}
const { images, variants, ...rest } = product;
return {
...rest,
images: removeEdgesAndNodes(images),
variants: removeEdgesAndNodes(variants)
};
};
const reshapeProducts = (products: ShopifyProduct[]) => {
const reshapedProducts = [];
for (const product of products) {
if (product) {
const reshapedProduct = reshapeProduct(product);
if (reshapedProduct) {
reshapedProducts.push(reshapedProduct);
}
}
}
return reshapedProducts;
};
export async function createCart(): Promise<Cart> {
const res = await shopifyFetch<ShopifyCreateCartOperation>({
query: createCartMutation,
cache: 'no-store'
});
return reshapeCart(res.body.data.cartCreate.cart);
}
export async function addToCart(
cartId: string,
lines: { merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyAddToCartOperation>({
query: addToCartMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesAdd.cart);
}
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
const res = await shopifyFetch<ShopifyRemoveFromCartOperation>({
query: removeFromCartMutation,
variables: {
cartId,
lineIds
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesRemove.cart);
}
export async function updateCart(
cartId: string,
lines: { id: string; merchandiseId: string; quantity: number }[]
): Promise<Cart> {
const res = await shopifyFetch<ShopifyUpdateCartOperation>({
query: editCartItemsMutation,
variables: {
cartId,
lines
},
cache: 'no-store'
});
return reshapeCart(res.body.data.cartLinesUpdate.cart);
}
export async function getCart(cartId: string): Promise<Cart | null> {
const res = await shopifyFetch<ShopifyCartOperation>({
query: getCartQuery,
variables: { cartId },
cache: 'no-store'
});
if (!res.body.data.cart) {
return null;
}
return reshapeCart(res.body.data.cart);
}
export async function getCollection(handle: string): Promise<Collection | undefined> {
const res = await shopifyFetch<ShopifyCollectionOperation>({
query: getCollectionQuery,
variables: {
handle
}
});
return reshapeCollection(res.body.data.collection);
}
export async function getCollectionProducts({
collection,
reverse,
sortKey
}: {
collection: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyCollectionProductsOperation>({
query: getCollectionProductsQuery,
variables: {
handle: collection,
reverse,
sortKey
}
});
if (!res.body.data.collection) {
console.log(`No collection found for \`${collection}\``);
return [];
}
return reshapeProducts(removeEdgesAndNodes(res.body.data.collection.products));
}
export async function getCollections(): Promise<Collection[]> {
const res = await shopifyFetch<ShopifyCollectionsOperation>({ query: getCollectionsQuery });
const shopifyCollections = removeEdgesAndNodes(res.body?.data?.collections);
const collections = [
{
handle: '',
title: 'All',
description: 'All products',
seo: {
title: 'All',
description: 'All products'
},
path: '/search',
updatedAt: new Date().toISOString()
},
// Filter out the `hidden` collections.
// Collections that start with `hidden-*` need to be hidden on the search page.
...reshapeCollections(shopifyCollections).filter(
(collection) => !collection.handle.startsWith('hidden')
)
];
return collections;
}
export async function getMenu(handle: string): Promise<Menu[]> {
const res = await shopifyFetch<ShopifyMenuOperation>({
query: getMenuQuery,
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', '')
})) || []
);
}
export async function getPage(handle: string): Promise<Page> {
const res = await shopifyFetch<ShopifyPageOperation>({
query: getPageQuery,
variables: { handle }
});
return res.body.data.pageByHandle;
}
export async function getPages(): Promise<Page[]> {
const res = await shopifyFetch<ShopifyPagesOperation>({
query: getPagesQuery
});
return removeEdgesAndNodes(res.body.data.pages);
}
export async function getProduct(handle: string): Promise<Product | undefined> {
const res = await shopifyFetch<ShopifyProductOperation>({
query: getProductQuery,
variables: {
handle
}
});
return reshapeProduct(res.body.data.product, false);
}
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
query: getProductRecommendationsQuery,
variables: {
productId
}
});
return reshapeProducts(res.body.data.productRecommendations);
}
export async function getProducts({
query,
reverse,
sortKey
}: {
query?: string;
reverse?: boolean;
sortKey?: string;
}): Promise<Product[]> {
const res = await shopifyFetch<ShopifyProductsOperation>({
query: getProductsQuery,
variables: {
query,
reverse,
sortKey
}
});
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
}

View File

@ -1,45 +0,0 @@
import cartFragment from '../fragments/cart';
export const addToCartMutation = /* GraphQL */ `
mutation addToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const createCartMutation = /* GraphQL */ `
mutation createCart($lineItems: [CartLineInput!]) {
cartCreate(input: { lines: $lineItems }) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const editCartItemsMutation = /* GraphQL */ `
mutation editCartItems($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
cartLinesUpdate(cartId: $cartId, lines: $lines) {
cart {
...cart
}
}
}
${cartFragment}
`;
export const removeFromCartMutation = /* GraphQL */ `
mutation removeFromCart($cartId: ID!, $lineIds: [ID!]!) {
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
cart {
...cart
}
}
}
${cartFragment}
`;

View File

@ -1,10 +0,0 @@
import cartFragment from '../fragments/cart';
export const getCartQuery = /* GraphQL */ `
query getCart($cartId: ID!) {
cart(id: $cartId) {
...cart
}
}
${cartFragment}
`;

View File

@ -1,56 +0,0 @@
import productFragment from '../fragments/product';
import seoFragment from '../fragments/seo';
const collectionFragment = /* GraphQL */ `
fragment collection on Collection {
handle
title
description
seo {
...seo
}
updatedAt
}
${seoFragment}
`;
export const getCollectionQuery = /* GraphQL */ `
query getCollection($handle: String!) {
collection(handle: $handle) {
...collection
}
}
${collectionFragment}
`;
export const getCollectionsQuery = /* GraphQL */ `
query getCollections {
collections(first: 100, sortKey: TITLE) {
edges {
node {
...collection
}
}
}
}
${collectionFragment}
`;
export const getCollectionProductsQuery = /* GraphQL */ `
query getCollectionProducts(
$handle: String!
$sortKey: ProductCollectionSortKeys
$reverse: Boolean
) {
collection(handle: $handle) {
products(sortKey: $sortKey, reverse: $reverse, first: 100) {
edges {
node {
...product
}
}
}
}
}
${productFragment}
`;

View File

@ -1,10 +0,0 @@
export const getMenuQuery = /* GraphQL */ `
query getMenu($handle: String!) {
menu(handle: $handle) {
items {
title
url
}
}
}
`;

View File

@ -1,41 +0,0 @@
import seoFragment from '../fragments/seo';
const pageFragment = /* GraphQL */ `
fragment page on Page {
... on Page {
id
title
handle
body
bodySummary
seo {
...seo
}
createdAt
updatedAt
}
}
${seoFragment}
`;
export const getPageQuery = /* GraphQL */ `
query getPage($handle: String!) {
pageByHandle(handle: $handle) {
...page
}
}
${pageFragment}
`;
export const getPagesQuery = /* GraphQL */ `
query getPages {
pages(first: 100) {
edges {
node {
...page
}
}
}
}
${pageFragment}
`;

View File

@ -1,32 +0,0 @@
import productFragment from '../fragments/product';
export const getProductQuery = /* GraphQL */ `
query getProduct($handle: String!) {
product(handle: $handle) {
...product
}
}
${productFragment}
`;
export const getProductsQuery = /* GraphQL */ `
query getProducts($sortKey: ProductSortKeys, $reverse: Boolean, $query: String) {
products(sortKey: $sortKey, reverse: $reverse, query: $query, first: 100) {
edges {
node {
...product
}
}
}
}
${productFragment}
`;
export const getProductRecommendationsQuery = /* GraphQL */ `
query getProductRecommendations($productId: ID!) {
productRecommendations(productId: $productId) {
...product
}
}
${productFragment}
`;

View File

@ -1,265 +0,0 @@
export type Maybe<T> = T | null;
export type Connection<T> = {
edges: Array<Edge<T>>;
};
export type Edge<T> = {
node: T;
};
export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[];
};
export type CartItem = {
id: string;
quantity: number;
cost: {
totalAmount: Money;
};
merchandise: {
id: string;
title: string;
selectedOptions: {
name: string;
value: string;
}[];
product: Product;
};
};
export type Collection = ShopifyCollection & {
path: string;
};
export type Image = {
url: string;
altText: string;
width: number;
height: number;
};
export type Menu = {
title: string;
path: string;
};
export type Money = {
amount: string;
currencyCode: string;
};
export type Page = {
id: string;
title: string;
handle: string;
body: string;
bodySummary: string;
seo?: SEO;
createdAt: string;
updatedAt: string;
};
export type Product = Omit<ShopifyProduct, 'variants' | 'images'> & {
variants: ProductVariant[];
images: Image[];
};
export type ProductOption = {
id: string;
name: string;
values: string[];
};
export type ProductVariant = {
id: string;
title: string;
availableForSale: boolean;
selectedOptions: {
name: string;
value: string;
}[];
price: Money;
};
export type SEO = {
title: string;
description: string;
};
export type ShopifyCart = {
id: string;
checkoutUrl: string;
cost: {
subtotalAmount: Money;
totalAmount: Money;
totalTaxAmount: Money;
};
lines: Connection<CartItem>;
totalQuantity: number;
};
export type ShopifyCollection = {
handle: string;
title: string;
description: string;
seo: SEO;
updatedAt: string;
};
export type ShopifyProduct = {
id: string;
handle: string;
availableForSale: boolean;
title: string;
description: string;
descriptionHtml: string;
options: ProductOption[];
priceRange: {
maxVariantPrice: Money;
minVariantPrice: Money;
};
variants: Connection<ProductVariant>;
featuredImage: Image;
images: Connection<Image>;
seo: SEO;
tags: string[];
updatedAt: string;
};
export type ShopifyCartOperation = {
data: {
cart: ShopifyCart;
};
variables: {
cartId: string;
};
};
export type ShopifyCreateCartOperation = {
data: { cartCreate: { cart: ShopifyCart } };
};
export type ShopifyAddToCartOperation = {
data: {
cartLinesAdd: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyRemoveFromCartOperation = {
data: {
cartLinesRemove: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lineIds: string[];
};
};
export type ShopifyUpdateCartOperation = {
data: {
cartLinesUpdate: {
cart: ShopifyCart;
};
};
variables: {
cartId: string;
lines: {
id: string;
merchandiseId: string;
quantity: number;
}[];
};
};
export type ShopifyCollectionOperation = {
data: {
collection: ShopifyCollection;
};
variables: {
handle: string;
};
};
export type ShopifyCollectionProductsOperation = {
data: {
collection: {
products: Connection<ShopifyProduct>;
};
};
variables: {
handle: string;
reverse?: boolean;
sortKey?: string;
};
};
export type ShopifyCollectionsOperation = {
data: {
collections: Connection<ShopifyCollection>;
};
};
export type ShopifyMenuOperation = {
data: {
menu?: {
items: {
title: string;
url: string;
}[];
};
};
variables: {
handle: string;
};
};
export type ShopifyPageOperation = {
data: { pageByHandle: Page };
variables: { handle: string };
};
export type ShopifyPagesOperation = {
data: {
pages: Connection<Page>;
};
};
export type ShopifyProductOperation = {
data: { product: ShopifyProduct };
variables: {
handle: string;
};
};
export type ShopifyProductRecommendationsOperation = {
data: {
productRecommendations: ShopifyProduct[];
};
variables: {
productId: string;
};
};
export type ShopifyProductsOperation = {
data: {
products: Connection<ShopifyProduct>;
};
variables: {
query?: string;
reverse?: boolean;
sortKey?: string;
};
};

View File

@ -1,4 +1,4 @@
export interface ShopifyErrorLike { export interface VercelCommerceErrorLike {
status: number; status: number;
message: Error; message: Error;
} }
@ -7,7 +7,7 @@ export const isObject = (object: unknown): object is Record<string, unknown> =>
return typeof object === 'object' && object !== null && !Array.isArray(object); return typeof object === 'object' && object !== null && !Array.isArray(object);
}; };
export const isShopifyError = (error: unknown): error is ShopifyErrorLike => { export const isVercelCommerceError = (error: unknown): error is VercelCommerceErrorLike => {
if (!isObject(error)) return false; if (!isObject(error)) return false;
if (error instanceof Error) return true; if (error instanceof Error) return true;

View File

@ -5,12 +5,9 @@ module.exports = {
ignoreDuringBuilds: true ignoreDuringBuilds: true
}, },
images: { images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [ remotePatterns: [
{ {
protocol: 'https', hostname: process.env.BIGCOMMERCE_CDN_HOSTNAME ?? '*.bigcommerce.com'
hostname: 'cdn.shopify.com',
pathname: '/s/files/**'
} }
] ]
} }

1146
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff