currency and checkout

This commit is contained in:
Jieren Chen 2024-09-08 16:53:43 -04:00
parent 4653f74188
commit b6ba260b77
No known key found for this signature in database
GPG Key ID: 2FF322D21B5DB10B
15 changed files with 179 additions and 81 deletions

View File

@ -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 { GeistSans } from 'geist/font/sans';
import { getCart } from 'lib/fourthwall';
import { ensureStartsWith } from 'lib/utils'; import { ensureStartsWith } from 'lib/utils';
import { cookies } from 'next/headers';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Toaster } from 'sonner';
import './globals.css'; import './globals.css';
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env; const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
@ -37,21 +31,11 @@ export const metadata = {
}; };
export default async function RootLayout({ children }: { children: ReactNode }) { 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, 'USD');
return ( return (
<html lang="en" className={GeistSans.variable}> <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"> <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}> {children}
<Navbar />
<main>
{children}
<Toaster closeButton />
<WelcomeToast />
</main>
</CartProvider>
</body> </body>
</html> </html>
); );

View File

@ -1,6 +1,9 @@
import { Carousel } from 'components/carousel'; import { Carousel } from 'components/carousel';
import { ThreeItemGrid } from 'components/grid/three-items'; import { ThreeItemGrid } from 'components/grid/three-items';
import Footer from 'components/layout/footer'; import Footer from 'components/layout/footer';
import { Wrapper } from 'components/wrapper';
import { getCart } from 'lib/fourthwall';
import { cookies } from 'next/headers';
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 Shopify.',
@ -9,14 +12,17 @@ export const metadata = {
} }
}; };
export default function HomePage({ searchParams }: { searchParams: { currency?: string } }) { export default async function HomePage({ searchParams }: { searchParams: { currency?: string } }) {
const cartId = cookies().get('cartId')?.value;
const currency = searchParams.currency || 'USD'; const currency = searchParams.currency || 'USD';
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId, currency);
return ( return (
<> <Wrapper currency={currency} cart={cart}>
<ThreeItemGrid currency={currency} /> <ThreeItemGrid currency={currency} />
<Carousel currency={currency}/> <Carousel currency={currency} />
<Footer /> <Footer />
</> </Wrapper>
); );
} }

View File

