Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
POSTGRES_URL=
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=

NEXTAUTH_SECRET=your_super_secret_key_here
NEXTAUTH_URL=http://localhost:3000
DISCORD_CLIENT_ID=your_discord_id_here
DISCORD_CLIENT_SECRET=your_discord_secret_here
GOOGLE_CLIENT_ID=your_google_id_here
GOOGLE_CLIENT_SECRET=your_google_secret_here
GITHUB_CLIENT_ID=your_github_id_here
GITHUB_CLIENT_SECRET=your_github_secret_here
APPLE_CLIENT_ID=your_apple_id_here
APPLE_CLIENT_SECRET=your_apple_secret_here
RESEND_API_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=
NEXT_PUBLIC_STRIPE_MAX_PRICE_ID=
NEXT_PUBLIC_STRIPE_ULTRA_PRICE_ID=
9 changes: 5 additions & 4 deletions app/[sheetId]/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,11 @@ export default function List({
title={titleAttrs?.map(attr => item[camelCase(attr.title)]) as string[]}
footer={
shouldShowTags
? tagsAttrs?.flatMap(attr =>
(item[camelCase(attr.title)] as string)
?.split(',')
?.map(tag => <Badge key={tag}>{tag}</Badge>),
? tagsAttrs?.flatMap(
attr =>
(item[camelCase(attr.title)] as string)
?.split(',')
?.map(tag => <Badge key={tag}>{tag}</Badge>),
)
: null
}
Expand Down
45 changes: 45 additions & 0 deletions app/account/AccountCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Card } from '@/components/ui/card'

interface AccountCardProps {
params: {
header: string
description: string
price?: number
}
children: React.ReactNode
}

export function AccountCard({ params, children }: AccountCardProps) {
const { header, description } = params
return (
<Card>
<div id="body" className="p-4 ">
<h3 className="text-xl font-semibold">{header}</h3>
<p className="text-muted-foreground">{description}</p>
</div>
{children}
</Card>
)
}

export function AccountCardBody({ children }: { children: React.ReactNode }) {
return <div className="p-4">{children}</div>
}

export function AccountCardFooter({
description,
children,
}: {
children: React.ReactNode
description: string
}) {
return (
<div
className="bg-primary-foreground dark:bg-slate-900 dark:border-slate-800 p-4 border border-zinc-200 flex justify-between items-center"
id="footer"
>
<p className="text-muted-foreground text-sm">{description}</p>
{children}
</div>
)
}
67 changes: 67 additions & 0 deletions app/account/PlanSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'
import { AccountCard, AccountCardBody, AccountCardFooter } from './AccountCard'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
import { AuthSession } from '@/lib/auth/utils'

interface PlanSettingsProps {
stripeSubscriptionId: string | null
stripeCurrentPeriodEnd: Date | null
stripeCustomerId: string | null
isSubscribed: boolean | '' | null
isCanceled: boolean
id?: string | undefined
name?: string | undefined
description?: string | undefined
stripePriceId?: string | undefined
price?: number | undefined
}
export default function PlanSettings({
subscriptionPlan,
session,
}: {
subscriptionPlan: PlanSettingsProps
session: AuthSession['session']
}) {
return (
<AccountCard
params={{
header: 'Your Plan',
description: subscriptionPlan.isSubscribed
? `You are currently on the ${subscriptionPlan.name} plan.`
: `You are not subscribed to any plan.`.concat(
!session?.user?.email || session?.user?.email.length < 5
? ' Please add your email to upgrade your account.'
: '',
),
}}
>
<AccountCardBody>
{subscriptionPlan.isSubscribed ? (
<h3 className="font-semibold text-lg">
${subscriptionPlan.price ? subscriptionPlan.price / 100 : 0} / month
</h3>
) : null}
{subscriptionPlan.stripeCurrentPeriodEnd ? (
<p className="text-sm mb-4 text-muted-foreground ">
Your plan will{' '}
{!subscriptionPlan.isSubscribed
? null
: subscriptionPlan.isCanceled
? 'cancel'
: 'renew'}
{' on '}
<span className="font-semibold">
{subscriptionPlan.stripeCurrentPeriodEnd.toLocaleDateString('en-us')}
</span>
</p>
) : null}
</AccountCardBody>
<AccountCardFooter description="Manage your subscription on Stripe.">
<Link href="/account/billing">
<Button variant="outline">Go to billing</Button>
</Link>
</AccountCardFooter>
</AccountCard>
)
}
54 changes: 54 additions & 0 deletions app/account/UpdateEmailCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { AccountCard, AccountCardFooter, AccountCardBody } from './AccountCard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'

export default function UpdateEmailCard({ email }: { email: string }) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
const router = useRouter()

const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault()
const target = event.target as HTMLFormElement
const form = new FormData(target)
const { email } = Object.fromEntries(form.entries()) as { email: string }
if (email.length < 3) {
toast({
description: 'Email must be longer than 3 characters.',
variant: 'destructive',
})
return
}

startTransition(async () => {
const res = await fetch('/api/account', {
method: 'PUT',
body: JSON.stringify({ email }),
headers: { 'Content-Type': 'application/json' },
})
if (res.status === 200) toast({ description: 'Successfully updated email!' })
router.refresh()
})
}

return (
<AccountCard
params={{
header: 'Your Email',
description: 'Please enter the email address you want to use with your account.',
}}
>
<form onSubmit={handleSubmit}>
<AccountCardBody>
<Input defaultValue={email ?? ''} name="email" disabled={true} />
</AccountCardBody>
<AccountCardFooter description="We will email vou to verify the change.">
<Button disabled={true}>Update Email</Button>
</AccountCardFooter>
</form>
</AccountCard>
)
}
55 changes: 55 additions & 0 deletions app/account/UpdateNameCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client'
import { AccountCard, AccountCardFooter, AccountCardBody } from './AccountCard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'

