mirror of
https://github.com/tabler/tabler.git
synced 2025-12-26 11:16:12 +04:00
lemon squeezy integration
This commit is contained in:
@@ -44,3 +44,4 @@ NEXTAUTH_URL=""
|
||||
# Lemon squeezy
|
||||
LEMON_SQUEEZY_API_KEY=""
|
||||
LEMON_SQUEEZY_SIGNING_SECRET=""
|
||||
LEMON_SQUEEZY_STORE_ID=""
|
||||
|
||||
41
site/app/(marketing)/billing/change-plan/page.tsx
Normal file
41
site/app/(marketing)/billing/change-plan/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getSession } from '@/lib/auth'
|
||||
import Link from 'next/link'
|
||||
import { PlansComponent } from '@/components/Manage'
|
||||
import { getPlans, getSubscription } from '@/lib/data'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Change plan'
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getSession()
|
||||
|
||||
const sub = await getSubscription(session?.user?.id)
|
||||
|
||||
if (!sub) {
|
||||
redirect('/billing')
|
||||
}
|
||||
|
||||
const plans = await getPlans()
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="page-title text-center">Change plan</h2>
|
||||
|
||||
<Link href="/billing/" className="mb-6">← Back to billing</Link>
|
||||
|
||||
{sub.status == 'on_trial' && (
|
||||
<div className="my-8 p-4 h-subheader">
|
||||
You are currently on a free trial. You will not be charged when changing plans during a trial.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PlansComponent plans={plans} sub={sub} />
|
||||
|
||||
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
28
site/app/(marketing)/billing/page.tsx
Normal file
28
site/app/(marketing)/billing/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getPlans, getSubscription } from '@/lib/data'
|
||||
/* Full in-app billing component */
|
||||
import { SubscriptionComponent } from '@/components/Subscription'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing'
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getSession()
|
||||
|
||||
const plans = await getPlans()
|
||||
|
||||
const sub = await getSubscription(session?.user?.id)
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="page-title text-center">Billing</h2>
|
||||
|
||||
<SubscriptionComponent sub={sub} plans={plans} />
|
||||
|
||||
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
107
site/app/(marketing)/billing/refresh-plans/page.tsx
Normal file
107
site/app/(marketing)/billing/refresh-plans/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
|
||||
|
||||
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
|
||||
|
||||
export const dynamic = 'force-dynamic' // Don't cache API results
|
||||
|
||||
async function getPlans() {
|
||||
|
||||
const params = { include: ['product'] as Array<'product' | 'files'>, perPage: 50 }
|
||||
|
||||
let hasNextPage = true;
|
||||
let page = 1;
|
||||
|
||||
let variants = [] as {}[]
|
||||
let products = [] as Record<string, any>
|
||||
|
||||
while (hasNextPage) {
|
||||
const resp = await ls.getVariants(params);
|
||||
|
||||
variants = variants.concat(resp['data'])
|
||||
products = products.concat(resp['included'])
|
||||
|
||||
if (resp['meta']['page']['lastPage'] > page) {
|
||||
page += 1
|
||||
params['page'] = page
|
||||
} else {
|
||||
hasNextPage = false
|
||||
}
|
||||
}
|
||||
|
||||
// Nest products inside variants
|
||||
const prods = {};
|
||||
for (let i = 0; i < products.length; i++) {
|
||||
prods[products[i]['id']] = products[i]['attributes']
|
||||
}
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
variants[i]['product'] = prods[variants[i]['attributes']['product_id']]
|
||||
}
|
||||
|
||||
// Save locally
|
||||
let variantId,
|
||||
variant,
|
||||
product,
|
||||
productId
|
||||
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
|
||||
variant = variants[i]
|
||||
|
||||
if ( !variant['attributes']['is_subscription'] ) {
|
||||
console.log('Not a subscription')
|
||||
continue
|
||||
}
|
||||
|
||||
if ( String(variant['product']['store_id']) !== process.env.LEMON_SQUEEZY_STORE_ID ) {
|
||||
console.log(`Store ID ${variant['product']['store_id']} does not match (${process.env.LEMON_SQUEEZY_STORE_ID})`)
|
||||
continue
|
||||
}
|
||||
|
||||
variantId = parseInt(variant['id'])
|
||||
product = variant['product']
|
||||
productId = parseInt(variant['attributes']['product_id'])
|
||||
|
||||
// Get variant's Price objects
|
||||
let prices = await ls.getPrices({ variantId: variantId, perPage: 100 })
|
||||
// The first object is the latest/current price
|
||||
let variant_price = prices['data'][0]['attributes']['unit_price']
|
||||
variant = variant['attributes']
|
||||
|
||||
const updateData = {
|
||||
productId: productId,
|
||||
name: product['name'],
|
||||
variantName: variant['name'],
|
||||
status: variant['status'],
|
||||
sort: variant['sort'],
|
||||
description: variant['description'],
|
||||
price: variant_price, // display price in the app matches current Price object in LS
|
||||
interval: variant['interval'],
|
||||
intervalCount: variant['interval_count'],
|
||||
}
|
||||
const createData = { ...updateData, variantId}
|
||||
|
||||
try {
|
||||
await prisma.plan.upsert({
|
||||
where: {
|
||||
variantId: variantId
|
||||
},
|
||||
update: updateData,
|
||||
create: createData
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(variant)
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
await getPlans()
|
||||
|
||||
return (
|
||||
<p>
|
||||
Done!
|
||||
</p>
|
||||
)
|
||||
}
|
||||
152
site/app/(marketing)/billing/webhook/route.ts
Normal file
152
site/app/(marketing)/billing/webhook/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
|
||||
|
||||
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
|
||||
|
||||
|
||||
async function processEvent(event) {
|
||||
|
||||
let processingError = ''
|
||||
|
||||
const customData = event.body['meta']['custom_data'] || null
|
||||
|
||||
if (!customData || !customData['user_id']) {
|
||||
|
||||
processingError = 'No user ID, can\'t process'
|
||||
|
||||
} else {
|
||||
|
||||
const obj = event.body['data']
|
||||
|
||||
if (event.eventName.startsWith('subscription_payment_')) {
|
||||
// Save subscription invoices; obj is a "Subscription invoice"
|
||||
|
||||
/* Not implemented */
|
||||
|
||||
} else if (event.eventName.startsWith('subscription_')) {
|
||||
// Save subscriptions; obj is a "Subscription"
|
||||
|
||||
const data = obj['attributes']
|
||||
|
||||
// We assume the Plan table is up to date
|
||||
const plan = await prisma.plan.findUnique({
|
||||
where: {
|
||||
variantId: data['variant_id']
|
||||
},
|
||||
})
|
||||
|
||||
if (!plan) {
|
||||
|
||||
processingError = 'Plan not found in DB. Could not process webhook event.'
|
||||
|
||||
} else {
|
||||
|
||||
// Update the subscription
|
||||
|
||||
const lemonSqueezyId = parseInt(obj['id'])
|
||||
|
||||
// Get subscription's Price object
|
||||
// We save the Price value to the subscription so we can display it in the UI
|
||||
let priceData = await ls.getPrice({ id: data['first_subscription_item']['price_id'] })
|
||||
|
||||
const updateData = {
|
||||
orderId: data['order_id'],
|
||||
name: data['user_name'],
|
||||
email: data['user_email'],
|
||||
status: data['status'],
|
||||
renewsAt: data['renews_at'],
|
||||
endsAt: data['ends_at'],
|
||||
trialEndsAt: data['trial_ends_at'],
|
||||
planId: plan['id'],
|
||||
userId: customData['user_id'],
|
||||
price: priceData['data']['attributes']['unit_price'],
|
||||
subscriptionItemId: data['first_subscription_item']['id'],
|
||||
// Save this for usage-based billing reporting; no need to if you use quantity-based billing
|
||||
isUsageBased: data['first_subscription_item']['is_usage_based'],
|
||||
}
|
||||
|
||||
const createData = { ...updateData, lemonSqueezyId}
|
||||
createData.price = plan.price
|
||||
|
||||
try {
|
||||
// Create/update subscription
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
lemonSqueezyId: lemonSqueezyId
|
||||
},
|
||||
update: updateData,
|
||||
create: createData,
|
||||
})
|
||||
} catch (error) {
|
||||
processingError = error
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} else if (event.eventName.startsWith('order_')) {
|
||||
// Save orders; obj is a "Order"
|
||||
|
||||
/* Not implemented */
|
||||
|
||||
} else if (event.eventName.startsWith('license_')) {
|
||||
// Save license keys; obj is a "License key"
|
||||
|
||||
/* Not implemented */
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
// Mark event as processed
|
||||
await prisma.webhookEvent.update({
|
||||
where: {
|
||||
id: event.id
|
||||
},
|
||||
data: {
|
||||
processed: true,
|
||||
processingError
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
// Make sure request is from Lemon Squeezy
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const rawBody = await request.text()
|
||||
|
||||
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8')
|
||||
const signature = Buffer.from(request.headers.get('X-Signature') || '', 'utf8')
|
||||
|
||||
if (!crypto.timingSafeEqual(digest, signature)) {
|
||||
throw new Error('Invalid signature.')
|
||||
}
|
||||
|
||||
// Now save the event
|
||||
|
||||
const data = JSON.parse(rawBody)
|
||||
|
||||
const event = await prisma.webhookEvent.create({
|
||||
data: {
|
||||
eventName: data['meta']['event_name'],
|
||||
body: data
|
||||
},
|
||||
})
|
||||
|
||||
// Process the event
|
||||
// This could be done out of the main thread
|
||||
|
||||
processEvent(event)
|
||||
|
||||
return new Response('Done');
|
||||
}
|
||||
55
site/app/api/checkouts/route.ts
Normal file
55
site/app/api/checkouts/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
|
||||
|
||||
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
|
||||
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: true, message: 'Not logged in.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const res = await request.json()
|
||||
|
||||
if ( !res.variantId ) {
|
||||
return NextResponse.json({ error: true, message: 'No variant ID was provided.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Customise the checkout experience
|
||||
// All the options: https://docs.lemonsqueezy.com/api/checkouts#create-a-checkout
|
||||
const attributes = {
|
||||
'checkout_options': {
|
||||
'embed': true,
|
||||
'media': false,
|
||||
'button_color': '#fde68a'
|
||||
},
|
||||
'checkout_data': {
|
||||
'email': session.user?.email, // Displays in the checkout form
|
||||
'custom': {
|
||||
'user_id': session.user?.id // Sent in the background; visible in webhooks and API calls
|
||||
}
|
||||
},
|
||||
'product_options': {
|
||||
'enabled_variants': [res.variantId], // Only show the selected variant in the checkout
|
||||
'redirect_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
|
||||
'receipt_link_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
|
||||
'receipt_button_text': 'Go to your account',
|
||||
'receipt_thank_you_note': 'Thank you for signing up to Lemonstand!'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const checkout = await ls.createCheckout({
|
||||
storeId: Number(process.env.LEMON_SQUEEZY_STORE_ID),
|
||||
variantId: res.variantId,
|
||||
attributes
|
||||
})
|
||||
|
||||
return NextResponse.json({'error': false, 'url': checkout['data']['attributes']['url']}, {status: 200})
|
||||
} catch (e) {
|
||||
return NextResponse.json({'error': true, 'message': e.message}, {status: 400})
|
||||
}
|
||||
}
|
||||
121
site/app/api/subscriptions/[id]/route.ts
Normal file
121
site/app/api/subscriptions/[id]/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPlan } from '@/lib/data'
|
||||
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
|
||||
|
||||
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
|
||||
|
||||
export async function GET(_: Request, { params }: { params: { id: string } }) {
|
||||
/**
|
||||
* Used by some buttons to get subscription update billing and customer portal URLs
|
||||
*/
|
||||
try {
|
||||
const subscription = await ls.getSubscription({ id: Number(params.id) })
|
||||
return NextResponse.json({ error: false, subscription: {
|
||||
update_billing_url: subscription['data']['attributes']['urls']['update_payment_method'],
|
||||
customer_portal_url: subscription['data']['attributes']['urls']['customer_portal']
|
||||
} }, { status: 200 })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
||||
|
||||
const res = await request.json()
|
||||
|
||||
let subscription
|
||||
|
||||
if (res.variantId && res.productId) {
|
||||
|
||||
// Update plan
|
||||
|
||||
try {
|
||||
subscription = await ls.updateSubscription({
|
||||
id: Number(params.id),
|
||||
productId: res.productId,
|
||||
variantId: res.variantId,
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
|
||||
}
|
||||
|
||||
} else if (res.action == 'resume') {
|
||||
|
||||
// Resume
|
||||
|
||||
try {
|
||||
subscription = await ls.resumeSubscription({ id: Number(params.id) })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
|
||||
}
|
||||
|
||||
} else if (res.action == 'cancel') {
|
||||
|
||||
// Cancel
|
||||
|
||||
try {
|
||||
subscription = await ls.cancelSubscription({ id: Number(params.id) })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
|
||||
}
|
||||
|
||||
} else if (res.action == 'pause') {
|
||||
|
||||
// Pause
|
||||
|
||||
try {
|
||||
subscription = await ls.pauseSubscription({ id: Number(params.id) })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
|
||||
}
|
||||
|
||||
} else if (res.action == 'unpause') {
|
||||
|
||||
// Unpause
|
||||
|
||||
try {
|
||||
subscription = await ls.unpauseSubscription({ id: Number(params.id) })
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// Missing data in request
|
||||
|
||||
return NextResponse.json({ error: true, message: 'Valid data not found.' }, { status: 400 })
|
||||
|
||||
}
|
||||
|
||||
// Return values needed to refresh state in UI
|
||||
// DB will be updated in the background with webhooks
|
||||
|
||||
// Get price
|
||||
let resp = await ls.getPrice({ id: subscription['data']['attributes']['first_subscription_item']['price_id'] })
|
||||
let subItemPrice = resp['data']['attributes']['unit_price']
|
||||
|
||||
// Return a filtered subscription object to the UI
|
||||
const sub = {
|
||||
product_id: subscription['data']['attributes']['product_id'],
|
||||
variant_id: subscription['data']['attributes']['variant_id'],
|
||||
status: subscription['data']['attributes']['status'],
|
||||
card_brand: subscription['data']['attributes']['card_brand'],
|
||||
card_last_four: subscription['data']['attributes']['card_last_four'],
|
||||
trial_ends_at: subscription['data']['attributes']['trial_ends_at'],
|
||||
renews_at: subscription['data']['attributes']['renews_at'],
|
||||
ends_at: subscription['data']['attributes']['ends_at'],
|
||||
resumes_at: subscription['data']['attributes']['resumes_at'],
|
||||
plan: {},
|
||||
price: subItemPrice,
|
||||
}
|
||||
|
||||
// Get plan's data
|
||||
const plan = await getPlan(sub.variant_id)
|
||||
sub.plan = {
|
||||
interval: plan?.interval,
|
||||
name: plan?.variantName
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: false, subscription: sub }, { status: 200 })
|
||||
|
||||
}
|
||||
292
site/components/Manage.tsx
Normal file
292
site/components/Manage.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
'use client'
|
||||
|
||||
import { useState, MouseEvent, Dispatch, SetStateAction } from 'react'
|
||||
import PlanCards from '@/components/Plan'
|
||||
import { Plan, Subscription, SubscriptionState } from '@/types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export const UpdateBillingLink = ({ subscription, elementType }:
|
||||
{ subscription: SubscriptionState, elementType?: string }
|
||||
) => {
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
|
||||
async function openUpdateModal(e: MouseEvent<HTMLAnchorElement>) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
/* Send request */
|
||||
const res = await fetch(`/api/subscriptions/${subscription?.id}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
alert(result.message)
|
||||
setIsMutating(false)
|
||||
|
||||
} else {
|
||||
|
||||
LemonSqueezy.Url.Open(result.subscription.update_billing_url)
|
||||
setIsMutating(false)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (elementType == 'button') {
|
||||
return (
|
||||
<a
|
||||
href=""
|
||||
onClick={openUpdateModal}
|
||||
className={clsx('px-6 py-2 font-bold btn btn-block', {
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Update your payment method
|
||||
</a>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<a
|
||||
href=""
|
||||
onClick={openUpdateModal}
|
||||
className={clsx('mb-2', {
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Update your payment method
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const CancelLink = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
|
||||
async function handleCancel(e: MouseEvent<HTMLAnchorElement>) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (confirm(`Please confirm you want to cancel your subscription.`)) {
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
/* Send request */
|
||||
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'cancel'
|
||||
})
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
alert(result.message)
|
||||
setIsMutating(false)
|
||||
|
||||
} else {
|
||||
|
||||
setSubscription({
|
||||
...subscription,
|
||||
status: result['subscription']['status'],
|
||||
expiryDate: result['subscription']['ends_at'],
|
||||
})
|
||||
|
||||
alert('Your subscription has been cancelled.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href=""
|
||||
onClick={handleCancel}
|
||||
className={clsx('mb-2', {
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const ResumeButton = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
|
||||
const resumeSubscription = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (confirm(`Please confirm you want to resume your subscription. You will be charged the regular subscription fee.`)) {
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
/* Send request */
|
||||
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'resume'
|
||||
})
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
alert(result.message)
|
||||
setIsMutating(false)
|
||||
} else {
|
||||
|
||||
setSubscription({
|
||||
...subscription,
|
||||
status: result['subscription']['status'],
|
||||
renewalDate: result['subscription']['renews_at'],
|
||||
})
|
||||
|
||||
alert('Your subscription is now active again!.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href=""
|
||||
onClick={resumeSubscription}
|
||||
className={clsx('px-6 py-2 font-bold btn btn-block', {
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Resume your subscription
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const PauseLink = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
|
||||
async function handlePause(e: MouseEvent<HTMLAnchorElement>) {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (confirm(`Please confirm you want to pause your subscription.`)) {
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
/* Send request */
|
||||
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'pause'
|
||||
})
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
alert(result.message)
|
||||
setIsMutating(false)
|
||||
|
||||
} else {
|
||||
|
||||
setSubscription({
|
||||
...subscription,
|
||||
status: result['subscription']['status'],
|
||||
unpauseDate: result['subscription']['resumes_at'],
|
||||
})
|
||||
|
||||
alert('Your subscription has been paused.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href=""
|
||||
className={clsx('mb-2', {
|
||||
disabled: isMutating
|
||||
})}
|
||||
onClick={handlePause}
|
||||
>
|
||||
Pause payments
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const UnpauseButton = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
|
||||
const unpauseSubscription = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (confirm(`Please confirm you want to unpause your subscription. Your payments will reactivate on their original schedule.`)) {
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
/* Send request */
|
||||
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'unpause'
|
||||
})
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
alert(result.message)
|
||||
setIsMutating(false)
|
||||
} else {
|
||||
|
||||
setSubscription({
|
||||
...subscription,
|
||||
status: result['subscription']['status'],
|
||||
renewalDate: result['subscription']['renews_at'],
|
||||
})
|
||||
|
||||
alert('Your subscription is now active again!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a href=""
|
||||
onClick={unpauseSubscription}
|
||||
className={clsx('px-6 py-2 font-bold btn btn-block', {
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Unpause your subscription
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const PlansComponent = ({ plans, sub }:
|
||||
{ plans: Plan[], sub: Subscription }
|
||||
) => {
|
||||
|
||||
const [subscription, setSubscription] = useState<SubscriptionState>(() => {
|
||||
if (sub) {
|
||||
return {
|
||||
id: sub.lemonSqueezyId,
|
||||
planName: sub.plan?.variantName,
|
||||
planInterval: sub.plan?.interval,
|
||||
productId: sub.plan?.productId,
|
||||
variantId: sub.plan?.variantId,
|
||||
status: sub.status,
|
||||
renewalDate: sub.renewsAt,
|
||||
trialEndDate: sub.trialEndsAt,
|
||||
expiryDate: sub.endsAt,
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<PlanCards plans={plans} subscription={subscription} setSubscription={setSubscription} />
|
||||
)
|
||||
}
|
||||
122
site/components/Plan.tsx
Normal file
122
site/components/Plan.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Dispatch, SetStateAction } from 'react'
|
||||
import PlanButton from '@/components/PlanButton'
|
||||
import { isPlanFeatured } from '@/helpers';
|
||||
import clsx from 'clsx';
|
||||
import Icon from '@/components/Icon';
|
||||
import type { Plan, SubscriptionState } from '@/types';
|
||||
|
||||
function formatPrice(price: number) {
|
||||
const priceString = price.toString()
|
||||
const dollars = priceString.substring(0, priceString.length-2)
|
||||
const cents = priceString.substring(priceString.length-2)
|
||||
if (cents === '00') return dollars
|
||||
return `${dollars}.${cents}`
|
||||
}
|
||||
|
||||
const formatDescription = (description?: string) => {
|
||||
if (!description) return;
|
||||
const pricingFeatures = description
|
||||
.replaceAll('<p>','')
|
||||
.replaceAll('</p>','')
|
||||
.split('<br>')
|
||||
return <ul className="pricing-features">
|
||||
{
|
||||
pricingFeatures.map((pricingFeature) => (
|
||||
<li key={pricingFeature}>
|
||||
<Icon name="check" className="text-green mr-2" />
|
||||
{pricingFeature}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
const IntervalSwitcher = ({ intervalValue, changeInterval }:
|
||||
{ intervalValue: string, changeInterval: Dispatch<SetStateAction<string>> }
|
||||
) => {
|
||||
return (
|
||||
<div className="text-center mb-5">
|
||||
<span className="mr-2">Monthly</span>
|
||||
<label className="form-switch">
|
||||
<input
|
||||
className="form-switch-input"
|
||||
type="checkbox"
|
||||
checked={intervalValue == 'year'}
|
||||
onChange={(e) => changeInterval(e.target.checked ? 'year' : 'month')}
|
||||
/>
|
||||
<span className="slider"/>
|
||||
</label>
|
||||
<span className="ml-2">Yearly</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const PlanCard = ({ plan, subscription, intervalValue, setSubscription }:
|
||||
{ plan: Plan, subscription: SubscriptionState, intervalValue: string, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx({
|
||||
'featured': isPlanFeatured(plan),
|
||||
'pricing-card': plan.interval === intervalValue,
|
||||
'visually-hidden': plan.interval !== intervalValue,
|
||||
})}
|
||||
>
|
||||
{
|
||||
isPlanFeatured(plan) &&
|
||||
<div className="pricing-label">
|
||||
<div className="label label-primary label-sm">Popular</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h4 className="pricing-title">{plan.variantName}</h4>
|
||||
|
||||
<div className="pricing-price">
|
||||
<span className="pricing-price-currency">$</span>
|
||||
{ formatPrice(plan.price) }
|
||||
<div className="pricing-price-description">
|
||||
<div>per {plan.interval}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formatDescription(plan.description || '')}
|
||||
|
||||
<PlanButton plan={plan} subscription={subscription} setSubscription={setSubscription} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PlanCards = ({ plans, subscription, setSubscription }:
|
||||
{ plans: Plan[], subscription?: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
|
||||
const [intervalValue, setIntervalValue] = useState('month')
|
||||
|
||||
// Make sure Lemon.js is loaded
|
||||
useEffect(() => {
|
||||
window.createLemonSqueezy()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntervalSwitcher intervalValue={intervalValue} changeInterval={setIntervalValue} />
|
||||
|
||||
<div className="pricing">
|
||||
|
||||
{plans.map(plan => (
|
||||
<PlanCard plan={plan} subscription={subscription} intervalValue={intervalValue} key={plan.variantId} setSubscription={setSubscription} />
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
<p className="h-subheader mt-8 text-center">
|
||||
Payments are processed securely by Lemon Squeezy.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanCards
|
||||
124
site/components/PlanButton.tsx
Normal file
124
site/components/PlanButton.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { Dispatch, SetStateAction, useState, MouseEvent } from 'react'
|
||||
import clsx from 'clsx';
|
||||
import { isPlanFeatured } from '@/helpers';
|
||||
import { Plan, SubscriptionState } from '@/types';
|
||||
|
||||
const PlanButton = ({ plan, subscription, setSubscription }:
|
||||
{ plan: Plan, subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
|
||||
const createCheckout = async (e: MouseEvent<HTMLAnchorElement>, variantId: number) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (isMutating) return
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
// Create a checkout
|
||||
const res = await fetch('/api/checkouts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
variantId: variantId
|
||||
})
|
||||
})
|
||||
const checkout = await res.json()
|
||||
if (checkout.error) {
|
||||
alert(checkout.message)
|
||||
} else {
|
||||
LemonSqueezy.Url.Open(checkout['url'])
|
||||
}
|
||||
|
||||
setIsMutating(false)
|
||||
}
|
||||
|
||||
const changePlan = async (e: MouseEvent<HTMLAnchorElement>, subscription: SubscriptionState, plan: Plan) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (isMutating) return
|
||||
|
||||
if (confirm(`Please confirm you want to change to the ${plan.variantName} ${plan.interval}ly plan. \
|
||||
For upgrades you will be charged a prorated amount.`)) {
|
||||
|
||||
setIsMutating(true)
|
||||
|
||||
// Send request
|
||||
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
variantId: plan.variantId,
|
||||
productId: plan.productId
|
||||
})
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
alert(result.message)
|
||||
} else {
|
||||
setSubscription({
|
||||
...subscription,
|
||||
productId: result['subscription']['product_id'],
|
||||
variantId: result['subscription']['variant_id'],
|
||||
planName: result['subscription']['plan']['name'],
|
||||
planInterval: result['subscription']['plan']['interval'],
|
||||
status: result['subscription']['status'],
|
||||
renewalDate: result['subscription']['renews_at'],
|
||||
price: result['subscription']['price']
|
||||
})
|
||||
|
||||
alert('Your subscription plan has changed!')
|
||||
|
||||
// Webhooks will update the DB in the background
|
||||
}
|
||||
|
||||
setIsMutating(false)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!subscription || subscription.status == 'expired') ? (
|
||||
// Show a "Sign up" button to customers with no subscription
|
||||
|
||||
<div className="pricing-btn">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => createCheckout(e, plan.variantId)}
|
||||
className={clsx('btn btn-block', {
|
||||
'btn-primary': isPlanFeatured(plan),
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{subscription?.variantId == plan.variantId ? (
|
||||
<div className="pricing-btn">
|
||||
<span className="font-bold select-none">Your current plan</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pricing-btn">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => changePlan(e, subscription, plan)}
|
||||
className={clsx('btn btn-block', {
|
||||
'btn-primary': isPlanFeatured(plan),
|
||||
disabled: isMutating
|
||||
})}
|
||||
>
|
||||
Change to this plan
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanButton
|
||||
238
site/components/Subscription.tsx
Normal file
238
site/components/Subscription.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, SetStateAction, Dispatch } from 'react'
|
||||
import Link from 'next/link'
|
||||
import PlanCards from '@/components/Plan'
|
||||
import {
|
||||
UpdateBillingLink,
|
||||
CancelLink,
|
||||
ResumeButton,
|
||||
PauseLink,
|
||||
UnpauseButton
|
||||
} from '@/components/Manage'
|
||||
import { Plan, Subscription, SubscriptionState } from '@/types'
|
||||
|
||||
export const SubscriptionComponent = ({ sub, plans }:
|
||||
{ sub: Subscription | null, plans: Plan[] }
|
||||
) => {
|
||||
|
||||
// Make sure Lemon.js is loaded
|
||||
useEffect(() => {
|
||||
window.createLemonSqueezy()
|
||||
}, [])
|
||||
|
||||
const [subscription, setSubscription] = useState<SubscriptionState>(() => {
|
||||
if (sub) {
|
||||
return {
|
||||
id: sub.lemonSqueezyId,
|
||||
planName: sub.plan?.variantName,
|
||||
planInterval: sub.plan?.interval,
|
||||
productId: sub.plan?.productId,
|
||||
variantId: sub.plan?.variantId,
|
||||
status: sub.status,
|
||||
renewalDate: sub.renewsAt,
|
||||
trialEndDate: sub.trialEndsAt,
|
||||
expiryDate: sub.endsAt,
|
||||
unpauseDate: sub.resumesAt,
|
||||
price: sub.price / 100,
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (sub) {
|
||||
|
||||
switch(subscription?.status) {
|
||||
|
||||
case 'active':
|
||||
return <ActiveSubscription subscription={subscription} setSubscription={setSubscription} />
|
||||
case 'on_trial':
|
||||
return <TrialSubscription subscription={subscription} setSubscription={setSubscription} />
|
||||
case 'past_due':
|
||||
return <PastDueSubscription subscription={subscription} setSubscription={setSubscription} />
|
||||
case 'cancelled':
|
||||
return <CancelledSubscription subscription={subscription} setSubscription={setSubscription} />
|
||||
case 'paused':
|
||||
return <PausedSubscription subscription={subscription} setSubscription={setSubscription} />
|
||||
case 'unpaid':
|
||||
return <UnpaidSubscription subscription={subscription} setSubscription={setSubscription} />
|
||||
case 'expired':
|
||||
return <ExpiredSubscription subscription={subscription} plans={plans} setSubscription={setSubscription} />
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-center">Please sign up to a paid plan.</p>
|
||||
|
||||
<PlanCards plans={plans} setSubscription={setSubscription} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActiveSubscription = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
|
||||
</p>
|
||||
|
||||
<p className="mb-2">Your next renewal will be on {formatDate(subscription?.renewalDate)}.</p>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<p className="mb-4">
|
||||
<Link href="/billing/change-plan" className="px-6 py-2 font-bold">
|
||||
Change plan →
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p><UpdateBillingLink subscription={subscription} /></p>
|
||||
|
||||
<p><PauseLink subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
|
||||
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const CancelledSubscription = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
|
||||
</p>
|
||||
|
||||
<p className="mb-8">Your subscription has been cancelled and <b>will end on {formatDate(subscription?.expiryDate)}</b>. After this date you will no longer have access to the app.</p>
|
||||
|
||||
<p><ResumeButton subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const PausedSubscription = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
|
||||
</p>
|
||||
|
||||
{subscription?.unpauseDate ? (
|
||||
<p className="mb-8">Your subscription payments are currently paused. Your subscription will automatically resume on {formatDate(subscription?.unpauseDate)}.</p>
|
||||
) : (
|
||||
<p className="mb-8">Your subscription payments are currently paused.</p>
|
||||
)}
|
||||
|
||||
<p><UnpauseButton subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const TrialSubscription = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
You are currently on a free trial of the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
|
||||
</p>
|
||||
|
||||
<p className="mb-6">Your trial ends on {formatDate(subscription?.trialEndDate)}. You can cancel your subscription before this date and you won't be charged.</p>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<p className="mb-4">
|
||||
<Link href="/billing/change-plan" className="px-6 py-2 font-bold">
|
||||
Change plan →
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p><UpdateBillingLink subscription={subscription} /></p>
|
||||
|
||||
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const PastDueSubscription = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<div className="my-8 p-4">
|
||||
Your latest payment failed. We will re-try this payment up to four times, after which your subscription will be cancelled.<br />
|
||||
If you need to update your billing details, you can do so below.
|
||||
</div>
|
||||
|
||||
<p className="mb-2">
|
||||
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
|
||||
</p>
|
||||
|
||||
<p className="mb-2">We will attempt a payment on {formatDate(subscription?.renewalDate)}.</p>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<p><UpdateBillingLink subscription={subscription} /></p>
|
||||
|
||||
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const UnpaidSubscription = ({ subscription, setSubscription }:
|
||||
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
/*
|
||||
Unpaid subscriptions have had four failed recovery payments.
|
||||
If you have dunning enabled in your store settings, customers will be sent emails trying to reactivate their subscription.
|
||||
If you don't have dunning enabled the subscription will remain "unpaid".
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2">We haven't been able to make a successful payment and your subscription is currently marked as unpaid.</p>
|
||||
|
||||
<p className="mb-6">Please update your billing information to regain access.</p>
|
||||
|
||||
<p><UpdateBillingLink subscription={subscription} elementType="button" /></p>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ExpiredSubscription = ({ subscription, plans, setSubscription }:
|
||||
{ subscription: SubscriptionState, plans: Plan[], setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2">Your subscription expired on {formatDate(subscription?.expiryDate)}.</p>
|
||||
|
||||
<p className="mb-2">Please create a new subscription to regain access.</p>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<PlanCards subscription={subscription} plans={plans} setSubscription={setSubscription} />
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(date?: Date | null) {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: "2-digit",
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Plan } from "@/types";
|
||||
|
||||
export const groupBy = function (xs, key) {
|
||||
return xs.reduce(function (rv, x) {
|
||||
;(rv[x[key]] = rv[x[key]] || []).push(x);
|
||||
@@ -72,4 +74,8 @@ export const getNextAuthErrorMessage = (error: string): string => {
|
||||
default:
|
||||
return 'Unable to sign in.';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const isPlanFeatured = (plan: Plan) => {
|
||||
return plan.variantName === 'Advanced'
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import prisma from '@/lib/prisma';
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import GitHubProvider from 'next-auth/providers/github';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import Auth0Provider from 'next-auth/providers/auth0';
|
||||
import prisma from '@/lib/prisma'
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
import GoogleProvider from 'next-auth/providers/google'
|
||||
import { NextAuthOptions } from 'next-auth'
|
||||
import Auth0Provider from 'next-auth/providers/auth0'
|
||||
import { getServerSession } from 'next-auth/next'
|
||||
import type { ExtendedSession } from '@/types'
|
||||
|
||||
export const authConfig: NextAuthOptions = {
|
||||
providers: [
|
||||
@@ -70,6 +72,18 @@ export const authConfig: NextAuthOptions = {
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
callbacks: {
|
||||
// Add user ID to session from token
|
||||
session: async ({ session, token }) => {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.sub,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
// callbacks: {
|
||||
// session: ({ session, token }) => {
|
||||
// return {
|
||||
@@ -92,3 +106,7 @@ export const authConfig: NextAuthOptions = {
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
export function getSession(): Promise<ExtendedSession> {
|
||||
return getServerSession(authConfig) as Promise<ExtendedSession>
|
||||
}
|
||||
50
site/lib/data.ts
Normal file
50
site/lib/data.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export async function getPlans() {
|
||||
// Gets all active plans
|
||||
return await prisma.plan.findMany({
|
||||
where: {
|
||||
NOT: [
|
||||
{ status: 'draft' },
|
||||
{ status: 'pending' }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
subscriptions: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export async function getPlan(variantId: number) {
|
||||
// Gets single active plan by ID
|
||||
return await prisma.plan.findFirst({
|
||||
where: {
|
||||
variantId: variantId,
|
||||
NOT: [
|
||||
{ status: 'draft' },
|
||||
{ status: 'pending' }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
subscriptions: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export async function getSubscription(userId?: string) {
|
||||
// Gets the most recent subscription
|
||||
return await prisma.subscription.findFirst({
|
||||
where: {
|
||||
userId: userId
|
||||
},
|
||||
include: {
|
||||
plan: true,
|
||||
user: true
|
||||
},
|
||||
orderBy: {
|
||||
lemonSqueezyId: 'desc'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
"dependencies": {
|
||||
"@glidejs/glide": "^3.6.0",
|
||||
"@headlessui/react": "^1.7.16",
|
||||
"@lemonsqueezy/lemonsqueezy.js": "^1.2.2",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "2.3.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
|
||||
7
site/pnpm-lock.yaml
generated
7
site/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ dependencies:
|
||||
'@headlessui/react':
|
||||
specifier: ^1.7.16
|
||||
version: 1.7.16(react-dom@18.2.0)(react@18.2.0)
|
||||
'@lemonsqueezy/lemonsqueezy.js':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2
|
||||
'@mdx-js/loader':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(webpack@5.88.2)
|
||||
@@ -2337,6 +2340,10 @@ packages:
|
||||
tslib: 2.4.1
|
||||
dev: false
|
||||
|
||||
/@lemonsqueezy/lemonsqueezy.js@1.2.2:
|
||||
resolution: {integrity: sha512-6laXtCHYv89boG1TGM0fZtWrp3B8XWIDIeCWVyj37XO4ECyR1EeVIgMQlPbxixuuf7IL+UOGjkQX60Gi8DJKNQ==}
|
||||
dev: false
|
||||
|
||||
/@mdx-js/esbuild@2.3.0(esbuild@0.17.18):
|
||||
resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==}
|
||||
peerDependencies:
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionStatus" AS ENUM ('on_trial', 'active', 'paused', 'past_due', 'unpaid', 'cancelled', 'expired');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CardBrand" AS ENUM ('visa', 'mastercard', 'american_express', 'discover', 'jcb', 'diners_club');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"provider_account_id" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
"refresh_token_expires_in" INTEGER,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"session_token" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT,
|
||||
"email_verified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verificationtokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Subscription" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"store_id" INTEGER,
|
||||
"customer_id" INTEGER,
|
||||
"order_id" INTEGER,
|
||||
"order_item_id" INTEGER,
|
||||
"product_id" INTEGER,
|
||||
"variant_id" INTEGER,
|
||||
"product_name" TEXT,
|
||||
"variant_name" TEXT,
|
||||
"user_name" TEXT,
|
||||
"user_email" TEXT,
|
||||
"status" "SubscriptionStatus" NOT NULL,
|
||||
"status_formatted" TEXT,
|
||||
"card_brand" "CardBrand" NOT NULL,
|
||||
"card_last_four" TEXT NOT NULL,
|
||||
"pause" JSONB,
|
||||
"cancelled" BOOLEAN,
|
||||
|
||||
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
138
site/prisma/migrations/20231206000400_init/migration.sql
Normal file
138
site/prisma/migrations/20231206000400_init/migration.sql
Normal file
@@ -0,0 +1,138 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"provider_account_id" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
"refresh_token_expires_in" INTEGER,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"session_token" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT,
|
||||
"email_verified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verificationtokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Subscription" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"lemon_squeezy_id" INTEGER NOT NULL,
|
||||
"order_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"renews_at" TIMESTAMP(3),
|
||||
"ends_at" TIMESTAMP(3),
|
||||
"trial_ends_at" TIMESTAMP(3),
|
||||
"resumes_at" TIMESTAMP(3),
|
||||
"price" INTEGER NOT NULL,
|
||||
"plan_id" INTEGER NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"is_usage_based" BOOLEAN NOT NULL DEFAULT false,
|
||||
"subscription_item_id" INTEGER,
|
||||
|
||||
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Plan" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"product_id" INTEGER NOT NULL,
|
||||
"variant_id" INTEGER NOT NULL,
|
||||
"name" TEXT,
|
||||
"description" TEXT,
|
||||
"variant_name" TEXT NOT NULL,
|
||||
"sort" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"price" INTEGER NOT NULL,
|
||||
"interval" TEXT NOT NULL,
|
||||
"interval_count" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookEvent" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"event_name" TEXT NOT NULL,
|
||||
"processed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"body" JSONB NOT NULL,
|
||||
"processing_error" TEXT,
|
||||
|
||||
CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Subscription_lemon_squeezy_id_key" ON "Subscription"("lemon_squeezy_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Subscription_order_id_key" ON "Subscription"("order_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Subscription_subscription_item_id_key" ON "Subscription"("subscription_item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Subscription_plan_id_lemon_squeezy_id_idx" ON "Subscription"("plan_id", "lemon_squeezy_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Plan_variant_id_key" ON "Plan"("variant_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -3,9 +3,9 @@ generator client {
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL")
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL")
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING")
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -39,13 +39,14 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
image String?
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
subscription Subscription[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -59,41 +60,48 @@ model VerificationToken {
|
||||
@@map("verificationtokens")
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
on_trial
|
||||
active
|
||||
paused
|
||||
past_due
|
||||
unpaid
|
||||
cancelled
|
||||
expired
|
||||
}
|
||||
|
||||
enum CardBrand {
|
||||
visa
|
||||
mastercard
|
||||
american_express
|
||||
discover
|
||||
jcb
|
||||
diners_club
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id Int @id @default(autoincrement())
|
||||
store_id Int?
|
||||
customer_id Int?
|
||||
order_id Int?
|
||||
order_item_id Int?
|
||||
product_id Int?
|
||||
variant_id Int?
|
||||
product_name String?
|
||||
variant_name String?
|
||||
user_name String?
|
||||
user_email String?
|
||||
status SubscriptionStatus
|
||||
status_formatted String?
|
||||
card_brand CardBrand
|
||||
card_last_four String
|
||||
pause Json?
|
||||
cancelled Boolean?
|
||||
id Int @id @default(autoincrement())
|
||||
lemonSqueezyId Int @unique @map("lemon_squeezy_id")
|
||||
orderId Int @unique @map("order_id")
|
||||
name String
|
||||
email String
|
||||
status String
|
||||
renewsAt DateTime? @map("renews_at")
|
||||
endsAt DateTime? @map("ends_at")
|
||||
trialEndsAt DateTime? @map("trial_ends_at")
|
||||
resumesAt DateTime? @map("resumes_at")
|
||||
price Int
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
planId Int @map("plan_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @map("user_id")
|
||||
isUsageBased Boolean @default(false) @map("is_usage_based")
|
||||
subscriptionItemId Int? @unique @map("subscription_item_id")
|
||||
|
||||
@@index([planId, lemonSqueezyId])
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @map("product_id")
|
||||
variantId Int @unique @map("variant_id")
|
||||
name String? // Need to get from Product
|
||||
description String?
|
||||
variantName String @map("variant_name")
|
||||
sort Int
|
||||
status String
|
||||
price Int
|
||||
interval String
|
||||
intervalCount Int @default(1) @map("interval_count")
|
||||
subscriptions Subscription[]
|
||||
}
|
||||
|
||||
model WebhookEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
eventName String @map("event_name")
|
||||
processed Boolean @default(false)
|
||||
body Json
|
||||
processingError String? @map("processing_error")
|
||||
}
|
||||
|
||||
@@ -353,3 +353,57 @@ $form-range-thumb-transition: background-color .3s ease-in-out, bor
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* Switch */
|
||||
.form-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 1.5rem;
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.form-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px $color-primary;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(1rem);
|
||||
-ms-transform: translateX(1rem);
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { type Session } from 'next-auth';
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export type IconType = {
|
||||
name: string
|
||||
tags: string[]
|
||||
@@ -18,3 +21,37 @@ export type DocsItem = {
|
||||
}
|
||||
|
||||
export type DocsConfigType = DocsItem[]
|
||||
|
||||
export type ExtendedSession = Session & { user?: { id?: string} }
|
||||
|
||||
const userWithRelations = Prisma.validator<Prisma.UserDefaultArgs>()({
|
||||
include: { accounts: true, sessions: true, subscription: true },
|
||||
})
|
||||
|
||||
export type User = Prisma.UserGetPayload<typeof userWithRelations>
|
||||
|
||||
const planWithRelations = Prisma.validator<Prisma.PlanDefaultArgs>()({
|
||||
include: { subscriptions: true },
|
||||
})
|
||||
|
||||
export type Plan = Prisma.PlanGetPayload<typeof planWithRelations>
|
||||
|
||||
const subscriptionWithRelations = Prisma.validator<Prisma.SubscriptionDefaultArgs>()({
|
||||
include: { plan: true, user: true },
|
||||
})
|
||||
|
||||
export type Subscription = Prisma.SubscriptionGetPayload<typeof subscriptionWithRelations>
|
||||
|
||||
export type SubscriptionState = {
|
||||
id?: number,
|
||||
planName?: string,
|
||||
planInterval?: string,
|
||||
productId?: number,
|
||||
variantId?: number,
|
||||
status?: string,
|
||||
renewalDate?: Date | null,
|
||||
trialEndDate?: Date | null,
|
||||
expiryDate?: Date | null,
|
||||
unpauseDate?: Date | null,
|
||||
price?: number,
|
||||
} | undefined
|
||||
Reference in New Issue
Block a user