Skip to content

Commit

Permalink
feat(app): add stripe subscriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
mrcnk committed Nov 5, 2023
1 parent 0546089 commit a35a6c3
Show file tree
Hide file tree
Showing 45 changed files with 837 additions and 303 deletions.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ [email protected]
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=

# Subscriptions
ZENOTE_SELF_HOSTED=true
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@stripe/stripe-js": "^2.1.11",
"@t3-oss/env-nextjs": "^0.7.0",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/react-query": "^4.32.6",
Expand Down Expand Up @@ -83,6 +84,7 @@
"react-day-picker": "^8.9.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.47.0",
"stripe": "^14.3.0",
"superjson": "^1.13.1",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
Expand Down
66 changes: 66 additions & 0 deletions src/app/api/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { env } from '@/env.mjs'
import { getStripeServer } from '@/lib/stripe'
import { db } from '@/server/db'
import { users } from '@/server/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import Stripe from 'stripe'

export async function POST(req: Request) {
if (!env.STRIPE_WEBHOOK_SECRET)
return NextResponse.json(
{ message: 'Stripe not configured' },
{ status: 500 }
)
const stripe = getStripeServer()
if (!stripe)
return NextResponse.json(
{ message: 'Stripe not configured' },
{ status: 500 }
)
const body = await req.text()
const sig = req.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(
body,
sig!,
env.STRIPE_WEBHOOK_SECRET
)
if (!event)
return NextResponse.json({ message: 'Invalid event' }, { status: 500 })
switch (event.type) {
case 'customer.subscription.created':
await db
.update(users)
.set({
subscriptionTier: 'standard',
stripeId: String(event.data.object.customer)
})
.where(eq(users.id, event.data.object.metadata.localId ?? ''))
break
case 'customer.subscription.deleted':
await db
.update(users)
.set({
subscriptionTier: 'none'
})
.where(eq(users.id, event.data.object.metadata.localId ?? ''))
break
case 'payment_intent.succeeded':
await db
.update(users)
.set({
subscriptionTier: 'lifetime',
stripeId: String(event.data.object.customer)
})
.where(eq(users.id, event.data.object.metadata.localId ?? ''))
break
}
return NextResponse.json({ ok: true })
} catch (error) {
if (error instanceof Stripe.errors.StripeError) {
const { message } = error
return NextResponse.json({ message }, { status: error.statusCode })
}
}
}
8 changes: 7 additions & 1 deletion src/app/channels/[channelId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
import { api } from '@/trpc/server'
import { NotesList } from '@/components/channels/notes-list'
import { QuickEditorForm } from '@/components/notes-create/quick-editor-form'
import { ChannelMenu } from '@/components/channels/channel-menu'

const ChannelPage = async ({ params }: { params: { channelId: string } }) => {
const { channel, notes } = await api.channels.get.query({
Expand All @@ -12,7 +13,12 @@ const ChannelPage = async ({ params }: { params: { channelId: string } }) => {
return (
<div className="flex flex-1 flex-col">
<Navbar
title={channel.name}
title={
<div className="flex items-center gap-2">
<h3 className="font-semibold">{channel.name}</h3>
<ChannelMenu channel={channel} hidding={false} />
</div>
}
addon={
<Button size="sm" variant="secondary">
Share
Expand Down
9 changes: 9 additions & 0 deletions src/app/channels/[channelId]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const ChannelSettingsPage = () => {
return (
<div className="container flex max-w-[40rem] flex-col gap-4 py-8">
<h1>Settings</h1>
</div>
)
}

export default ChannelSettingsPage
9 changes: 8 additions & 1 deletion src/app/channels/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ const HomePage = async () => {
const bookmarkedNotes = await api.bookmarks.index.query()
return (
<div className="flex flex-1 flex-col gap-8">
<Navbar title="Dashboard" addon={<Button size="sm" variant="secondary"><BellIcon size={16} /></Button>} />
<Navbar
title="Dashboard"
addon={
<Button size="sm" variant="secondary">
<BellIcon size={16} />
</Button>
}
/>
<div className="container flex flex-col gap-4">
<h2 className="text-2xl font-semibold">FlowBox</h2>
<div className="grid grid-cols-2 gap-4">
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { headers } from 'next/headers'

import { TRPCReactProvider } from '@/trpc/react'
import { Providers } from '@/components/providers'
import { Toaster } from '@/components/ui/toaster'

const inter = Inter({
subsets: ['latin'],
Expand All @@ -27,6 +28,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
<TRPCReactProvider headers={headers()}>
<Providers>{children}</Providers>
</TRPCReactProvider>
<Toaster />
</body>
</html>
)
Expand Down
13 changes: 13 additions & 0 deletions src/app/subscribe/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { SubscribePricing } from '@/components/subscribe/subscribe-pricing'
import { api } from '@/trpc/server'

const SubscribePage = async () => {
const { data: priceList } = await api.subscriptions.prices.query()
return (
<div className="flex flex-1 items-center justify-center">
<SubscribePricing priceList={priceList} />
</div>
)
}

export default SubscribePage
20 changes: 10 additions & 10 deletions src/app/users/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { ProfileForm } from "@/components/profile/profile-form"
import { api } from "@/trpc/server"
import { ProfileForm } from '@/components/profile/profile-form'
import { api } from '@/trpc/server'

const ProfilePage = async () => {
const user = await api.users.me.query()
if (!user) return null
return (
<div className="container flex flex-col max-w-[40rem] gap-4 py-8">
<ProfileForm user={user} />
</div>
)
const user = await api.users.me.query()
if (!user) return null
return (
<div className="container flex max-w-[40rem] flex-col gap-4 py-8">
<ProfileForm user={user} />
</div>
)
}

export default ProfilePage
export default ProfilePage
28 changes: 28 additions & 0 deletions src/components/channels/channel-delete-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client'

import { useAppStore } from '@/store/app'
import { ConfirmationDialog } from '../confirmation-dialog'
import { api } from '@/trpc/react'
import { useRouter } from 'next/navigation'

export const ChannelDeleteDialog = () => {
const router = useRouter()
const { mutateAsync: deleteChannel } = api.channels.delete.useMutation()
const deletingChannelId = useAppStore((state) => state.deletingChannelId)
const setDeletingChannelId = useAppStore(
(state) => state.setDeletingChannelId
)
return (
<ConfirmationDialog
title="Are you absolutely sure?"
description="This action will delete this channel and all its notes."
onConfirm={async () => {
await deleteChannel({ id: deletingChannelId ?? '' })
router.push('/channels')
router.refresh()
}}
open={!!deletingChannelId}
setOpen={(nextValue) => nextValue === false && setDeletingChannelId(null)}
/>
)
}
59 changes: 59 additions & 0 deletions src/components/channels/channel-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import { MoreVerticalIcon, Edit2Icon, TrashIcon } from 'lucide-react'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { useAppStore } from '@/store/app'
import { ChannelProps } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useRouter } from 'next/navigation'

export const ChannelMenu = ({
channel,
hidding = true
}: {
channel: ChannelProps
hidding?: boolean
}) => {
const router = useRouter()
const setDeletingChannelId = useAppStore(
(state) => state.setDeletingChannelId
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'transition-opacity group-hover:opacity-100',
hidding && 'opacity-0'
)}
>
<MoreVerticalIcon size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom">
<DropdownMenuItem
className="gap-2"
onClick={() => router.push(`/channels/${channel.id}/settings`)}
>
<Edit2Icon size={16} />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer gap-2 text-red-700 dark:text-red-500"
onClick={() => setDeletingChannelId(channel.id)}
>
<TrashIcon size={16} />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Loading

0 comments on commit a35a6c3

Please sign in to comment.