feat: e2e rapyd working

This commit is contained in:
Gunnar Torfi 2025-03-15 21:20:35 +00:00
parent 31b46232b0
commit 91e4079b7e
16 changed files with 1581 additions and 986 deletions

68
app/actions/cart.ts Normal file
View File

@ -0,0 +1,68 @@
"use server";
import { CartState } from "@/components/cart/cart-context";
import { getProductById } from "@/lib/store/products";
type StorageCartItem = {
variantId: string;
productId: string;
quantity: number;
};
export const calculateCartTotals = async (
items: StorageCartItem[]
): Promise<CartState> => {
const cartLines = [];
let totalQuantity = 0;
// Fetch products and build cart lines
for (const item of items) {
const product = await getProductById({ id: item.productId });
if (product) {
const variant = product.variants.find((v) => v.id === item.variantId);
if (variant) {
cartLines.push({
merchandise: {
...variant,
product,
},
quantity: item.quantity,
});
totalQuantity += item.quantity;
}
}
}
// Calculate totals
const subtotalAmount = cartLines
.reduce(
(sum, item) =>
sum + parseFloat(item.merchandise.price.amount) * item.quantity,
0
)
.toFixed(2);
const taxAmount = (parseFloat(subtotalAmount) * 0.1).toFixed(2);
const totalAmount = (
parseFloat(subtotalAmount) + parseFloat(taxAmount)
).toFixed(2);
return {
lines: cartLines,
totalQuantity,
cost: {
subtotalAmount: {
amount: subtotalAmount,
currencyCode: "ISK",
},
totalAmount: {
amount: totalAmount,
currencyCode: "ISK",
},
totalTaxAmount: {
amount: taxAmount,
currencyCode: "ISK",
},
},
};
};

View File

@ -1,19 +1,23 @@
import { Navbar } from "@/components/layout/navbar";
import { CartProvider } from "components/cart/cart-context";
import { Inter } from "next/font/google";
import { ReactNode } from "react";
import { Toaster } from "sonner";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export default async function RootLayout({
children,
}: {
children: ReactNode;
}) {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>
<CartProvider>{children}</CartProvider>
<CartProvider>
<Navbar />
<main>
{children}
<Toaster closeButton />
</main>
</CartProvider>
</body>
</html>
);

3
app/order-error/page.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function OrderError() {
return <div>Order Error</div>;
}

View File

@ -0,0 +1,3 @@
export default function OrderSuccessful() {
return <div>Order Successful</div>;
}

View File

