Skip to content

Commit

Permalink
feat: separate team plan and remind user to upgrade (#30)
Browse files Browse the repository at this point in the history
* wip

* update clerk

* individual plan for organization

* Alert after user does not upgrade after 7 days

* fix
  • Loading branch information
unnoq authored Feb 18, 2024
1 parent 2e8beb0 commit 3b819bd
Show file tree
Hide file tree
Showing 18 changed files with 210 additions and 53 deletions.
1 change: 0 additions & 1 deletion @api/.dev.example.vars
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
AUTH_SECRET="secret_value"
DATABASE_URL=
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
LEMONSQUEEZY_API_KEY=
Expand Down
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"
78 changes: 60 additions & 18 deletions @web/components/subscription-card.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,56 @@
import type { Subscription } from '@api/lib/subscription'
import { Button } from '@web/components/ui/button'
import { useLifetimeAccessSubscription } from '@web/hooks/use-lifetime-access-subscription'
import type { Tenant } from '@web/lib/auth'
import { 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 = useLifetimeAccessSubscription()

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 +72,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 +84,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 +120,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 +135,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 +156,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 +168,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
23 changes: 23 additions & 0 deletions @web/hooks/use-lifetime-access-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useTenant } from '@web/lib/auth'
import { env } from '@web/lib/env'
import { match } from 'ts-pattern'

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

return 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()
}
5 changes: 4 additions & 1 deletion @web/layouts/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RedirectToSignIn, SignedIn, SignedOut } from '@clerk/clerk-react'
import { BillingProvider } from '@web/providers/billing'
import { Outlet } from 'react-router-dom'

export function AuthLayout() {
Expand All @@ -16,7 +17,9 @@ export function AuthLayout() {
/>
</SignedOut>
<SignedIn>
<Outlet />
<BillingProvider>
<Outlet />
</BillingProvider>
</SignedIn>
</>
)
Expand Down
5 changes: 4 additions & 1 deletion @web/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export function useAuthedUser() {
return result
}

type Tenant = BaseTenant & {
export type Tenant = BaseTenant & {
createdAt: Date | null | undefined
publicMetadata: TenantPublicMetadata
}

Expand All @@ -36,6 +37,7 @@ export function useTenant(): Tenant {
.with('org:admin', () => 'admin' as const)
.with('org:member', () => 'member' as const)
.exhaustive(),
createdAt: organization?.createdAt,
publicMetadata: tenantPublicMetadataSchema.parse(organization?.publicMetadata),
}
}
Expand All @@ -44,6 +46,7 @@ export function useTenant(): Tenant {
type: 'user',
id: auth.userId,
role: 'admin',
createdAt: user.createdAt,
publicMetadata: tenantPublicMetadataSchema.parse(user.publicMetadata),
}
}
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,
})
2 changes: 1 addition & 1 deletion @web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"dependencies": {
"@api": "workspace:@dinstack/api@^",
"@clerk/clerk-react": "5.0.0-beta-v5.17",
"@clerk/clerk-react": "5.0.0-beta-v5.18",
"@clerk/themes": "2.0.0-beta-v5.3",
"@knocklabs/client": "^0.8.17",
"@knocklabs/react": "^0.1.4",
Expand Down
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
Loading

0 comments on commit 3b819bd

Please sign in to comment.