mirror of
https://github.com/vercel/commerce.git
synced 2025-05-08 18:57:51 +00:00
Use Fourthwall Headless APIs (#1)
This commit is contained in:
parent
694c5c17ba
commit
a4d2102933
14
.env.example
14
.env.example
@ -1,7 +1,7 @@
|
||||
COMPANY_NAME="Vercel Inc."
|
||||
TWITTER_CREATOR="@vercel"
|
||||
TWITTER_SITE="https://nextjs.org/commerce"
|
||||
SITE_NAME="Next.js Commerce"
|
||||
SHOPIFY_REVALIDATION_SECRET=""
|
||||
SHOPIFY_STOREFRONT_ACCESS_TOKEN=""
|
||||
SHOPIFY_STORE_DOMAIN="[your-shopify-store-subdomain].myshopify.com"
|
||||
# API specifics
|
||||
NEXT_PUBLIC_FW_API_URL="https://api.staging.fourthwall.com"
|
||||
|
||||
# Site specifics
|
||||
NEXT_PUBLIC_FW_COLLECTION="launch"
|
||||
NEXT_PUBLIC_FW_PUBLIC_TOKEN=""
|
||||
NEXT_PUBLIC_FW_CHECKOUT="https://jieren-shop.staging.fourthwall.com"
|
||||
|
@ -1,12 +0,0 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getPage } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
const title = page.seo?.title || page.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { page: string };
|
||||
}): Promise<Metadata> {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return {
|
||||
title: page.seo?.title || page.title,
|
||||
description: page.seo?.description || page.bodySummary,
|
||||
openGraph: {
|
||||
publishedTime: page.createdAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
type: 'article'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-8 text-5xl font-bold">{page.title}</h1>
|
||||
<Prose className="mb-8" html={page.body as string} />
|
||||
<p className="text-sm italic">
|
||||
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(page.updatedAt))}.`}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,12 +1,6 @@
|
||||
import { CartProvider } from 'components/cart/cart-context';
|
||||
import { Navbar } from 'components/layout/navbar';
|
||||
import { WelcomeToast } from 'components/welcome-toast';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { ensureStartsWith } from 'lib/utils';
|
||||
import { cookies } from 'next/headers';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import './globals.css';
|
||||
|
||||
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||
@ -37,21 +31,11 @@ export const metadata = {
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
// Don't await the fetch, pass the Promise to the context provider
|
||||
const cart = getCart(cartId);
|
||||
|
||||
return (
|
||||
<html lang="en" className={GeistSans.variable}>
|
||||
<body className="bg-neutral-50 text-black selection:bg-teal-300 dark:bg-neutral-900 dark:text-white dark:selection:bg-pink-500 dark:selection:text-white">
|
||||
<CartProvider cartPromise={cart}>
|
||||
<Navbar />
|
||||
<main>
|
||||
{children}
|
||||
<Toaster closeButton />
|
||||
<WelcomeToast />
|
||||
</main>
|
||||
</CartProvider>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
18
app/page.tsx
18
app/page.tsx
@ -1,6 +1,9 @@
|
||||
import { Carousel } from 'components/carousel';
|
||||
import { ThreeItemGrid } from 'components/grid/three-items';
|
||||
import Footer from 'components/layout/footer';
|
||||
import { Wrapper } from 'components/wrapper';
|
||||
import { getCart } from 'lib/fourthwall';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const metadata = {
|
||||
description: 'High-performance ecommerce store built with Next.js, Vercel, and Shopify.',
|
||||
@ -9,12 +12,17 @@ export const metadata = {
|
||||
}
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
export default async function HomePage({ searchParams }: { searchParams: { currency?: string } }) {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
const currency = searchParams.currency || 'USD';
|
||||
// Don't await the fetch, pass the Promise to the context provider
|
||||
const cart = getCart(cartId, currency);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThreeItemGrid />
|
||||
<Carousel />
|
||||
<Wrapper currency={currency} cart={cart}>
|
||||
<ThreeItemGrid currency={currency} />
|
||||
<Carousel currency={currency} />
|
||||
<Footer />
|
||||
</>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
@ -6,9 +6,10 @@ import Footer from 'components/layout/footer';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { ProductProvider } from 'components/product/product-context';
|
||||
import { ProductDescription } from 'components/product/product-description';
|
||||
import { Wrapper } from 'components/wrapper';
|
||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||
import { getProduct, getProductRecommendations } from 'lib/shopify';
|
||||
import { Image } from 'lib/shopify/types';
|
||||
import { getCart, getProduct, getProductRecommendations } from 'lib/fourthwall';
|
||||
import { cookies } from 'next/headers';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
@ -17,7 +18,7 @@ export async function generateMetadata({
|
||||
}: {
|
||||
params: { handle: string };
|
||||
}): Promise<Metadata> {
|
||||
const product = await getProduct(params.handle);
|
||||
const product = await getProduct({ handle: params.handle, currency: 'USD' });
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
@ -50,8 +51,16 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: { params: { handle: string } }) {
|
||||
const product = await getProduct(params.handle);
|
||||
export default async function ProductPage({ params, searchParams }: { params: { handle: string }, searchParams: { currency?: string } }) {
|
||||
const currency = searchParams.currency || 'USD';
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
const cart = getCart(cartId, currency)
|
||||
|
||||
const product = await getProduct({
|
||||
handle: params.handle,
|
||||
currency,
|
||||
});
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
@ -73,40 +82,39 @@ export default async function ProductPage({ params }: { params: { handle: string
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
|
||||
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
|
||||
}
|
||||
>
|
||||
<Gallery
|
||||
images={product.images.slice(0, 5).map((image: Image) => ({
|
||||
src: image.url,
|
||||
altText: image.altText
|
||||
}))}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Wrapper currency={currency} cart={cart}>
|
||||
<ProductProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="mx-auto max-w-screen-2xl px-4">
|
||||
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
|
||||
<div className="h-full w-full basis-full lg:basis-4/6">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
|
||||
}
|
||||
>
|
||||
<Gallery
|
||||
product={product}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="basis-full lg:basis-2/6">
|
||||
<Suspense fallback={null}>
|
||||
<ProductDescription product={product} />
|
||||
</Suspense>
|
||||
<div className="basis-full lg:basis-2/6">
|
||||
<Suspense fallback={null}>
|
||||
<ProductDescription product={product} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<RelatedProducts id={product.id} />
|
||||
</div>
|
||||
<RelatedProducts id={product.id} />
|
||||
</div>
|
||||
<Footer />
|
||||
</ProductProvider>
|
||||
<Footer />
|
||||
</ProductProvider>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,11 +0,0 @@
|
||||
import OpengraphImage from 'components/opengraph-image';
|
||||
import { getCollection } from 'lib/shopify';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { collection: string } }) {
|
||||
const collection = await getCollection(params.collection);
|
||||
const title = collection?.seo?.title || collection?.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { collection: string };
|
||||
}): Promise<Metadata> {
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: { collection: string };
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
||||
return (
|
||||
<section>
|
||||
{products.length === 0 ? (
|
||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||
) : (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
// Ensure children are re-rendered when the search query changes
|
||||
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams();
|
||||
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import Footer from 'components/layout/footer';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import ChildrenWrapper from './children-wrapper';
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 text-black md:flex-row dark:text-white">
|
||||
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||
<Collections />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">
|
||||
<ChildrenWrapper>{children}</ChildrenWrapper>
|
||||
</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import Grid from 'components/grid';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 h-6" />
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
{Array(12)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import Grid from 'components/grid';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import { getProducts } from 'lib/shopify';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search',
|
||||
description: 'Search for products in the store.'
|
||||
};
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams?: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? 'There are no products that match '
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
{products.length > 0 ? (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import { getCollectionProducts } from 'lib/fourthwall';
|
||||
import Link from 'next/link';
|
||||
import { GridTileImage } from './grid/tile';
|
||||
|
||||
export async function Carousel() {
|
||||
export async function Carousel({currency}: {currency: string}) {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
const products = await getCollectionProducts({ collection: 'hidden-homepage-carousel' });
|
||||
const products = await getCollectionProducts({
|
||||
collection: process.env.NEXT_PUBLIC_FW_COLLECTION || '',
|
||||
currency,
|
||||
});
|
||||
|
||||
if (!products?.length) return null;
|
||||
|
||||
@ -19,7 +22,7 @@ export async function Carousel() {
|
||||
key={`${product.handle}${i}`}
|
||||
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||
>
|
||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
||||
<Link href={`/product/${product.handle}?currency=${currency}`} className="relative h-full w-full">
|
||||
<GridTileImage
|
||||
alt={product.title}
|
||||
label={{
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { TAGS } from 'lib/constants';
|
||||
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { addToCart, createCart, createCheckout, getCart, removeFromCart, updateCart } from 'lib/fourthwall';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
@ -29,7 +29,7 @@ export async function removeItem(prevState: any, merchandiseId: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
const cart = await getCart(cartId, 'USD');
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
@ -64,7 +64,7 @@ export async function updateItemQuantity(
|
||||
const { merchandiseId, quantity } = payload;
|
||||
|
||||
try {
|
||||
const cart = await getCart(cartId);
|
||||
const cart = await getCart(cartId, 'USD');
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
@ -96,20 +96,22 @@ export async function updateItemQuantity(
|
||||
}
|
||||
}
|
||||
|
||||
export async function redirectToCheckout() {
|
||||
export async function redirectToCheckout(currency: string) {
|
||||
let cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return 'Missing cart ID';
|
||||
}
|
||||
|
||||
let cart = await getCart(cartId);
|
||||
let cart = await getCart(cartId, 'USD');
|
||||
|
||||
if (!cart) {
|
||||
return 'Error fetching cart';
|
||||
}
|
||||
|
||||
redirect(cart.checkoutUrl);
|
||||
const { id } = await createCheckout(cartId, currency);
|
||||
|
||||
redirect(`${process.env.FW_CHECKOUT}/checkout/${id}`);
|
||||
}
|
||||
|
||||
export async function createCartAndSetCookie() {
|
||||
|
@ -4,7 +4,7 @@ import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import { useProduct } from 'components/product/product-context';
|
||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||
import { Product, ProductVariant } from 'lib/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
import { useCart } from './cart-context';
|
||||
|
||||
@ -27,7 +27,6 @@ function SubmitButton({
|
||||
);
|
||||
}
|
||||
|
||||
console.log(selectedVariantId);
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<button
|
||||
|
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
|
||||
import type { Cart, CartItem, Product, ProductVariant } from 'lib/types';
|
||||
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
|
||||
|
||||
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||
@ -84,7 +84,6 @@ function updateCartTotals(lines: CartItem[]): Pick<Cart, 'totalQuantity' | 'cost
|
||||
cost: {
|
||||
subtotalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalAmount: { amount: totalAmount.toString(), currencyCode },
|
||||
totalTaxAmount: { amount: '0', currencyCode }
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -95,10 +94,10 @@ function createEmptyCart(): Cart {
|
||||
checkoutUrl: '',
|
||||
totalQuantity: 0,
|
||||
lines: [],
|
||||
currency: 'USD',
|
||||
cost: {
|
||||
subtotalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalAmount: { amount: '0', currencyCode: 'USD' },
|
||||
totalTaxAmount: { amount: '0', currencyCode: 'USD' }
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -147,7 +146,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
||||
|
||||
export function CartProvider({
|
||||
children,
|
||||
cartPromise
|
||||
cartPromise,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cartPromise: Promise<Cart | undefined>;
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import type { CartItem } from 'lib/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
|
||||
export function DeleteItemButton({
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { updateItemQuantity } from 'components/cart/actions';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import type { CartItem } from 'lib/types';
|
||||
import { useFormState } from 'react-dom';
|
||||
|
||||
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||
|
@ -177,11 +177,7 @@ export default function CartModal() {
|
||||
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||
<p>Taxes</p>
|
||||
<Price
|
||||
className="text-right text-base text-black dark:text-white"
|
||||
amount={cart.cost.totalTaxAmount.amount}
|
||||
currencyCode={cart.cost.totalTaxAmount.currencyCode}
|
||||
/>
|
||||
<p className="text-right">Calculated at checkout</p>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 pt-1 dark:border-neutral-700">
|
||||
<p>Shipping</p>
|
||||
@ -196,7 +192,7 @@ export default function CartModal() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form action={redirectToCheckout}>
|
||||
<form action={() => redirectToCheckout(cart.currency)}>
|
||||
<CheckoutButton />
|
||||
</form>
|
||||
</div>
|
||||
|
@ -9,7 +9,7 @@ export default function OpenCart({
|
||||
quantity?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white">
|
||||
<div className="relative flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors hover:bg-gray-50 dark:border-neutral-700 dark:text-white">
|
||||
<ShoppingCartIcon
|
||||
className={clsx('h-4 transition-all ease-in-out hover:scale-110', className)}
|
||||
/>
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { getCollectionProducts } from 'lib/shopify';
|
||||
import type { Product } from 'lib/shopify/types';
|
||||
import { getCollectionProducts } from 'lib/fourthwall';
|
||||
import type { Product } from 'lib/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
function ThreeItemGridItem({
|
||||
item,
|
||||
currency,
|
||||
size,
|
||||
priority
|
||||
}: {
|
||||
item: Product;
|
||||
currency: string;
|
||||
size: 'full' | 'half';
|
||||
priority?: boolean;
|
||||
}) {
|
||||
@ -18,7 +20,7 @@ function ThreeItemGridItem({
|
||||
>
|
||||
<Link
|
||||
className="relative block aspect-square h-full w-full"
|
||||
href={`/product/${item.handle}`}
|
||||
href={`/product/${item.handle}?currency=${currency}`}
|
||||
prefetch={true}
|
||||
>
|
||||
<GridTileImage
|
||||
@ -41,21 +43,22 @@ function ThreeItemGridItem({
|
||||
);
|
||||
}
|
||||
|
||||
export async function ThreeItemGrid() {
|
||||
// Collections that start with `hidden-*` are hidden from the search page.
|
||||
export async function ThreeItemGrid({currency}: { currency: string}) {
|
||||
const homepageItems = await getCollectionProducts({
|
||||
collection: 'hidden-homepage-featured-items'
|
||||
collection: process.env.NEXT_PUBLIC_FW_COLLECTION || '',
|
||||
currency,
|
||||
});
|
||||
|
||||
|
||||
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
|
||||
|
||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||
|
||||
return (
|
||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} currency={currency}/>
|
||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} currency={currency}/>
|
||||
<ThreeItemGridItem size="half" item={thirdProduct} currency={currency}/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import { Menu } from 'lib/types';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
@ -2,7 +2,7 @@ import Link from 'next/link';
|
||||
|
||||
import FooterMenu from 'components/layout/footer-menu';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { getMenu } from 'lib/fourthwall';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const { COMPANY_NAME, SITE_NAME } = process.env;
|
||||
|
61
components/layout/navbar/currency.tsx
Normal file
61
components/layout/navbar/currency.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const Currencies = [
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'CAD',
|
||||
'AUD',
|
||||
'JPY',
|
||||
];
|
||||
|
||||
export function CurrencySelector({ currency }: { currency: string; }) {
|
||||
const selectedCurrency = currency;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleSelect = (currency: string) => {
|
||||
// navigate to the current page with the new currency as query param
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
newParams.set('currency', currency);
|
||||
window.history.pushState({}, '', `${window.location.pathname}?${newParams}`);
|
||||
window.location.reload();
|
||||
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 justify-center items-center w-full rounded-md border border-neutral-200 px-4 py-2 bg-white text-sm font-medium text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-indigo-500"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{selectedCurrency}
|
||||
<svg className="-mr-1 ml-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
|
||||
<div className="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
|
||||
{Currencies.map((currency) => (
|
||||
<button
|
||||
key={currency}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||
role="menuitem"
|
||||
onClick={() => handleSelect(currency)}
|
||||
>
|
||||
{currency}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,24 +1,11 @@
|
||||
import CartModal from 'components/cart/modal';
|
||||
import LogoSquare from 'components/logo-square';
|
||||
import { getMenu } from 'lib/shopify';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
import MobileMenu from './mobile-menu';
|
||||
import Search, { SearchSkeleton } from './search';
|
||||
|
||||
const { SITE_NAME } = process.env;
|
||||
|
||||
export async function Navbar() {
|
||||
const menu = await getMenu('next-js-frontend-header-menu');
|
||||
import { CurrencySelector } from './currency';
|
||||
|
||||
export function Navbar({currency}: {currency: string}) {
|
||||
return (
|
||||
<nav className="relative flex items-center justify-between p-4 lg:px-6">
|
||||
<div className="block flex-none md:hidden">
|
||||
<Suspense fallback={null}>
|
||||
<MobileMenu menu={menu} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full md:w-1/3">
|
||||
<Link
|
||||
@ -28,31 +15,14 @@ export async function Navbar() {
|
||||
>
|
||||
<LogoSquare />
|
||||
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
|
||||
{SITE_NAME}
|
||||
Launch on Fourthwall!
|
||||
</div>
|
||||
</Link>
|
||||
{menu.length ? (
|
||||
<ul className="hidden gap-6 text-sm md:flex md:items-center">
|
||||
{menu.map((item: Menu) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
href={item.path}
|
||||
prefetch={true}
|
||||
className="text-neutral-500 underline-offset-4 hover:text-black hover:underline dark:text-neutral-400 dark:hover:text-neutral-300"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden justify-center md:flex md:w-1/3">
|
||||
<Suspense fallback={<SearchSkeleton />}>
|
||||
<Search />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex justify-end md:w-1/3">
|
||||
<div className="flex justify-end md:w-1/3 gap-4">
|
||||
<CurrencySelector currency={currency} />
|
||||
<CartModal />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,100 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Fragment, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Menu } from 'lib/shopify/types';
|
||||
import Search, { SearchSkeleton } from './search';
|
||||
|
||||
export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const openMobileMenu = () => setIsOpen(true);
|
||||
const closeMobileMenu = () => setIsOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth > 768) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={openMobileMenu}
|
||||
aria-label="Open mobile menu"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
|
||||
>
|
||||
<Bars3Icon className="h-4" />
|
||||
</button>
|
||||
<Transition show={isOpen}>
|
||||
<Dialog onClose={closeMobileMenu} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-all ease-in-out duration-300"
|
||||
enterFrom="opacity-0 backdrop-blur-none"
|
||||
enterTo="opacity-100 backdrop-blur-[.5px]"
|
||||
leave="transition-all ease-in-out duration-200"
|
||||
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||
leaveTo="opacity-0 backdrop-blur-none"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
||||
</Transition.Child>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-all ease-in-out duration-300"
|
||||
enterFrom="translate-x-[-100%]"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-all ease-in-out duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-[-100%]"
|
||||
>
|
||||
<Dialog.Panel className="fixed bottom-0 left-0 right-0 top-0 flex h-full w-full flex-col bg-white pb-6 dark:bg-black">
|
||||
<div className="p-4">
|
||||
<button
|
||||
className="mb-4 flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors dark:border-neutral-700 dark:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
aria-label="Close mobile menu"
|
||||
>
|
||||
<XMarkIcon className="h-6" />
|
||||
</button>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<Suspense fallback={<SearchSkeleton />}>
|
||||
<Search />
|
||||
</Suspense>
|
||||
</div>
|
||||
{menu.length ? (
|
||||
<ul className="flex w-full flex-col">
|
||||
{menu.map((item: Menu) => (
|
||||
<li
|
||||
className="py-2 text-xl text-black transition-colors hover:text-neutral-500 dark:text-white"
|
||||
key={item.title}
|
||||
>
|
||||
<Link href={item.path} prefetch={true} onClick={closeMobileMenu}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import Form from 'next/form';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function Search() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<Form action="/search" className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<input
|
||||
key={searchParams?.get('q')}
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search for products..."
|
||||
autoComplete="off"
|
||||
defaultValue={searchParams?.get('q') || ''}
|
||||
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||
<MagnifyingGlassIcon className="h-4" />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchSkeleton() {
|
||||
return (
|
||||
<form className="w-max-[550px] relative w-full lg:w-80 xl:w-full">
|
||||
<input
|
||||
placeholder="Search for products..."
|
||||
className="w-full rounded-lg border bg-white px-4 py-2 text-sm text-black placeholder:text-neutral-500 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||
<MagnifyingGlassIcon className="h-4" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import Grid from 'components/grid';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { Product } from 'lib/types';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function ProductGridItems({ products }: { products: Product[] }) {
|
||||
|
@ -3,13 +3,20 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { GridTileImage } from 'components/grid/tile';
|
||||
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||
import { Product } from 'lib/types';
|
||||
import Image from 'next/image';
|
||||
|
||||
export function Gallery({ images }: { images: { src: string; altText: string }[] }) {
|
||||
export function Gallery({ product }: { product: Product }) {
|
||||
const { state, updateImage } = useProduct();
|
||||
const updateURL = useUpdateURL();
|
||||
const imageIndex = state.image ? parseInt(state.image) : 0;
|
||||
|
||||
const selectedVariant = product.variants.find((variant) => {
|
||||
return variant.selectedOptions.find((option) => option.name === 'Color' && option.value === state['color']);
|
||||
});
|
||||
|
||||
const images = selectedVariant?.images || product.images.slice(0, 5);
|
||||
|
||||
const nextImageIndex = imageIndex + 1 < images.length ? imageIndex + 1 : 0;
|
||||
const previousImageIndex = imageIndex === 0 ? images.length - 1 : imageIndex - 1;
|
||||
|
||||
@ -25,7 +32,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
fill
|
||||
sizes="(min-width: 1024px) 66vw, 100vw"
|
||||
alt={images[imageIndex]?.altText as string}
|
||||
src={images[imageIndex]?.src as string}
|
||||
src={images[imageIndex]?.url as string}
|
||||
priority={true}
|
||||
/>
|
||||
)}
|
||||
@ -65,7 +72,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
const isActive = index === imageIndex;
|
||||
|
||||
return (
|
||||
<li key={image.src} className="h-20 w-20">
|
||||
<li key={image.url} className="h-20 w-20">
|
||||
<button
|
||||
formAction={() => {
|
||||
const newState = updateImage(index.toString());
|
||||
@ -76,7 +83,7 @@ export function Gallery({ images }: { images: { src: string; altText: string }[]
|
||||
>
|
||||
<GridTileImage
|
||||
alt={image.altText}
|
||||
src={image.src}
|
||||
src={image.url}
|
||||
width={80}
|
||||
height={80}
|
||||
active={isActive}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import Price from 'components/price';
|
||||
import Prose from 'components/prose';
|
||||
import { Product } from 'lib/shopify/types';
|
||||
import { Product } from 'lib/types';
|
||||
import { VariantSelector } from './variant-selector';
|
||||
|
||||
export function ProductDescription({ product }: { product: Product }) {
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { useProduct, useUpdateURL } from 'components/product/product-context';
|
||||
import { ProductOption, ProductVariant } from 'lib/shopify/types';
|
||||
import { ProductOption, ProductVariant } from 'lib/types';
|
||||
|
||||
type Combination = {
|
||||
id: string;
|
||||
|
17
components/wrapper.tsx
Normal file
17
components/wrapper.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Cart } from "lib/types";
|
||||
import { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { CartProvider } from "./cart/cart-context";
|
||||
import { Navbar } from "./layout/navbar";
|
||||
import { WelcomeToast } from "./welcome-toast";
|
||||
|
||||
export function Wrapper({ children, currency, cart }: { children: ReactNode, currency: string, cart: Promise<Cart | undefined> }) {
|
||||
return <CartProvider cartPromise={cart}>
|
||||
<Navbar currency={currency} />
|
||||
<main>
|
||||
{children}
|
||||
<Toaster closeButton />
|
||||
<WelcomeToast />
|
||||
</main>
|
||||
</CartProvider>
|
||||
}
|
203
lib/fourthwall/index.ts
Normal file
203
lib/fourthwall/index.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import { Cart, Menu, Product } from "lib/types";
|
||||
import { reshapeCart, reshapeProduct, reshapeProducts } from "./reshape";
|
||||
import { FourthwallCart, FourthwallCheckout, FourthwallProduct } from "./types";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_FW_API_URL;
|
||||
const FW_PUBLIC_TOKEN = process.env.NEXT_PUBLIC_FW_PUBLIC_TOKEN;
|
||||
|
||||
/**
|
||||
* Helpers
|
||||
*/
|
||||
async function fourthwallGet<T>(url: string, options: RequestInit = {}): Promise<{ status: number; body: T }> {
|
||||
try {
|
||||
const result = await fetch(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-FW-Public-Token': FW_PUBLIC_TOKEN || '',
|
||||
...options.headers
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const body = await result.json();
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
throw {
|
||||
error: e,
|
||||
url
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fourthwallPost<T>(url: string, data: any, options: RequestInit = {}): Promise<{ status: number; body: T }> {
|
||||
try {
|
||||
const result = await fetch(url, {
|
||||
method: 'POST',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-FW-Public-Token': FW_PUBLIC_TOKEN || '',
|
||||
...options.headers
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
throw {
|
||||
error: e,
|
||||
url,
|
||||
data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection operations
|
||||
*/
|
||||
export async function getCollectionProducts({
|
||||
collection,
|
||||
currency,
|
||||
reverse,
|
||||
sortKey
|
||||
}: {
|
||||
collection: string;
|
||||
currency: string;
|
||||
reverse?: boolean;
|
||||
sortKey?: string;
|
||||
}): Promise<Product[]> {
|
||||
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${collection}/products?¤cy=${currency}`, {
|
||||
headers: {
|
||||
'X-ShopId': process.env.FW_SHOPID || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.body.results) {
|
||||
console.warn(`No collection found for \`${collection}\``);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return reshapeProducts(res.body.results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Product operations
|
||||
*/
|
||||
export async function getProduct({ handle, currency } : { handle: string, currency: string }): Promise<Product | undefined> {
|
||||
// TODO: replace with real URL
|
||||
const res = await fourthwallGet<FourthwallProduct>(`${API_URL}/api/public/v1.0/products/${handle}?¤cy=${currency}`);
|
||||
|
||||
return reshapeProduct(res.body);
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
// TODO: replace with real URL
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cart operations
|
||||
*/
|
||||
export async function getCart(cartId: string | undefined, currency: string): Promise<Cart | undefined> {
|
||||
if (!cartId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await fourthwallGet<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}?currency=${currency}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body);
|
||||
}
|
||||
|
||||
export async function createCart(): Promise<Cart> {
|
||||
const res = await fourthwallPost<FourthwallCart>(`https://api.staging.fourthwall.com/api/public/v1.0/carts`, {
|
||||
items: []
|
||||
});
|
||||
|
||||
return reshapeCart(res.body);
|
||||
}
|
||||
|
||||
export async function addToCart(
|
||||
cartId: string,
|
||||
lines: { merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
|
||||
const items = lines.map((line) => ({
|
||||
variantId: line.merchandiseId,
|
||||
quantity: line.quantity
|
||||
}));
|
||||
|
||||
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/add`, {
|
||||
items,
|
||||
}, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body);
|
||||
}
|
||||
|
||||
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
|
||||
const items = lineIds.map((id) => ({
|
||||
variantId: id
|
||||
}));
|
||||
|
||||
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/remove`, {
|
||||
items,
|
||||
}, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body);
|
||||
}
|
||||
|
||||
export async function updateCart(
|
||||
cartId: string,
|
||||
lines: { id: string; merchandiseId: string; quantity: number }[]
|
||||
): Promise<Cart> {
|
||||
const items = lines.map((line) => ({
|
||||
variantId: line.merchandiseId,
|
||||
quantity: line.quantity
|
||||
}));
|
||||
|
||||
const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/change`, {
|
||||
items,
|
||||
}, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return reshapeCart(res.body);
|
||||
}
|
||||
|
||||
export async function createCheckout(
|
||||
cartId: string,
|
||||
cartCurrency: string
|
||||
): Promise<FourthwallCheckout> {
|
||||
const res = await fourthwallPost<{ id: string }>(`${API_URL}/api/public/v1.0/checkouts`, {
|
||||
cartId,
|
||||
cartCurrency
|
||||
});
|
||||
|
||||
return res.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Stubbed out
|
||||
*/
|
||||
export async function getMenu(handle: string): Promise<Menu[]> {
|
||||
return [];
|
||||
}
|
175
lib/fourthwall/reshape.ts
Normal file
175
lib/fourthwall/reshape.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { Cart, CartItem, Image, Money, Product, ProductVariant } from "lib/types";
|
||||
import { FourthwallCart, FourthwallCartItem, FourthwallMoney, FourthwallProduct, FourthwallProductImage, FourthwallProductVariant } from "./types";
|
||||
|
||||
/**
|
||||
* Utils
|
||||
*/
|
||||
const DEFAULT_IMAGE: Image = {
|
||||
url: '',
|
||||
altText: '',
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
|
||||
|
||||
const reshapeMoney = (money: FourthwallMoney): Money => {
|
||||
return {
|
||||
amount: money.value.toString(),
|
||||
currencyCode: money.currency
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Products
|
||||
*/
|
||||
export const reshapeProducts = (products: FourthwallProduct[]) => {
|
||||
const reshapedProducts = [];
|
||||
|
||||
for (const product of products) {
|
||||
if (product) {
|
||||
const reshapedProduct = reshapeProduct(product);
|
||||
|
||||
if (reshapedProduct) {
|
||||
reshapedProducts.push(reshapedProduct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reshapedProducts;
|
||||
};
|
||||
|
||||
export const reshapeProduct = (product: FourthwallProduct): Product | undefined => {
|
||||
if (!product) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { images, variants, ...rest } = product;
|
||||
|
||||
const minPrice = Math.min(...variants.map((v) => v.unitPrice.value));
|
||||
const maxPrice = Math.max(...variants.map((v) => v.unitPrice.value));
|
||||
|
||||
const currencyCode = variants[0]?.unitPrice.currency || 'USD';
|
||||
|
||||
const sizes = new Set(variants.map((v) => v.attributes.size.name));
|
||||
const colors = new Set(variants.map((v) => v.attributes.color.name));
|
||||
|
||||
return {
|
||||
...rest,
|
||||
handle: product.slug,
|
||||
title: product.name,
|
||||
descriptionHtml: product.description,
|
||||
description: product.description,
|
||||
images: reshapeImages(images, product.name),
|
||||
variants: reshapeVariants(variants),
|
||||
priceRange: {
|
||||
minVariantPrice: {
|
||||
amount: minPrice.toString(),
|
||||
currencyCode,
|
||||
},
|
||||
maxVariantPrice: {
|
||||
amount: maxPrice.toString(),
|
||||
currencyCode,
|
||||
}
|
||||
},
|
||||
featuredImage: reshapeImages(images, product.name)[0] || DEFAULT_IMAGE,
|
||||
options: [{
|
||||
id: 'color',
|
||||
name: 'Color',
|
||||
values: [...colors]
|
||||
}, {
|
||||
id: 'size',
|
||||
name: 'Size',
|
||||
values: [...sizes]
|
||||
}],
|
||||
// TODO: stubbed out
|
||||
availableForSale: true,
|
||||
seo: {
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
},
|
||||
tags: [],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeImages = (images: FourthwallProductImage[], productTitle: string): Image[] => {
|
||||
return images.map((image) => {
|
||||
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
|
||||
return {
|
||||
...image,
|
||||
altText: `${productTitle} - ${filename}`
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const reshapeVariants = (variants: FourthwallProductVariant[]): ProductVariant[] => {
|
||||
return variants.map((v) => ({
|
||||
id: v.id,
|
||||
title: v.name,
|
||||
availableForSale: true,
|
||||
images: reshapeImages(v.images, v.name),
|
||||
selectedOptions: [{
|
||||
name: 'Size',
|
||||
value: v.attributes.size.name
|
||||
}, {
|
||||
name: 'Color',
|
||||
value: v.attributes.color.name
|
||||
}],
|
||||
price: reshapeMoney(v.unitPrice),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Cart
|
||||
*/
|
||||
const reshapeCartItem = (item: FourthwallCartItem): CartItem => {
|
||||
return {
|
||||
id: item.variant.id,
|
||||
quantity: item.quantity,
|
||||
cost: {
|
||||
totalAmount: reshapeMoney(item.variant.unitPrice)
|
||||
},
|
||||
merchandise: {
|
||||
id: item.variant.id,
|
||||
title: item.variant.name,
|
||||
// TODO: Stubbed out
|
||||
selectedOptions: [],
|
||||
product: {
|
||||
// TODO: need this product info in model
|
||||
id: 'TT',
|
||||
handle: 'TT',
|
||||
title: 'TT',
|
||||
featuredImage: {
|
||||
url: item.variant.images[0]?.url || 'TT',
|
||||
altText: 'TT',
|
||||
width: item.variant.images[0]?.width || 100,
|
||||
height: item.variant.images[0]?.height || 100
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const reshapeCart = (cart: FourthwallCart): Cart => {
|
||||
const totalValue = cart.items.map((item) => item.quantity * item.variant.unitPrice.value).reduce((a, b) => a + b, 0);
|
||||
const currencyCode = cart.items[0]?.variant.unitPrice.currency || 'USD';
|
||||
|
||||
return {
|
||||
...cart,
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: totalValue.toString(),
|
||||
currencyCode,
|
||||
},
|
||||
subtotalAmount: {
|
||||
amount: totalValue.toString(),
|
||||
currencyCode,
|
||||
},
|
||||
},
|
||||
lines: cart.items.map(reshapeCartItem),
|
||||
currency: currencyCode,
|
||||
// TODO: Stubbed out
|
||||
checkoutUrl: 'TT',
|
||||
totalQuantity: cart.items.map((item) => item.quantity).reduce((a, b) => a + b, 0)
|
||||
};
|
||||
};
|
56
lib/fourthwall/types.ts
Normal file
56
lib/fourthwall/types.ts
Normal file
@ -0,0 +1,56 @@
|
||||
export type FourthwallMoney = {
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export type FourthwallProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
|
||||
images: FourthwallProductImage[];
|
||||
variants: FourthwallProductVariant[];
|
||||
};
|
||||
|
||||
export type FourthwallProductImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type FourthwallProductVariant = {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
unitPrice: FourthwallMoney;
|
||||
|
||||
images: FourthwallProductImage[];
|
||||
|
||||
// other attr
|
||||
attributes: {
|
||||
description: string;
|
||||
color: {
|
||||
name: string;
|
||||
swatch: string;
|
||||
},
|
||||
size: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export type FourthwallCart = {
|
||||
id: string | undefined;
|
||||
items: FourthwallCartItem[];
|
||||
};
|
||||
|
||||
export type FourthwallCartItem = {
|
||||
variant: FourthwallProductVariant;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type FourthwallCheckout = {
|
||||
id: string
|
||||
};
|
@ -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;
|
@ -1,10 +0,0 @@
|
||||
const imageFragment = /* GraphQL */ `
|
||||
fragment image on Image {
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
`;
|
||||
|
||||
export default imageFragment;
|
@ -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;
|
@ -1,8 +0,0 @@
|
||||
const seoFragment = /* GraphQL */ `
|
||||
fragment seo on SEO {
|
||||
description
|
||||
title
|
||||
}
|
||||
`;
|
||||
|
||||
export default seoFragment;
|
@ -1,455 +0,0 @@
|
||||
import { HIDDEN_PRODUCT_TAG, SHOPIFY_GRAPHQL_API_ENDPOINT, TAGS } from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { ensureStartsWith } from 'lib/utils';
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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,
|
||||
Image,
|
||||
Menu,
|
||||
Page,
|
||||
Product,
|
||||
ShopifyAddToCartOperation,
|
||||
ShopifyCart,
|
||||
ShopifyCartOperation,
|
||||
ShopifyCollection,
|
||||
ShopifyCollectionOperation,
|
||||
ShopifyCollectionProductsOperation,
|
||||
ShopifyCollectionsOperation,
|
||||
ShopifyCreateCartOperation,
|
||||
ShopifyMenuOperation,
|
||||
ShopifyPageOperation,
|
||||
ShopifyPagesOperation,
|
||||
ShopifyProduct,
|
||||
ShopifyProductOperation,
|
||||
ShopifyProductRecommendationsOperation,
|
||||
ShopifyProductsOperation,
|
||||
ShopifyRemoveFromCartOperation,
|
||||
ShopifyUpdateCartOperation
|
||||
} from './types';
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
||||
: '';
|
||||
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>({
|
||||
cache = 'force-cache',
|
||||
headers,
|
||||
query,
|
||||
tags,
|
||||
variables
|
||||
}: {
|
||||
cache?: RequestCache;
|
||||
headers?: HeadersInit;
|
||||
query: string;
|
||||
tags?: string[];
|
||||
variables?: ExtractVariables<T>;
|
||||
}): 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,
|
||||
...(tags && { next: { tags } })
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
|
||||
if (body.errors) {
|
||||
throw body.errors[0];
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
cause: e.cause?.toString() || 'unknown',
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removeEdgesAndNodes = <T>(array: Connection<T>): T[] => {
|
||||
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 reshapeImages = (images: Connection<Image>, productTitle: string) => {
|
||||
const flattened = removeEdgesAndNodes(images);
|
||||
|
||||
return flattened.map((image) => {
|
||||
const filename = image.url.match(/.*\/(.*)\..*/)?.[1];
|
||||
return {
|
||||
...image,
|
||||
altText: image.altText || `${productTitle} - ${filename}`
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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: reshapeImages(images, product.title),
|
||||
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 | undefined): Promise<Cart | undefined> {
|
||||
if (!cartId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await shopifyFetch<ShopifyCartOperation>({
|
||||
query: getCartQuery,
|
||||
variables: { cartId },
|
||||
tags: [TAGS.cart]
|
||||
});
|
||||
|
||||
// Old carts becomes `null` when you checkout.
|
||||
if (!res.body.data.cart) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return reshapeCart(res.body.data.cart);
|
||||
}
|
||||
|
||||
export async function getCollection(handle: string): Promise<Collection | undefined> {
|
||||
const res = await shopifyFetch<ShopifyCollectionOperation>({
|
||||
query: getCollectionQuery,
|
||||
tags: [TAGS.collections],
|
||||
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,
|
||||
tags: [TAGS.collections, TAGS.products],
|
||||
variables: {
|
||||
handle: collection,
|
||||
reverse,
|
||||
sortKey: sortKey === 'CREATED_AT' ? 'CREATED' : 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,
|
||||
tags: [TAGS.collections]
|
||||
});
|
||||
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,
|
||||
tags: [TAGS.collections],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
res.body?.data?.menu?.items.map((item: { title: string; url: string }) => ({
|
||||
title: item.title,
|
||||
path: item.url.replace(domain, '').replace('/collections', '/search').replace('/pages', '')
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPage(handle: string): Promise<Page> {
|
||||
const res = await shopifyFetch<ShopifyPageOperation>({
|
||||
query: getPageQuery,
|
||||
cache: 'no-store',
|
||||
variables: { handle }
|
||||
});
|
||||
|
||||
return res.body.data.pageByHandle;
|
||||
}
|
||||
|
||||
export async function getPages(): Promise<Page[]> {
|
||||
const res = await shopifyFetch<ShopifyPagesOperation>({
|
||||
query: getPagesQuery,
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
return removeEdgesAndNodes(res.body.data.pages);
|
||||
}
|
||||
|
||||
export async function getProduct(handle: string): Promise<Product | undefined> {
|
||||
const res = await shopifyFetch<ShopifyProductOperation>({
|
||||
query: getProductQuery,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
handle
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProduct(res.body.data.product, false);
|
||||
}
|
||||
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
const res = await shopifyFetch<ShopifyProductRecommendationsOperation>({
|
||||
query: getProductRecommendationsQuery,
|
||||
tags: [TAGS.products],
|
||||
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,
|
||||
tags: [TAGS.products],
|
||||
variables: {
|
||||
query,
|
||||
reverse,
|
||||
sortKey
|
||||
}
|
||||
});
|
||||
|
||||
return reshapeProducts(removeEdgesAndNodes(res.body.data.products));
|
||||
}
|
||||
|
||||
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||
// We always need to respond with a 200 status code to Shopify,
|
||||
// otherwise it will continue to retry the request.
|
||||
const collectionWebhooks = ['collections/create', 'collections/delete', 'collections/update'];
|
||||
const productWebhooks = ['products/create', 'products/delete', 'products/update'];
|
||||
const topic = headers().get('x-shopify-topic') || 'unknown';
|
||||
const secret = req.nextUrl.searchParams.get('secret');
|
||||
const isCollectionUpdate = collectionWebhooks.includes(topic);
|
||||
const isProductUpdate = productWebhooks.includes(topic);
|
||||
|
||||
if (!secret || secret !== process.env.SHOPIFY_REVALIDATION_SECRET) {
|
||||
console.error('Invalid revalidation secret.');
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (!isCollectionUpdate && !isProductUpdate) {
|
||||
// We don't need to revalidate anything for any other topics.
|
||||
return NextResponse.json({ status: 200 });
|
||||
}
|
||||
|
||||
if (isCollectionUpdate) {
|
||||
revalidateTag(TAGS.collections);
|
||||
}
|
||||
|
||||
if (isProductUpdate) {
|
||||
revalidateTag(TAGS.products);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 200, revalidated: true, now: Date.now() });
|
||||
}
|
@ -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}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
import cartFragment from '../fragments/cart';
|
||||
|
||||
export const getCartQuery = /* GraphQL */ `
|
||||
query getCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...cart
|
||||
}
|
||||
}
|
||||
${cartFragment}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -1,10 +0,0 @@
|
||||
export const getMenuQuery = /* GraphQL */ `
|
||||
query getMenu($handle: String!) {
|
||||
menu(handle: $handle) {
|
||||
items {
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -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}
|
||||
`;
|
@ -1,272 +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 CartProduct = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
featuredImage: Image;
|
||||
};
|
||||
|
||||
export type CartItem = {
|
||||
id: string | undefined;
|
||||
quantity: number;
|
||||
cost: {
|
||||
totalAmount: Money;
|
||||
};
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
product: CartProduct;
|
||||
};
|
||||
};
|
||||
|
||||
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 | undefined;
|
||||
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;
|
||||
};
|
||||
};
|
125
lib/types.ts
Normal file
125
lib/types.ts
Normal file
@ -0,0 +1,125 @@
|
||||
export type Maybe<T> = T | null;
|
||||
|
||||
export type Connection<T> = {
|
||||
edges: Array<Edge<T>>;
|
||||
};
|
||||
|
||||
export type Edge<T> = {
|
||||
node: T;
|
||||
};
|
||||
|
||||
export type Cart = {
|
||||
id: string | undefined;
|
||||
checkoutUrl: string;
|
||||
cost: {
|
||||
subtotalAmount: Money;
|
||||
totalAmount: Money;
|
||||
};
|
||||
totalQuantity: number;
|
||||
lines: CartItem[];
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type CartProduct = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
featuredImage: Image;
|
||||
};
|
||||
|
||||
export type CartItem = {
|
||||
id: string | undefined;
|
||||
quantity: number;
|
||||
cost: {
|
||||
totalAmount: Money;
|
||||
};
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
selectedOptions: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
product: CartProduct;
|
||||
};
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
handle: string;
|
||||
title: string;
|
||||
description: string;
|
||||
seo: SEO;
|
||||
updatedAt: string;
|
||||
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 = {
|
||||
id: string;
|
||||
handle: string;
|
||||
availableForSale: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
options: ProductOption[];
|
||||
priceRange: {
|
||||
maxVariantPrice: Money;
|
||||
minVariantPrice: Money;
|
||||
};
|
||||
featuredImage: Image;
|
||||
seo: SEO;
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
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;
|
||||
images: Image[];
|
||||
};
|
||||
|
||||
export type SEO = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
@ -7,6 +7,11 @@ module.exports = {
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.shopify.com',
|
||||
pathname: '/s/files/**'
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'storage.googleapis.com',
|
||||
pathname: '/cdn.staging.fourthwall.com/**'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user