@ -6,8 +6,10 @@ import Footer from 'components/layout/footer';
import { Gallery } from 'components/product/gallery'; import { Gallery } from 'components/product/gallery';
import { ProductProvider } from 'components/product/product-context'; import { ProductProvider } from 'components/product/product-context';
import { ProductDescription } from 'components/product/product-description'; import { ProductDescription } from 'components/product/product-description';
import { Wrapper } from 'components/wrapper';
import { HIDDEN_PRODUCT_TAG } from 'lib/constants'; import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
import { getProduct, getProductRecommendations } from 'lib/fourthwall'; import { getCart, getProduct, getProductRecommendations } from 'lib/fourthwall';
import { cookies } from 'next/headers';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react'; import { Suspense } from 'react';
@ -50,9 +52,14 @@ export async function generateMetadata({
} }
export default async function ProductPage({ params, searchParams }: { params: { handle: string }, searchParams: { currency?: string } }) { 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({ const product = await getProduct({
handle: params.handle, handle: params.handle,
currency: searchParams.currency || 'USD' currency,
}); });
if (!product) return notFound(); if (!product) return notFound();
@ -75,37 +82,39 @@ export default async function ProductPage({ params, searchParams }: { params: {
}; };
return ( return (
<ProductProvider> <Wrapper currency={currency} cart={cart}>
<script <ProductProvider>
type="application/ld+json" <script
dangerouslySetInnerHTML={{ type="application/ld+json"
__html: JSON.stringify(productJsonLd) 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="mx-auto max-w-screen-2xl px-4">
<div className="h-full w-full basis-full lg:basis-4/6"> <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">
<Suspense <div className="h-full w-full basis-full lg:basis-4/6">
fallback={ <Suspense
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" /> fallback={
} <div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
> }
<Gallery >
product={product} <Gallery
/> product={product}
</Suspense> />
</div> </Suspense>
</div>
<div className="basis-full lg:basis-2/6"> <div className="basis-full lg:basis-2/6">
<Suspense fallback={null}> <Suspense fallback={null}>
<ProductDescription product={product} /> <ProductDescription product={product} />
</Suspense> </Suspense>
</div>
</div> </div>
<RelatedProducts id={product.id} />
</div> </div>
<RelatedProducts id={product.id} /> <Footer />
</div> </ProductProvider>
<Footer /> </Wrapper>
</ProductProvider>
); );
} }

View File

@ -1,7 +1,7 @@
'use server'; 'use server';
import { TAGS } from 'lib/constants'; import { TAGS } from 'lib/constants';
import { addToCart, createCart, getCart, removeFromCart, updateCart } from 'lib/fourthwall'; import { addToCart, createCart, createCheckout, getCart, removeFromCart, updateCart } from 'lib/fourthwall';
import { revalidateTag } from 'next/cache'; import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -29,7 +29,7 @@ export async function removeItem(prevState: any, merchandiseId: string) {
} }
try { try {
const cart = await getCart(cartId); const cart = await getCart(cartId, 'USD');
if (!cart) { if (!cart) {
return 'Error fetching cart'; return 'Error fetching cart';
@ -64,7 +64,7 @@ export async function updateItemQuantity(
const { merchandiseId, quantity } = payload; const { merchandiseId, quantity } = payload;
try { try {
const cart = await getCart(cartId); const cart = await getCart(cartId, 'USD');
if (!cart) { if (!cart) {
return 'Error fetching 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; let cartId = cookies().get('cartId')?.value;
if (!cartId) { if (!cartId) {
return 'Missing cart ID'; return 'Missing cart ID';
} }
let cart = await getCart(cartId); let cart = await getCart(cartId, 'USD');
if (!cart) { if (!cart) {
return 'Error fetching 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() { export async function createCartAndSetCookie() {

View File

@ -27,7 +27,6 @@ function SubmitButton({
); );
} }
console.log(selectedVariantId);
if (!selectedVariantId) { if (!selectedVariantId) {
return ( return (
<button <button

View File

@ -145,7 +145,7 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
export function CartProvider({ export function CartProvider({
children, children,
cartPromise cartPromise,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
cartPromise: Promise<Cart | undefined>; cartPromise: Promise<Cart | undefined>;

View File

@ -192,7 +192,7 @@ export default function CartModal() {
/> />
</div> </div>
</div> </div>
<form action={redirectToCheckout}> <form action={() => redirectToCheckout(cart.currency)}>
<CheckoutButton /> <CheckoutButton />
</form> </form>
</div> </div>

View File

@ -43,13 +43,13 @@ function ThreeItemGridItem({
); );
} }
export async function ThreeItemGrid({ currency } : { currency: string }) { export async function ThreeItemGrid({currency}: { currency: string}) {
// Collections that start with `hidden-*` are hidden from the search page.
const homepageItems = await getCollectionProducts({ const homepageItems = await getCollectionProducts({
collection: process.env.FW_COLLECTION || '', collection: process.env.FW_COLLECTION || '',
currency currency,
}); });
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null; if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
const [firstProduct, secondProduct, thirdProduct] = homepageItems; const [firstProduct, secondProduct, thirdProduct] = homepageItems;

View 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 justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 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>
);
}

View File

@ -1,17 +1,17 @@
import CartModal from 'components/cart/modal'; import CartModal from 'components/cart/modal';
import LogoSquare from 'components/logo-square'; import LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/fourthwall';
import { Menu } from 'lib/shopify/types'; import { Menu } from 'lib/shopify/types';
import Link from 'next/link'; import Link from 'next/link';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { CurrencySelector } from './currency';
import MobileMenu from './mobile-menu'; import MobileMenu from './mobile-menu';
import Search, { SearchSkeleton } from './search'; import Search, { SearchSkeleton } from './search';
const { SITE_NAME } = process.env; const { SITE_NAME } = process.env;
export async function Navbar() { export function Navbar({currency}: {currency: string}) {
const menu = await getMenu('next-js-frontend-header-menu'); const menu: any[] = [];
return ( return (
<nav className="relative flex items-center justify-between p-4 lg:px-6"> <nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden"> <div className="block flex-none md:hidden">
@ -53,9 +53,7 @@ export async function Navbar() {
</Suspense> </Suspense>
</div> </div>
<div className="flex justify-end md:w-1/3"> <div className="flex justify-end md:w-1/3">
<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"> <CurrencySelector currency={currency} />
USD
</div>
<CartModal /> <CartModal />
</div> </div>
</div> </div>

17
components/wrapper.tsx Normal file
View File

@ -0,0 +1,17 @@
import { Cart } from "lib/shopify/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>
}

View File

@ -1,6 +1,9 @@
import { Cart, Menu, Product } from "lib/shopify/types"; import { Cart, Menu, Product } from "lib/shopify/types";
import { reshapeCart, reshapeProduct, reshapeProducts } from "./reshape"; import { reshapeCart, reshapeProduct, reshapeProducts } from "./reshape";
import { FourthwallCart, FourthwallProduct } from "./types"; import { FourthwallCart, FourthwallCheckout, FourthwallProduct } from "./types";
const API_URL = process.env.FW_API_URL
const API_SECRET = process.env.FW_SECRET
/** /**
* Helpers * Helpers
@ -74,16 +77,14 @@ export async function getCollectionProducts({
reverse?: boolean; reverse?: boolean;
sortKey?: string; sortKey?: string;
}): Promise<Product[]> { }): Promise<Product[]> {
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${process.env.FW_URL}/api/public/v1.0/collections/${collection}/products?secret=${process.env.FW_SECRET}&currency=${currency}`, { const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${collection}/products?secret=${API_SECRET}&currency=${currency}`, {
headers: { headers: {
'X-ShopId': process.env.FW_SHOPID || '' 'X-ShopId': process.env.FW_SHOPID || ''
} }
}); });
console.warn(JSON.stringify(res.body.results, null, 2));
if (!res.body.results) { if (!res.body.results) {
console.log(`No collection found for \`${collection}\``); console.warn(`No collection found for \`${collection}\``);
return []; return [];
} }
@ -96,7 +97,7 @@ export async function getCollectionProducts({
*/ */
export async function getProduct({ handle, currency } : { handle: string, currency: string }): Promise<Product | undefined> { export async function getProduct({ handle, currency } : { handle: string, currency: string }): Promise<Product | undefined> {
// TODO: replace with real URL // TODO: replace with real URL
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${process.env.FW_URL}/api/public/v1.0/collections/${process.env.FW_COLLECTION}/products?secret=${process.env.FW_SECRET}&currency=${currency}`, { const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${process.env.FW_COLLECTION}/products?secret=${API_SECRET}&currency=${currency}`, {
headers: { headers: {
'X-ShopId': process.env.FW_SHOPID || '' 'X-ShopId': process.env.FW_SHOPID || ''
} }
@ -120,7 +121,7 @@ export async function getCart(cartId: string | undefined, currency: string): Pro
return undefined; return undefined;
} }
const res = await fourthwallGet<FourthwallCart>(`${process.env.FW_URL}/api/public/v1.0/carts/${cartId}?secret=${process.env.FW_SECRET}`, { const res = await fourthwallGet<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}?secret=${API_SECRET}&currency=${currency}`, {
cache: 'no-store' cache: 'no-store'
}); });
@ -128,7 +129,7 @@ export async function getCart(cartId: string | undefined, currency: string): Pro
} }
export async function createCart(): Promise<Cart> { export async function createCart(): Promise<Cart> {
const res = await fourthwallPost<FourthwallCart>(`https://api.staging.fourthwall.com/api/public/v1.0/carts?secret=${process.env.FW_SECRET}`, { const res = await fourthwallPost<FourthwallCart>(`https://api.staging.fourthwall.com/api/public/v1.0/carts?secret=${API_SECRET}`, {
items: [] items: []
}, { }, {
headers: { headers: {
@ -149,7 +150,7 @@ export async function addToCart(
quantity: line.quantity quantity: line.quantity
})); }));
const res = await fourthwallPost<FourthwallCart>(`${process.env.FW_URL}/api/public/v1.0/carts/${cartId}/add?secret=${process.env.FW_SECRET}`, { const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/add?secret=${API_SECRET}`, {
items, items,
}, { }, {
headers: { headers: {
@ -166,7 +167,7 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
variantId: id variantId: id
})); }));
const res = await fourthwallPost<FourthwallCart>(`${process.env.FW_URL}/api/public/v1.0/carts/${cartId}/remove?secret=${process.env.FW_SECRET}`, { const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/remove?secret=${API_SECRET}`, {
items, items,
}, { }, {
headers: { headers: {
@ -187,7 +188,7 @@ export async function updateCart(
quantity: line.quantity quantity: line.quantity
})); }));
const res = await fourthwallPost<FourthwallCart>(`${process.env.FW_URL}/api/public/v1.0/carts/${cartId}/change?secret=${process.env.FW_SECRET}`, { const res = await fourthwallPost<FourthwallCart>(`${API_URL}/api/public/v1.0/carts/${cartId}/change?secret=${API_SECRET}`, {
items, items,
}, { }, {
headers: { headers: {
@ -199,6 +200,21 @@ export async function updateCart(
return reshapeCart(res.body); 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?secret=${API_SECRET}`, {
cartId,
cartCurrency
}, {
headers: {
'X-ShopId': process.env.FW_SHOPID || ''
}
});
return res.body;
}
/** /**
* TODO: Stubbed out * TODO: Stubbed out

View File

@ -167,6 +167,7 @@ export const reshapeCart = (cart: FourthwallCart): Cart => {
}, },
}, },
lines: cart.items.map(reshapeCartItem), lines: cart.items.map(reshapeCartItem),
currency: currencyCode,
// TODO: Stubbed out // TODO: Stubbed out
checkoutUrl: 'TT', checkoutUrl: 'TT',
totalQuantity: cart.items.map((item) => item.quantity).reduce((a, b) => a + b, 0) totalQuantity: cart.items.map((item) => item.quantity).reduce((a, b) => a + b, 0)

View File

@ -51,3 +51,7 @@ export type FourthwallCartItem = {
variant: FourthwallProductVariant; variant: FourthwallProductVariant;
quantity: number; quantity: number;
}; };
export type FourthwallCheckout = {
id: string
};

View File

@ -10,6 +10,7 @@ export type Edge<T> = {
export type Cart = Omit<ShopifyCart, 'lines'> & { export type Cart = Omit<ShopifyCart, 'lines'> & {
lines: CartItem[]; lines: CartItem[];
currency: string;
}; };
export type CartProduct = { export type CartProduct = {