export default function UpdateNameCard({ name }: { name: string }) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
const router = useRouter()
const handleSubmit = async (event: React.SyntheticEvent) => {
event.preventDefault()
const target = event.target as HTMLFormElement
const form = new FormData(target)
const { name } = Object.fromEntries(form.entries()) as { name: string }
if (name.length < 3) {
toast({
description: 'Name must be longer than 3 characters.',
variant: 'destructive',
})
return
}

startTransition(async () => {
const res = await fetch('/api/account', {
method: 'PUT',
body: JSON.stringify({ name }),
headers: { 'Content-Type': 'application/json' },
})
if (res.status === 200) toast({ description: 'Successfully updated name!' })
router.refresh()
})
}

return (
<AccountCard
params={{
header: 'Your Name',
description:
'Please enter your full name, or a display name you are comfortable with.',
}}
>
<form onSubmit={handleSubmit}>
<AccountCardBody>
<Input defaultValue={name ?? ''} name="name" disabled={true} />
</AccountCardBody>
<AccountCardFooter description="64 characters maximum">
<Button disabled={true}>Update Name</Button>
</AccountCardFooter>
</form>
</AccountCard>
)
}
13 changes: 13 additions & 0 deletions app/account/UserSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'
import UpdateNameCard from './UpdateNameCard'
import UpdateEmailCard from './UpdateEmailCard'
import { AuthSession } from '@/lib/auth/utils'

export default function UserSettings({ session }: { session: AuthSession['session'] }) {
return (
<>
<UpdateNameCard name={session?.user.name ?? ''} />
<UpdateEmailCard email={session?.user.email ?? ''} />
</>
)
}
67 changes: 67 additions & 0 deletions app/account/billing/ManageSubscription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client'

import { Button } from '@/components/ui/button'
import React from 'react'
import { toast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'

interface ManageUserSubscriptionButtonProps {
userId: string
email: string
isCurrentPlan: boolean
isSubscribed: boolean
stripeCustomerId?: string | null
stripePriceId: string
}

export function ManageUserSubscriptionButton({
userId,
email,
isCurrentPlan,
isSubscribed,
stripeCustomerId,
stripePriceId,
}: ManageUserSubscriptionButtonProps) {
const [isPending, startTransition] = React.useTransition()

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()

startTransition(async () => {
try {
const res = await fetch('/api/billing/manage-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
userId,
isSubscribed,
isCurrentPlan,
stripeCustomerId,
stripePriceId,
}),
})
const session: { url: string } = await res.json()
if (session) {
window.location.href = session.url ?? '/dashboard/billing'
}
} catch (err) {
console.error((err as Error).message)
toast({ description: 'Something went wrong, please try again later.' })
}
})
}

return (
<form onSubmit={handleSubmit} className="w-full">
<Button
disabled={isPending}
className="w-full"
variant={isCurrentPlan ? 'default' : 'outline'}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isCurrentPlan ? 'Manage Subscription' : 'Subscribe'}
</Button>
</form>
)
}
19 changes: 19 additions & 0 deletions app/account/billing/SuccessToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'

import { useToast } from '@/components/ui/use-toast'
import { useSearchParams } from 'next/navigation'
import { useEffect } from 'react'

export default function SuccessToast() {
const searchParams = useSearchParams()
const { toast } = useToast()

const success = searchParams.get('success') as Boolean | null
useEffect(() => {
if (success) {
toast({ description: 'Successfully updated subscription.' })
}
}, [success, toast])

return null
}
Loading