mirror of
https://github.com/vercel/commerce.git
synced 2025-03-14 14:42:31 +00:00
Replaces Route Handlers with Server Actions (#1050)
This commit is contained in:
parent
7eb8816854
commit
585b3bbff8
@ -1,75 +0,0 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
|
||||
function formatErrorMessage(err: Error): string {
|
||||
return JSON.stringify(err, Object.getOwnPropertyNames(err));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<Response> {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
const { merchandiseId } = await req.json();
|
||||
|
||||
if (!cartId?.length || !merchandiseId?.length) {
|
||||
return NextResponse.json({ error: 'Missing cartId or variantId' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId, quantity: 1 }]);
|
||||
return NextResponse.json({ status: 204 });
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest): Promise<Response> {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
const { variantId, quantity, lineId } = await req.json();
|
||||
|
||||
if (!cartId || !variantId || !quantity || !lineId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing cartId, variantId, lineId, or quantity' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineId,
|
||||
merchandiseId: variantId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
return NextResponse.json({ status: 204 });
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest): Promise<Response> {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
const { lineId } = await req.json();
|
||||
|
||||
if (!cartId || !lineId) {
|
||||
return NextResponse.json({ error: 'Missing cartId or lineId' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
return NextResponse.json({ status: 204 });
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
return NextResponse.json({ message: formatErrorMessage(e.message) }, { status: e.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 500 });
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import { Suspense } from 'react';
|
||||
import Grid from 'components/grid';
|
||||
import Footer from 'components/layout/footer';
|
||||
import ProductGridItems from 'components/layout/product-grid-items';
|
||||
import { AddToCart } from 'components/product/add-to-cart';
|
||||
import { AddToCart } from 'components/cart/add-to-cart';
|
||||
import { Gallery } from 'components/product/gallery';
|
||||
import { VariantSelector } from 'components/product/variant-selector';
|
||||
import Prose from 'components/prose';
|
||||
|
57
components/cart/actions.ts
Normal file
57
components/cart/actions.ts
Normal file
@ -0,0 +1,57 @@
|
||||
'use server';
|
||||
|
||||
import { addToCart, removeFromCart, updateCart } from 'lib/shopify';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const addItem = async (variantId: string | undefined): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId || !variantId) {
|
||||
return new Error('Missing cartId or variantId');
|
||||
}
|
||||
try {
|
||||
await addToCart(cartId, [{ merchandiseId: variantId, quantity: 1 }]);
|
||||
} catch (e) {
|
||||
return new Error('Error adding item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeItem = async (lineId: string): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
try {
|
||||
await removeFromCart(cartId, [lineId]);
|
||||
} catch (e) {
|
||||
return new Error('Error removing item', { cause: e });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateItemQuantity = async ({
|
||||
lineId,
|
||||
variantId,
|
||||
quantity
|
||||
}: {
|
||||
lineId: string;
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}): Promise<Error | undefined> => {
|
||||
const cartId = cookies().get('cartId')?.value;
|
||||
|
||||
if (!cartId) {
|
||||
return new Error('Missing cartId');
|
||||
}
|
||||
try {
|
||||
await updateCart(cartId, [
|
||||
{
|
||||
id: lineId,
|
||||
merchandiseId: variantId,
|
||||
quantity
|
||||
}
|
||||
]);
|
||||
} catch (e) {
|
||||
return new Error('Error updating item quantity', { cause: e });
|
||||
}
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { addItem } from 'components/cart/actions';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
@ -18,7 +19,6 @@ export function AddToCart({
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const variant = variants.find((variant: ProductVariant) =>
|
||||
@ -32,49 +32,33 @@ export function AddToCart({
|
||||
}
|
||||
}, [searchParams, variants, setSelectedVariantId]);
|
||||
|
||||
const isMutating = adding || isPending;
|
||||
|
||||
async function handleAdd() {
|
||||
if (!availableForSale) return;
|
||||
|
||||
setAdding(true);
|
||||
|
||||
const response = await fetch(`/api/cart`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
merchandiseId: selectedVariantId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(false);
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Add item to cart"
|
||||
disabled={isMutating}
|
||||
onClick={handleAdd}
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
if (!availableForSale) return;
|
||||
startTransition(async () => {
|
||||
const error = await addItem(selectedVariantId);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-center bg-black p-4 text-sm uppercase tracking-wide text-white opacity-90 hover:opacity-100 dark:bg-white dark:text-black',
|
||||
{
|
||||
'cursor-not-allowed opacity-60': !availableForSale,
|
||||
'cursor-not-allowed': isMutating
|
||||
'cursor-not-allowed': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
||||
{isMutating ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
||||
{isPending ? <LoadingDots className="bg-white dark:bg-black" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
@ -1,50 +1,40 @@
|
||||
import CloseIcon from 'components/icons/close';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { startTransition, useState } from 'react';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import { useTransition } from 'react';
|
||||
import { removeItem } from 'components/cart/actions';
|
||||
|
||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||
const router = useRouter();
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function handleRemove() {
|
||||
setRemoving(true);
|
||||
|
||||
const response = await fetch(`/api/cart`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
lineId: item.id
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoving(false);
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-label="Remove cart item"
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error = await removeItem(item.id);
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
||||
{
|
||||
'cursor-not-allowed px-0': removing
|
||||
'cursor-not-allowed px-0': isPending
|
||||
}
|
||||
)}
|
||||
>
|
||||
{removing ? (
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : (
|
||||
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { startTransition, useState } from 'react';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||
import MinusIcon from 'components/icons/minus';
|
||||
import PlusIcon from 'components/icons/plus';
|
||||
import LoadingDots from 'components/loading-dots';
|
||||
import type { CartItem } from 'lib/shopify/types';
|
||||
import LoadingDots from '../loading-dots';
|
||||
|
||||
export default function EditItemQuantityButton({
|
||||
item,
|
||||
@ -15,47 +16,40 @@ export default function EditItemQuantityButton({
|
||||
type: 'plus' | 'minus';
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function handleEdit() {
|
||||
setEditing(true);
|
||||
|
||||
const response = await fetch(`/api/cart`, {
|
||||
method: type === 'minus' && item.quantity - 1 === 0 ? 'DELETE' : 'PUT',
|
||||
body: JSON.stringify({
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||
onClick={handleEdit}
|
||||
disabled={editing}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const error =
|
||||
type === 'minus' && item.quantity - 1 === 0
|
||||
? await removeItem(item.id)
|
||||
: await updateItemQuantity({
|
||||
lineId: item.id,
|
||||
variantId: item.merchandise.id,
|
||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||
});
|
||||
|
||||
if (error) {
|
||||
alert(error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={isPending}
|
||||
className={clsx(
|
||||
'ease flex min-w-[36px] max-w-[36px] items-center justify-center border px-2 transition-all duration-200 hover:border-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-900',
|
||||
{
|
||||
'cursor-not-allowed': editing,
|
||||
'cursor-not-allowed': isPending,
|
||||
'ml-auto': type === 'minus'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{editing ? (
|
||||
{isPending ? (
|
||||
<LoadingDots className="bg-black dark:bg-white" />
|
||||
) : type === 'plus' ? (
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
|
@ -4,6 +4,9 @@ module.exports = {
|
||||
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
experimental: {
|
||||
serverActions: true
|
||||
},
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
|
@ -27,7 +27,7 @@
|
||||
"clsx": "^1.2.1",
|
||||
"framer-motion": "^10.12.16",
|
||||
"is-empty-iterable": "^3.0.0",
|
||||
"next": "13.4.4",
|
||||
"next": "13.4.6",
|
||||
"react": "18.2.0",
|
||||
"react-cookie": "^4.1.1",
|
||||
"react-dom": "18.2.0"
|
||||
|
1202
pnpm-lock.yaml
generated
1202
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user