1
0
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:
tabler.developer@gmail.com
2023-12-07 00:56:02 +01:00
parent e4c76be517
commit 09a8e2d2c5
21 changed files with 1649 additions and 145 deletions

View File

@@ -44,3 +44,4 @@ NEXTAUTH_URL=""
# Lemon squeezy
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_SIGNING_SECRET=""
LEMON_SQUEEZY_STORE_ID=""

View 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">&larr; 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>
)
}

View 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>
)
}

View 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>
)
}

View 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');
}

View 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})
}
}

View 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
View 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
View 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

View 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

View 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 &rarr;
</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&apos;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 &rarr;
</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&apos;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'
})
}

View File

@@ -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'
}

View File

@@ -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
View 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'
}
})
}

View File

@@ -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
View File

@@ -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:

View File

@@ -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;

View 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;

View File

@@ -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")
}

View File

@@ -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);
}

View File

@@ -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