Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions app/api/cart/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { shopifyFetch } from "@/app/lib/shopify";
import { cookies } from "next/headers";

const CART_COOKIE = "cart_id";

type CartCreateData = {
cartCreate: {
cart: {
id: string;
} | null;
userErrors: {
message: string;
}[];
};
};

type CartLinesAddData = {
cartLinesAdd: {
cart: {
id: string;
} | null;
userErrors: {
message: string;
}[];
};
};

const CART_CREATE_MUTATION = `
mutation CartCreate($lines: [CartLineInput!]) {
cartCreate(input: { lines: $lines }) {
cart {
id
}
userErrors {
message
}
}
}
`;

const CART_LINES_ADD_MUTATION = `
mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
}
userErrors {
message
}
}
}
`;

const CART_QUERY = `
query Cart($cartId: ID!) {
cart(id: $cartId) {
id
checkoutUrl
totalQuantity
cost {
subtotalAmount {
amount
currencyCode
}
totalAmount {
amount
currencyCode
}
}
lines(first: 50) {
edges {
node {
id
quantity
cost {
totalAmount {
amount
currencyCode
}
}
merchandise {
... on ProductVariant {
id
title
product {
title
}
price {
amount
currencyCode
}
}
}
}
}
}
}
}
`;

export type CartQueryData = {
cart: {
id: string;
checkoutUrl: string;
totalQuantity: number;
cost: {
subtotalAmount: { amount: string; currencyCode: string };
totalAmount: { amount: string; currencyCode: string };
};
lines: {
edges: {
node: {
id: string;
quantity: number;
cost: {
totalAmount: { amount: string; currencyCode: string };
};
merchandise: {
id: string;
title: string;
product: { title: string };
price: { amount: string; currencyCode: string };
};
};
}[];
};
} | null;
};

async function getOrCreateCartId(variantId: string): Promise<string> {
const cookieStore = await cookies();
const existing = cookieStore.get(CART_COOKIE)?.value;
const line = {
merchandiseId: variantId,
quantity: 1,
};

if (existing) {
const data = await shopifyFetch<CartLinesAddData>(CART_LINES_ADD_MUTATION, {
cartId: existing,
lines: [line],
});

if (data.cartLinesAdd.userErrors.length === 0 && data.cartLinesAdd.cart?.id) {
return data.cartLinesAdd.cart.id;
}
}

const data = await shopifyFetch<CartCreateData>(CART_CREATE_MUTATION, {
lines: [line],
});

const cartId = data.cartCreate.cart?.id;

if(!cartId) {
const msg = data.cartCreate.userErrors[0]?.message ?? "Unknown error creating cart";
throw new Error(msg);
}

cookieStore.set(CART_COOKIE, cartId, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});

return cartId;
}

export async function POST(request: Request) {
try {
const body = await request.json();
const variantId = body.variantId as string | undefined;

if (!variantId) {
return new Response(
JSON.stringify({ ok: false, error: "Missing variantId" }),
{ status: 400 }
);
}

const cartId = await getOrCreateCartId(variantId);

return new Response(JSON.stringify({ ok: true, cartId }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
} catch (e: any) {
console.error(e);
return new Response(
JSON.stringify({ ok: false, error: e.message ?? "Unknown error" }),
{ status: 500 }
);
}
}

export async function GET() {
try {
const cookieStore = await cookies();
const cartId = cookieStore.get(CART_COOKIE)?.value;

if (!cartId) {
return new Response(
JSON.stringify({ ok: true, cart: null }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}

const data = await shopifyFetch<CartQueryData>(CART_QUERY, { cartId });

return new Response(
JSON.stringify({ ok: true, cart: data.cart }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (e: any) {
console.error(e);
return new Response(
JSON.stringify({ ok: false, error: e.message ?? "Unknown error" }),
{ status: 500 }
);
}
}
66 changes: 66 additions & 0 deletions app/components/AddToCartButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { useState } from "react";
import { notifyCartUpdated } from "../lib/cartEvents";

type Props = {
variantId: string | null;
};

export function AddToCartButton({ variantId }: Props) {
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
const [errorMessge, setErrorMessage] = useState<string | null>(null);

const disabled = !variantId || status === "loading";

async function handleClick() {
if (!variantId) return;
setStatus("loading");
setErrorMessage(null);

try {
const res = await fetch("/api/cart", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ variantId }),
});

const data = await res.json();

if (!res.ok || !data.ok) {
throw new Error(data.error || "Failed to add to cart");
}

notifyCartUpdated();
setStatus("done");
setTimeout(() => setStatus("idle"), 1500);
} catch (e: any) {
console.error(e);
setErrorMessage(e.message ?? "Failed to add to cart");
setStatus("error");
setTimeout(() => setStatus("idle"), 2000);
}
}

let label = "Add to cart";
if (status === "loading") label = "Adding...";
else if (status === "done") label = "Added!";
else if (status === "error") label = "Try again";

return(
<div className="flex flex-col gap-1">
<button type="button" onClick={handleClick} disabled={disabled} className={`w-full rounded-md border px-3 py-1.5 text-xs font-medium
${disabled
? "cursor-not-allowed border-slate-700 bg-slate-800 text-slate-500"
: "border-purple-500 bg-purple-500 text-white hover:bg-purple-400 cursor-pointer"
}`}
>{label}</button>

{status === "error" && errorMessge && (
<p className="text-[10px] text-red-400">{errorMessge}</p>
)}
</div>
);
}
72 changes: 72 additions & 0 deletions app/components/CartButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { CART_UPDATE_EVENT } from "../lib/cartEvents";
import { CartQueryData } from "../api/cart/route";

export function CartButton() {
const [itemCount, setItemCount] = useState(0);
const [cart, setCart] = useState<CartQueryData['cart'] | null>(null);
const [open, setOpen] = useState(false);

async function loadCart() {
try {
const res = await fetch("/api/cart");
const {ok, cart} = await res.json();
if (ok && cart) {
setItemCount(cart?.totalQuantity || 0);
setCart(cart);
}
console.log(cart);
} catch (err) {
console.error("Failed to load cart", err);
}
}

useEffect(() => {
loadCart();

function handleUpdated() {
loadCart();
}

window.addEventListener(CART_UPDATE_EVENT, handleUpdated);
return () => window.removeEventListener(CART_UPDATE_EVENT, handleUpdated);
}, []);

return(
<>
<div className="flex cursor-pointer" onClick={() => setOpen(true)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
</svg>

<div className="flex ml-[-5px] mt-[-6px] justify-center bg-fuchsia-500 text-black font-bold rounded-full text-[12px] h-[15px] w-[15px]">
<div className="">{itemCount}</div>
</div>
</div>
<Modal open={open} onClose={() => setOpen(false)} title="Cart">
<div className="flex flex-col gap-3">
{cart?.lines.edges.map((item, index) => (
<div key={index} className="flex justify-between">
<div>
<div className="font-bold underline">{item.node.merchandise.product.title}</div>
<div className="font-light">{item.node.merchandise.title}</div>
</div>
<div>
<div className="text-pink-500">${item.node.cost.totalAmount.amount}</div>
<div className="flex justify-end text-[12px]">qty: {item.node.quantity}</div>
</div>
</div>
))}
</div>
<div className="border-t border-slate-800 my-2" />
<div className="flex justify-between">
<div>Subtotal</div>
<div>${cart?.cost?.subtotalAmount?.amount}</div>
</div>
</Modal>
</>
)
}
Loading