mirror of
https://github.com/tabler/tabler.git
synced 2026-07-15 01:51:43 +04:00
remove old lemon squeezy integration
This commit is contained in:
+1
-3
@@ -48,6 +48,4 @@ _site
|
||||
.contentlayer
|
||||
|
||||
public/static/tabler-icons/icons/*
|
||||
public/static/tabler-icons/icons-png/*
|
||||
|
||||
certificates
|
||||
public/static/tabler-icons/icons-png/*
|
||||
@@ -1,12 +0,0 @@
|
||||
import Products from '@/components/Products';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Subscription',
|
||||
description: 'Tabler subscription',
|
||||
};
|
||||
|
||||
export default function Subscription() {
|
||||
return (
|
||||
<Products/>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const Attributes = z
|
||||
.object({
|
||||
pause: z.any().optional(),
|
||||
status: z.enum(['on_trial', 'active', 'paused', 'past_due', 'unpaid', 'cancelled', 'expired']),
|
||||
order_id: z.number(),
|
||||
store_id: z.number(),
|
||||
cancelled: z.boolean().nullable().optional(),
|
||||
user_name: z.string(),
|
||||
card_brand: z.enum(['visa', 'mastercard', 'american_express', 'discover', 'jcb', 'diners_club']),
|
||||
product_id: z.number(),
|
||||
variant_id: z.number(),
|
||||
customer_id: z.number(),
|
||||
product_name: z.string(),
|
||||
variant_name: z.string(),
|
||||
order_item_id: z.number(),
|
||||
card_last_four: z.string(),
|
||||
status_formatted: z.string(),
|
||||
})
|
||||
|
||||
const Links = z
|
||||
.object({
|
||||
self: z.string(),
|
||||
related: z.string().optional(),
|
||||
})
|
||||
|
||||
const Order = z
|
||||
.object({
|
||||
links: Links,
|
||||
})
|
||||
|
||||
const Relationships = z
|
||||
.object({
|
||||
order: Order,
|
||||
store: Order,
|
||||
product: Order,
|
||||
variant: Order,
|
||||
customer: Order,
|
||||
'order-item': Order,
|
||||
'subscription-invoices': Order,
|
||||
})
|
||||
|
||||
const Data = z
|
||||
.object({
|
||||
id: z.coerce.number(),
|
||||
type: z.string(),
|
||||
links: Links,
|
||||
attributes: Attributes,
|
||||
relationships: Relationships,
|
||||
})
|
||||
|
||||
const Meta = z
|
||||
.object({
|
||||
test_mode: z.boolean(),
|
||||
event_name: z.string(),
|
||||
})
|
||||
|
||||
export const SLemonSqueezyWebhookRequest = z
|
||||
.object({
|
||||
data: Data,
|
||||
meta: Meta,
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import crypto from 'crypto'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { SLemonSqueezyWebhookRequest } from './models'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.LEMON_SQUEEZY_SIGNING_SECRET
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('LEMON_SQUEEZY_SIGNING_SECRET is not set')
|
||||
}
|
||||
|
||||
const rawBody = await request.text()
|
||||
|
||||
if (!rawBody) {
|
||||
throw new Error('No body')
|
||||
}
|
||||
|
||||
const xSignature = request.headers.get('X-Signature')
|
||||
|
||||
const hmac = crypto.createHmac('sha256', secret)
|
||||
|
||||
hmac.update(rawBody)
|
||||
const digest = hmac.digest('hex')
|
||||
|
||||
if (
|
||||
!xSignature ||
|
||||
!crypto.timingSafeEqual(Buffer.from(digest, 'hex'), Buffer.from(xSignature, 'hex'))
|
||||
) {
|
||||
throw new Error('Invalid signature.')
|
||||
}
|
||||
|
||||
const body = JSON.parse(rawBody)
|
||||
|
||||
const type = body.data.type
|
||||
|
||||
if (type === 'subscriptions') {
|
||||
const parsedBody = SLemonSqueezyWebhookRequest.parse(body)
|
||||
|
||||
if (parsedBody.meta.event_name === 'subscription_created') {
|
||||
const createdSubscription = await prisma.subscription.create({
|
||||
data: {
|
||||
id: parsedBody.data.id,
|
||||
...parsedBody.data.attributes,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Inserted subscription with id ${createdSubscription.id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedBody.meta.event_name === 'subscription_updated') {
|
||||
const updatedSubscription = await prisma.subscription.update({
|
||||
where: {
|
||||
id: parsedBody.data.id,
|
||||
},
|
||||
data: {
|
||||
id: parsedBody.data.id,
|
||||
...parsedBody.data.attributes,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Updated subscription with id: ${updatedSubscription.id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { getProductVariants } from '@/lib/lemon-squeezy';
|
||||
import Icon from '@/components/Icon';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
export default function ProductVariants({ productId }: { productId: string }) {
|
||||
const { data: session } = useSession();
|
||||
const [productVariants, setProductVariants] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProductsVariants(productId)
|
||||
}, []);
|
||||
|
||||
const fetchProductsVariants = async (productId: string) => {
|
||||
const productVariants = await getProductVariants(productId)
|
||||
setProductVariants(productVariants)
|
||||
};
|
||||
|
||||
const formatLemonPrice = (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 formatLemonVariantDescription = (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 isFeatured = (variant) => {
|
||||
return variant.attributes.interval === 'year' && variant.attributes.is_subscription
|
||||
}
|
||||
|
||||
const createCheckoutLink = (variantSlug) => {
|
||||
const url = new URL(`https://buy.tabler.io/checkout/buy/${variantSlug}?embed=1`)
|
||||
|
||||
if (!session?.user) return ''
|
||||
|
||||
const email = session?.user?.email
|
||||
const name = session?.user?.name
|
||||
// const userId = session?.userId
|
||||
|
||||
// url.searchParams.append('checkout[custom][user_id]', userId)
|
||||
if (email) url.searchParams.append('checkout[email]', email)
|
||||
if (name) url.searchParams.append('checkout[name]', name)
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pricing">
|
||||
{
|
||||
productVariants &&
|
||||
productVariants.data.map(variant => (
|
||||
<div key={variant.id} className={clsx('pricing-card', { featured: isFeatured(variant) })}>
|
||||
{
|
||||
isFeatured(variant) &&
|
||||
<div className="pricing-label">
|
||||
<div className="label label-primary label-sm">Popular</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h4 className="pricing-title">{variant.attributes.name}</h4>
|
||||
|
||||
<div className="pricing-price">
|
||||
<span className="pricing-price-currency">$</span>
|
||||
{ formatLemonPrice(variant.attributes.price) }
|
||||
{
|
||||
variant.attributes.is_subscription &&
|
||||
<div className="pricing-price-description">
|
||||
<div>per {variant.attributes.interval}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{formatLemonVariantDescription(variant.attributes.description)}
|
||||
|
||||
<div className="pricing-btn">
|
||||
<a
|
||||
href={createCheckoutLink(variant.attributes.slug)}
|
||||
className={clsx('btn btn-block', {
|
||||
'btn-primary': isFeatured(variant) || productVariants.data.length === 1
|
||||
})}
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getProducts } from '@/lib/lemon-squeezy';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ProductVariants from './ProductVariants';
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
}, []);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const products = await getProducts()
|
||||
setProducts(products)
|
||||
};
|
||||
|
||||
const formatLemonProductDescription = (description: string) => {
|
||||
if (!description) return;
|
||||
return description
|
||||
.replaceAll('<p>','')
|
||||
.replaceAll('</p>','')
|
||||
}
|
||||
|
||||
return (<>{
|
||||
products &&
|
||||
products.data.map(product => {
|
||||
return <section key={product.id} className="section">
|
||||
<div className="container">
|
||||
<div className="section-header">
|
||||
<h2 className="page-title">{product.attributes.name}</h2>
|
||||
<p className="section-description">
|
||||
{formatLemonProductDescription(product.attributes.description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProductVariants productId={product.id}/>
|
||||
</div>
|
||||
</section>
|
||||
})
|
||||
}</>);
|
||||
}
|
||||
|
||||
@@ -248,13 +248,13 @@ const NavigationAuth = () => {
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="navbar-dropdown-menu">
|
||||
<div className="navbar-dropdown-menu-content">
|
||||
<div onClick={() => router.push('subscription')} className="navbar-dropdown-menu-link">
|
||||
<div onClick={() => router.push('billing')} className="navbar-dropdown-menu-link">
|
||||
<div className="row items-center g-3">
|
||||
<div className="col-auto">
|
||||
<Shape icon='rocket'/>
|
||||
</div>
|
||||
<div className="col">
|
||||
<h5>Subscription</h5>
|
||||
<h5>Billing</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use server"
|
||||
|
||||
import { SLemonSqueezyRequest, TLemonSqueezyRequest } from "./zod-lemon-squeezy";
|
||||
|
||||
const lemonSqueezyBaseUrl = 'https://api.lemonsqueezy.com/v1';
|
||||
const lemonSqueezyApiKey = process.env.LEMON_SQUEEZY_API_KEY;
|
||||
|
||||
if (!lemonSqueezyApiKey) throw new Error("No LEMON_SQUEEZY_API_KEY environment variable set");
|
||||
|
||||
function createHeaders() {
|
||||
const headers = new Headers();
|
||||
headers.append('Accept', 'application/vnd.api+json');
|
||||
headers.append('Content-Type', 'application/vnd.api+json');
|
||||
headers.append('Authorization', `Bearer ${lemonSqueezyApiKey}`);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function createRequestOptions(method: string, headers: Headers): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
cache: "no-store"
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProductVariants(productId: string): Promise<TLemonSqueezyRequest> {
|
||||
const url = `${lemonSqueezyBaseUrl}/variants?filter[product_id]=${productId}`;
|
||||
const headers = createHeaders();
|
||||
const requestOptions = createRequestOptions('GET', headers);
|
||||
|
||||
const response: Response = await fetch(url, requestOptions);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const parsedData = SLemonSqueezyRequest.parse(data);
|
||||
|
||||
return parsedData;
|
||||
}
|
||||
|
||||
export async function getProducts(): Promise<TLemonSqueezyRequest> {
|
||||
const url = `${lemonSqueezyBaseUrl}/products`;
|
||||
const headers = createHeaders();
|
||||
const requestOptions = createRequestOptions('GET', headers);
|
||||
|
||||
const response: Response = await fetch(url, requestOptions);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const parsedData = SLemonSqueezyRequest.parse(data);
|
||||
|
||||
return parsedData;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const SPagination = z.object({
|
||||
currentPage: z.number(),
|
||||
from: z.number(),
|
||||
lastPage: z.number(),
|
||||
perPage: z.number(),
|
||||
to: z.number(),
|
||||
total: z.number(),
|
||||
});
|
||||
|
||||
const SMeta = z.object({
|
||||
page: SPagination,
|
||||
});
|
||||
|
||||
const SJsonapi = z.object({
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
const SLinks = z.object({
|
||||
first: z.string(),
|
||||
last: z.string(),
|
||||
});
|
||||
|
||||
const SProductRelationships = z.object({
|
||||
links: z.object({
|
||||
related: z.string(),
|
||||
self: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const SAttributes = z.object({
|
||||
product_id: z.number().optional(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullable(),
|
||||
price: z.number(),
|
||||
is_subscription: z.boolean().optional(),
|
||||
interval: z.string().nullable().optional(),
|
||||
interval_count: z.number().nullable().optional(),
|
||||
has_free_trial: z.boolean().optional(),
|
||||
trial_interval: z.string().optional(),
|
||||
trial_interval_count: z.number().optional(),
|
||||
pay_what_you_want: z.boolean(),
|
||||
min_price: z.number().optional(),
|
||||
suggested_price: z.number().optional(),
|
||||
has_license_keys: z.boolean().optional(),
|
||||
license_activation_limit: z.number().optional(),
|
||||
is_license_limit_unlimited: z.boolean().optional(),
|
||||
license_length_value: z.number().optional(),
|
||||
license_length_unit: z.string().optional(),
|
||||
is_license_length_unlimited: z.boolean().optional(),
|
||||
sort: z.number().optional(),
|
||||
status: z.string(),
|
||||
status_formatted: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
const SVariants = z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
attributes: SAttributes,
|
||||
relationships: z.object({
|
||||
product: SProductRelationships.optional(),
|
||||
}),
|
||||
links: z.object({
|
||||
self: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SLemonSqueezyRequest = z.object({
|
||||
meta: SMeta,
|
||||
jsonapi: SJsonapi,
|
||||
links: SLinks,
|
||||
data: z.array(SVariants),
|
||||
});
|
||||
|
||||
export type TLemonSqueezyRequest = z.infer<typeof SLemonSqueezyRequest>;
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export { default } from 'next-auth/middleware';
|
||||
|
||||
export const config = { matcher: ['/subscription'] };
|
||||
export const config = { matcher: ['/billing'] };
|
||||
Reference in New Issue
Block a user