diff --git a/apps/website/src/app/api/discord/route.ts b/apps/website/src/app/api/discord/route.ts index a70562e82..e952e9c5d 100644 --- a/apps/website/src/app/api/discord/route.ts +++ b/apps/website/src/app/api/discord/route.ts @@ -4,12 +4,18 @@ const PRO_ROLE_ID = '990532440126808094' import { NextRequest, NextResponse } from 'next/server' -export const GET = async () => { +export const GET = async (req: NextRequest) => { + const code = req.nextUrl.searchParams.get('code') + + if (!code) { + return NextResponse.redirect(req.nextUrl.origin) + } + const supabase = createClient() const { data: { session }, - } = await supabase.auth.getSession() + } = await supabase.auth.exchangeCodeForSession(code) if (!session) { return NextResponse.json({ @@ -17,7 +23,7 @@ export const GET = async () => { message: 'You need to be logged in.', }) } - console.log(session) + if (!session.provider_token) { return NextResponse.json({ success: false, diff --git a/apps/website/src/components/layout/navigation-menu.tsx b/apps/website/src/components/layout/navigation-menu.tsx index c2499fa03..c61504d2b 100644 --- a/apps/website/src/components/layout/navigation-menu.tsx +++ b/apps/website/src/components/layout/navigation-menu.tsx @@ -402,6 +402,21 @@ export const Navigation = () => { /> + + + } + borderRadius="md" + as={Link} + href="https://github.com/saas-js/saas-ui" + /> + + + + + {isAuthenticated ? ( { + router.push('/account')}> + Account + router.push('/redeem')}> Redeem license @@ -442,21 +460,6 @@ export const Navigation = () => { )} - - - } - borderRadius="md" - as={Link} - href="https://github.com/saas-js/saas-ui" - /> - - - - - { + router.push('/account')}> + Account + router.push('/redeem')}> Redeem license diff --git a/apps/website/src/components/redeem-form/index.tsx b/apps/website/src/components/redeem-form/index.tsx index c69441095..ff935139d 100644 --- a/apps/website/src/components/redeem-form/index.tsx +++ b/apps/website/src/components/redeem-form/index.tsx @@ -7,12 +7,8 @@ import { Text, Spinner, Center, - IconButton, - ButtonGroup, Card, CardBody, - Button, - Box, } from '@chakra-ui/react' import { useRouter } from 'next/router' @@ -20,7 +16,6 @@ import { ButtonLink } from '@/components/link' import { useLocalStorage, - Link, FormLayout, SubmitButton, useSnackbar, @@ -30,23 +25,12 @@ import { Form } from '@saas-ui/forms/zod' import * as z from 'zod' -import { FaGithub, FaDiscord } from 'react-icons/fa' - import confetti from 'canvas-confetti' -import { useCurrentUser } from '@saas-ui/auth' -import { User } from '@supabase/supabase-js' -import { DiscordRoles } from './discord-roles' export function RedeemForm(props) { const router = useRouter() const snackbar = useSnackbar() - const user = useCurrentUser() - - const hasDiscord = user?.identities?.some( - (identity) => identity.provider === 'discord' - ) - const [data, setData] = useLocalStorage<{ licenseKey: string githubAccount: string @@ -57,7 +41,7 @@ export function RedeemForm(props) { const [licenseKey, setLicenseKey] = useState('') const [loading, setLoading] = useState(true) - + const [success, setSuccess] = useState(false) const celebrate = () => { confetti({ zIndex: 999, @@ -133,6 +117,7 @@ export function RedeemForm(props) { discordInvite: response.discordInvite, githubInvited: response.githubInvited, }) + setSuccess(true) }) .catch((error) => { console.error(error) @@ -141,19 +126,6 @@ export function RedeemForm(props) { }) } - useEffect(() => { - if (user && data?.licenseKey && !user.user_metadata?.licenses) { - fetch('/api/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - licenseKey: data.licenseKey, - githubAccount: data.githubAccount, - }), - }) - } - }, [user, data]) - let content if (loading) { @@ -162,7 +134,7 @@ export function RedeemForm(props) { ) - } else if (data) { + } else if (success && data) { content = ( @@ -181,35 +153,7 @@ export function RedeemForm(props) { You will receive a Github invite shortly. )} - - Your opinion is very important, please don't hesitate to reach - out when you have any questions or feedback, especially if you - don't like something :) - - - - - - Documentation - - } - target="_blank" - > - saas-ui-pro - - - } - target="_blank" - > - nextjs-starter-kit - - + Continue to your account ) } else { diff --git a/apps/website/src/lib/supabase.ts b/apps/website/src/lib/supabase.ts index fbfbc98e5..2fd9c9e45 100644 --- a/apps/website/src/lib/supabase.ts +++ b/apps/website/src/lib/supabase.ts @@ -1,9 +1,11 @@ import { createBrowserClient, createServerClient as _createServerClient, - serialize, + serializeCookieHeader, type CookieOptions, } from '@supabase/ssr' +import { type GetServerSidePropsContext } from 'next' + import type { NextApiRequest, NextApiResponse } from 'next' const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! @@ -21,11 +23,37 @@ export const createServerClient = ( return req.cookies[name] }, set(name: string, value: string, options: CookieOptions) { - res.setHeader('Set-Cookie', serialize(name, value, options)) + res.setHeader('Set-Cookie', serializeCookieHeader(name, value, options)) }, remove(name: string, options: CookieOptions) { - res.setHeader('Set-Cookie', serialize(name, '', options)) + res.setHeader('Set-Cookie', serializeCookieHeader(name, '', options)) }, }, }) } + +export const getServerSidePropsClient = ({ + req, + res, +}: GetServerSidePropsContext) => { + const supabase = _createServerClient(supabaseUrl, supabaseKey, { + cookies: { + getAll() { + return Object.keys(req.cookies).map((name) => ({ + name, + value: req.cookies[name] || '', + })) + }, + setAll(cookiesToSet) { + res.setHeader( + 'Set-Cookie', + cookiesToSet.map(({ name, value, options }) => + serializeCookieHeader(name, value, options) + ) + ) + }, + }, + }) + + return supabase +} diff --git a/apps/website/src/pages/account.tsx b/apps/website/src/pages/account.tsx new file mode 100644 index 000000000..1d71d8de6 --- /dev/null +++ b/apps/website/src/pages/account.tsx @@ -0,0 +1,380 @@ +import { + Box, + Card, + CardBody, + Heading, + Grid, + HStack, + Spacer, + Button, + Table, + Thead, + Tr, + Th, + Tbody, + Td, + useClipboard, + IconButton, + Tooltip, + Badge, + Divider, +} from '@chakra-ui/react' +import { Container, Text } from '@chakra-ui/react' +import { + EmptyState, + StructuredList, + StructuredListItem, + StructuredListCell, + useSnackbar, +} from '@saas-ui/react' + +import SEO from '@/components/seo' + +import { ButtonLink } from '@/components/link' +import { getServerSidePropsClient } from '@/lib/supabase' +import type { InferGetServerSidePropsType } from 'next' +import { useState } from 'react' +import { LuCopy, LuCheck } from 'react-icons/lu' +import { format } from 'date-fns' +import Link from 'next/link' +import { DiscordRoles } from '@/components/redeem-form/discord-roles' + +type Licenses = InferGetServerSidePropsType< + typeof getServerSideProps +>['licenses'] + +export default function AccountPage(props: { licenses: Licenses }) { + return ( + + + + + + + + + {props.licenses?.length && } + + + + + + ) +} + +function Licenses({ licenses }: { licenses: Licenses }) { + let content: React.ReactNode = null + + if (!licenses?.length) { + content = ( + + ) + } else { + content = ( + + + + + + + + + + + + {licenses.map((license) => ( + + + + + + + + ))} + +
+ License key + + Type + + Github + + Activations + + Expires +
+ + + + {license.key} + + + + {license.product}{license.githubAccount}{license.activations} + {license.status === 'expired' ? ( + + ) : license.expiresAt ? ( + format(new Date(license.expiresAt), 'P') + ) : ( + 'Never' + )} +
+ ) + } + + return ( + + + + Your licenses + + + + + Purchase license + + + + {content} + + + ) +} + +const renewLicense = async (licenseKey: string) => { + const result = await fetch('/api/renew', { + method: 'POST', + body: JSON.stringify({ licenseKey }), + }) + + const { url } = await result.json() + + return url +} + +function CopyButton({ text }: { text: string }) { + const { onCopy, hasCopied } = useClipboard(text) + + return ( + + {hasCopied ? : } + + ) +} + +function RenewButton({ licenseKey }: { licenseKey: string }) { + const snackbar = useSnackbar() + + const [loading, setLoading] = useState(false) + + const handleRenew = async () => { + setLoading(true) + try { + const url = await renewLicense(licenseKey) + window.location.href = url + } catch (err) { + snackbar.error({ + title: 'Renewal failed', + description: 'Please contact us if the issue persists.', + }) + } finally { + setLoading(false) + } + } + + return ( + + + + ) +} + +function Products() { + return ( + + + Your products + + + + + + + Blocks + + + Pre-built React components + + + + + + + Next.js starter kit + + + Production-ready Next.js starter kit + + + + + + + Saas UI Pro + + + Premium components and templates + + + + + + + + + + + + ) +} + +export const getServerSideProps = async (ctx) => { + const supabase = getServerSidePropsClient(ctx) + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { + redirect: { + destination: '/login', + permanent: false, + }, + } + } + + const licenses: Array<{ + githubAccount: string + key: string + email: string + product: string + activationLimit: number + activations: number + createdAt: string + expiresAt: string + status: string + }> = [] + + for await (const license of user.user_metadata.licenses) { + try { + const data = await getLicense(license.licenseKey) + + licenses.push({ + ...data, + githubAccount: license.githubAccount, + }) + } catch (err) { + continue + } + } + + return { + props: { + licenses, + }, + } +} + +const getLicense = async (licenseKey: string) => { + const API_KEY = process.env.LEMON_API_KEY + + const response = await fetch( + `https://api.lemonsqueezy.com/v1/licenses/validate`, + { + headers: { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + method: 'POST', + body: `license_key=${licenseKey}`, + } + ) + + const result = (await response.json()) as { + valid: boolean + error: string + license_key: { + status: string + key: string + created_at: string + expires_at: string + activation_limit: number + activation_usage: number + } + meta: { + customer_email: string + variant_name: string + } + } + + if (!result) { + throw new Error('Invalid license') + } + + return { + status: result.license_key.status, + key: result.license_key.key, + createdAt: result.license_key.created_at, + expiresAt: result.license_key.expires_at, + activationLimit: result.license_key.activation_limit, + activations: result.license_key.activation_usage, + email: result.meta?.customer_email, + product: result.meta?.variant_name, + } +} diff --git a/apps/website/src/pages/api/lmsq.ts b/apps/website/src/pages/api/lmsq.ts index eb713fc34..00ccab4a9 100644 --- a/apps/website/src/pages/api/lmsq.ts +++ b/apps/website/src/pages/api/lmsq.ts @@ -41,39 +41,12 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { meta, data } = json - if (meta.event_name === 'order_created') { - const response = await fetch( - `https://api.lemonsqueezy.com/v1/orders/${data.id}/customer`, - { - headers: { - Authorization: `Bearer ${process.env.LEMON_API_KEY}`, - Accept: 'application/json', - }, - method: 'GET', - } - ) - - const customer = await response.json() - const attr = customer.data.attributes - - const [firstName, ...lastName] = attr.name.split(' ') + const licenseKey = meta.custom_data?.license_key - const event = { - eventName: 'order_created', - email: attr.email, - firstName, - lastName: lastName.join(' '), - license: data.attributes.first_order_item.product_name, - } - - fetch('https://app.loops.so/api/v1/events/send', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${process.env.LOOPS_API_KEY}`, - }, - body: JSON.stringify(event), - }) + if (meta.event_name === 'order_created' && licenseKey) { + await handleRenewOrder(licenseKey) + } else if (meta.event_name === 'order_created') { + await handleNewOrder(data) } res.status(200).json({ success: true }) @@ -84,4 +57,86 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } +async function handleRenewOrder(licenseKey) { + const response = await fetch( + `https://api.lemonsqueezy.com/v1/licenses/validate`, + { + headers: { + Authorization: `Bearer ${process.env.LEMON_API_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + method: 'POST', + body: `license_key=${licenseKey}`, + } + ) + + const result = await response.json() + + if (!result?.license_key) { + console.log('Error renewing license', result) + return + } + + const updatedResponse = await fetch( + `https://api.lemonsqueezy.com/v1/license-keys/${result.license_key.id}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${process.env.LEMON_API_KEY}`, + }, + body: JSON.stringify({ + data: { + type: 'license-keys', + id: String(result.license_key.id), + attributes: { + expires_at: new Date( + Date.now() + 365 * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + }, + }), + } + ) + + const updatedResult = await updatedResponse.json() + + console.log('Updated license', updatedResult) +} + +async function handleNewOrder(data: any) { + const response = await fetch( + `https://api.lemonsqueezy.com/v1/orders/${data.id}/customer`, + { + headers: { + Authorization: `Bearer ${process.env.LEMON_API_KEY}`, + Accept: 'application/json', + }, + method: 'GET', + } + ) + + const customer = await response.json() + const attr = customer.data.attributes + + const [firstName, ...lastName] = attr.name.split(' ') + + const event = { + eventName: 'order_created', + email: attr.email, + firstName, + lastName: lastName.join(' '), + license: data.attributes.first_order_item.product_name, + } + + fetch('https://app.loops.so/api/v1/events/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.LOOPS_API_KEY}`, + }, + body: JSON.stringify(event), + }) +} + export default handler diff --git a/apps/website/src/pages/api/renew.ts b/apps/website/src/pages/api/renew.ts new file mode 100644 index 000000000..7e3e2d5b4 --- /dev/null +++ b/apps/website/src/pages/api/renew.ts @@ -0,0 +1,213 @@ +import { createServerClient } from '@/lib/supabase' +import type { NextApiRequest, NextApiResponse } from 'next' + +import fetch from 'node-fetch' + +const syncLicense = async (req: NextApiRequest, res: NextApiResponse) => { + const supabase = createServerClient(req, res) + const { data } = await supabase.auth.getUser() + + await supabase.auth.updateUser({ + data: { + licenses: [ + { + licenseKey: req.body.licenseKey, + githubAccount: req.body.githubAccount, + }, + ].concat( + (data.user?.user_metadata.licenses || []).filter( + ({ licenseKey }) => licenseKey !== req.body.licenseKey + ) + ), + }, + }) +} + +const sendDiscordNotification = async ({ + licenseKey, + product, + githubAccount, + githubInvited, + npmAccount, +}) => { + const DISCORD_WEBHOOK = process.env.DISCORD_WEBHOOK + try { + if (DISCORD_WEBHOOK) { + const body = JSON.stringify({ + content: `A license has been activated 🚀`, + embeds: [ + { + fields: [ + { + name: 'License key', + value: licenseKey, + }, + { + name: 'Product', + value: product, + }, + { + name: 'Github', + value: githubAccount, + }, + { + name: 'Github Invited', + value: githubInvited || 'Failed', + }, + { + name: 'Npm account', + value: npmAccount?.id || npmAccount?.message || 'Failed', + }, + ], + }, + ], + }) + const result = await fetch(DISCORD_WEBHOOK, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body, + }) + return result + } + } catch (err) { + console.error(err) + } +} + +async function getLicenseOrder(licenseKey: string) { + const API_KEY = process.env.LEMON_API_KEY + + const response = await fetch( + `https://api.lemonsqueezy.com/v1/licenses/validate`, + { + headers: { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + method: 'POST', + body: `license_key=${licenseKey}`, + } + ) + + const data = await response.json() + + if (data.meta?.order_id) { + const response = await fetch( + `https://api.lemonsqueezy.com/v1/orders/${data.meta.order_id}`, + { + headers: { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + } + ) + + const order = await response.json() + + return order + } + + return null +} + +async function createCheckoutSession({ + licenseKey, + price, + email, +}: { + licenseKey: string + price: number + email: string +}) { + const API_KEY = process.env.LEMON_API_KEY + + const baseUrl = + process.env.NODE_ENV === 'production' + ? 'https://saas-ui.dev' + : 'http://localhost:3020' + + const postData = JSON.stringify({ + data: { + type: 'checkouts', + attributes: { + custom_price: price, + product_options: { + name: 'Saas UI Pro renewal', + description: '1 year renewal of your Saas UI Pro license.', + redirect_url: `${baseUrl}/account`, + receipt_link_url: `${baseUrl}/account`, + }, + checkout_data: { + email, + custom: { + license_key: licenseKey, + }, + }, + }, + relationships: { + store: { + data: { + type: 'stores', + id: '10206', + }, + }, + variant: { + data: { + type: 'variants', + id: process.env.LEMON_SQUEEZY_RENEW_VARIANT_ID, + }, + }, + }, + }, + }) + + const response = await fetch(`https://api.lemonsqueezy.com/v1/checkouts`, { + method: 'POST', + headers: { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: postData, + }) + + const data = await response.json() + + if (data.errors) { + console.error(data.errors) + throw new Error(data.errors[0].detail) + } + + return data.data.attributes.url +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + try { + const body = JSON.parse(req.body) + + const order = await getLicenseOrder(body.licenseKey) + console.log(order) + const price = order.data.attributes.subtotal * 0.6 + const email = order.data.attributes.user_email + + const url = await createCheckoutSession({ + licenseKey: body.licenseKey, + price, + email, + }) + + res.status(200).json({ + success: true, + url, + }) + } catch (error) { + console.error(error) + return res.status(200).json({ success: false, error: error.toString() }) + } +} + +export default handler