Skip to content

Commit

Permalink
individual plan for organization
Browse files Browse the repository at this point in the history
  • Loading branch information
unnoq committed Feb 18, 2024
1 parent a124d9e commit f030d1e
Show file tree
Hide file tree
Showing 11 changed files with 112 additions and 39 deletions.
7 changes: 0 additions & 7 deletions @api/features/billing/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,6 @@ export async function handleBillingWebhookRequest(ctx: ContextWithRequest) {

await match(event)
.with({ meta: { event_name: P.union('order_created', 'order_refunded') } }, async (e) => {
if (
e.data.attributes.first_order_item.variant_id !==
ctx.env.LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID
) {
throw new Error('Not allow variant_id')
}

const expiresAt = match(e.data.attributes.status)
.with('failed', () => new Date())
.with('pending', () => new Date())
Expand Down
3 changes: 2 additions & 1 deletion @api/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export const envSchema = z.object({
LEMONSQUEEZY_API_KEY: z.string(),
LEMONSQUEEZY_STORE_ID: z.number(),
LEMONSQUEEZY_WEBHOOK_SIGNING_SECRET: z.string(),
LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID: z.number(),
LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID: z.number(),
LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID: z.number(),
POSTHOG_HOST: z.string().url(),
POSTHOG_API_KEY: z.string(),
SUPPORT_EMAIL: z.string().email(),
Expand Down
9 changes: 6 additions & 3 deletions @api/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ mode = "smart"
WORKER_ENV = "development"
WEB_BASE_URL = "https://localhost:4000/"
LEMONSQUEEZY_STORE_ID = 62784
LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID = 202053
LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID = 202053
LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID = 256458
POSTHOG_HOST = "https://us.posthog.com"
POSTHOG_API_KEY = "phc_crWHQabJDjkR3ho9RLs8PY5lzut61EhMv7ghEKoIFwH"
SUPPORT_EMAIL = "[email protected]"
Expand All @@ -33,7 +34,8 @@ custom_domain = true
WORKER_ENV = "development"
WEB_BASE_URL = "https://dinstack-web-preview.dinsterizer.com/"
LEMONSQUEEZY_STORE_ID = 62787
LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID = 202054
LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID = 202054
LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID = 256465
POSTHOG_HOST = "https://us.posthog.com"
POSTHOG_API_KEY = "phc_gklhvpYMslkQq2pki2H4TuG53uAfSViDll6fJN7C8Ny"
SUPPORT_EMAIL = "[email protected]"
Expand All @@ -56,7 +58,8 @@ custom_domain = true
WORKER_ENV = "production"
WEB_BASE_URL = "https://dinstack-web.dinsterizer.com/"
LEMONSQUEEZY_STORE_ID = 62788
LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID = 202055
LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID = 202055
LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID = 256466
POSTHOG_HOST = "https://us.posthog.com"
POSTHOG_API_KEY = "phc_jkWq40BEKkhB00QCm00j5Oc9uxmvA0YObvGdv5Hnr92"
SUPPORT_EMAIL = "[email protected]"
Expand Down
4 changes: 3 additions & 1 deletion @web/.env.local
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ VITE_POSTHOG_API_KEY="phc_crWHQabJDjkR3ho9RLs8PY5lzut61EhMv7ghEKoIFwH"
VITE_KNOCK_PUBLIC_API_KEY="pk_test_5ov8yrcMZht3cOqntTGNNdykRoJT0QF8tMopGyQp5-w"
VITE_KNOCK_FEED_CHANNEL_ID="070321a3-7a8f-4ee5-916d-bd023d2ce252"

VITE_LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID="202053"
VITE_LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID="256458"

VITE_PUBLIC_BUCKET_BASE_URL=http://localhost:8000/public/
VITE_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
VITE_LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID="202053"
VITE_CLERK_PUBLISHABLE_KEY="pk_test_YXdha2Uta29hbGEtMC5jbGVyay5hY2NvdW50cy5kZXYk"
4 changes: 3 additions & 1 deletion @web/.env.preview
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ VITE_POSTHOG_API_KEY="phc_gklhvpYMslkQq2pki2H4TuG53uAfSViDll6fJN7C8Ny"
VITE_KNOCK_PUBLIC_API_KEY="pk_test_5ov8yrcMZht3cOqntTGNNdykRoJT0QF8tMopGyQp5-w"
VITE_KNOCK_FEED_CHANNEL_ID="070321a3-7a8f-4ee5-916d-bd023d2ce252"

VITE_LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID="202054"
VITE_LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID="256465"

VITE_PUBLIC_BUCKET_BASE_URL=https://dinstack-public-bucket-preview.dinsterizer.com/
VITE_TURNSTILE_SITE_KEY="0x4AAAAAAAPQhKcv2lS2gz9M"
VITE_LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID="202054"
VITE_CLERK_PUBLISHABLE_KEY="pk_test_YXdha2Uta29hbGEtMC5jbGVyay5hY2NvdW50cy5kZXYk"
4 changes: 3 additions & 1 deletion @web/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ VITE_POSTHOG_API_KEY="phc_jkWq40BEKkhB00QCm00j5Oc9uxmvA0YObvGdv5Hnr92"
VITE_KNOCK_PUBLIC_API_KEY="pk_XTnj-5XPw1TWBxp-BlJ_JrPXHCmikazlnZk6uBqz79A"
VITE_KNOCK_FEED_CHANNEL_ID="070321a3-7a8f-4ee5-916d-bd023d2ce252"

VITE_LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID="202055"
VITE_LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID="256466"

VITE_PUBLIC_BUCKET_BASE_URL=https://dinstack-public-bucket.dinsterizer.com/
VITE_TURNSTILE_SITE_KEY="0x4AAAAAAAPQhWirYwPa3Z3s"
VITE_LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID="202055"
VITE_CLERK_PUBLISHABLE_KEY="pk_live_Y2xlcmsuZGluc3Rlcml6ZXIuY29tJA"
92 changes: 73 additions & 19 deletions @web/components/subscription-card.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,68 @@
import type { Subscription } from '@api/lib/subscription'
import { Button } from '@web/components/ui/button'
import { useTenant } from '@web/lib/auth'
import { Tenant, useTenant } from '@web/lib/auth'
import { env } from '@web/lib/env'
import { trpc } from '@web/lib/trpc'
import { CheckIcon } from 'lucide-react'
import { match } from 'ts-pattern'

const includedFeatures = [
// TODO: fill these features
const personalFeatures = [
'Private forum access',
'Member resources',
'Entry to annual conference',
'Official member t-shirt',
]

const teamFeatures = [...personalFeatures, 'Unlimited members']

export function SubscriptionCard() {
const tenant = useTenant()

const subscription = tenant.publicMetadata.subscriptions.find(
(s) => s.variantId === env.LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID && s.expiresAt === null,
)
const subscription = match(tenant.type)
.with('user', () => {
return tenant.publicMetadata.subscriptions.find(
(s) =>
s.variantId === env.LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID &&
s.expiresAt === null,
)
})
.with('organization', () => {
return tenant.publicMetadata.subscriptions.find(
(s) =>
s.variantId === env.LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID && s.expiresAt === null,
)
})
.exhaustive()

return <div>{subscription ? <PaidStatus subscription={subscription} /> : <UnpaidStatus />}</div>
return (
<div>
{subscription ? (
<PaidStatus subscription={subscription} type={tenant.type} />
) : (
<UnpaidStatus type={tenant.type} />
)}
</div>
)
}

function PaidStatus(props: { subscription: Subscription }) {
function PaidStatus(props: { subscription: Subscription; type: Tenant['type'] }) {
const features = match(props.type)
.with('user', () => personalFeatures)
.with('organization', () => teamFeatures)
.exhaustive()

const title = match(props.type)
.with('user', () => 'Lifetime Access (Personal)')
.with('organization', () => 'Lifetime Access (Team)')
.exhaustive()

return (
<div className="rounded-2xl border lg:mx-0 lg:flex">
<div className="lg:flex">
<div className="p-8 sm:p-10 lg:flex-auto">
<h3 className="text-2xl font-bold tracking-tight">Lifetime membership</h3>
<h3 className="text-2xl font-bold tracking-tight">{title}</h3>
<p className="mt-6 text-base leading-7 text-muted-foreground">
Enjoying a Lifetime of Membership! You locked in your membership on{' '}
Enjoying the lifetime Access, which was unlocked on{' '}
<span className="font-medium text-foreground/75">
{props.subscription.createdAt.toDateString()}
</span>
Expand All @@ -50,7 +84,7 @@ function PaidStatus(props: { subscription: Subscription }) {
role="list"
className="mt-8 grid grid-cols-1 gap-4 text-sm leading-6 text-muted-foreground sm:grid-cols-2 sm:gap-6"
>
{includedFeatures.map((feature) => (
{features.map((feature) => (
<li key={feature} className="flex gap-x-3">
<CheckIcon className="h-6 w-6 flex-none text-primary" aria-hidden="true" />
{feature}
Expand All @@ -62,11 +96,21 @@ function PaidStatus(props: { subscription: Subscription }) {
)
}

function UnpaidStatus() {
function UnpaidStatus(props: { type: Tenant['type'] }) {
const features = match(props.type)
.with('user', () => personalFeatures)
.with('organization', () => teamFeatures)
.exhaustive()

const title = match(props.type)
.with('user', () => 'Lifetime Access (Personal)')
.with('organization', () => 'Lifetime Access (Team)')
.exhaustive()

return (
<div className="rounded-2xl border lg:mx-0 lg:flex">
<div className="lg:flex">
<div className="p-8 sm:p-10 lg:flex-auto">
<h3 className="text-2xl font-bold tracking-tight">Lifetime membership</h3>
<h3 className="text-2xl font-bold tracking-tight">{title}</h3>
<p className="mt-6 text-base leading-7 text-muted-foreground">
Gain exclusive access and receive lifetime support with a{' '}
<span className="font-medium text-foreground/75">14-day money-back guarantee</span>. For
Expand All @@ -88,7 +132,7 @@ function UnpaidStatus() {
role="list"
className="mt-8 grid grid-cols-1 gap-4 text-sm leading-6 text-muted-foreground sm:grid-cols-2 sm:gap-6"
>
{includedFeatures.map((feature) => (
{features.map((feature) => (
<li key={feature} className="flex gap-x-3">
<CheckIcon className="h-6 w-6 flex-none text-primary" aria-hidden="true" />
{feature}
Expand All @@ -103,12 +147,17 @@ function UnpaidStatus() {
Pay once, own it forever
</p>
<p className="mt-6 flex items-baseline justify-center gap-x-2">
<span className="text-5xl font-bold tracking-tight">$349</span>
<span className="text-5xl font-bold tracking-tight">
{match(props.type)
.with('user', () => '$9.9')
.with('organization', () => '$28.9')
.exhaustive()}
</span>
<span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground">
USD
</span>
</p>
<CheckoutButton />
<CheckoutButton type={props.type} />
<p className="mt-6 text-xs leading-5 text-muted-foreground">
Invoices and receipts available for easy company reimbursement
</p>
Expand All @@ -119,7 +168,7 @@ function UnpaidStatus() {
)
}

function CheckoutButton() {
function CheckoutButton(props: { type: Tenant['type'] }) {
const mutation = trpc.billing.checkout.useMutation({
onSuccess(data) {
if ('LemonSqueezy' in window) {
Expand All @@ -131,14 +180,19 @@ function CheckoutButton() {
},
})

const variantId = match(props.type)
.with('user', () => env.LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID)
.with('organization', () => env.LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID)
.exhaustive()

return (
<Button
type="button"
className="mt-10 w-full gap-2"
disabled={mutation.isPending}
onClick={() => {
mutation.mutate({
variantId: env.LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID,
variantId,
darkMode: document.documentElement.classList.contains('dark'),
})
}}
Expand Down
2 changes: 1 addition & 1 deletion @web/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function useAuthedUser() {
return result
}

type Tenant = BaseTenant & {
export type Tenant = BaseTenant & {
publicMetadata: TenantPublicMetadata
}

Expand Down
11 changes: 8 additions & 3 deletions @web/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ export const env = z
KNOCK_PUBLIC_API_KEY: z.string(),
KNOCK_FEED_CHANNEL_ID: z.string(),

LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID: z.coerce.number(),
LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID: z.coerce.number(),

TURNSTILE_SITE_KEY: z.string(),
PUBLIC_BUCKET_BASE_URL: z.string().url(),
LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID: z.coerce.number(),
CLERK_PUBLISHABLE_KEY: z.string(),
})
.parse({
Expand All @@ -42,9 +44,12 @@ export const env = z
KNOCK_PUBLIC_API_KEY: import.meta.env.VITE_KNOCK_PUBLIC_API_KEY,
KNOCK_FEED_CHANNEL_ID: import.meta.env.VITE_KNOCK_FEED_CHANNEL_ID,

LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID: import.meta.env
.VITE_LEMONSQUEEZY_PERSONAL_LIFETIME_ACCESS_VARIANT_ID,
LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID: import.meta.env
.VITE_LEMONSQUEEZY_TEAM_LIFETIME_ACCESS_VARIANT_ID,

PUBLIC_BUCKET_BASE_URL: import.meta.env.VITE_PUBLIC_BUCKET_BASE_URL,
TURNSTILE_SITE_KEY: import.meta.env.VITE_TURNSTILE_SITE_KEY,
LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID: import.meta.env
.VITE_LEMONSQUEEZY_LIFETIME_MEMBERSHIP_VARIANT_ID,
CLERK_PUBLISHABLE_KEY: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
})
4 changes: 3 additions & 1 deletion @web/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export function Component() {
</div>

<section className="mt-6 md:mt-8 xl:mt-12">
<SubscriptionCard />
<div className="rounded-2xl border lg:mx-0 ">
<SubscriptionCard />
</div>
</section>

<TestMutation />
Expand Down
11 changes: 10 additions & 1 deletion @web/providers/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom'
import { match } from 'ts-pattern'

type Appearance = ComponentPropsWithoutRef<typeof ClerkProvider>['appearance']
type RouterFn = ComponentPropsWithoutRef<typeof ClerkProvider>['routerPush']

const lightAppearance = {
variables: {
Expand Down Expand Up @@ -47,7 +48,7 @@ const darkAppearance = {
} satisfies Appearance

export function AuthProvider(props: { children: React.ReactNode }) {
const navigate = useNavigate()
const internalNavigate = useNavigate()
const theme = useSystemStore().theme

const appearance = match(theme)
Expand All @@ -58,6 +59,14 @@ export function AuthProvider(props: { children: React.ReactNode }) {
)
.exhaustive()

const navigate: RouterFn = (to, meta) => {
if (meta?.__internal_metadata?.navigationType === 'window') {
window.location.href = to
} else {
internalNavigate(to)
}
}

return (
<ClerkProvider
publishableKey={env.CLERK_PUBLISHABLE_KEY}
Expand Down

0 comments on commit f030d1e

Please sign in to comment.