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 { getCart } from 'lib/fourthwall';
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, 'USD');
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>
);

View File

@ -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,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';
// Don't await the fetch, pass the Promise to the context provider
const cart = getCart(cartId, currency);
return (
<>
<Wrapper currency={currency} cart={cart}>
<ThreeItemGrid currency={currency} />
<Carousel currency={currency}/>
<Carousel currency={currency} />
<Footer />
</>
</Wrapper>
);
}

View File

@ -6,8 +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/fourthwall';
import { getCart, getProduct, getProductRecommendations } from 'lib/fourthwall';
import { cookies } from 'next/headers';
import Link from 'next/link';
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 } }) {
const currency = searchParams.currency || 'USD';
const cartId = cookies().get('cartId')?.value;
const cart = getCart(cartId, currency)
const product = await getProduct({
handle: params.handle,
currency: searchParams.currency || 'USD'
currency,
});
if (!product) return notFound();
@ -75,37 +82,39 @@ export default async function ProductPage({ params, searchParams }: { params: {
};
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
product={product}
/>
</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>
);
}

View File

@ -1,7 +1,7 @@
'use server';
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 { 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() {

View File

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

View File

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

View File

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

View File

@ -43,13 +43,13 @@ function ThreeItemGridItem({
);
}
export async function ThreeItemGrid({ currency } : { currency: string }) {
// Collections that start with `hidden-*` are hidden from the search page.
export async function ThreeItemGrid({currency}: { currency: string}) {
const homepageItems = await getCollectionProducts({
collection: process.env.FW_COLLECTION || '',
currency
currency,
});
if (!homepageItems[0] || !homepageItems[1] || !homepageItems[2]) return null;
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 LogoSquare from 'components/logo-square';
import { getMenu } from 'lib/fourthwall';
import { Menu } from 'lib/shopify/types';
import Link from 'next/link';
import { Suspense } from 'react';
import { CurrencySelector } from './currency';
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');
export function Navbar({currency}: {currency: string}) {
const menu: any[] = [];
return (
<nav className="relative flex items-center justify-between p-4 lg:px-6">
<div className="block flex-none md:hidden">
@ -53,9 +53,7 @@ export async function Navbar() {
</Suspense>
</div>
<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">
USD
</div>
<CurrencySelector currency={currency} />
<CartModal />
</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 { 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
@ -74,16 +77,14 @@ export async function getCollectionProducts({
reverse?: boolean;
sortKey?: string;
}): 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: {
'X-ShopId': process.env.FW_SHOPID || ''
}
});
console.warn(JSON.stringify(res.body.results, null, 2));
if (!res.body.results) {
console.log(`No collection found for \`${collection}\``);
console.warn(`No collection found for \`${collection}\``);
return [];
}
@ -96,7 +97,7 @@ export async function getCollectionProducts({
*/
export async function getProduct({ handle, currency } : { handle: string, currency: string }): Promise<Product | undefined> {
// 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: {
'X-ShopId': process.env.FW_SHOPID || ''
}
@ -120,7 +121,7 @@ export async function getCart(cartId: string | undefined, currency: string): Pro
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'
});
@ -128,7 +129,7 @@ export async function getCart(cartId: string | undefined, currency: string): Pro
}
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: []
}, {
headers: {
@ -149,7 +150,7 @@ export async function addToCart(
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,
}, {
headers: {
@ -166,7 +167,7 @@ export async function removeFromCart(cartId: string, lineIds: string[]): Promise
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,
}, {
headers: {
@ -187,7 +188,7 @@ export async function updateCart(
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,
}, {
headers: {
@ -199,6 +200,21 @@ export async function updateCart(
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

View File

@ -167,6 +167,7 @@ export const reshapeCart = (cart: FourthwallCart): Cart => {
},
},
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)

View File

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

View File

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