Skip to content

Commit

Permalink
feat: e2e stripe checkout functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
spencerlepine committed Oct 23, 2024
1 parent 5294447 commit bc9babe
Show file tree
Hide file tree
Showing 17 changed files with 654 additions and 52 deletions.
14 changes: 4 additions & 10 deletions .env.template
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
MY_SECRET_VALUE="foobar"

# Auth0 - https://auth0.com/docs/quickstart/webapp/nextjs/interactive
# Allowed callback URLs: https://my-app.vercel.app/api/auth/callback, http://localhost:3000/api/auth/callback
# Allowed logout URLs: https://my-app.vercel.app, http://localhost:3000
AUTH0_SECRET='use [openssl rand -hex 32] to generate a 32 bytes value'
AUTH0_BASE_URL='http://localhost:3000'
AUTH0_ISSUER_BASE_URL='https://{yourDomain}'
AUTH0_CLIENT_ID='{yourClientId}'
AUTH0_CLIENT_SECRET='{yourClientSecret}'
NEXT_PUBLIC_URL="http://localhost:3000"
NEXT_PUBLIC_STRIPE_KEY="pk_asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
STRIPE_SECRET_KEY="sk_asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
STRIPE_WEBHOOK_SECRET="whsec_asdfasdfasdfasdfasdfa"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ docker-compose -f ./docker/development docker-compose.yml up -d
# visit http://locahost:3001
```

### Local Stripe Webhook Testing

```sh
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/v1/webhook/checkout
# *open separate terminal*
stripe trigger checkout.session.completed --add checkout_session:metadata.printifyOrderId=123
```

## License

GNU General Public License v3.0 or later
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"next": "14.2.11",
"react": "^18",
"react-dom": "^18",
"sharp": "^0.33.5",
"stripe": "^16.9.0",
"use-shopping-cart": "^3.2.0"
},
Expand Down
Binary file added public/icons/check-mark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes.
47 changes: 19 additions & 28 deletions src/app/cart/page.tsx → src/app/(orders)/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,33 @@
'use client';

import { useRouter } from 'next/navigation';
import CartItemCard from '@/components/CartItemCard';
import { formatPriceForDisplay } from '@/lib/stripe';
import { useShoppingCart } from 'use-shopping-cart';
import { CartItem } from '@/types';

export default function CartPage() {
const {
cartCount,
cartDetails,
removeItem,
totalPrice,
// redirectToCheckout, // don't use this out-of-box, redirect with POST request from my route endpoint
addItem,
decrementItem,
} = useShoppingCart();
const router = useRouter();

const { cartCount, cartDetails, removeItem, totalPrice, addItem, decrementItem } = useShoppingCart();
const cartItems = Object.values(cartDetails ?? {});

async function handleCheckoutClick() {
alert('feature: work-in-progress');
// TODO_STRIPE
// if (cartCount && cartCount > 0) {
// try {
// const res = await fetch('/api/v1/checkout', {
// method: 'POST',
// body: JSON.stringify(cartDetails),
// });
// const data = await res.json();
// // TODO_STRIPE - checkout redirect
// // instead of useShoppingCart stripe, use our own stripe-js
// const result = await redirectToCheckout(data.sessionId);
// if (result?.error) {
// console.error(result);
// }
// } catch (error) {
// console.error(error);
// }
// }
if (!cartCount || cartCount === 0) {
return;
}

try {
const res = await fetch('/api/v1/checkout', {
method: 'POST',
body: JSON.stringify({ cartItems }),
});
const { checkoutUrl } = await res.json();
if (!checkoutUrl) return alert('Unable to checkout at this time. Please try again later.');
router.push(checkoutUrl);
} catch (error) {
console.error(error);
}
}

const handleRemove = (cartItem: CartItem) => {
Expand Down
20 changes: 20 additions & 0 deletions src/app/(orders)/order-confirmation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { memo } from 'react';
import OrderConfirmation from '@/components/OrderConfirmation';
import { validateStripeSession } from '@/lib/stripe';
import { notFound } from 'next/navigation';

// Redirect page after successful checkout
// <baseUrl>/order-confirmation?session_id=cs_test_b1FKoQomBOaQFgMqW1lU6oYXRwIruD6AbGV804gMZRrptJr1bF91sDmK5T
const OrderConfirmationPage: React.FC<{ searchParams: { [key: string]: string | undefined } }> = async ({ searchParams }) => {
const sessionId = searchParams['session_id'];
const { validSession } = await validateStripeSession(sessionId);
if (!validSession) return notFound();

return (
<div className="my-8 mx-20">
<OrderConfirmation />
</div>
);
};

export default memo(OrderConfirmationPage);
96 changes: 96 additions & 0 deletions src/app/api/v1/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createCheckoutSession, formatCartItemsForStripe } from '@/lib/stripe';
import validateCartItems from '@/utils/validateCartItems';
import { NextRequest, NextResponse } from 'next/server';

/**
* @openapi
* /v1/checkout:
* post:
* summary: Creates a Stripe checkout session.
* tags:
* - Checkout
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* cartItems:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* name:
* type: string
* description:
* type: string
* quantity:
* type: integer
* price:
* type: number
* image:
* type: string
* currency:
* type: string
* price_data:
* type: object
* product_data:
* type: object
* properties:
* size:
* type: string
* productId:
* type: string
* category:
* type: string
* type: string
* responses:
* 200:
* description: Checkout session created successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* checkoutUrl:
* type: string
* 400:
* description: Error creating checkout session.
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
*/
export const POST = async (request: NextRequest) => {
try {
const body = await request.json();
const { cartItems: clientCartItems } = body;

const cartItems = validateCartItems(clientCartItems);
if (!cartItems) {
console.error('[Checkout] invalid client cart', clientCartItems);
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}

// TODO_PRINTIFY
const printifyOrderId = 'asdf1234';
const stripeLineItems = formatCartItemsForStripe(cartItems);
const session = await createCheckoutSession(stripeLineItems, { printifyOrderId });

if (!session || !session.url) {
console.error('[Stripe] error creating checkout session:', session);
return NextResponse.json({ message: 'Unable to create Stripe checkout session' }, { status: 400 });
}

return NextResponse.json({ checkoutUrl: session.url });
} catch (error) {
console.error('Error processing request:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
56 changes: 56 additions & 0 deletions src/app/api/v1/webhook/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Stripe as StripeType } from 'stripe';
import { stripe } from '@/lib/stripe';
import { NextRequest, NextResponse } from 'next/server';

async function fulfillCheckout(printifyOrderId: string) {
console.log('Fulfilling Checkout Session - printifyOrderId:' + printifyOrderId);
// TODO_PRINTIFY
return;

// // TODO: Make this function safe to run multiple times,
// // even concurrently, with the same session ID

// // TODO: Make sure fulfillment hasn't already been
// // peformed for this Checkout Session

// const checkoutSession = await retrieveCheckoutSession(sessionId)
// const { shipping_details, line_items, metadata } = checkoutSession
// const { printifyOrderId } = metadata

// // Check the Checkout Session's payment_status property
// // to determine if fulfillment should be peformed
// if (checkoutSession.payment_status !== 'unpaid') {
// // TODO: Perform fulfillment of the line items

// // TODO: Record/save fulfillment status for this
// // Checkout Session
// await sendOrderToProduction(printifyOrderId)
}

export const POST = async (request: NextRequest) => {
try {
const secret = process.env.STRIPE_WEBHOOK_SECRET || '';
if (!secret) {
throw new Error('Missing STRIPE_WEBHOOK_SECRET environment variable');
}

const body = await (await request.blob()).text();
const signature = request.headers.get('stripe-signature') as string;
const event: StripeType.Event = stripe.webhooks.constructEvent(body, signature, secret);

if (['checkout.session.completed', 'checkout.session.async_payment_succeeded'].includes(event.type)) {
// @ts-expect-error - acceptable error
const printifyOrderId = event.data.object?.metadata?.printifyOrderId;
if (!printifyOrderId) {
throw new Error(`missing printifyOrderId on metadata, ${event.id}`);
}

await fulfillCheckout(printifyOrderId);
}

return NextResponse.json({ result: event, ok: true });
} catch (error) {
console.error('Error processing request:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
4 changes: 2 additions & 2 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client';

import SearchBar from './SearchBar';
import SearchBar from '@/components/SearchBar';
import { useShoppingCart } from 'use-shopping-cart';

const Header: React.FC = () => {
const { cartCount } = useShoppingCart();

// TODO_AUTH_ACCOUNT
// TODO_AUTH_ORDERS
return (
<header className="flex justify-between items-center px-4 py-2 bg-white shadow-md">
<div className="container mx-auto flex justify-between items-center">
Expand Down
29 changes: 29 additions & 0 deletions src/components/OrderConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import Image from 'next/image';
import Link from 'next/link';
import React, { useEffect } from 'react';
import { useShoppingCart } from 'use-shopping-cart';

const OrderConfirmation = () => {
const { clearCart } = useShoppingCart();

useEffect(() => {
clearCart();
}, [clearCart]);

return (
<div className="flex justify-center items-center my-32">
<div className="text-center">
<Image width={100} height={100} src="/icons/check-mark.png" alt="Check Mark Icon" className="w-32 h-32 object-cover mx-auto mb-4" />
<h3 className="text-2xl font-bold mb-2">Thank you for your order!</h3>
{/* TODO_AUTH_ORDER */}
<p className="text max-w-md text-gray-500">
You&apos;ll receive an email receipt shortly. You can checkout the status of your order anytime by visiting the <Link href="/account">&quot;Account&quot;</Link> page
</p>
</div>
</div>
);
};

export default OrderConfirmation;
2 changes: 1 addition & 1 deletion src/components/ProductSizeSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { Product, Size } from '@/types';
import AddToCartBtn from './AddToCartBtn';
import AddToCartBtn from '@/components/AddToCartBtn';
import { formatPriceForDisplay } from '@/lib/stripe';
import { useState } from 'react';
import { DEFAULT_STICKER_SIZES } from '@/lib/products';
Expand Down
2 changes: 1 addition & 1 deletion src/components/RelatedProducts.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getRelatedProductsByCategory } from '@/lib/catalog';
import ProductCard from './ProductCard';
import ProductCard from '@/components/ProductCard';
import { Category } from '@/types';

const RelatedProducts: React.FC<{ productId: string; category: Category }> = ({ productId, category }) => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/products.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Product, Size } from '@/types';

const STICKER_SIZES = {
export const STICKER_SIZES = {
TWO_BY_TWO_IN: '2x2in',
THREE_BY_THREE_IN: '3x3in',
FOUR_BY_FOUR_IN: '4x4in',
Expand Down
Loading

0 comments on commit bc9babe

Please sign in to comment.