mirror of
https://github.com/vercel/commerce.git
synced 2025-05-29 20:56:58 +00:00
currency and checkout
This commit is contained in:
parent
4653f74188
commit
b6ba260b77
@ -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>
|
||||
);
|
||||
|
14
app/page.tsx
14
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,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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -27,7 +27,6 @@ function SubmitButton({
|
||||
);
|
||||
}
|
||||
|
||||
console.log(selectedVariantId);
|
||||
if (!selectedVariantId) {
|
||||
return (
|
||||
<button
|
||||
|
@ -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>;
|
||||
|
@ -192,7 +192,7 @@ export default function CartModal() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form action={redirectToCheckout}>
|
||||
<form action={() => redirectToCheckout(cart.currency)}>
|
||||
<CheckoutButton />
|
||||
</form>
|
||||
</div>
|
||||
|
@ -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;
|
||||
|
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 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>
|
||||
);
|
||||
}
|
@ -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
17
components/wrapper.tsx
Normal 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>
|
||||
}
|
@ -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}¤cy=${currency}`, {
|
||||
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${collection}/products?secret=${API_SECRET}¤cy=${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}¤cy=${currency}`, {
|
||||
const res = await fourthwallGet<{results: FourthwallProduct[]}>(`${API_URL}/api/public/v1.0/collections/${process.env.FW_COLLECTION}/products?secret=${API_SECRET}¤cy=${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}¤cy=${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
|
||||
|
@ -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)
|
||||
|
@ -51,3 +51,7 @@ export type FourthwallCartItem = {
|
||||
variant: FourthwallProductVariant;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type FourthwallCheckout = {
|
||||
id: string
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ export type Edge<T> = {
|
||||
|
||||
export type Cart = Omit<ShopifyCart, 'lines'> & {
|
||||
lines: CartItem[];
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type CartProduct = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user