mirror of
https://github.com/vercel/commerce.git
synced 2025-04-28 05:47:50 +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 Grid from 'components/grid';
|
||||||
import Footer from 'components/layout/footer';
|
import Footer from 'components/layout/footer';
|
||||||
import ProductGridItems from 'components/layout/product-grid-items';
|
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 { Gallery } from 'components/product/gallery';
|
||||||
import { VariantSelector } from 'components/product/variant-selector';
|
import { VariantSelector } from 'components/product/variant-selector';
|
||||||
import Prose from 'components/prose';
|
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';
|
'use client';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { addItem } from 'components/cart/actions';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState, useTransition } from 'react';
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
|
|
||||||
@ -18,7 +19,6 @@ export function AddToCart({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const variant = variants.find((variant: ProductVariant) =>
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
@ -32,49 +32,33 @@ export function AddToCart({
|
|||||||
}
|
}
|
||||||
}, [searchParams, variants, setSelectedVariantId]);
|
}, [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 (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Add item to cart"
|
aria-label="Add item to cart"
|
||||||
disabled={isMutating}
|
disabled={isPending}
|
||||||
onClick={handleAdd}
|
onClick={() => {
|
||||||
|
if (!availableForSale) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const error = await addItem(selectedVariantId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
className={clsx(
|
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',
|
'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 opacity-60': !availableForSale,
|
||||||
'cursor-not-allowed': isMutating
|
'cursor-not-allowed': isPending
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>{availableForSale ? 'Add To Cart' : 'Out Of Stock'}</span>
|
<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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,50 +1,40 @@
|
|||||||
import CloseIcon from 'components/icons/close';
|
import CloseIcon from 'components/icons/close';
|
||||||
import LoadingDots from 'components/loading-dots';
|
import LoadingDots from 'components/loading-dots';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { startTransition, useState } from 'react';
|
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { removeItem } from 'components/cart/actions';
|
||||||
|
|
||||||
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
export default function DeleteItemButton({ item }: { item: CartItem }) {
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Remove cart item"
|
aria-label="Remove cart item"
|
||||||
onClick={handleRemove}
|
onClick={() => {
|
||||||
disabled={removing}
|
startTransition(async () => {
|
||||||
|
const error = await removeItem(item.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
className={clsx(
|
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',
|
'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" />
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
) : (
|
) : (
|
||||||
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
|
<CloseIcon className="hover:text-accent-3 mx-[1px] h-4 w-4" />
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { startTransition, useState } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { removeItem, updateItemQuantity } from 'components/cart/actions';
|
||||||
import MinusIcon from 'components/icons/minus';
|
import MinusIcon from 'components/icons/minus';
|
||||||
import PlusIcon from 'components/icons/plus';
|
import PlusIcon from 'components/icons/plus';
|
||||||
|
import LoadingDots from 'components/loading-dots';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
import LoadingDots from '../loading-dots';
|
|
||||||
|
|
||||||
export default function EditItemQuantityButton({
|
export default function EditItemQuantityButton({
|
||||||
item,
|
item,
|
||||||
@ -15,47 +16,40 @@ export default function EditItemQuantityButton({
|
|||||||
type: 'plus' | 'minus';
|
type: 'plus' | 'minus';
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
aria-label={type === 'plus' ? 'Increase item quantity' : 'Reduce item quantity'}
|
||||||
onClick={handleEdit}
|
onClick={() => {
|
||||||
disabled={editing}
|
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(
|
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',
|
'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'
|
'ml-auto': type === 'minus'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{isPending ? (
|
||||||
<LoadingDots className="bg-black dark:bg-white" />
|
<LoadingDots className="bg-black dark:bg-white" />
|
||||||
) : type === 'plus' ? (
|
) : type === 'plus' ? (
|
||||||
<PlusIcon className="h-4 w-4" />
|
<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.
|
// Disabling on production builds because we're running checks on PRs via GitHub Actions.
|
||||||
ignoreDuringBuilds: true
|
ignoreDuringBuilds: true
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
serverActions: true
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"framer-motion": "^10.12.16",
|
"framer-motion": "^10.12.16",
|
||||||
"is-empty-iterable": "^3.0.0",
|
"is-empty-iterable": "^3.0.0",
|
||||||
"next": "13.4.4",
|
"next": "13.4.6",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-cookie": "^4.1.1",
|
"react-cookie": "^4.1.1",
|
||||||
"react-dom": "18.2.0"
|
"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