From e308157c88ae011257f8280862394515338eb1d3 Mon Sep 17 00:00:00 2001 From: Reporter Date: Thu, 23 Apr 2026 21:45:14 +0000 Subject: [PATCH 1/3] Replace Snipcart with Stripe Checkout and Mailgun. Implemented client-side cookie cart, Stripe Checkout integration, and Mailgun order alerts via webhooks. --- .env | 4 +- package.json | 9 +- pnpm-lock.yaml | 108 +++++++++--- src/app/api/checkout/route.ts | 63 +++++++ .../product/[photo_id]/pricing.json/route.ts | 66 ------- src/app/api/stripe/webhook/route.ts | 49 ++++++ src/app/cart/page.tsx | 165 ++++++++++++++++++ src/app/layout.tsx | 2 - src/app/photography/layout.tsx | 2 - .../photo/[photo_id]/ProductDetails.tsx | 29 +-- src/app/success/page.tsx | 41 +++++ src/components/organisms/NavBar/NavBar.tsx | 14 ++ .../organisms/Snipcart/CartButton.tsx | 19 -- .../organisms/Snipcart/Snipcart.tsx | 26 --- .../organisms/Snipcart/snipcart.css | 4 - src/constants/photoPricing.ts | 2 +- src/lib/mailgun.ts | 54 ++++++ src/lib/stripe.ts | 9 + src/store/cart.ts | 77 ++++++++ src/utils/getSnipcartPublicKey.ts | 11 -- src/utils/pages.ts | 2 + src/utils/snipcart.ts | 54 ------ 22 files changed, 579 insertions(+), 231 deletions(-) create mode 100644 src/app/api/checkout/route.ts delete mode 100644 src/app/api/product/[photo_id]/pricing.json/route.ts create mode 100644 src/app/api/stripe/webhook/route.ts create mode 100644 src/app/cart/page.tsx create mode 100644 src/app/success/page.tsx delete mode 100644 src/components/organisms/Snipcart/CartButton.tsx delete mode 100644 src/components/organisms/Snipcart/Snipcart.tsx delete mode 100644 src/components/organisms/Snipcart/snipcart.css create mode 100644 src/lib/mailgun.ts create mode 100644 src/lib/stripe.ts create mode 100644 src/store/cart.ts delete mode 100644 src/utils/getSnipcartPublicKey.ts delete mode 100644 src/utils/snipcart.ts diff --git a/.env b/.env index 6c072e865..11a64f017 100644 --- a/.env +++ b/.env @@ -3,7 +3,9 @@ MEETING_SCHEDULER_URL= NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST= -NEXT_PUBLIC_SNIPCART_KEY= SENDGRID_API_KEY= MAILGUN_API_KEY= MAILGUN_DOMAIN= +STRIPE_SECRET_KEY=sk_test_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/package.json b/package.json index 3c6640e10..6b1ce456b 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@radix-ui/react-tooltip": "^1.0.7", "@sendgrid/mail": "^8.1.0", "@types/react-syntax-highlighter": "^15.5.10", + "axios": "^1.15.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "form-data": "^4.0.4", "gray-matter": "^4.0.3", + "js-cookie": "^3.0.5", "lodash": "^4.17.21", "mailgun.js": "^12.0.3", "moment": "^2.29.4", @@ -68,9 +70,11 @@ "sonner": "^1.3.1", "sqlite": "^5.1.1", "sqlite3": "^5.1.6", + "stripe": "^22.0.2", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^5.0.12" }, "devDependencies": { "@next/bundle-analyzer": "^14.0.4", @@ -79,8 +83,9 @@ "@testing-library/jest-dom": "^6.4.5", "@testing-library/react": "^15.0.7", "@types/jest": "^29.5.11", + "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.6", - "@types/node": "^20", + "@types/node": "^20.9.2", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d4024fa..b5843d213 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: '@types/react-syntax-highlighter': specifier: ^15.5.10 version: 15.5.10 + axios: + specifier: ^1.15.2 + version: 1.15.2 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -74,6 +77,9 @@ dependencies: gray-matter: specifier: ^4.0.3 version: 4.0.3 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -134,6 +140,9 @@ dependencies: sqlite3: specifier: ^5.1.6 version: 5.1.6 + stripe: + specifier: ^22.0.2 + version: 22.0.2(@types/node@20.9.2) tailwind-merge: specifier: ^2.1.0 version: 2.1.0 @@ -143,6 +152,9 @@ dependencies: zod: specifier: ^3.22.4 version: 3.22.4 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@18.2.37)(react@18.2.0) devDependencies: '@next/bundle-analyzer': @@ -163,11 +175,14 @@ devDependencies: '@types/jest': specifier: ^29.5.11 version: 29.5.11 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/lodash': specifier: ^4.17.6 version: 4.17.6 '@types/node': - specifier: ^20 + specifier: ^20.9.2 version: 20.9.2 '@types/react': specifier: ^18 @@ -2416,7 +2431,7 @@ packages: engines: {node: '>=12.*'} dependencies: '@sendgrid/helpers': 8.0.0 - axios: 1.6.2 + axios: 1.15.2 transitivePeerDependencies: - debug dev: false @@ -2674,6 +2689,10 @@ packages: pretty-format: 29.7.0 dev: true + /@types/js-cookie@3.0.6: + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + dev: true + /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: @@ -3293,22 +3312,12 @@ packages: engines: {node: '>=4'} dev: true - /axios@1.12.2: - resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + /axios@1.15.2: + resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} dependencies: follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - dev: false - - /axios@1.6.2: - resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} - dependencies: - follow-redirects: 1.15.3 - form-data: 4.0.4 - proxy-from-env: 1.1.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug dev: false @@ -4955,16 +4964,6 @@ packages: optional: true dev: false - /follow-redirects@1.15.3: - resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -4989,6 +4988,17 @@ packages: hasown: 2.0.2 mime-types: 2.1.35 + /form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + dev: false + /format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -6325,6 +6335,11 @@ packages: nopt: 7.2.0 dev: false + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6544,7 +6559,7 @@ packages: resolution: {integrity: sha512-ki4+xNDA/MjIQAWHB2TK5t5lJN99l45iIRU4SmTqhkY2RCfwQD04s59R3I5YCKdkY/Y5598XRKdkQOj29+eydw==} engines: {node: '>=18.0.0'} dependencies: - axios: 1.12.2 + axios: 1.15.2 base-64: 1.0.0 url-join: 4.0.1 transitivePeerDependencies: @@ -7664,8 +7679,9 @@ packages: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} dev: false - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + /proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} dev: false /psl@1.9.0: @@ -8533,6 +8549,18 @@ packages: engines: {node: '>=8'} dev: true + /stripe@22.0.2(@types/node@20.9.2): + resolution: {integrity: sha512-2/BLrQ3oB1zlNfeL/LfHFjTGx6EQn0j+ztrrTJHuDjV5VVIpk92oSDaxyKLUr3pG3dnee2LZqhFUv2Bf0G1/3g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 20.9.2 + dev: false + /style-to-object@0.4.4: resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} dependencies: @@ -9466,6 +9494,28 @@ packages: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false + /zustand@5.0.12(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + dev: false + /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 000000000..a9102d466 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { stripe } from "../../../lib/stripe"; +import { photoPricing } from "../../../constants/photoPricing"; +import { getPhotoName, PhotoIdType } from "../../../utils/cdn/cdnAssets"; + +export async function POST(req: Request) { + try { + const { items } = await req.json(); + + if (!items || !Array.isArray(items)) { + return NextResponse.json({ error: "Invalid items" }, { status: 400 }); + } + + const line_items = items.map((item: { id: string; quantity: number }) => { + // id is photoID__priceID + const parts = item.id.split("__"); + if (parts.length < 2) { + throw new Error(`Invalid item ID format: ${item.id}`); + } + + const priceID = parts.pop()!; + const photoID = parts.join("__"); + + const priceVariant = photoPricing.find((p) => p.id === priceID); + + if (!priceVariant) { + throw new Error(`Invalid price variant: ${priceID}`); + } + + const name = `${getPhotoName(photoID as PhotoIdType)} (${priceVariant.name})`; + + return { + price_data: { + currency: "usd", + product_data: { + name: name, + metadata: { + photoID, + priceID, + }, + }, + unit_amount: Math.round(priceVariant.price * 100), // convert to cents + }, + quantity: item.quantity, + }; + }); + + const baseUrl = process.env.BASE_URL || 'http://localhost:3000'; + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ["card"], + line_items, + mode: "payment", + success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}/cart`, + }); + + return NextResponse.json({ url: session.url }); + } catch (error: any) { + console.error("Stripe error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/product/[photo_id]/pricing.json/route.ts b/src/app/api/product/[photo_id]/pricing.json/route.ts deleted file mode 100644 index dab53d281..000000000 --- a/src/app/api/product/[photo_id]/pricing.json/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NextRequest } from "next/server"; -import { getPhotoIDFromURLComponent } from "@/utils/cdn/cdnAssets"; -import { photoPricing } from "@/constants/photoPricing"; -import { getSnipcartProduct } from "@/utils/snipcart"; - -export interface ISnipcartValidationResponse { - /** - * The ID of the product - */ - id: string; - - /** - * The price of the product - */ - price: number; - - /** - * The custom fields of the product - */ - customFields: []; - - /** - * The URL of the product JSON. - */ - url: string; -} - -interface RouteParams { - params: { photo_id: string }; -} - -export async function GET( - _request: NextRequest, - { params: { photo_id: photoIdURLComponent } }: RouteParams, -) { - const photoID = getPhotoIDFromURLComponent(photoIdURLComponent); - if (!photoID) { - console.log(`Photo ID "${photoID}" not found`); - return new Response( - JSON.stringify( - { - error: `Photo ID "${photoID}" not found`, - }, - null, - 2, - ), - { status: 404 }, - ); - } - - const productVariants = photoPricing.map((p) => { - const product = getSnipcartProduct(photoID, p); - - const productVariant: ISnipcartValidationResponse = { - id: product.id, - price: product.price, - customFields: [], - url: product.url, - }; - - return productVariant; - }); - - // Return JSON array of product variants - return Response.json(productVariants); -} diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts new file mode 100644 index 000000000..21d680304 --- /dev/null +++ b/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { sendOrderEmail } from '@/lib/mailgun'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + // @ts-ignore - version might vary slightly depending on the installed stripe package + apiVersion: '2023-10-16', +}); + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const signature = req.headers.get('stripe-signature')!; + + if (!signature) { + return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }); + } + + if (!webhookSecret) { + console.error('STRIPE_WEBHOOK_SECRET is not set'); + return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret); + } catch (err: any) { + console.error(`Webhook signature verification failed: ${err.message}`); + return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 }); + } + + // Handle the event + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + + console.log(`Checkout session completed: ${session.id}`); + + try { + await sendOrderEmail(session); + } catch (error) { + console.error('Failed to send order email:', error); + // We still return 200 to Stripe to acknowledge receipt of the event + } + } + + return NextResponse.json({ received: true }); +} diff --git a/src/app/cart/page.tsx b/src/app/cart/page.tsx new file mode 100644 index 000000000..22372547b --- /dev/null +++ b/src/app/cart/page.tsx @@ -0,0 +1,165 @@ +"use client"; + +import React, { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { toast } from "sonner"; +import { PageWrapper } from "@/components/organisms/PageWrapper/PageWrapper"; +import { useCartStore, selectTotalPrice } from "@/store/cart"; +import { useIsClient } from "@/hooks/useIsClient/useIsClient"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { PAGES } from "@/utils/pages"; + +const CartPage = () => { + const isClient = useIsClient(); + const { items, updateQuantity, removeItem, clearCart } = useCartStore(); + const totalPrice = useCartStore(selectTotalPrice); + const [isCheckingOut, setIsCheckingOut] = useState(false); + + const handleCheckout = async () => { + if (items.length === 0) return; + + setIsCheckingOut(true); + try { + const response = await fetch("/api/checkout", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ items }), + }); + + const data = await response.json(); + + if (data.url) { + window.location.href = data.url; + } else { + throw new Error(data.error || "Failed to create checkout session"); + } + } catch (error) { + console.error("Checkout error:", error); + toast.error("Something went wrong. Please try again."); + setIsCheckingOut(false); + } + }; + + if (!isClient) { + return ( + +
+

Loading cart...

+
+
+ ); + } + + if (items.length === 0) { + return ( + +
+

Your cart is empty

+

Looks like you haven't added any prints to your cart yet.

+ + + +
+
+ ); + } + + return ( + +

Your Cart

+
+
+ {items.map((item) => ( + +
+
+ {item.name} +
+
+
+
+

{item.name}

+

Price: ${item.price}

+
+ +
+
+ + {item.quantity} + +
+
+
+
+ ))} + +
+
+ + + Order Summary + + +
+ Subtotal + ${totalPrice} +
+
+ Shipping + Calculated at checkout +
+
+ Total + ${totalPrice} +
+
+ + + +
+
+
+
+ ); +}; + +export default CartPage; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 747658794..dca315d5c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,6 @@ import { HydrationOverlay } from "@builder.io/react-hydration-overlay"; import { Cutive_Mono, Montserrat, Mulish } from "next/font/google"; import "./globals.css"; import dynamic from "next/dynamic"; -import { Snipcart } from "../components/organisms/Snipcart/Snipcart"; import { PHProvider } from "./providers"; import { siteMetadata } from "@/constants/siteMetadata"; import { NavBar } from "@/components/organisms/NavBar/NavBar"; @@ -95,7 +94,6 @@ export default function RootLayout({ )} > - {isDev ? ( diff --git a/src/app/photography/layout.tsx b/src/app/photography/layout.tsx index e22d4eefe..50c753786 100644 --- a/src/app/photography/layout.tsx +++ b/src/app/photography/layout.tsx @@ -1,5 +1,4 @@ import { Metadata } from "next"; -import { CartButton } from "@/components/organisms/Snipcart/CartButton"; import { siteMetadata } from "@/constants/siteMetadata"; export const metadata: Metadata = { @@ -14,7 +13,6 @@ export default function PhotographyLayout({ }) { return ( <> - {children} ); diff --git a/src/app/photography/photo/[photo_id]/ProductDetails.tsx b/src/app/photography/photo/[photo_id]/ProductDetails.tsx index d7776fb69..a5108b16f 100644 --- a/src/app/photography/photo/[photo_id]/ProductDetails.tsx +++ b/src/app/photography/photo/[photo_id]/ProductDetails.tsx @@ -17,10 +17,10 @@ import { } from "@/constants/photoPricing"; import { PhotoTags } from "@/constants/photoTags/photoTags"; import { siteMetadata } from "@/constants/siteMetadata"; -import { PhotoIdType, getPhotoName } from "@/utils/cdn/cdnAssets"; +import { PhotoIdType, getPhotoName, getCdnAsset } from "@/utils/cdn/cdnAssets"; import { PAGES } from "@/utils/pages"; -import { getSnipcartProduct } from "@/utils/snipcart"; import { PhotoSize } from "@/utils/photos/getPhotoSize"; +import { useCartStore } from "@/store/cart"; type ProductDetailsProps = { photoID: PhotoIdType; @@ -36,6 +36,7 @@ export const ProductDetails: React.FC = ({ photoSize, }) => { const posthog = usePostHog(); + const addItem = useCartStore((state) => state.addItem); const [selectedSizeID, setSelectedSizeID] = useState(defaultPhotoSize.id); const selectedSize = @@ -51,7 +52,6 @@ export const ProductDetails: React.FC = ({ ? "horizontal" : "vertical"; - const snipcartProduct = getSnipcartProduct(photoID, selectedSize); return (
@@ -59,7 +59,6 @@ export const ProductDetails: React.FC = ({

${selectedSize.price}

-

{snipcartProduct.description}

{tags.map((tag) => ( @@ -88,18 +87,20 @@ export const ProductDetails: React.FC = ({
+ + + + +
+
+ + ); +}; + +export default SuccessPage; diff --git a/src/components/organisms/NavBar/NavBar.tsx b/src/components/organisms/NavBar/NavBar.tsx index f2db24f25..f67c6df69 100644 --- a/src/components/organisms/NavBar/NavBar.tsx +++ b/src/components/organisms/NavBar/NavBar.tsx @@ -9,12 +9,14 @@ import { PhotographyDropdown } from "./PhotographyDropdown"; import { MobileNav } from "./MobileNav"; import { PersonalLogo } from "@/components/atoms/PersonalLogo/PersonalLogo"; import { CarrotDownIcon } from "@/components/icons/CarrotDownIcon/CarrotDownIcon"; +import { ShoppingCartIcon } from "@/components/icons/ShoppingCartIcon/ShoppingCartIcon"; import { useScreenSize } from "@/hooks/useScreenSize/useScreenSize"; import { useScrollPosition } from "@/hooks/useScrollPosition/useScrollPosition"; import { clamp, interpolate } from "@/utils/math"; import { PAGES } from "@/utils/pages"; import { classNames } from "@/utils/style"; import { useIsClient } from "@/hooks/useIsClient/useIsClient"; +import { useCartStore, selectTotalItems } from "@/store/cart"; /** * Standard nav bar for the site. Floats on certain pages, otherwise is relative. Dropdowns for @@ -22,6 +24,7 @@ import { useIsClient } from "@/hooks/useIsClient/useIsClient"; */ export const NavBar: React.FC = () => { const isClient = useIsClient(); + const totalItems = useCartStore(selectTotalItems); const { scrollY } = useScrollPosition(); const screenSize = useScreenSize(); @@ -187,6 +190,17 @@ export const NavBar: React.FC = () => { Contact +
  • + setIsDropdownOpen(undefined)} + > + + Cart ({totalItems}) + +
  • diff --git a/src/components/organisms/Snipcart/CartButton.tsx b/src/components/organisms/Snipcart/CartButton.tsx deleted file mode 100644 index a35cd0868..000000000 --- a/src/components/organisms/Snipcart/CartButton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ShoppingCartIcon } from "@/components/icons/ShoppingCartIcon/ShoppingCartIcon"; - -export const CartButton = () => { - return ( -
    - - - {/* Hidden Metadata (could remove, no real use) */} - - -
    - ); -}; diff --git a/src/components/organisms/Snipcart/Snipcart.tsx b/src/components/organisms/Snipcart/Snipcart.tsx deleted file mode 100644 index 93765e4d8..000000000 --- a/src/components/organisms/Snipcart/Snipcart.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable @next/next/no-css-tags */ -import Script from "next/script"; - -import "./snipcart.css"; -import { getSnipcartPublicKey } from "@/utils/getSnipcartPublicKey"; - -/** - * Contains logic neccessary for instantiating Snipcart cart management. Does not render anything - * visible (see `CartButton` for that). There was an issue with mounting and unmounting this - * component in the nested PhotographyLayout. Consequently, it is now included on all pages. - */ -export const Snipcart = () => { - const snipcartKey = getSnipcartPublicKey(); - return ( -
    - - - -