@ -1,46 +1,58 @@
"use server";
import { createCheckout } from "@/lib/rapyd/checkout";
import { getProductById } from "@/lib/store/products";
import { TAGS } from "lib/constants";
import { revalidateTag } from "next/cache";
import { RequestCookies } from "next/dist/server/web/spec-extension/cookies";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
interface Cart {
id: string;
}
export interface CartItem {
id: string;
quantity: number;
amount: number;
}
const CART_COOKIE = "cart";
const getCartFromCookie = (): CartItem[] => {
const cookieStore = cookies() as unknown as RequestCookies;
const getCartFromCookie = async (): Promise<CartItem[]> => {
const cookieStore = await cookies();
const cartCookie = cookieStore.get(CART_COOKIE)?.value;
return cartCookie ? JSON.parse(cartCookie) : [];
};
const setCartCookie = (cart: CartItem[]) => {
const cookieStore = cookies() as unknown as RequestCookies;
const setCartCookie = async (cart: CartItem[]) => {
const cookieStore = await cookies();
cookieStore.set(CART_COOKIE, JSON.stringify(cart));
};
export const addToCart = async (productId: string) => {
const cart = getCartFromCookie();
const cart = await getCartFromCookie();
const product = await getProductById({ id: productId });
const existingItem = cart.find((item) => item.id === productId);
if (existingItem) {
existingItem.quantity += 1;
} else {
cart.push({ id: productId, quantity: 1 });
cart.push({
id: productId,
quantity: 1,
amount: parseFloat(product?.variants[0]?.price.amount ?? "0"),
});
}
setCartCookie(cart);
await setCartCookie(cart);
return cart;
};
export const removeFromCart = async (productId: string) => {
const cart = getCartFromCookie();
const cart = await getCartFromCookie();
const updatedCart = cart.filter((item) => item.id !== productId);
setCartCookie(updatedCart);
await setCartCookie(updatedCart);
return updatedCart;
};
@ -48,7 +60,7 @@ export const updateCartItemQuantity = async (
productId: string,
quantity: number
) => {
const cart = getCartFromCookie();
const cart = await getCartFromCookie();
const item = cart.find((item) => item.id === productId);
if (item) {
@ -56,7 +68,7 @@ export const updateCartItemQuantity = async (
}
const updatedCart = cart.filter((item) => item.quantity > 0);
setCartCookie(updatedCart);
await setCartCookie(updatedCart);
return updatedCart;
};
@ -126,3 +138,34 @@ export async function updateItemQuantity(
return "Error updating item quantity";
}
}
export async function createCart() {
const cart = {
id: crypto.randomUUID(),
};
return cart;
}
export async function createCartAndSetCookie() {
let cart = await createCart();
(await cookies()).set("cartId", cart.id!);
}
export async function redirectToCheckout() {
let cart = await getCart();
const totalAmount = cart.reduce(
(acc, item) => acc + item.quantity * item.amount,
0
);
const checkout = await createCheckout({
amount: totalAmount,
description: "Cart",
merchantReferenceId: crypto.randomUUID(),
completeCheckoutUrl: process.env.NEXT_PUBLIC_APP_URL + "/order-successful",
cancelCheckoutUrl: process.env.NEXT_PUBLIC_APP_URL + "/order-error",
});
redirect(checkout.redirect_url);
}

View File

@ -73,23 +73,17 @@ export function AddToCart({ product }: { product: Product }) {
);
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
const selectedVariantId = variant?.id || defaultVariantId;
const addItemAction = formAction.bind(null, selectedVariantId);
const finalVariant = variants.find(
(variant) => variant.id === selectedVariantId
)!;
console.log({
variant,
defaultVariantId,
selectedVariantId,
product,
});
return (
<form
action={async () => {
addCartItem(finalVariant, product);
addItemAction();
if (selectedVariantId) {
await addItem(selectedVariantId);
addCartItem(finalVariant, product);
}
}}
>
<SubmitButton

View File

@ -1,7 +1,8 @@
"use client";
import { calculateCartTotals } from "@/app/actions/cart";
import { Product, ProductVariant } from "lib/store/types";
import { createContext, useContext, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
type CartItem = {
merchandise: ProductVariant & {
@ -10,7 +11,7 @@ type CartItem = {
quantity: number;
};
type CartState = {
export type CartState = {
lines: CartItem[];
totalQuantity: number;
cost: {
@ -38,180 +39,113 @@ type CartContextType = {
const CartContext = createContext<CartContextType | undefined>(undefined);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<CartState>({
lines: [],
totalQuantity: 0,
cost: {
subtotalAmount: {
amount: "0",
currencyCode: "USD",
},
totalAmount: {
amount: "0",
currencyCode: "USD",
},
totalTaxAmount: {
amount: "0",
currencyCode: "USD",
},
const CART_STORAGE_KEY = "cartItems";
// Only store minimal cart data in sessionStorage
type StorageCartItem = {
variantId: string;
productId: string;
quantity: number;
};
const defaultCartState: CartState = {
lines: [],
totalQuantity: 0,
cost: {
subtotalAmount: {
amount: "0",
currencyCode: "ISK",
},
});
totalAmount: {
amount: "0",
currencyCode: "ISK",
},
totalTaxAmount: {
amount: "0",
currencyCode: "ISK",
},
},
};
const addCartItem = (variant: ProductVariant, product: Product) => {
setCart((prevCart) => {
const existingItem = prevCart.lines.find(
(item) => item.merchandise.id === variant.id
);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<CartState>(defaultCartState);
let newLines;
if (existingItem) {
newLines = prevCart.lines.map((item) =>
item.merchandise.id === variant.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
newLines = [
...prevCart.lines,
{
merchandise: {
...variant,
product,
},
quantity: 1,
},
];
// Load cart from sessionStorage and calculate totals
useEffect(() => {
const loadCart = async () => {
const savedCart = sessionStorage.getItem(CART_STORAGE_KEY);
if (savedCart) {
const storageItems: StorageCartItem[] = JSON.parse(savedCart);
const calculatedCart = await calculateCartTotals(storageItems);
setCart(calculatedCart);
}
};
const totalQuantity = newLines.reduce(
(sum, item) => sum + item.quantity,
0
loadCart();
}, []);
const addCartItem = async (variant: ProductVariant, product: Product) => {
const savedCart = sessionStorage.getItem(CART_STORAGE_KEY);
const storageItems: StorageCartItem[] = savedCart
? JSON.parse(savedCart)
: [];
const existingItem = storageItems.find(
(item) => item.variantId === variant.id
);
let newStorageItems: StorageCartItem[];
if (existingItem) {
newStorageItems = storageItems.map((item) =>
item.variantId === variant.id
? { ...item, quantity: item.quantity + 1 }
: item
);
const subtotalAmount = newLines
.reduce(
(sum, item) =>
sum + parseFloat(item.merchandise.price.amount) * item.quantity,
0
)
.toFixed(2);
// For this example, we'll assume tax rate is 10%
const taxAmount = (parseFloat(subtotalAmount) * 0.1).toFixed(2);
const totalAmount = (
parseFloat(subtotalAmount) + parseFloat(taxAmount)
).toFixed(2);
return {
lines: newLines,
totalQuantity,
cost: {
subtotalAmount: {
amount: subtotalAmount,
currencyCode: "USD",
},
totalAmount: {
amount: totalAmount,
currencyCode: "USD",
},
totalTaxAmount: {
amount: taxAmount,
currencyCode: "USD",
},
} else {
newStorageItems = [
...storageItems,
{
variantId: variant.id,
productId: product.id,
quantity: 1,
},
};
});
];
}
sessionStorage.setItem(CART_STORAGE_KEY, JSON.stringify(newStorageItems));
const calculatedCart = await calculateCartTotals(newStorageItems);
setCart(calculatedCart);
};
const removeCartItem = (variantId: string) => {
setCart((prevCart) => {
const newLines = prevCart.lines.filter(
(item) => item.merchandise.id !== variantId
);
const removeCartItem = async (variantId: string) => {
const savedCart = sessionStorage.getItem(CART_STORAGE_KEY);
if (!savedCart) return;
const totalQuantity = newLines.reduce(
(sum, item) => sum + item.quantity,
0
);
const storageItems: StorageCartItem[] = JSON.parse(savedCart);
const newStorageItems = storageItems.filter(
(item) => item.variantId !== variantId
);
const subtotalAmount = newLines
.reduce(
(sum, item) =>
sum + parseFloat(item.merchandise.price.amount) * item.quantity,
0
)
.toFixed(2);
const taxAmount = (parseFloat(subtotalAmount) * 0.1).toFixed(2);
const totalAmount = (
parseFloat(subtotalAmount) + parseFloat(taxAmount)
).toFixed(2);
return {
lines: newLines,
totalQuantity,
cost: {
subtotalAmount: {
amount: subtotalAmount,
currencyCode: "USD",
},
totalAmount: {
amount: totalAmount,
currencyCode: "USD",
},
totalTaxAmount: {
amount: taxAmount,
currencyCode: "USD",
},
},
};
});
sessionStorage.setItem(CART_STORAGE_KEY, JSON.stringify(newStorageItems));
const calculatedCart = await calculateCartTotals(newStorageItems);
setCart(calculatedCart);
};
const updateCartItem = (variantId: string, quantity: number) => {
setCart((prevCart) => {
const newLines = prevCart.lines.map((item) =>
item.merchandise.id === variantId ? { ...item, quantity } : item
);
const updateCartItem = async (variantId: string, quantity: number) => {
const savedCart = sessionStorage.getItem(CART_STORAGE_KEY);
if (!savedCart) return;
const totalQuantity = newLines.reduce(
(sum, item) => sum + item.quantity,
0
);
const storageItems: StorageCartItem[] = JSON.parse(savedCart);
const newStorageItems =
quantity > 0
? storageItems.map((item) =>
item.variantId === variantId ? { ...item, quantity } : item
)
: storageItems.filter((item) => item.variantId !== variantId);
const subtotalAmount = newLines
.reduce(
(sum, item) =>
sum + parseFloat(item.merchandise.price.amount) * item.quantity,
0
)
.toFixed(2);
const taxAmount = (parseFloat(subtotalAmount) * 0.1).toFixed(2);
const totalAmount = (
parseFloat(subtotalAmount) + parseFloat(taxAmount)
).toFixed(2);
return {
lines: newLines,
totalQuantity,
cost: {
subtotalAmount: {
amount: subtotalAmount,
currencyCode: "USD",
},
totalAmount: {
amount: totalAmount,
currencyCode: "USD",
},
totalTaxAmount: {
amount: taxAmount,
currencyCode: "USD",
},
},
};
});
sessionStorage.setItem(CART_STORAGE_KEY, JSON.stringify(newStorageItems));
const calculatedCart = await calculateCartTotals(newStorageItems);
setCart(calculatedCart);
};
return (

View File

@ -1,310 +1,270 @@
"use client";
import { getImageUrl } from "@/lib/utils/image";
import { Dialog, Transition } from "@headlessui/react";
import { ShoppingCartIcon, XMarkIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import LoadingDots from "components/loading-dots";
import Price from "components/price";
import { DEFAULT_OPTION } from "lib/constants";
import { createUrl } from "lib/utils";
import { getImageUrl } from "lib/utils/image";
import Image from "next/image";
import Link from "next/link";
import { Fragment, useEffect, useRef } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { createCartAndSetCookie, redirectToCheckout } from "./actions";
import { useCart } from "./cart-context";
import { DeleteItemButton } from "./delete-item-button";
import { EditItemQuantityButton } from "./edit-item-quantity-button";
import OpenCart from "./open-cart";
type MerchandiseSearchParams = {
[key: string]: string;
};
export default function CartModal() {
const { cart, updateCartItem, removeCartItem } = useCart();
const quantityRef = useRef(cart.totalQuantity);
const openCart = () => {};
const closeCart = () => {};
const { cart, updateCartItem } = useCart();
const [isOpen, setIsOpen] = useState(false);
const quantityRef = useRef(cart?.totalQuantity);
const openCart = () => setIsOpen(true);
const closeCart = () => setIsOpen(false);
useEffect(() => {
// Open cart modal when quantity changes.
if (cart.totalQuantity !== quantityRef.current) {
// But only if it's not already open.
openCart();
if (!cart) {
createCartAndSetCookie();
}
quantityRef.current = cart.totalQuantity;
}, [cart.totalQuantity, openCart]);
}, [cart]);
useEffect(() => {
if (
cart?.totalQuantity &&
cart?.totalQuantity !== quantityRef.current &&
cart?.totalQuantity > 0
) {
if (!isOpen) {
setIsOpen(true);
}
quantityRef.current = cart?.totalQuantity;
}
}, [isOpen, cart?.totalQuantity, quantityRef]);
return (
<>
<button
aria-label="Open cart"
onClick={openCart}
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"
>
<ShoppingCartIcon className="h-4 transition-all ease-in-out hover:scale-110" />
{cart.totalQuantity > 0 && (
<div className="absolute right-0 top-0 -mr-2 -mt-2 h-4 w-4 rounded bg-blue-600 text-[11px] font-medium text-white">
{cart.totalQuantity}
</div>
)}
<button aria-label="Open cart" onClick={openCart}>
<OpenCart quantity={cart?.totalQuantity} />
</button>
<Transition.Root show={true} as={Fragment}>
<Transition show={isOpen}>
<Dialog onClose={closeCart} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0"
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-neutral-400/25 backdrop-blur-sm" />
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
</Transition.Child>
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="pointer-events-auto w-screen max-w-md">
<div className="flex h-full flex-col overflow-y-scroll bg-white shadow-xl dark:bg-black">
<div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6">
<div className="flex items-start justify-between">
<Dialog.Title className="text-lg font-medium text-black dark:text-white">
Cart
</Dialog.Title>
<div className="ml-3 flex h-7 items-center">
<button
type="button"
className="relative -m-2 p-2 text-neutral-400 hover:text-neutral-500"
onClick={closeCart}
>
<span className="absolute -inset-0.5" />
<span className="sr-only">Close panel</span>
<XMarkIcon
className="h-6 w-6"
aria-hidden="true"
/>
</button>
</div>
</div>
<div className="mt-8">
<div className="flow-root">
{cart.lines.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center space-y-1">
<ShoppingCartIcon
className="w-16"
aria-hidden="true"
/>
<p className="text-sm font-medium text-black dark:text-white">
Your cart is empty.
</p>
</div>
) : (
<ul
role="list"
className="-my-6 divide-y divide-neutral-200"
>
{cart.lines
.sort((a, b) =>
a.merchandise.product.title.localeCompare(
b.merchandise.product.title
)
)
.map((item) => {
const merchandiseSearchParams =
{} as MerchandiseSearchParams;
item.merchandise.selectedOptions.forEach(
({ name, value }) => {
if (value !== DEFAULT_OPTION) {
merchandiseSearchParams[
name.toLowerCase()
] = value;
}
}
);
const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`,
new URLSearchParams(
merchandiseSearchParams
)
);
return (
<li
key={item.merchandise.id}
className="flex py-6"
>
<div className="relative h-24 w-24 flex-shrink-0 overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-800">
<Image
src={
item.merchandise.product
.featuredImage
? getImageUrl(
item.merchandise.product
.featuredImage.source
)
: ""
}
alt={item.merchandise.product.title}
className="h-full w-full object-cover object-center"
fill
/>
</div>
<div className="ml-4 flex flex-1 flex-col">
<div>
<div className="flex justify-between text-base font-medium text-black dark:text-white">
<h3>
<Link
href={merchandiseUrl}
className="font-medium"
>
{
item.merchandise.product
.title
}
</Link>
</h3>
<p className="ml-4">
<Price
amount={
item.merchandise.price
.amount
}
currencyCode={
item.merchandise.price
.currencyCode
}
/>
</p>
</div>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
</div>
<div className="flex flex-1 items-end justify-between text-sm">
<div className="flex items-center border rounded-full">
<EditItemQuantityButton
item={item}
type="minus"
onClick={() => {
updateCartItem(
item.merchandise.id,
item.quantity - 1
);
}}
/>
<p className="mx-2 flex-1 text-center">
<span className="w-8">
{item.quantity}
</span>
</p>
<EditItemQuantityButton
item={item}
type="plus"
onClick={() => {
updateCartItem(
item.merchandise.id,
item.quantity + 1
);
}}
/>
</div>
<div className="ml-4">
<button
type="button"
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
onClick={() =>
removeCartItem(
item.merchandise.id
)
}
>
Remove
</button>
</div>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
{cart.lines.length > 0 ? (
<div className="border-t border-neutral-200 px-4 py-6 sm:px-6">
<div className="flex justify-between text-base font-medium text-black dark:text-white">
<p>Subtotal</p>
<p>
<Price
amount={cart.cost.subtotalAmount.amount}
currencyCode={
cart.cost.subtotalAmount.currencyCode
}
/>
</p>
</div>
<div className="flex justify-between text-base font-medium text-black dark:text-white">
<p>Taxes</p>
<p>
<Price
amount={cart.cost.totalTaxAmount.amount}
currencyCode={
cart.cost.totalTaxAmount.currencyCode
}
/>
</p>
</div>
<div className="flex justify-between text-base font-medium text-black dark:text-white">
<p>Total</p>
<p>
<Price
amount={cart.cost.totalAmount.amount}
currencyCode={
cart.cost.totalAmount.currencyCode
}
/>
</p>
</div>
<div className="mt-6">
<button className="w-full rounded-full bg-blue-600 px-6 py-3 text-center font-medium text-white hover:bg-blue-500">
Checkout
</button>
</div>
<div className="mt-6 flex justify-center text-center text-sm text-neutral-500">
<p>
or{" "}
<button
type="button"
className="font-medium text-blue-600 hover:text-blue-500"
onClick={closeCart}
>
Continue Shopping
<span aria-hidden="true"> &rarr;</span>
</button>
</p>
</div>
</div>
) : null}
</div>
</Dialog.Panel>
</Transition.Child>
<Transition.Child
as={Fragment}
enter="transition-all ease-in-out duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition-all ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">My Cart</p>
<button aria-label="Close cart" onClick={closeCart}>
<CloseCart />
</button>
</div>
</div>
</div>
{!cart || cart.lines.length === 0 ? (
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
<ShoppingCartIcon className="h-16" />
<p className="mt-6 text-center text-2xl font-bold">
Your cart is empty.
</p>
</div>
) : (
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
<ul className="grow overflow-auto py-4">
{cart.lines
.sort((a, b) =>
a.merchandise.product.title.localeCompare(
b.merchandise.product.title
)
)
.map((item, i) => {
const merchandiseSearchParams =
{} as MerchandiseSearchParams;
item.merchandise.selectedOptions.forEach(
({ name, value }) => {
if (value !== DEFAULT_OPTION) {
merchandiseSearchParams[name.toLowerCase()] =
value;
}
}
);
const merchandiseUrl = createUrl(
`/product/${item.merchandise.product.handle}`,
new URLSearchParams(merchandiseSearchParams)
);
return (
<li
key={i}
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
>
<div className="relative flex w-full flex-row justify-between px-1 py-4">
<div className="absolute z-40 -ml-1 -mt-2">
<DeleteItemButton
item={item}
onClick={() =>
updateCartItem(item.merchandise.id, 0)
}
/>
</div>
<div className="flex flex-row">
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
<Image
className="h-full w-full object-cover"
width={64}
height={64}
alt={
item.merchandise.product.featuredImage
.altText ||
item.merchandise.product.title
}
src={getImageUrl(
item.merchandise.product.featuredImage
.source
)}
/>
</div>
<Link
href={merchandiseUrl}
onClick={closeCart}
className="z-30 ml-2 flex flex-row space-x-4"
>
<div className="flex flex-1 flex-col text-base">
<span className="leading-tight">
{item.merchandise.product.title}
</span>
{item.merchandise.title !==
DEFAULT_OPTION ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{item.merchandise.title}
</p>
) : null}
</div>
</Link>
</div>
<div className="flex h-16 flex-col justify-between">
<Price
className="flex justify-end space-y-2 text-right text-sm"
amount={item.merchandise.price.amount}
currencyCode={
item.merchandise.price.currencyCode
}
/>
<div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
<EditItemQuantityButton
item={item}
type="minus"
onClick={() =>
updateCartItem(
item.merchandise.id,
item.quantity - 1
)
}
/>
<p className="w-6 text-center">
<span className="w-full text-sm">
{item.quantity}
</span>
</p>
<EditItemQuantityButton
item={item}
type="plus"
onClick={() =>
updateCartItem(
item.merchandise.id,
item.quantity + 1
)
}
/>
</div>
</div>
</div>
</li>
);
})}
</ul>
<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}
/>
</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>
<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>Total</p>
<Price
className="text-right text-base text-black dark:text-white"
amount={cart.cost.totalAmount.amount}
currencyCode={cart.cost.totalAmount.currencyCode}
/>
</div>
</div>
<form action={redirectToCheckout}>
<CheckoutButton />
</form>
</div>
)}
</Dialog.Panel>
</Transition.Child>
</Dialog>
</Transition.Root>
</Transition>
</>
);
}
function CloseCart({ className }: { className?: string }) {
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">
<XMarkIcon
className={clsx(
"h-6 transition-all ease-in-out hover:scale-110",
className
)}
/>
</div>
);
}
function CheckoutButton() {
const { pending } = useFormStatus();
return (
<button
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
type="submit"
disabled={pending}
>
{pending ? <LoadingDots className="bg-white" /> : "Proceed to Checkout"}
</button>
);
}

View File

@ -1,32 +1,42 @@
import Link from "next/link";
import LogoSquare from "@/components/logo-square";
import { getMenu } from "@/lib/store/menu";
import CartModal from "components/cart/modal";
import LogoSquare from "components/logo-square";
import Link from "next/link";
import { Suspense } from "react";
import MobileMenu from "./mobile-menu";
import Search from "./search";
import Search, { SearchSkeleton } from "./search";
export default async function Navbar() {
const { SITE_NAME } = process.env;
export async function Navbar() {
const menu = await getMenu("navbar");
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
href="/"
prefetch={true}
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
>
<LogoSquare />
<span className="ml-2 flex-none text-sm font-medium uppercase">
Store
</span>
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
{SITE_NAME}
</div>
</Link>
{menu?.items ? (
{menu?.items.length ? (
<ul className="hidden gap-6 text-sm md:flex md:items-center">
{menu.items.map((item) => (
<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}
@ -37,35 +47,14 @@ export default async function Navbar() {
) : null}
</div>
<div className="hidden justify-center md:flex md:w-1/3">
<Search />
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
</div>
<div className="flex justify-end md:w-1/3">
<div className="flex items-center">
<button
aria-label="Open cart"
className="ml-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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-4 w-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
</button>
</div>
<CartModal />
</div>
</div>
<div className="flex md:hidden">
<MobileMenu menu={menu} />
</div>
</nav>
);
}

View File

@ -1,23 +1,20 @@
import clsx from 'clsx';
const Price = ({
amount,
className,
currencyCode = 'USD',
currencyCodeClassName
currencyCode = "ISK",
currencyCodeClassName,
}: {
amount: string;
className?: string;
currencyCode: string;
currencyCodeClassName?: string;
} & React.ComponentProps<'p'>) => (
} & React.ComponentProps<"p">) => (
<p suppressHydrationWarning={true} className={className}>
{`${new Intl.NumberFormat(undefined, {
style: 'currency',
style: "currency",
currency: currencyCode,
currencyDisplay: 'narrowSymbol'
currencyDisplay: "narrowSymbol",
}).format(parseFloat(amount))}`}
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
</p>
);

View File

@ -1,10 +1,21 @@
"use client";
import { AddToCart } from "components/cart/add-to-cart";
import Price from "components/price";
import Prose from "components/prose";
import { Product } from "lib/store/types";
import { Product, ProductVariant } from "lib/store/types";
import { useProduct } from "./product-context";
import { VariantSelector } from "./variant-selector";
export function ProductDescription({ product }: { product: Product }) {
const { updateOption } = useProduct();
const handleVariantChange = (variant: ProductVariant) => {
variant.selectedOptions.forEach(({ name, value }) => {
updateOption(name, value);
});
};
return (
<>
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
@ -20,6 +31,7 @@ export function ProductDescription({ product }: { product: Product }) {
options={product.options}
variants={product.variants}
selectedVariant={product.variants[0]!}
onVariantChange={handleVariantChange}
/>
{product.descriptionHtml ? (
<Prose

View File

@ -1,4 +1,4 @@
import { makeRequest } from "@/lib/rapyd/utilities";
import { makeRequest } from "@/lib/rapyd/make-rapyd-request";
import { cache } from "react";
import "server-only";
@ -58,11 +58,13 @@ export const createCheckout = cache(
const response = await makeRequest({
method: "post",
path: "/v1/checkout",
urlPath: "/v1/checkout",
body: checkoutBody,
});
return response as CheckoutResponse;
console.log(response.body.data);
return response.body.data as unknown as CheckoutResponse;
}
);

View File

@ -0,0 +1,193 @@
import { createHmac, randomBytes } from "crypto";
import type { IncomingHttpHeaders } from "http";
import https from "https";
interface RapydRequestOptions {
method: "get" | "put" | "post" | "delete";
urlPath: string;
body?: Record<string, unknown> | null;
}
interface RapydResponse {
statusCode: number;
headers: IncomingHttpHeaders;
body: Record<string, unknown>;
}
const BASE_URL = process.env.RAPYD_BASE_URL;
const secretKey = process.env.RAPYD_SECRET_KEY ?? "";
const accessKey = process.env.RAPYD_ACCESS_KEY ?? "";
if (!BASE_URL || !secretKey || !accessKey) {
throw new Error(
"RAPYD_BASE_URL, RAPYD_SECRET_KEY, and RAPYD_ACCESS_KEY must be set"
);
}
const log = false;
const makeRequest = async ({
method,
urlPath,
body = null,
}: RapydRequestOptions): Promise<RapydResponse> => {
try {
const httpMethod = method.toLowerCase();
const httpBaseURL = BASE_URL.replace(/^https?:\/\//, "").replace(
/\/+$/,
""
);
const httpURLPath = urlPath.startsWith("/") ? urlPath : `/${urlPath}`;
const salt = generateRandomString(8);
const idempotency = new Date().getTime().toString();
const timestamp = Math.round(new Date().getTime() / 1000);
const signature = sign({
method: httpMethod,
urlPath: httpURLPath,
salt,
timestamp,
body,
});
const options = {
hostname: httpBaseURL,
port: 443,
path: httpURLPath,
method: httpMethod,
headers: {
"Content-Type": "application/json",
salt,
timestamp,
signature,
access_key: accessKey,
idempotency,
},
};
return await httpRequest(options, body);
} catch (error) {
console.error("Error generating request options:", error);
throw error;
}
};
interface SignOptions {
method: string;
urlPath: string;
salt: string;
timestamp: number;
body: Record<string, unknown> | null;
}
const sign = ({
method,
urlPath,
salt,
timestamp,
body,
}: SignOptions): string => {
try {
let bodyString = "";
if (body) {
bodyString = JSON.stringify(body);
bodyString = bodyString === "{}" ? "" : bodyString;
}
const toSign =
method.toLowerCase() +
urlPath +
salt +
timestamp +
accessKey +
secretKey +
bodyString;
log && console.log(`toSign: ${toSign}`);
const hash = createHmac("sha256", secretKey);
hash.update(toSign);
const signature = Buffer.from(hash.digest("hex")).toString("base64");
log && console.log(`signature: ${signature}`);
return signature;
} catch (error) {
console.error("Error generating signature:", error);
throw error;
}
};
const generateRandomString = (size: number): string => {
try {
return randomBytes(size).toString("hex");
} catch (error) {
console.error("Error generating salt:", error);
throw error;
}
};
interface HttpRequestOptions {
hostname: string;
port: number;
path: string;
method: string;
headers: Record<string, string | number>;
}
const httpRequest = async (
options: HttpRequestOptions,
body: Record<string, unknown> | null
): Promise<RapydResponse> => {
return new Promise((resolve, reject) => {
try {
const bodyString = body ? JSON.stringify(body) : "";
log && console.log(`httpRequest options: ${JSON.stringify(options)}`);
const req = https.request(options, (res) => {
let responseData = "";
const response: Omit<RapydResponse, "body"> & { body: string } = {
statusCode: res.statusCode ?? 500,
headers: res.headers,
body: "",
};
res.on("data", (data: Buffer) => {
responseData += data.toString();
});
res.on("end", () => {
try {
const parsedBody = responseData ? JSON.parse(responseData) : {};
const fullResponse: RapydResponse = {
...response,
body: parsedBody,
};
log &&
console.log(
`httpRequest response: ${JSON.stringify(fullResponse)}`
);
if (fullResponse.statusCode !== 200) {
return reject(fullResponse);
}
return resolve(fullResponse);
} catch (error) {
reject(new Error("Failed to parse response body"));
}
});
});
req.on("error", (error: Error) => {
reject(error);
});
req.write(bodyString);
req.end();
} catch (error) {
reject(error);
}
});
};
export { makeRequest, type RapydRequestOptions, type RapydResponse };

View File

@ -133,6 +133,7 @@ const makeRequest = async ({
requestConfig.body = stringifiedBody;
}
console.log("requestConfig", requestConfig);
const response = await fetch(url, requestConfig);
if (!response.ok) {

View File

@ -90,6 +90,14 @@ export const getProduct = ({
return Promise.resolve(products.find((p) => p.handle === handle));
};
export const getProductById = ({
id,
}: {
id: string;
}): Promise<Product | undefined> => {
return Promise.resolve(products.find((p) => p.id === id));
};
export const getProducts = ({
query,
reverse,

1334
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff