Skip to content

Commit

Permalink
feat(services): add stripe preview
Browse files Browse the repository at this point in the history
  • Loading branch information
wzulfikar committed Mar 29, 2021
1 parent 2027c38 commit 11589d9
Show file tree
Hide file tree
Showing 14 changed files with 541 additions and 2 deletions.
7 changes: 7 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ NEXT_PUBLIC_FATHOM_URL="https://cdn.usefathom.com/script.js"
NEXT_PUBLIC_SHARE_ANALYTIC=""

NEXT_PUBLIC_GITHUB_REPO="https://github.com/wzulfikar/nextjs-10"

# Stripe config for https://github.com/ynnoj/next-stripe.
# Use $(.env.local) to override.
NEXT_PUBLIC_STRIPE_PUBLIC_KEY="some-key"
STRIPE_RESTRICTED_KEY="some-key"
STRIPE_WEBHOOK_SECRET="some-secret"
STRIPE_WEBHOOK_ENDPOINT="https://nextjs-10-template.vercel.app/api/webhook/stripe"
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
"test:types": "yarn tsc",
"husky:pre-commit": "lint-staged",
"husky:pre-push": "concurrently \"yarn test\" \"yarn test:types\"",
"checkly:visual_tests": "cross-env LOCAL=true node ./checkly/visual_tests/public_pages.js"
"checkly:visual_tests": "cross-env LOCAL=true node ./checkly/visual_tests/public_pages.js",
"stripe:local-webhook": "stripe listen --forward-to=${BASE_URL:-localhost:3000}/api/webhook/stripe"
},
"dependencies": {
"@ant-design/icons": "^4.6.2",
"@headlessui/react": "^0.3.1",
"@sentry/browser": "^6.2.3",
"@stripe/stripe-js": "^1.13.2",
"@tailwindcss/jit": "^0.1.10",
"@tailwindcss/line-clamp": "^0.2.0",
"antd": "^4.14.1",
Expand All @@ -29,6 +31,7 @@
"next": "10.0.9",
"next-dark-mode": "^3.0.0",
"next-plugin-antd-less": "^0.3.0",
"next-stripe": "^1.0.0-beta.9",
"postcss": "^8.2.8",
"react": "17.0.2",
"react-device-detect": "^1.17.0",
Expand Down
2 changes: 1 addition & 1 deletion pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class MyDocument extends Document {
return (
<Html>
<Head />
<body>
<body className="antialiased">
<script src="/darkmode-noflash.js" />
<Main />
<NextScript />
Expand Down
22 changes: 22 additions & 0 deletions pages/_preview/StripePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import initStripe from '@src/lib/stripe/init';

import StripePreview from '@src/components/_preview/containers/StripePreview';

export default StripePreview;

export async function getStaticProps() {
const stripe = initStripe();
const prices = await stripe.prices.list({
active: true,
limit: 10,

// Inline product info in response
expand: ['data.product'],

// Only return prices of type `recurring` or `one_time`.
// Leave it empty to inlude both types.
// type: 'recurring',
});

return { props: { prices: prices.data } };
}
5 changes: 5 additions & 0 deletions pages/api/stripe/[...nextstripe].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import NextStripe from 'next-stripe';

export default NextStripe({
stripe_key: process.env.STRIPE_RESTRICTED_KEY,
});
105 changes: 105 additions & 0 deletions pages/api/webhook/stripe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Adapted from https://github.com/vercel/nextjs-subscription-payments/blob/main/pages/api/webhooks.js
*/

import initStripe from '@src/lib/stripe/init';
import {
upsertProductRecord,
upsertPriceRecord,
manageSubscriptionStatusChange,
} from '@src/services/stripe-webhook';

export const config = {
api: {
bodyParser: false, // Stripe needs to use the raw body to construct the event.
},
};

async function buffer(readable) {
const chunks = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}

const relevantEvents = new Set([
'product.created',
'product.updated',
'price.created',
'price.updated',
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
]);

const webhookHandler = async (req, res) => {
if (req.method === 'POST') {
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

let event;

try {
const stripe = initStripe();
event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
} catch (err) {
console.error(`[ERROR][stripe] webhook error: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}

console.info('[INFO][stripe] new webhook event:', {
type: event.type,
relevant: relevantEvents.has(event.type),
});

if (relevantEvents.has(event.type)) {
try {
switch (event.type) {
case 'product.created':
case 'product.updated':
await upsertProductRecord(event.data.object);
break;
case 'price.created':
case 'price.updated':
await upsertPriceRecord(event.data.object);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await manageSubscriptionStatusChange(
event.data.object.id,
event.data.object.customer,
event.type === 'customer.subscription.created'
);
break;
case 'checkout.session.completed':
const checkoutSession = event.data.object;
if (checkoutSession.mode === 'subscription') {
const subscriptionId = checkoutSession.subscription;
await manageSubscriptionStatusChange(
subscriptionId,
checkoutSession.customer,
true
);
}
break;
default:
throw new Error('Unhandled relevant event!');
}
} catch (error) {
console.log(error);
return res.json({ error: 'Webhook handler failed. View logs.' });
}
}

res.json({ received: true });
} else {
res.setHeader('Allow', 'POST');
res.status(405).end('Method Not Allowed');
}
};

export default webhookHandler;
30 changes: 30 additions & 0 deletions src/components/_preview/containers/StripePreview/controller.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useState } from 'react';

import { loadStripe } from '@stripe/stripe-js';
import { createCheckoutSession } from 'next-stripe/client';

import StripePreview from './elements';

export default function StripePreviewController({ prices }) {
async function onCheckout(priceId, mode: 'subscription' | 'payment') {
// Create stripe checkout session
const qty = 1; // Assume user will buy one item
const session = await createCheckoutSession({
success_url: window.location.href,
cancel_url: window.location.href,
line_items: [{ price: priceId, quantity: qty }],
payment_method_types: ['card'],

// Use `payment` mode if the `priceId` is one-time purchase
// or `subscription` if it's a recurring payment.
mode: mode,
});

const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
if (stripe) {
stripe.redirectToCheckout({ sessionId: session.id });
}
}

return <StripePreview prices={prices} onCheckout={onCheckout} />;
}
92 changes: 92 additions & 0 deletions src/components/_preview/containers/StripePreview/elements.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useState } from 'react';
import { AiFillInfoCircle } from 'react-icons/ai';

function PriceList({ prices, onCheckout, recurringSuffix = null }) {
const [isCheckingOut, setIsCheckingOut] = useState(null);

async function _onCheckout(priceId, mode) {
setIsCheckingOut(priceId);
await onCheckout(priceId, mode);
setIsCheckingOut(null);
}

return (
<ul className="grid sm:grid-cols-3 gap-4">
{prices.map((price) => (
<li
className="grid grid-cols-2 border border-gray-800 dark:border-white p-4 cursor-pointer hover:text-blue-600 dark:hover:border-blue-600"
key={price.id}
onClick={() =>
_onCheckout(
price.id,
Boolean(recurringSuffix) ? 'subscription' : 'payment'
)
}
>
<div>
<h2 className="font-bold">{price.product.name}</h2>
<img src={price.product.images[0]} />
<p>
{(price.unit_amount / 100).toFixed(2)}{' '}
{price.currency.toUpperCase()} {recurringSuffix}
</p>
</div>
<button className="ml-auto font-medium">
{price.id === isCheckingOut ? 'Processing..' : 'Checkout'} &rarr;
</button>
</li>
))}
</ul>
);
}

export default function StripePreview({ prices, onCheckout }) {
const pricesByType = { one_time: [], recurring: [] };
prices.forEach((price) => pricesByType[price.type].push(price));

return (
<div className="bg-gray-200 dark:bg-black dark:text-white min-h-screen px-4 py-8">
<h1 className="text-2xl font-bold text-center bg-clip-text text-transparent bg-gradient-to-r from-green-400 to-blue-500">
Stripe Checkout
</h1>
<div className="flex flex-col mx-auto max-w-4xl">
<p className="flex items-center mt-6 text-sm text-gray-600 dark:text-gray-400">
<AiFillInfoCircle className="mr-1" />
You can use Stripe test card during checkout:{' '}
<code className="ml-1">4242 4242 4242 4242</code>
</p>

{/* One-time products */}
<div>
<h3 className="text-lg mb-8 pb-4 pt-4 font-semibold text-green-600 border-b border-green-600">
One-Time Payments
</h3>
<PriceList prices={pricesByType.one_time} onCheckout={onCheckout} />
</div>

{/* Recurring products */}
<div>
<h3 className="text-lg mb-6 pb-4 pt-8 font-semibold text-green-600 border-b border-green-600">
Subscriptions
</h3>
<h4 className="pb-4">Pay monthly:</h4>
<PriceList
prices={pricesByType.recurring.filter(
(price) => price.recurring.interval === 'month'
)}
onCheckout={onCheckout}
recurringSuffix="/ mth"
/>
<h4 className="pb-4 pt-6">Pay yearly:</h4>
<PriceList
prices={pricesByType.recurring.filter(
(price) => price.recurring.interval === 'year'
)}
onCheckout={onCheckout}
recurringSuffix="/ yr"
/>
</div>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions src/components/_preview/containers/StripePreview/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import controller from './controller';

export default controller;
5 changes: 5 additions & 0 deletions src/lib/date/dateFromSecond.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function dateFromSecond(secs: number): Date {
const t = new Date('1970-01-01T00:30:00Z'); // Unix epoch start.
t.setSeconds(secs);
return t;
}
17 changes: 17 additions & 0 deletions src/lib/stripe/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Stripe from 'stripe';

/**
* @description
* Create stripe instance (server-side) using `STRIPE_RESTRICTED_KEY` environment variable.
*
* @example
* import initStripe from '@src/lib/stripe/init
* const stripe = initStripe()
*/
const initStripe = () =>
new Stripe(process.env.STRIPE_RESTRICTED_KEY, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2020-08-27',
});

export default initStripe;
Loading

0 comments on commit 11589d9

Please sign in to comment.