diff --git a/.env.example b/.env.example index e173a4b..9e85e69 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,9 @@ EMAIL_FROM=noreply@example.com OPENAI_API_KEY= OPENAI_BASE_URL= OPENAI_MODEL= + +# Subscriptions +ZENOTE_SELF_HOSTED=true +STRIPE_SECRET_KEY= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/bun.lockb b/bun.lockb index 77a04a4..893c8fd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 60e96b5..dacc78f 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/app/api/webhook/route.ts b/src/app/api/webhook/route.ts new file mode 100644 index 0000000..ae5004e --- /dev/null +++ b/src/app/api/webhook/route.ts @@ -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 }) + } + } +} diff --git a/src/app/channels/[channelId]/page.tsx b/src/app/channels/[channelId]/page.tsx index 366fd85..58ee356 100644 --- a/src/app/channels/[channelId]/page.tsx +++ b/src/app/channels/[channelId]/page.tsx @@ -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({ @@ -12,7 +13,12 @@ const ChannelPage = async ({ params }: { params: { channelId: string } }) => { return (
+

{channel.name}

+ +
+ } addon={ } /> + + + + } + />

FlowBox

diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b098d4c..e1d6c90 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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'], @@ -27,6 +28,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => { {children} + ) diff --git a/src/app/subscribe/page.tsx b/src/app/subscribe/page.tsx new file mode 100644 index 0000000..1bae97c --- /dev/null +++ b/src/app/subscribe/page.tsx @@ -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 ( +
+ +
+ ) +} + +export default SubscribePage diff --git a/src/app/users/profile/page.tsx b/src/app/users/profile/page.tsx index b39198c..c5dad35 100644 --- a/src/app/users/profile/page.tsx +++ b/src/app/users/profile/page.tsx @@ -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 ( -
- -
- ) + const user = await api.users.me.query() + if (!user) return null + return ( +
+ +
+ ) } -export default ProfilePage \ No newline at end of file +export default ProfilePage diff --git a/src/components/channels/channel-delete-dialog.tsx b/src/components/channels/channel-delete-dialog.tsx new file mode 100644 index 0000000..475310d --- /dev/null +++ b/src/components/channels/channel-delete-dialog.tsx @@ -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 ( + { + await deleteChannel({ id: deletingChannelId ?? '' }) + router.push('/channels') + router.refresh() + }} + open={!!deletingChannelId} + setOpen={(nextValue) => nextValue === false && setDeletingChannelId(null)} + /> + ) +} diff --git a/src/components/channels/channel-menu.tsx b/src/components/channels/channel-menu.tsx new file mode 100644 index 0000000..e0e96cf --- /dev/null +++ b/src/components/channels/channel-menu.tsx @@ -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 ( + + + + + + router.push(`/channels/${channel.id}/settings`)} + > + + Edit + + setDeletingChannelId(channel.id)} + > + + Delete + + + + ) +} diff --git a/src/components/channels/note-renderer.tsx b/src/components/channels/note-renderer.tsx index c648649..674a431 100644 --- a/src/components/channels/note-renderer.tsx +++ b/src/components/channels/note-renderer.tsx @@ -3,7 +3,13 @@ import NextLink from 'next/link' import { Card } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { CheckIcon, PencilIcon, StarIcon, StarOffIcon } from 'lucide-react' +import { + CheckIcon, + ClockIcon, + PencilIcon, + StarIcon, + StarOffIcon +} from 'lucide-react' import { format } from 'date-fns' import { Tooltip, @@ -18,15 +24,24 @@ import { NoteOptions } from '../notes/note-options' import { useSession } from 'next-auth/react' import { api } from '@/trpc/react' import { useRouter } from 'next/navigation' -import { NoteProps } from '@/lib/types' +import { type NoteProps } from '@/lib/types' import { cn } from '@/lib/utils' +import { useAppStore } from '@/store/app' -export const NoteRenderer = ({ note, short = false }: { note: NoteProps, short?: boolean }) => { +export const NoteRenderer = ({ + note, + short = false +}: { + note: NoteProps + short?: boolean +}) => { const router = useRouter() const json = useMemo(() => JSON.parse(note.content ?? '{}'), [note.content]) const output = useMemo(() => generateHTML(json, config.extensions), [json]) + const setDueDateNoteId = useAppStore((state) => state.setDueDateNoteId) const { data } = useSession() const { mutateAsync: toggleBookmark } = api.bookmarks.toggle.useMutation() + const { mutateAsync: updateNote } = api.notes.update.useMutation() const toggleNoteBookmark = async () => { await toggleBookmark({ noteId: note.id ?? '', @@ -34,12 +49,20 @@ export const NoteRenderer = ({ note, short = false }: { note: NoteProps, short?: }) router.refresh() } + const markCompleted = async () => { + await updateNote({ + ...note, + dueDate: null + }) + router.refresh() + } const isBookmarked = note.noteBookmarks?.length ?? 0 > 0 return (
@@ -49,15 +72,38 @@ export const NoteRenderer = ({ note, short = false }: { note: NoteProps, short?:
{!short &&

{note.user?.name}

} - - {format(note.createdAt ?? 0, 'dd.mm.yyyy hh:mm')} + + {format(note.createdAt ?? 0, 'PP p')}
- + {note.dueDate ? ( + + ) : ( + + + + + Set Due Date + + )} - {isBookmarked ? 'Remove Bookmark' : 'Bookmark'} + + {isBookmarked ? 'Remove Bookmark' : 'Bookmark'} + {!short && }
diff --git a/src/components/layout/channels-list.tsx b/src/components/layout/channels-list.tsx index 29a0363..f426fcb 100644 --- a/src/components/layout/channels-list.tsx +++ b/src/components/layout/channels-list.tsx @@ -1,14 +1,7 @@ 'use client' import { Button } from '@/components/ui/button' -import { - PlusIcon, - CircleIcon, - CircleDotIcon, - Edit2Icon, - TrashIcon, - MoreVerticalIcon -} from 'lucide-react' +import { PlusIcon, CircleIcon, CircleDotIcon } from 'lucide-react' import { useState } from 'react' import { Input } from '@/components/ui/input' import { api } from '@/trpc/react' @@ -18,16 +11,11 @@ import { insertChannelSchema } from '@/server/db/schema' import NextLink from 'next/link' import { type z } from 'zod' import { useSession } from 'next-auth/react' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { ConfirmationDialog } from '@/components/confirmation-dialog' import { useParams } from 'next/navigation' import { cn } from '@/lib/utils' import { useAppStore } from '@/store/app' +import { ChannelMenu } from '../channels/channel-menu' type CreateChannelFormProps = { onBlur: () => void @@ -70,24 +58,12 @@ export const ChannelsList = () => { const setDeletingChannelId = useAppStore( (state) => state.setDeletingChannelId ) - const onCreated = () => { + const onCreated = async () => { setCreatingChannel(false) - refetch() + await refetch() } return ( <> - { - await deleteChannel({ id: deletingChannelId || '' }) - refetch() - }} - open={!!deletingChannelId} - setOpen={(nextValue) => - nextValue === false && setDeletingChannelId(null) - } - />

Channels

@@ -132,30 +108,7 @@ export const ChannelsList = () => { {channel.name} - - - - - - - - Edit - - setDeletingChannelId(channel.id)} - > - - Delete - - - +
) })} diff --git a/src/components/layout/dashboard-layout.tsx b/src/components/layout/dashboard-layout.tsx index 6eee859..78f92dc 100644 --- a/src/components/layout/dashboard-layout.tsx +++ b/src/components/layout/dashboard-layout.tsx @@ -4,6 +4,7 @@ import { getServerAuthSession } from '@/server/auth' import { redirect } from 'next/navigation' import { NoteDeleteDialog } from '@/components/notes/note-delete-dialog' import { NoteDueDateDialog } from '@/components/notes/note-due-date-dialog' +import { ChannelDeleteDialog } from '../channels/channel-delete-dialog' export const DashboardLayout = async ({ children @@ -18,6 +19,7 @@ export const DashboardLayout = async ({ +
{children}
) diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 36eaa76..bc0544e 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -1,7 +1,7 @@ 'use client' export type NavbarProps = { - title: string + title: React.ReactNode addon?: React.ReactNode } @@ -9,11 +9,13 @@ export const Navbar = ({ title, addon }: NavbarProps) => { return (
-

{title}

-
-
- {addon} + {typeof title === 'string' ? ( +

{title}

+ ) : ( + title + )}
+
{addon}
) } diff --git a/src/components/layout/sidebar-user.tsx b/src/components/layout/sidebar-user.tsx index 286500e..f65e204 100644 --- a/src/components/layout/sidebar-user.tsx +++ b/src/components/layout/sidebar-user.tsx @@ -1,41 +1,47 @@ 'use client' -import { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenu } from "@/components/ui/dropdown-menu" -import { UserIcon, LogOutIcon } from "lucide-react" -import NextLink from "next/link" -import { signOut } from "next-auth/react" -import { UserAvatar } from "../users/user-avatar" -import { Button } from "../ui/button" -import { api } from "@/trpc/react" +import { + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenu +} from '@/components/ui/dropdown-menu' +import { UserIcon, LogOutIcon } from 'lucide-react' +import NextLink from 'next/link' +import { signOut } from 'next-auth/react' +import { UserAvatar } from '../users/user-avatar' +import { Button } from '../ui/button' +import { api } from '@/trpc/react' export const SidebarUser = () => { - const { data: me } = api.users.me.useQuery() - return ( -
+ const { data: me } = api.users.me.useQuery() + return ( +
- - - - - - + + + + + + Profile - - - - signOut()} - > - - Sign Out - - - -
- ) -} \ No newline at end of file + + + + signOut()} + > + + Sign Out + + + +
+ ) +} diff --git a/src/components/notes-create/quick-editor-form.tsx b/src/components/notes-create/quick-editor-form.tsx index a5e1bc9..db7df8a 100644 --- a/src/components/notes-create/quick-editor-form.tsx +++ b/src/components/notes-create/quick-editor-form.tsx @@ -21,7 +21,7 @@ export const QuickEditorForm = ({ saveCallback }: EditorFormProps) => { await createNote({ content, type: 'text' as never, - channelId: String(channelId), + channelId: String(channelId) }) saveCallback && saveCallback() } @@ -29,7 +29,7 @@ export const QuickEditorForm = ({ saveCallback }: EditorFormProps) => { const note = await createNote({ content: '', type: 'text' as never, - channelId: String(channelId), + channelId: String(channelId) }) router.refresh() router.push(`/notes/${note[0]?.id}`) diff --git a/src/components/notes-create/quick-editor.tsx b/src/components/notes-create/quick-editor.tsx index d5f3331..8378ea5 100644 --- a/src/components/notes-create/quick-editor.tsx +++ b/src/components/notes-create/quick-editor.tsx @@ -5,14 +5,12 @@ import { type Editor, Extension } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { StarsIcon } from 'lucide-react' import { useAppStore } from '@/store/app' import { cn } from '@/lib/utils' import { config } from '@/components/notes/editor-config' import Placeholder from '@tiptap/extension-placeholder' -import { useChat } from 'ai/react' -import { type FormEvent, useEffect } from 'react' -import { useParams, useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' import { EditorBubbleMenu } from '@/components/notes/editor-bubble-menu' export type OnSaveHandler = ({ content }: { content: string }) => Promise @@ -23,16 +21,7 @@ export type QuickEditorProps = { export const QuickEditor = ({ onSave }: QuickEditorProps) => { const router = useRouter() - const { channelId } = useParams() const noteValue = useAppStore((state) => state.noteValue) - console.log('>>>INNERV', noteValue) - const { handleSubmit, setInput } = useChat({ - api: '/api/chat', - initialInput: noteValue, - body: { - channelId - } - }) const handleSave = async ({ editor }: { editor: Editor }) => { if (editor.getText().length === 0) return const content = JSON.stringify(editor.getJSON()) @@ -71,11 +60,6 @@ export const QuickEditor = ({ onSave }: QuickEditorProps) => { }, [noteValue, editor]) if (!editor) return null const editorHasValue = editor.getText().length > 0 - const sendOpenAiPrompt = (e: FormEvent) => { - setInput(`${noteValue} - send response in HTML

wrapped paragraphs.`) - console.log('NV', noteValue) - handleSubmit(e) - } return ( <> {editor && } @@ -86,14 +70,6 @@ export const QuickEditor = ({ onSave }: QuickEditorProps) => { )} >

- {editorHasValue && ( -
- -
- )} {editorHasValue && ( diff --git a/src/components/notes-update/note-files.tsx b/src/components/notes-update/note-files.tsx index 981ba4f..4961aba 100644 --- a/src/components/notes-update/note-files.tsx +++ b/src/components/notes-update/note-files.tsx @@ -1,9 +1,9 @@ -import { Badge } from "../ui/badge" +import { Badge } from '../ui/badge' export const NoteFiles = () => { return (
-
+

Files

diff --git a/src/components/notes-update/note-menu.tsx b/src/components/notes-update/note-menu.tsx index 9dd26ed..2523ddc 100644 --- a/src/components/notes-update/note-menu.tsx +++ b/src/components/notes-update/note-menu.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button' import { useRouter } from 'next/navigation' import { BrushIcon, + CheckIcon, ChevronLeftIcon, ClockIcon, FileIcon, @@ -13,18 +14,19 @@ import { StarIcon, StarOffIcon } from 'lucide-react' -import { NoteProps } from '@/lib/types' +import { type NoteProps } from '@/lib/types' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu' -import { Editor } from '@tiptap/react' +import { type Editor } from '@tiptap/react' import { NoteOptions } from '../notes/note-options' import { useAppStore } from '@/store/app' import { api } from '@/trpc/react' import { useSession } from 'next-auth/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' export const NoteMenu = ({ editor, @@ -39,6 +41,7 @@ export const NoteMenu = ({ const router = useRouter() const { data } = useSession() const { mutateAsync: toggleBookmark } = api.bookmarks.toggle.useMutation() + const { mutateAsync: updateNote } = api.notes.update.useMutation() const toggleNoteBookmark = async () => { await toggleBookmark({ noteId: note.id ?? '', @@ -46,6 +49,13 @@ export const NoteMenu = ({ }) router.refresh() } + const markCompleted = async () => { + await updateNote({ + ...note, + dueDate: null + }) + router.refresh() + } const isBookmarked = note.noteBookmarks?.length ?? 0 > 0 return ( @@ -58,26 +68,70 @@ export const NoteMenu = ({ > - - + + + + + Comments + + + + + + Files +
- - + {isBookmarked ? ( + + + + + Remove Bookmark + + ) : ( + + + + + Bookmark + + )} + {note.dueDate ? ( + + + + + Mark Completed + + ) : ( + + + + + Set Due Date + + )} diff --git a/src/components/notes/bubble-start.tsx b/src/components/notes/bubble-start.tsx index 59e75e9..97a60ab 100644 --- a/src/components/notes/bubble-start.tsx +++ b/src/components/notes/bubble-start.tsx @@ -1,6 +1,6 @@ -import { LinkIcon } from "lucide-react" -import { Button } from "../ui/button" -import { MenuStep, PanelProps } from "./editor-bubble-menu" +import { LinkIcon } from 'lucide-react' +import { Button } from '../ui/button' +import { MenuStep, type PanelProps } from './editor-bubble-menu' export const BubbleStart = ({ editor, setMenuStep }: PanelProps) => { return ( @@ -62,10 +62,7 @@ export const BubbleStart = ({ editor, setMenuStep }: PanelProps) => { > 1. -
diff --git a/src/components/notes/editor-config.ts b/src/components/notes/editor-config.ts index 6460983..97ba98a 100644 --- a/src/components/notes/editor-config.ts +++ b/src/components/notes/editor-config.ts @@ -15,6 +15,6 @@ export const config = { inline: true, allowBase64: true }), - EditorTldraw, + EditorTldraw ] } diff --git a/src/components/notes/note-due-date-dialog.tsx b/src/components/notes/note-due-date-dialog.tsx index a80dfb6..7e6e174 100644 --- a/src/components/notes/note-due-date-dialog.tsx +++ b/src/components/notes/note-due-date-dialog.tsx @@ -21,8 +21,9 @@ import { Calendar } from '@/components/ui/calendar' import { useAppStore } from '@/store/app' import React from 'react' import { Input } from '@/components/ui/input' -import { SubmitHandler, useForm } from 'react-hook-form' +import { type SubmitHandler, useForm } from 'react-hook-form' import { api } from '@/trpc/react' +import { useRouter } from 'next/navigation' export type NoteDueDateDialogProps = { open: boolean @@ -35,25 +36,30 @@ type DueDateForm = { } export const NoteDueDateDialog = () => { + const router = useRouter() const dueDateNoteId = useAppStore((state) => state.dueDateNoteId) const { data: note } = api.notes.get.useQuery({ id: dueDateNoteId ?? '' }) const { mutateAsync: updateNote } = api.notes.update.useMutation() const setDueDateNoteId = useAppStore((state) => state.setDueDateNoteId) const { register, watch, handleSubmit, setValue } = useForm({ defaultValues: { - date: undefined, + date: new Date(), time: '' } }) const onSubmit: SubmitHandler = async (data) => { if (!note) return const [hours, minutes] = data.time.split(':') - const dueDate = set(data.date, {hours: parseInt(hours ?? '0'), minutes: parseInt(minutes ?? '0')}) + const dueDate = set(data.date, { + hours: parseInt(hours ?? '0'), + minutes: parseInt(minutes ?? '0') + }) await updateNote({ ...note, dueDate }) setDueDateNoteId(null) + router.refresh() } const date = watch('date') return ( @@ -79,7 +85,7 @@ export const NoteDueDateDialog = () => { )} > - {date ? format(date, 'PPP') : Pick a date} + {date ? format(date, 'PP') : Pick a date} diff --git a/src/components/notes/note-options.tsx b/src/components/notes/note-options.tsx index 87669b5..e536ed7 100644 --- a/src/components/notes/note-options.tsx +++ b/src/components/notes/note-options.tsx @@ -1,6 +1,6 @@ 'use client' -import { NoteProps } from '@/lib/types' +import { type NoteProps } from '@/lib/types' import { DropdownMenu, DropdownMenuContent, @@ -9,12 +9,7 @@ import { DropdownMenuTrigger } from '../ui/dropdown-menu' import { Button } from '../ui/button' -import { - LockIcon, - MoreVerticalIcon, - ShareIcon, - TrashIcon -} from 'lucide-react' +import { LockIcon, MoreVerticalIcon, ShareIcon, TrashIcon } from 'lucide-react' import { useAppStore } from '@/store/app' export type NoteOptionsProps = { diff --git a/src/components/notes/notes-search.tsx b/src/components/notes/notes-search.tsx index d69d2b1..e4ce899 100644 --- a/src/components/notes/notes-search.tsx +++ b/src/components/notes/notes-search.tsx @@ -23,8 +23,6 @@ export const NotesSearch = () => { const notesSearchOpen = useAppStore((state) => state.notesSearchOpen) const setNotesSearchOpen = useAppStore((state) => state.setNotesSearchOpen) - console.log('>>>Q', query, data) - React.useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { diff --git a/src/components/profile/profile-form.tsx b/src/components/profile/profile-form.tsx index 908eb50..2fa2649 100644 --- a/src/components/profile/profile-form.tsx +++ b/src/components/profile/profile-form.tsx @@ -1,52 +1,75 @@ 'use client' -import { SubmitHandler, useForm } from "react-hook-form" -import { Label } from "../ui/label" -import { Input } from "../ui/input" -import { Button } from "../ui/button" -import { UserProps } from "@/lib/types" -import { api } from "@/trpc/react" -import { useRouter } from "next/navigation" +import { type SubmitHandler, useForm } from 'react-hook-form' +import { Label } from '../ui/label' +import { Input } from '../ui/input' +import { Button } from '../ui/button' +import { type UserProps } from '@/lib/types' +import { api } from '@/trpc/react' +import { useRouter } from 'next/navigation' +import { useToast } from '../ui/use-toast' type ProfileData = { - name: string - username: string + name: string + username: string } export const ProfileForm = ({ user }: { user: UserProps }) => { - const router = useRouter() - const { mutateAsync: updateProfile } = api.users.update.useMutation() - const { register, handleSubmit } = useForm({ - defaultValues: { - name: user.name ?? '', - username: user.username - } - }) - const onSubmit: SubmitHandler = async (data) => { - await updateProfile({ - email: user.email, - ...data - }) - router.refresh() + const router = useRouter() + const { toast } = useToast() + const { mutateAsync: updateProfile } = api.users.update.useMutation() + const { register, handleSubmit } = useForm({ + defaultValues: { + name: user.name ?? '', + username: user.username } - return ( - -

Profile

-
- - -
-
- - -
-
- - -
-
- -
- - ) -} \ No newline at end of file + }) + const onSubmit: SubmitHandler = async (data) => { + await updateProfile({ + email: user.email, + ...data + }) + toast({ + title: 'Profile updated' + }) + router.refresh() + } + return ( +
+

Profile

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ ) +} diff --git a/src/components/progress.tsx b/src/components/progress.tsx index 00d9fb8..de2c35e 100644 --- a/src/components/progress.tsx +++ b/src/components/progress.tsx @@ -1,7 +1,7 @@ export const Progress = () => { - return ( -
-
-
- ) -} \ No newline at end of file + return ( +
+
+
+ ) +} diff --git a/src/components/subscribe/subscribe-pricing.tsx b/src/components/subscribe/subscribe-pricing.tsx new file mode 100644 index 0000000..9220aad --- /dev/null +++ b/src/components/subscribe/subscribe-pricing.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useState } from 'react' +import { Button } from '../ui/button' +import { + Card, + CardHeader, + CardTitle, + CardContent, + CardFooter, + CardDescription +} from '../ui/card' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import type Stripe from 'stripe' +import { formatCurrency } from '@/lib/format' +import { api } from '@/trpc/react' +import { useRouter } from 'next/navigation' + +export type SubscribePricing = { + priceList: Stripe.Price[] +} + +export const SubscribePricing = ({ priceList }: SubscribePricing) => { + const router = useRouter() + const [isYearly, setIsYearly] = useState(true) + const { mutateAsync: initCheckout } = api.subscriptions.checkout.useMutation() + const payOnceLicense = priceList.find( + (price) => price.lookup_key === 'pay_once_license' + ) + const annualPrice = priceList.find( + (price) => price.lookup_key === 'subscription_annual' + ) + const monthlyPrice = priceList.find( + (price) => price.lookup_key === 'subscription_monthly' + ) + const payOnceFormatedPrice = + payOnceLicense && + formatCurrency({ + value: (payOnceLicense.unit_amount ?? 0) / 100, + currency: payOnceLicense.currency + }) + const annualFormatedPrice = + annualPrice && + formatCurrency({ + value: (annualPrice.unit_amount ?? 0) / 100 / 12, + currency: annualPrice.currency + }) + const monthlyFormatedPrice = + monthlyPrice && + formatCurrency({ + value: (monthlyPrice.unit_amount ?? 0) / 100, + currency: monthlyPrice.currency + }) + const initializeCheckoutSession = async ({ + priceId, + mode + }: { + priceId: string + mode: 'subscription' | 'payment' + }) => { + const checkout = await initCheckout({ + priceId, + mode + }) + if (!checkout) return + if (checkout.url) router.push(checkout.url) + } + return ( + + + Subscribe to Zenote + + Last thing before you dive into Zenote. + + + + + +
+ Standard +
+ + +
+
+
+ +
    +
  • Unlimited Channels
  • +
  • Unlimited Notes
  • +
  • Completion AI
  • +
+
+ +

+ {isYearly ? annualFormatedPrice : monthlyFormatedPrice}/mt +

+ +
+
+

or

+ + + Eternal Access + + +

+ The same features as Standard plan and every new feature we add + later. +

+
+ +

{payOnceFormatedPrice} once

+ +
+
+
+
+ ) +} diff --git a/src/env.mjs b/src/env.mjs index 6019d7c..9fffb86 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -1,5 +1,5 @@ -import { createEnv } from "@t3-oss/env-nextjs"; -import { z } from "zod"; +import { createEnv } from '@t3-oss/env-nextjs' +import { z } from 'zod' export const env = createEnv({ server: { @@ -7,14 +7,14 @@ export const env = createEnv({ .string() .url() .refine( - (str) => !str.includes("YOUR_POSTGRES_URL_HERE"), - "You forgot to change the default URL" + (str) => !str.includes('YOUR_POSTGRES_URL_HERE'), + 'You forgot to change the default URL' ), NODE_ENV: z - .enum(["development", "test", "production"]) - .default("development"), + .enum(['development', 'test', 'production']) + .default('development'), NEXTAUTH_SECRET: - process.env.NODE_ENV === "production" + process.env.NODE_ENV === 'production' ? z.string() : z.string().optional(), NEXTAUTH_URL: z.preprocess( @@ -31,11 +31,14 @@ export const env = createEnv({ EMAIL_FROM: z.string(), OPENAI_API_KEY: z.string(), OPENAI_BASE_URL: z.string(), - OPENAI_MODEL: z.string() + OPENAI_MODEL: z.string(), + STRIPE_SECRET_KEY: z.string().optional(), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + ZENOTE_SELF_HOSTED: z.coerce.boolean().default(false) }, client: { - // NEXT_PUBLIC_CLIENTVAR: z.string(), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional() }, runtimeEnv: { @@ -50,8 +53,13 @@ export const env = createEnv({ EMAIL_FROM: process.env.EMAIL_FROM, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, - OPENAI_MODEL: process.env.OPENAI_MODEL + OPENAI_MODEL: process.env.OPENAI_MODEL, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + ZENOTE_SELF_HOSTED: process.env.ZENOTE_SELF_HOSTED }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, - emptyStringAsUndefined: true, -}); + emptyStringAsUndefined: true +}) diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..6569283 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,14 @@ +export const formatCurrency = ({ + value, + currency = 'usd' +}: { + value: number + currency: string +}) => { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }) + + return formatter.format(value) +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..747a645 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,10 @@ +import { env } from '@/env.mjs' +import { loadStripe } from '@stripe/stripe-js' +import Stripe from 'stripe' + +export const getStripeClient = () => + env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY && + loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + +export const getStripeServer = () => + env.STRIPE_SECRET_KEY && new Stripe(env.STRIPE_SECRET_KEY) diff --git a/src/lib/types.ts b/src/lib/types.ts index 309a0a8..4ff77fa 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,14 +1,36 @@ -import { insertBookmarkSchema, insertCommentSchema, insertUserSchema, type insertNoteSchema, selectUserSchema, selectCommentSchema, selectNoteSchema, selectBookmarkSchema } from '@/server/db/schema' +import { + type insertBookmarkSchema, + type insertCommentSchema, + type insertUserSchema, + type insertNoteSchema, + type selectUserSchema, + type selectCommentSchema, + type selectNoteSchema, + type selectBookmarkSchema, + type insertChannelSchema, + type selectChannelSchema +} from '@/server/db/schema' import { type z } from 'zod' -export type UserProps = z.infer -export type NoteBookmarkProps = z.infer +export type UserProps = z.infer< + typeof insertUserSchema & typeof selectUserSchema +> +export type ChannelProps = z.infer< + typeof insertChannelSchema & typeof selectChannelSchema +> +export type NoteBookmarkProps = z.infer< + typeof insertBookmarkSchema & typeof selectBookmarkSchema +> -export type CommentProps = z.infer & { +export type CommentProps = z.infer< + typeof insertCommentSchema & typeof selectCommentSchema +> & { user?: UserProps } -export type NoteProps = z.infer & { +export type NoteProps = z.infer< + typeof insertNoteSchema & typeof selectNoteSchema +> & { comments?: CommentProps[] user?: UserProps noteBookmarks?: NoteBookmarkProps[] diff --git a/src/server/api/root.ts b/src/server/api/root.ts index c8c8ebf..3d96476 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -4,6 +4,7 @@ import { notesRouter } from './routers/notes' import { commentsRouter } from '@/server/api/routers/comments' import { bookmarksRouter } from '@/server/api/routers/bookmarks' import { usersRouter } from './routers/users' +import { subscriptionsRouter } from './routers/subscriptions' /** * This is the primary router for your server. @@ -15,7 +16,8 @@ export const appRouter = createTRPCRouter({ notes: notesRouter, comments: commentsRouter, bookmarks: bookmarksRouter, - users: usersRouter + users: usersRouter, + subscriptions: subscriptionsRouter }) // export type definition of API diff --git a/src/server/api/routers/channels.ts b/src/server/api/routers/channels.ts index 4288d20..fdb058f 100644 --- a/src/server/api/routers/channels.ts +++ b/src/server/api/routers/channels.ts @@ -4,9 +4,10 @@ import { channelMemberships, channels, insertChannelSchema, + notes, selectChannelSchema } from '@/server/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, asc, eq } from 'drizzle-orm' import { TRPCError } from '@trpc/server' export const channelsRouter = createTRPCRouter({ @@ -35,7 +36,8 @@ export const channelsRouter = createTRPCRouter({ with: { user: true, noteBookmarks: true - } + }, + orderBy: [asc(notes.createdAt)] } } } diff --git a/src/server/api/routers/notes.ts b/src/server/api/routers/notes.ts index cbb3be9..b5ef6da 100644 --- a/src/server/api/routers/notes.ts +++ b/src/server/api/routers/notes.ts @@ -11,11 +11,14 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' export const notesRouter = createTRPCRouter({ - index: protectedProcedure - .query(({ ctx }) => - ctx.db.query.notes - .findMany({ where: and(isNotNull(notes.dueDate), eq(notes.userId, ctx.session.user.id)) }) - ), + index: protectedProcedure.query(({ ctx }) => + ctx.db.query.notes.findMany({ + where: and( + isNotNull(notes.dueDate), + eq(notes.userId, ctx.session.user.id) + ) + }) + ), search: protectedProcedure .input(z.object({ query: z.string().min(1) })) .query(async ({ ctx, input }) => { diff --git a/src/server/api/routers/subscriptions.ts b/src/server/api/routers/subscriptions.ts new file mode 100644 index 0000000..5edb4d2 --- /dev/null +++ b/src/server/api/routers/subscriptions.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc' +import { TRPCError } from '@trpc/server' +import Stripe from 'stripe' + +export const subscriptionsRouter = createTRPCRouter({ + prices: publicProcedure.query(async ({ ctx }) => { + if (!ctx.stripe) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' }) + return ctx.stripe.prices.list() + }), + checkout: protectedProcedure + .input( + z.object({ + priceId: z.string(), + mode: z.enum(['subscription', 'payment']) + }) + ) + .mutation(async ({ ctx, input }) => { + if (!ctx.stripe) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' }) + const origin = ctx.headers.get('origin') ?? 'http://localhost:3000' + try { + const session = await ctx.stripe.checkout.sessions.create({ + mode: input.mode, + line_items: [ + { + price: input.priceId, + quantity: 1 + } + ], + success_url: `${origin}/`, + cancel_url: `${origin}/subscribe`, + metadata: { + localId: ctx.session.user.id + } + }) + return session + } catch (error) { + if (error instanceof Stripe.errors.StripeError) { + const { message } = error + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message }) + } + } + }) +}) diff --git a/src/server/api/routers/users.ts b/src/server/api/routers/users.ts index f7399f9..509b2cb 100644 --- a/src/server/api/routers/users.ts +++ b/src/server/api/routers/users.ts @@ -1,16 +1,14 @@ -import { eq } from "drizzle-orm"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { insertUserSchema, users } from "@/server/db/schema"; +import { eq } from 'drizzle-orm' +import { createTRPCRouter, protectedProcedure } from '../trpc' +import { insertUserSchema, users } from '@/server/db/schema' export const usersRouter = createTRPCRouter({ - me: protectedProcedure.query(({ ctx }) => ctx.db.query.users.findFirst({ where: eq(users.id, ctx.session.user.id) })), - update: protectedProcedure - .input(insertUserSchema.omit({ id: true })) - .mutation(({ ctx, input }) => - ctx.db - .update(users) - .set(input) - .where(eq(users.id, ctx.session.user.id) - ) - ) -}) \ No newline at end of file + me: protectedProcedure.query(({ ctx }) => + ctx.db.query.users.findFirst({ where: eq(users.id, ctx.session.user.id) }) + ), + update: protectedProcedure + .input(insertUserSchema.omit({ id: true })) + .mutation(({ ctx, input }) => + ctx.db.update(users).set(input).where(eq(users.id, ctx.session.user.id)) + ) +}) diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index d94a905..9849e46 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -14,6 +14,7 @@ import { ZodError } from 'zod' import { getServerAuthSession } from '@/server/auth' import { db } from '@/server/db' +import { getStripeServer } from '@/lib/stripe' /** * 1. CONTEXT @@ -43,7 +44,8 @@ export const createInnerTRPCContext = async (opts: CreateContextOptions) => { return { session, headers: opts.headers, - db + db, + stripe: getStripeServer() } } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 2a0abcb..939545b 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -8,11 +8,16 @@ import { text, timestamp, varchar, - boolean, + boolean } from 'drizzle-orm/pg-core' import { type AdapterAccount } from 'next-auth/adapters' import { createInsertSchema, createSelectSchema } from 'drizzle-zod' -import { adjectives, animals, colors, uniqueNamesGenerator } from 'unique-names-generator' +import { + adjectives, + animals, + colors, + uniqueNamesGenerator +} from 'unique-names-generator' export const pgTable = pgTableCreator((name) => `zenote_${name}`) @@ -104,7 +109,12 @@ export const channelMemberships = pgTable('channel_membership', { export const users = pgTable('user', { id: text('id').primaryKey().unique().notNull(), name: varchar('name', { length: 255 }), - username: varchar('username', { length: 255 }).default(uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] })).notNull().unique(), + username: varchar('username', { length: 255 }) + .default( + uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals] }) + ) + .notNull() + .unique(), stripeId: varchar('stripeId', { length: 255 }), subscriptionTier: varchar('subscriptionTier', { length: 255,