Skip to content

Commit

Permalink
feat(website): plus (#402)
Browse files Browse the repository at this point in the history
  • Loading branch information
cschroeter authored Aug 1, 2024
1 parent 47692af commit 5e628b0
Show file tree
Hide file tree
Showing 25 changed files with 631 additions and 220 deletions.
2 changes: 1 addition & 1 deletion packages/panda/src/theme/recipes/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defineRecipe } from '@pandacss/dev'

export const button = defineRecipe({
className: 'button',
jsx: ['Button', 'IconButton'],
jsx: ['Button', 'IconButton', 'SubmitButton'],
base: {
alignItems: 'center',
appearance: 'none',
Expand Down
1 change: 1 addition & 0 deletions website/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ namespace NodeJS {
interface ProcessEnv {
FIGMA_KIT_URL: string
LEMON_SQUEEZY_API_KEY: string
LEMON_SQUEEZY_WEBHOOK_SECRET: string
}
}
2 changes: 1 addition & 1 deletion website/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ model VerificationToken {
model Order {
id String @id @default(cuid())
externalId String @unique
isRefunded Boolean @default(false)
refunded Boolean @default(false)
provider Provider
orderItems OrderItem[]
Expand Down
5 changes: 0 additions & 5 deletions website/src/app/[framework]/blocks/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,3 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
}
: {}
}

export const generateStaticParams = () =>
['react', 'solid', 'vue'].flatMap((framework) =>
blocks.map((block) => ({ framework, id: block.id })),
)
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
import type { Metadata } from 'next'
import { Container, Stack } from 'styled-system/jsx'
import { Faqs } from '~/components/marketing/faqs'
import { GetInTouch } from '~/components/marketing/get-in-touch'
import { PricingCard } from '~/components/marketing/pricing-card'
import { PageHeader } from '~/components/page-header'

export default async function Page() {
return (
<Container py="28" maxW="7xl">
<Container py={{ base: '16', md: '24' }} maxW="7xl">
<Stack gap={{ base: '16', md: '24' }} alignItems="center">
<PageHeader
heading=" Get lifetime access"
heading="Park UI Plus"
subHeading="Pricing"
description="Get access to all components and free updates. Customize it to your needs, and make it
yours today!"
description="Unlock exclusive blocks and support the development of Park UI."
/>
<Stack direction={{ base: 'column', sm: 'row' }} gap="8" align="start" maxW="4xl">
<PricingCard variant="personal" />
<PricingCard variant="team" />
</Stack>
{/* <Faqs />
<GetInTouch /> */}
<Faqs />
<GetInTouch />
</Stack>
</Container>
)
}

export const metadata: Metadata = {
title: 'Pricing',
description:
'Get access to all components and free updates. Customize it to your needs, and make it yours today!',
title: 'Park UI Plus',
description: 'Unlock exclusive blocks and support the development of Park UI.',
}

export const generateStaticParams = () =>
Expand Down
129 changes: 127 additions & 2 deletions website/src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use server'

import { signIn } from '~/lib/auth'
import { Schema } from '@effect/schema'
import { Effect, Match, pipe } from 'effect'
import { auth, signIn } from '~/lib/auth'
import { prisma } from '~/lib/prisma'

export const signInWithEmail = async (formData: FormData) =>
await signIn('postmark', {
Expand All @@ -13,3 +15,126 @@ export const signIngWithGithub = async (formData: FormData) =>

export const signInWithGoogle = async (formData: FormData) =>
signIn('google', { redirectTo: formData.get('redirectTo')?.toString() ?? '/' })

const Input = Schema.Struct({
licenseKey: Schema.String.pipe(Schema.pattern(/^[A-Z0-9]{3}-[A-Z0-9]{3}-[A-Z0-9]{3}$/)),
})

export const activateLicense = async (_: unknown, formData: FormData) =>
Effect.runPromise(
pipe(
Effect.all([
pipe(
Effect.promise(() => auth()),
Effect.map((session) => session?.user?.id),
Effect.filterOrFail(
(userId): userId is string => typeof userId === 'string',
() => new UnauthorizedError(),
),
),
pipe(
Schema.decodeUnknown(Input)({ licenseKey: formData.get('licenseKey') }),
Effect.map((input) => input.licenseKey),
Effect.flatMap((key) =>
Effect.tryPromise({
try: () => prisma.licenseKey.findUniqueOrThrow({ where: { key } }),
catch: (e) =>
Match.value(e).pipe(
Match.when({ code: 'P2025' }, () => new NotFoundError(key)),
Match.orElse(() => new InternalServerError()),
),
}),
),
Effect.filterOrFail(
(licenseKey) => licenseKey.userId === null,
() => new ConflictError(),
),
),
]),
Effect.flatMap(([userId, licenseKey]) =>
Effect.tryPromise({
try: () =>
prisma.licenseKey.update({
where: { id: licenseKey.id },
data: {
user: {
connect: { id: userId },
},
},
}),
catch: () => new InternalServerError(),
}),
),
Effect.map(() => ({ success: true, message: 'License key activated' })),
Effect.catchTags({
ParseError: () => Effect.succeed({ success: false, message: 'License key is invalid' }),
UnauthorizedError: () =>
Effect.succeed({ success: false, message: 'Please log in to activate your license key' }),
NotFoundError: () => Effect.succeed({ success: false, message: 'License key not found' }),
ConflictError: () =>
Effect.succeed({ success: false, message: 'License key is already in use' }),
InternalServerError: () =>
Effect.succeed({ success: false, message: 'An unexpcted error occured' }),
}),
),
)

export const hasUserPermission = async () =>
Effect.runPromise(
pipe(
Effect.promise(() => auth()),
Effect.map((session) => session?.user?.id),
Effect.filterOrFail(
(userId): userId is string => typeof userId === 'string',
() => new Error(),
),
Effect.flatMap((userId) =>
Effect.tryPromise({
try: () =>
prisma.licenseKey.findFirst({
where: { userId },
}),
catch: () => new Error(),
}),
),
Effect.map((license) => license !== null),
Effect.catchAll(() => Effect.succeed(false)),
),
)

export const findLicenseKeysByOrderId = async (externalId: string): Promise<string | string[]> =>
Effect.runPromise(
pipe(
Effect.tryPromise({
try: () =>
prisma.order.findUniqueOrThrow({
where: { externalId },
include: { orderItems: { include: { licenseKey: true } } },
}),
catch: (e) =>
Match.value(e).pipe(
Match.when({ code: 'P2025' }, () => new Error()),
Match.orElse(() => new Error()),
),
}),
Effect.map((order) => order.orderItems.map((item) => item.licenseKey.key)),
Effect.catchAll(() => Effect.succeed([])),
),
)

class InternalServerError {
readonly _tag = 'InternalServerError'
}

class NotFoundError {
readonly _tag = 'NotFoundError'
constructor(readonly id: string) {}
}

class ConflictError {
readonly _tag = 'ConflictError'
}

class UnauthorizedError {
readonly _tag = 'UnauthorizedError'
}
2 changes: 1 addition & 1 deletion website/src/app/api/figma-kit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@ export const GET = async (req: NextRequest) => {
),
)

return Response.redirect(isValid ? FIGMA_KIT_URL : 'https://park-ui.com/404')
return Response.redirect(isValid ? FIGMA_KIT_URL : 'https://park-ui.com')
}
115 changes: 115 additions & 0 deletions website/src/app/api/lemon/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import crypto from 'node:crypto'
import { Schema } from '@effect/schema'
import { Effect, Match, pipe } from 'effect'
import type { NextRequest } from 'next/server'
import { prisma } from '~/lib/prisma'

const Payload = Schema.Struct({
meta: Schema.Struct({
event_name: Schema.Literal('order_created', 'order_refunded'),
}),
data: Schema.Struct({
id: Schema.String,
attributes: Schema.Struct({
first_order_item: Schema.Struct({
product_name: Schema.Literal('Park UI Plus'),
variant_name: Schema.Literal('Personal', 'Team'),
}),
}),
}),
})

class InternalServerError {
readonly _tag = 'InternalServerError'
}

export const POST = async (request: NextRequest) => {
const programm = pipe(
Effect.all([
Effect.promise(() => request.text()),
Effect.fromNullable(Buffer.from(request.headers.get('x-signature') as string, 'utf8')),
]),
Effect.filterOrFail(
([text, signature]) => verifySignature(text, signature),
() => new InternalServerError(),
),
Effect.flatMap(([text]) => Schema.decodeUnknown(Payload)(JSON.parse(text))),
Effect.flatMap(({ data, meta }) =>
Match.value(meta.event_name).pipe(
Match.when('order_created', () =>
Effect.tryPromise({
try: () =>
prisma.order.create({
data: {
externalId: data.id,
provider: 'LemonSqueezy',
orderItems: {
create: {
productName: data.attributes.first_order_item.product_name,
variantName: data.attributes.first_order_item.variant_name,
licenseKey: {
create: {
key: generateLicenseKey(),
type: data.attributes.first_order_item.variant_name,
},
},
},
},
},
}),
catch: () => new InternalServerError(),
}),
),
Match.when('order_refunded', () =>
pipe(
Effect.tryPromise({
try: () =>
prisma.order.update({
where: { externalId: data.id },
data: { refunded: true },
}),
catch: () => new InternalServerError(),
}),
Effect.flatMap((order) =>
Effect.tryPromise({
try: () =>
prisma.licenseKey.updateMany({
data: { disabled: true },
where: { orderItem: { orderId: order.id } },
}),
catch: () => new InternalServerError(),
}),
),
),
),
Match.exhaustive,
),
),
Effect.map(() => ({ status: 200, message: 'OK' })),
Effect.catchTags({
ParseError: () => Effect.succeed({ status: 400, message: 'Payload is invalid' }),
InternalServerError: () => Effect.succeed({ status: 500, message: 'Invalid signature' }),
}),
)
const data = await Effect.runPromise(programm)

return Response.json(data, { status: data.status })
}

const verifySignature = (text: string, signature: Buffer): boolean => {
const hmac = crypto.createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET)
const digest = Buffer.from(hmac.update(text).digest('hex'), 'utf8')

return crypto.timingSafeEqual(digest, signature)
}

const generateLicenseKey = () =>
(
Math.random()
.toString(36)
.substring(2)
.substring(0, 9)
.match(/.{1,3}/g) ?? []
)
.join('-')
.toUpperCase()
3 changes: 1 addition & 2 deletions website/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'
import { ThemeProvider } from 'next-themes'
import Script from 'next/script'
import type { PropsWithChildren } from 'react'
import { cx } from 'styled-system/css'
import { Navbar } from '~/components/navigation/navbar'
import { inter, jakarta, outfit, raleway, roboto } from './fonts'
Expand Down Expand Up @@ -39,9 +38,9 @@ export default function RootLayout(props: Props) {
<html
lang="en"
className={cx(
outfit.variable,
inter.variable,
jakarta.variable,
outfit.variable,
raleway.variable,
roboto.variable,
)}
Expand Down
6 changes: 6 additions & 0 deletions website/src/app/thank-you/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { PropsWithChildren } from 'react'
import { styled } from 'styled-system/jsx'

export default function Layout(props: PropsWithChildren) {
return <styled.main pt="16" {...props} />
}
Loading

0 comments on commit 5e628b0

Please sign in to comment.