This commit is contained in:
Jordi Enric
2024-02-23 00:11:14 +01:00
parent 8fe0f5a86a
commit 92bc0c540f
75 changed files with 2384 additions and 879 deletions
+46
View File
@@ -0,0 +1,46 @@
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { type EmailOtpType } from "@supabase/supabase-js";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/";
const redirectTo = request.nextUrl.clone();
redirectTo.pathname = next;
if (token_hash && type) {
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options });
},
remove(name: string, options: CookieOptions) {
cookieStore.delete({ name, ...options });
},
},
}
);
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
return NextResponse.redirect(redirectTo);
}
}
// return the user to an error page with some instructions
redirectTo.pathname = "/auth/auth-code-error";
return NextResponse.redirect(redirectTo);
}
+5 -4
View File
@@ -7,7 +7,8 @@
"dev": "NODE_OPTIONS='--inspect' next dev -p 3000",
"lint": "next lint",
"start": "next start",
"supabase:typegen": "npx supabase gen types typescript --project-id ppfseefimhneysnokffx --schema public > src/types/supabase.ts"
"supabase:typegen": "npx supabase gen types typescript --local --schema public > src/types/supabase.ts",
"stripe:sync": "tsx ./src/scripts/stripe-sync.ts"
},
"dependencies": {
"@builder.io/react-hydration-overlay": "^0.0.8",
@@ -26,9 +27,8 @@
"@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^2.4.0",
"@stripe/stripe-js": "^2.4.0",
"@supabase/auth-helpers-nextjs": "^0.8.1",
"@supabase/auth-helpers-react": "^0.3.1",
"@supabase/supabase-js": "^2.21.0",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.39.6",
"@t3-oss/env-nextjs": "^0.2.1",
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query": "^4.28.0",
@@ -51,6 +51,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dotenv": "^16.4.4",
"formidable": "^3.5.1",
"formik": "^2.2.9",
"framer-motion": "^10.12.12",
-12
View File
@@ -1,12 +0,0 @@
import { useUser } from "@supabase/auth-helpers-react";
import React, { PropsWithChildren } from "react";
type Props = {};
const AppChecks = (props: PropsWithChildren<Props>) => {
// Global checks here
return <>{props.children}</>;
};
export default AppChecks;
@@ -1,4 +1,4 @@
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
@@ -1,4 +1,4 @@
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { Blog } from "@/lib/models/blogs/Blogs";
import { useBlogQuery } from "@/queries/blogs";
import {
@@ -0,0 +1,172 @@
import { Editor } from "@tiptap/react";
import {
BoldIcon,
CodeIcon,
Dot,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
ItalicIcon,
Link,
ListIcon,
Pilcrow,
Strikethrough,
} from "lucide-react";
import { PiCodeBlock, PiListNumbers } from "react-icons/pi";
const SIZE = 18;
const Separator = {
id: "separator",
icon: <Dot size={SIZE} />,
disabled: true,
command: () => {},
};
const BOLD_BTN = {
id: "bold",
tooltip: "Bold (Cmd+B)",
icon: <BoldIcon size={SIZE} />,
command: (editor: Editor) => editor?.chain().focus().toggleBold().run(),
};
const ITALIC_BTN = {
id: "italic",
tooltip: "Italic (Cmd+I)",
icon: <ItalicIcon size={SIZE} />,
command: (editor: Editor) => editor?.chain().focus().toggleItalic().run(),
};
const STRIKETHROUGH_BTN = {
id: "strike",
tooltip: "Strikethrough",
icon: <Strikethrough size={SIZE} />,
command: (editor: Editor) => editor?.chain().focus().toggleStrike().run(),
};
const CODE_BTN = {
id: "code",
tooltip: "Code (Cmd+E)",
icon: <CodeIcon size={SIZE} />,
command: (editor: Editor) => editor?.chain().focus().toggleCode().run(),
};
const CODE_BLOCK_BTN = {
id: "codeBlock",
tooltip: "Code Block",
icon: <PiCodeBlock size={SIZE} />,
command: (editor: Editor) => editor?.chain().focus().toggleCodeBlock().run(),
};
const LINK_BTN = {
id: "link",
tooltip: "Link",
icon: <Link size={SIZE} />,
command: (editor: Editor) => {
const url = window.prompt("Enter the URL");
if (url) {
editor?.chain().focus().setLink({ href: url }).run();
}
},
};
const LIST_BTN = {
id: "list",
tooltip: "List",
icon: <ListIcon size={SIZE} />,
command: (editor: Editor) => editor?.chain().focus().toggleBulletList().run(),
};
const NUMBERED_LIST_BTN = {
id: "numberedList",
tooltip: "Numbered List",
icon: <PiListNumbers size={SIZE} />,
command: (editor: Editor) =>
editor?.chain().focus().toggleOrderedList().run(),
};
const PARAGRAPH_BTN = {
id: "paragraph",
icon: <Pilcrow size={SIZE} />,
label: "Paragraph",
command: (editor: Editor) => editor?.chain().focus().setParagraph().run(),
};
const HEADING2_BTN = {
id: "heading2",
icon: <Heading2 size={SIZE} />,
label: "Heading 2",
command: (editor: Editor) =>
editor?.chain().focus().setHeading({ level: 2 }).run(),
};
const HEADING3_BTN = {
icon: <Heading3 size={SIZE} />,
label: "Heading 3",
command: (editor: Editor) =>
editor?.chain().focus().setHeading({ level: 3 }).run(),
};
const HEADING4_BTN = {
icon: <Heading4 size={SIZE} />,
label: "Heading 4",
command: (editor: Editor) =>
editor?.chain().focus().setHeading({ level: 4 }).run(),
};
const HEADING5_BTN = {
icon: <Heading5 size={SIZE} />,
label: "Heading 5",
command: (editor: Editor) =>
editor?.chain().focus().setHeading({ level: 5 }).run(),
};
const HEADING6_BTN = {
icon: <Heading6 size={SIZE} />,
label: "Heading 6",
command: (editor: Editor) =>
editor?.chain().focus().setHeading({ level: 6 }).run(),
};
export const TOP_MENU_BUTTONS = [
BOLD_BTN,
ITALIC_BTN,
STRIKETHROUGH_BTN,
CODE_BTN,
CODE_BLOCK_BTN,
Separator,
LINK_BTN,
LIST_BTN,
NUMBERED_LIST_BTN,
];
export const BUBBLE_MENU_BUTTONS = [
BOLD_BTN,
ITALIC_BTN,
STRIKETHROUGH_BTN,
CODE_BTN,
CODE_BLOCK_BTN,
Separator,
LINK_BTN,
LIST_BTN,
NUMBERED_LIST_BTN,
];
export const MENU_TYPE_BUTTONS = [
PARAGRAPH_BTN,
HEADING2_BTN,
HEADING3_BTN,
HEADING4_BTN,
HEADING5_BTN,
HEADING6_BTN,
];
export const NEW_LINE_BUTTONS = [
HEADING2_BTN,
HEADING3_BTN,
HEADING4_BTN,
HEADING5_BTN,
HEADING6_BTN,
];
@@ -1,4 +1,4 @@
import { Editor } from "@tiptap/react";
import { BubbleMenu, Editor, FloatingMenu } from "@tiptap/react";
import {
BoldIcon,
CodeIcon,
@@ -29,6 +29,7 @@ import {
TooltipContent,
TooltipProvider,
} from "../ui/tooltip";
import { NEW_LINE_BUTTONS, TOP_MENU_BUTTONS } from "./Editor.constants";
function EditorMenuButton({
children,
@@ -123,13 +124,13 @@ export function EditorMenu({ editor }: { editor: Editor | null }) {
active: editor?.isActive("link"),
},
{
tooltip: "List",
tooltip: "Bullet list",
icon: <ListIcon size={SIZE} />,
command: () => editor?.chain().focus().toggleBulletList().run(),
active: editor?.isActive("bulletList"),
},
{
tooltip: "Numbered List",
tooltip: "Numbered list",
icon: <PiListNumbers size={SIZE} />,
command: () => editor?.chain().focus().toggleOrderedList().run(),
active: editor?.isActive("orderedList"),
@@ -210,6 +211,48 @@ export function EditorMenu({ editor }: { editor: Editor | null }) {
{icon}
</EditorMenuButton>
))}
{editor && (
<BubbleMenu
className="flex items-center gap-0.5 rounded-xl bg-zinc-800 p-1.5 text-xs text-white shadow-md"
tippyOptions={{ duration: 100 }}
editor={editor}
>
{TOP_MENU_BUTTONS.map(({ icon, command, id }, i) => (
<button
className={cn("rounded-md p-1 text-xs text-white", {
"hover:bg-zinc-600": !editor.isActive(id),
"bg-zinc-600": editor.isActive(id),
})}
key={i + "menu-btn"}
onClick={() => command(editor)}
>
{icon}
</button>
))}
</BubbleMenu>
)}
{/* {editor && (
<FloatingMenu
className="rounded-md bg-zinc-50 p-0.5"
tippyOptions={{ duration: 100 }}
editor={editor}
>
{NEW_LINE_BUTTONS.map((item) => {
const { icon, label, command } = item;
return (
<button
key={label}
onClick={() => command(editor)}
className="rounded-md p-0.5 text-xs text-zinc-400 "
>
{icon}
</button>
);
})}
</FloatingMenu>
)} */}
</div>
);
}
@@ -1,7 +1,8 @@
import React, { useState } from "react";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { Plus, Trash } from "lucide-react";
import { Code, Info, Plus, Trash } from "lucide-react";
import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog";
type MetadataItem = {
key: string;
@@ -9,7 +10,13 @@ type MetadataItem = {
};
type Props = {
metadata?: MetadataItem[];
onSave: (metadata: MetadataItem[]) => void;
onChange: ({
metadata,
categories,
}: {
metadata: MetadataItem[];
categories: string[];
}) => void;
};
const EditorSettings = (props: Props) => {
@@ -23,56 +30,79 @@ const EditorSettings = (props: Props) => {
setMetadata([...metadata, { key: "", value: "" }]);
}
const handleMetadataChange = (
type: "key" | "value",
index: number,
event: React.ChangeEvent<HTMLInputElement>
) => {
let newMetadata = [...metadata];
if (!newMetadata[index]) {
return;
}
newMetadata[index]![type] = event.target.value;
setMetadata(newMetadata);
props.onChange({ metadata: newMetadata, categories: [] });
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
const formData = new FormData(e.target as HTMLFormElement);
const data = formData.values();
const newMetadata = [];
for (let i = 0; i < metadata.length; i++) {
const key = data.next().value;
const value = data.next().value;
if (key && value) {
newMetadata.push({ key, value });
}
}
console.log("DEBUG", newMetadata);
setMetadata(newMetadata);
props.onSave(metadata);
}}
className="prose-sm prose-h2:font-bold prose-h2:text-sm"
>
<div className="prose-sm prose-h2:font-bold prose-h2:text-sm">
<section>
<h2 className="m-0 border-b pb-2">Custom metadata</h2>
<div className="text-sm font-medium text-slate-700">
<div className="grid grid-cols-2 text-xs text-slate-500 *:p-1">
<div>Key</div>
<div className="flex items-center py-2 text-xs text-slate-500 *:p-1">
<div className="w-48">Key</div>
<div>Value</div>
<Dialog>
<DialogTrigger asChild>
<Button className="ml-auto" variant={"outline"} size={"sm"}>
<Code size={16} />
Preview
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<div>
<h2 className="flex items-center gap-1 text-lg font-medium">
<Info size={16} />
Metadata preview
</h2>
<p className="text-sm text-zinc-500">
This is the object that you will receive when you fetch the
post from your website.
</p>
</div>
<pre className="rounded-lg border p-2 text-zinc-600">
<code>{JSON.stringify(metadata, null, 2)}</code>
</pre>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-1">
{metadata.map((item, index) => (
<div className="flex gap-1" key={`${item.key}-metadata-${index}`}>
<div className="flex gap-1" key={`metadata-${index}`}>
<div className="grid flex-grow grid-cols-2 gap-1 *:rounded-md *:border *:p-1">
<Input
autoComplete="off"
placeholder="key"
id={index + "-metadata-key"}
name={index + "-metadata-key"}
name={"key"}
className="outline-none"
defaultValue={item.value}
value={item.key}
required
onChange={(e) => handleMetadataChange("key", index, e)}
/>
<Input
autoComplete="off"
placeholder="value"
id={index + "-metadata-value"}
name={index + "-metadata-value"}
name={"value"}
className="outline-none"
defaultValue={item.value}
value={item.value}
required
onChange={(e) => handleMetadataChange("value", index, e)}
/>
</div>
<div className="shrink">
@@ -86,6 +116,7 @@ const EditorSettings = (props: Props) => {
const newMetadata = [...metadata];
newMetadata.splice(index, 1);
setMetadata(newMetadata);
props.onChange({ metadata: newMetadata, categories: [] });
}}
>
<Trash size={16} />
@@ -110,12 +141,8 @@ const EditorSettings = (props: Props) => {
<div>
<h2>Categories</h2>
</div>
<div className="mt-4 flex justify-end">
<Button>Save</Button>
</div>
</section>
</form>
</div>
);
};
@@ -35,6 +35,7 @@ import { Sheet, SheetContent, SheetTrigger } from "../ui/sheet";
import EditorSettings from "./EditorSettings";
import TiptapLink from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { useSubscriptionQuery } from "@/queries/subscription";
const formSchema = z.object({
title: z.string(),
@@ -52,6 +53,7 @@ type EditorContent = {
slug: string;
cover_image?: string;
published: boolean;
metadata?: any;
};
type Props = {
@@ -64,7 +66,7 @@ type Props = {
published: boolean;
cover_image?: string;
content?: any;
metadata?: Record<string, any>;
metadata?: any;
};
};
@@ -80,6 +82,12 @@ export const ZendoEditor = (props: Props) => {
});
const router = useRouter();
const blogId = (router.query.blogId as string) || "demo";
const subscription = useSubscriptionQuery();
const [metadata, setMetadata] = React.useState(props.post?.metadata || []);
const isSubscribed = subscription.data?.status === "active";
const [coverImgUrl, setCoverImgUrl] = React.useState<string | undefined>(
props.post?.cover_image || ""
);
@@ -136,6 +144,10 @@ export const ZendoEditor = (props: Props) => {
});
const formSubmit = handleSubmit(async (data) => {
if (!isSubscribed) {
alert("You need an active subscription to publish more posts.");
return;
}
const content = editor?.getJSON() || {};
const slugHasChanged = data.slug !== props.post?.slug;
@@ -145,6 +157,7 @@ export const ZendoEditor = (props: Props) => {
slug: data.slug,
cover_image: data.cover_image || "",
published: data.published,
metadata,
});
if (slugHasChanged) {
@@ -158,7 +171,25 @@ export const ZendoEditor = (props: Props) => {
}
return (
<div className="bg-zinc-50 pb-40">
<div className="bg-zinc-50 pb-24">
{!isSubscribed && (
<>
<div className="absolute inset-0 z-40 flex items-center justify-center overflow-hidden bg-zinc-100/80">
<div className="max-w-xs rounded-lg border bg-white p-3 shadow-sm">
<span className="text-lg">🙏</span>
<h2 className="text-lg font-medium">
You need an active subscription to publish more posts.
</h2>
<Link
className="mt-4 inline-block py-2 text-orange-500 underline"
href="/account"
>
Manage your subscription
</Link>
</div>
</div>
</>
)}
<form
onSubmit={formSubmit}
className="sticky top-0 z-20 flex w-full items-center justify-between border-b bg-zinc-50 px-3 py-1.5 text-zinc-800"
@@ -259,8 +290,9 @@ export const ZendoEditor = (props: Props) => {
</SheetTrigger>
<SheetContent>
<EditorSettings
onSave={() => {
// TO DO - save settings
metadata={metadata}
onChange={(data) => {
setMetadata(data.metadata);
}}
></EditorSettings>
</SheetContent>
@@ -271,7 +303,7 @@ export const ZendoEditor = (props: Props) => {
</Button>
</div>
</form>
<div className="mx-auto mt-2 flex w-full max-w-2xl flex-col px-2 pb-40">
<div className="mx-auto mt-2 flex w-full max-w-2xl flex-col px-2 pb-6">
<div className="relative mt-2 flex items-center justify-center bg-zinc-100">
{coverImgUrl && (
<button
@@ -334,14 +366,14 @@ export const ZendoEditor = (props: Props) => {
/>
</div>
<div className="group">
<div className="sticky top-10 z-30">
<div className="sticky top-12 z-30">
<EditorMenu editor={editor} />
</div>
<div
onClick={() => {
editor?.commands.focus();
}}
className="prose prose-p:text-lg prose-h2:font-semibold -mt-2 min-h-[700px] cursor-text rounded-lg py-1.5 font-light leading-10 tracking-tight transition-all"
className="prose prose-p:text-lg prose-h2:font-semibold -mt-2 min-h-[700px] cursor-text rounded-lg py-1.5 font-light leading-10 tracking-normal transition-all"
>
<EditorContent editor={editor} />
</div>
@@ -1,7 +1,7 @@
import { toast } from "sonner";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { getSupabaseClient } from "../../lib/supabase";
import { getSupabaseBrowserClient } from "@/lib/supabase";
const uploadKey = new PluginKey("upload-image");
@@ -10,7 +10,6 @@ const UploadImagesPlugin = () =>
key: uploadKey,
state: {
init() {
console.log("init");
return DecorationSet.empty;
},
apply(tr, set) {
@@ -59,7 +58,6 @@ function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(null, null, (spec: any) => spec.id == id);
const pos = found.length ? found[0].from : null;
console.log(pos);
return pos;
}
@@ -134,7 +132,7 @@ export function startImageUpload(
});
}
const supa = getSupabaseClient();
const supa = getSupabaseBrowserClient();
export const handleImageUpload = async (file: File, blogId: string) => {
console.log("Uploading image to Supabase storage...");
+2 -2
View File
@@ -8,12 +8,12 @@ import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { Button } from "./ui/button";
import { toast } from "sonner";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { getSupabaseBrowserClient } from "@/lib/supabase";
type Props = {};
const Feedback = (props: Props) => {
const sb = useSupabaseClient();
const sb = getSupabaseBrowserClient();
return (
<div>
+5 -5
View File
@@ -11,10 +11,10 @@ const Footer = (props: Props) => {
label: "Home",
href: "/",
},
{
label: "Blog",
href: "/blog",
},
// {
// label: "Blog",
// href: "/blog",
// },
{
label: "Docs",
href: "/docs/getting-started",
@@ -26,7 +26,7 @@ const Footer = (props: Props) => {
];
return (
<footer className="mt-32 border-t bg-zinc-100 p-12 text-xs text-zinc-700">
<footer className="mt-6 border-t bg-zinc-100 p-12 text-xs text-zinc-700">
<div className="mx-auto flex max-w-4xl justify-between">
<div>
<ZendoLogo />
@@ -1,6 +1,6 @@
/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useRouter } from "next/router";
import { PropsWithChildren, useState } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -13,12 +13,19 @@ import {
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { BlogImage } from "@/lib/types/BlogImage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Button } from "../ui/button";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export type BlogImage = {
id: string;
name: string;
url: string;
createdAt: string;
updatedAt: string;
};
export function ImagePicker({
children,
onSelect,
@@ -2,10 +2,10 @@
/* eslint-disable jsx-a11y/alt-text */
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import imageCompression from "browser-image-compression";
import { Loader } from "lucide-react";
import { Input } from "../ui/input";
import { getSupabaseBrowserClient } from "@/lib/supabase";
type Props = {
blogId: string;
@@ -17,7 +17,7 @@ export const ImageUploader = ({ blogId, onSuccessfulUpload }: Props) => {
const [imageInfo, setImageInfo] = useState<any>(null);
const [loading, setLoading] = useState(false);
const supa = useSupabaseClient();
const supa = getSupabaseBrowserClient();
useEffect(() => {
// on mount, listen for paste events
+4 -2
View File
@@ -1,10 +1,12 @@
import { useUser } from "@supabase/auth-helpers-react";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useUser } from "@/utils/supabase/browser";
import { PropsWithChildren } from "react";
export function LoggedInUser({ children }: PropsWithChildren) {
const sb = getSupabaseBrowserClient();
const user = useUser();
if (!user?.id) {
if (!user) {
return null;
}
@@ -0,0 +1,23 @@
import { useCreateTeamMutation, useTeamsQuery } from "@/queries/teams";
import { useUser } from "@/utils/supabase/browser";
import { Loader } from "lucide-react";
import { useRouter } from "next/router";
import React, { PropsWithChildren, useEffect } from "react";
type Props = {};
export const GlobalAppLoading = () => {
return (
<div className="flex h-screen items-center justify-center bg-zinc-50">
<Loader className="animate-spin text-orange-500" size={32} />
</div>
);
};
const LoggedInUserChecks = (props: PropsWithChildren<Props>) => {
// Check notifications and other user related stuff here
return <>{props.children}</>;
};
export default LoggedInUserChecks;
+2 -3
View File
@@ -6,15 +6,14 @@ import {
DropdownMenuItem,
} from "./ui/dropdown-menu";
import { Button } from "./ui/button";
import { IoNotificationsCircle } from "react-icons/io5";
import { Bell, Loader } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getSupabaseClient } from "@/lib/supabase";
import { getSupabaseBrowserClient } from "@/lib/supabase";
type Props = {};
function useNotifications() {
const sb = getSupabaseClient();
const sb = getSupabaseBrowserClient();
return useQuery({
queryKey: ["notifications"],
+2 -4
View File
@@ -1,4 +1,3 @@
import { useUser } from "@supabase/auth-helpers-react";
import React from "react";
import {
DropdownMenu,
@@ -8,8 +7,7 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import Link from "next/link";
import { Button } from "./ui/button";
import { User2 } from "lucide-react";
import { useUser } from "@/utils/supabase/browser";
type Props = {};
@@ -19,7 +17,7 @@ const UserButton = (props: Props) => {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger className="rounded-full">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-800 font-bold text-white">
{user?.email?.slice(0, 1).toUpperCase()}
</div>
@@ -2,10 +2,9 @@
import React from "react";
import ZendoLogo from "../ZendoLogo";
import Link from "next/link";
import { LoggedInUser } from "../LoggedInUser";
import { FaTwitter } from "react-icons/fa";
import { useUser } from "@supabase/auth-helpers-react";
import { Button } from "../ui/button";
import { useUser } from "@/utils/supabase/browser";
type Props = {};
@@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mx-1.5 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-sm",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mx-1.5 min-w-[8rem] overflow-hidden rounded-lg border bg-white p-1 shadow-sm",
className
)}
{...props}
@@ -81,7 +81,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm text-slate-600 outline-none transition-colors hover:bg-slate-50 hover:text-slate-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm text-zinc-500 outline-none transition-colors hover:bg-zinc-100 hover:text-zinc-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
+1 -1
View File
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"border-input bg-background placeholder:text-muted-foreground flex h-9 w-full rounded-lg border px-2 py-1.5 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium hover:border-orange-300 focus-visible:border-orange-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 disabled:cursor-not-allowed disabled:opacity-50",
"border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-xl border px-2 py-1.5 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium hover:border-orange-300 focus-visible:border-orange-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
+75 -45
View File
@@ -1,65 +1,95 @@
import UserButton from "@/components/UserButton";
import ZendoLogo from "@/components/ZendoLogo";
import { Github, Twitter } from "lucide-react";
import Link from "next/link";
import { motion } from "framer-motion";
import Notifications from "@/components/Notifications";
import Feedback from "@/components/Feedback";
import { IoLogoGithub, IoLogoTwitter } from "react-icons/io5";
import Footer from "@/components/Footer";
import { useIsSubscribed, useSubscriptionQuery } from "@/queries/subscription";
import { useIsSubscribed } from "@/queries/subscription";
import AppChecks from "@/components/LoggedInUserChecks";
import { Loader } from "lucide-react";
import { HiOutlineInformationCircle } from "react-icons/hi";
import { useUser } from "@/utils/supabase/browser";
import { useRouter } from "next/router";
import { useEffect } from "react";
type Props = {
children?: React.ReactNode;
loading?: boolean;
};
export default function AppLayout({ children }: Props) {
export default function AppLayout({ children, loading = false }: Props) {
const isSubscribed = useIsSubscribed();
const user = useUser();
const router = useRouter();
useEffect(() => {
if (!user && !loading) {
router.push("/sign-in");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className={`flex min-h-screen flex-col border-b bg-zinc-50 font-sans`}>
<nav className="sticky top-0 z-20 mx-auto w-full max-w-5xl border-b bg-zinc-50">
<div className="mx-auto flex h-full items-center justify-between p-4">
<div className="z-20 flex h-full items-center gap-2">
<Link href="/blogs" className="rounded-md px-1 text-lg font-medium">
<ZendoLogo />
</Link>
</div>
<div className="flex items-center gap-1">
<Feedback />
<Link
className="rounded-full px-3 py-1 text-sm font-medium text-slate-600 hover:bg-orange-50 hover:text-orange-600"
href="/docs/getting-started"
>
Docs
</Link>
<Notifications />
<span className="ml-2">
<UserButton />
</span>
</div>
</div>
{!isSubscribed && (
<div className="bg-red-500">
<div className="mx-auto max-w-5xl p-4 text-center font-medium text-white">
<p>
You are not subscribed to a plan. Please{" "}
<Link className="underline" href="/account">
subscribe to a plan
</Link>{" "}
to keep using Zenblog.
</p>
<AppChecks>
<nav className="sticky top-0 z-20 mx-auto w-full max-w-5xl border-b bg-zinc-50">
<div className="mx-auto flex h-full items-center justify-between p-4">
<div className="z-20 flex h-full items-center gap-2">
<Link
href="/blogs"
className="rounded-md px-1 text-lg font-medium"
>
<ZendoLogo />
</Link>
</div>
<div className="flex items-center gap-1">
<Feedback />
<Link
className="rounded-full px-3 py-1 text-sm font-medium text-slate-600 hover:bg-orange-50 hover:text-orange-600"
href="/docs/getting-started"
>
Docs
</Link>
<Notifications />
<span className="ml-2">
<UserButton />
</span>
</div>
</div>
</nav>
{!isSubscribed && (
<div className="sticky top-[67px] z-30 flex items-center justify-center">
<Link
href="/account"
className="
mx-auto flex max-w-5xl items-center rounded-b-2xl border-x border-b-2 border-yellow-400 bg-yellow-100
p-1.5 px-4 text-center font-medium text-yellow-600"
>
<HiOutlineInformationCircle
className="mr-1 text-yellow-600"
size={20}
/>
You don&apos;t have an active subscription. Please{" "}
<span className="ml-1.5 underline"> subscribe to a plan</span>.
</Link>
</div>
)}
</nav>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="min-h-screen bg-zinc-50 pb-24"
>
{children}
</motion.div>
<Footer />
{loading ? (
<div className="flex h-[600px] items-center justify-center">
<Loader className="animate-spin text-orange-500" size={32} />
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="min-h-screen bg-zinc-50 pb-24"
>
{children}
</motion.div>
)}
<Footer />
</AppChecks>
</div>
);
}
-48
View File
@@ -1,48 +0,0 @@
import { useEffect, useState } from "react";
import { AuthError, AuthUser, User } from "@supabase/supabase-js";
import { createPagesBrowserClient } from "@supabase/auth-helpers-nextjs";
type UseAuthStatus = {
loading: boolean;
user: User | null;
error: AuthError | null;
};
export function useAuth() {
const [supabaseClient] = useState(() => createPagesBrowserClient());
const [isSignedIn, setIsSignedIn] = useState<boolean>(false);
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
supabaseClient.auth.getUser().then(({ data, error }) => {
if (data) {
setIsSignedIn(true);
setUser(data.user);
} else {
setIsSignedIn(false);
setUser(null);
}
});
const { data: authListener } = supabaseClient.auth.onAuthStateChange(
async (event, session) => {
if (event === "SIGNED_IN") {
setIsSignedIn(true);
}
if (event === "SIGNED_OUT") {
setIsSignedIn(false);
setUser(null);
}
}
);
return () => {
authListener?.subscription?.unsubscribe();
};
}, []);
return {
auth: supabaseClient.auth,
isSignedIn,
};
}
+1
View File
@@ -0,0 +1 @@
export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL as string;
@@ -215,7 +215,6 @@ export function createAPIClient() {
const body = new FormData();
body.append("file", file);
console.log("APIC: ", body);
return _fetch(
`/upload`,
{ method: "POST", body: JSON.stringify(body) },
+3 -1
View File
@@ -13,7 +13,8 @@ export const getPostBySlugRes = z.object({
updated_at: z.string(),
blog_id: z.string(),
user_id: z.string(),
cover_image: z.string().nullable(),
cover_image: z.string().optional(),
metadata: z.any().optional(),
});
export const getPostsRes = z.object({
@@ -47,4 +48,5 @@ export const PatchPost = z.object({
content: z.any(),
cover_image: z.string().nullable(),
published: z.boolean(),
metadata: z.any().nullable(),
});
@@ -1,5 +1,5 @@
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { NextApiRequest, NextApiResponse } from "next";
import { getSupabaseClient } from "../../supabase";
export async function getApiClientDB(
req: NextApiRequest,
@@ -7,7 +7,7 @@ export async function getApiClientDB(
) {
const key = req.headers["znd-api-key"];
const db = getSupabaseClient();
const db = getSupabaseBrowserClient();
const { data, error } = await db
.from("api_keys")
@@ -1,5 +1,9 @@
import { Database } from "@/types/supabase";
import { createPagesServerClient } from "@supabase/auth-helpers-nextjs";
import {
createServerClient,
type CookieOptions,
serialize,
} from "@supabase/ssr";
import type { NextApiRequest, NextApiResponse } from "next";
export async function getServerClient(
@@ -7,15 +11,28 @@ export async function getServerClient(
res: NextApiResponse
) {
try {
const supabaseServerClient = createPagesServerClient<Database>({
req,
res,
});
const supabase = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return req.cookies[name];
},
set(name: string, value: string, options: CookieOptions) {
res.appendHeader("Set-Cookie", serialize(name, value, options));
},
remove(name: string, options: CookieOptions) {
res.appendHeader("Set-Cookie", serialize(name, "", options));
},
},
}
);
const userRes = await supabaseServerClient.auth.getUser();
const userRes = await supabase.auth.getUser();
return {
user: userRes?.data.user,
db: supabaseServerClient,
db: supabase,
};
} catch (error) {
console.error(error);
@@ -1,10 +0,0 @@
// ONLY FOR DEVELOPMENT
export const STRIPE_CONSTANTS = {
products: {
proPlan: {
productId: "prod_PXkoPOxUafT0Ig",
monthlyPriceId: "price_1OinIFJfDYgxbs7Z6AQ8toxS",
yearlyPriceId: "price_1OifJ1JfDYgxbs7ZfZ0grcxW",
},
},
};
-2
View File
@@ -22,8 +22,6 @@ export async function createOrRetrieveCustomer({
query: "metadata['userId']:'" + userId + "'",
});
console.log(customers.data);
if (customers.data.length > 0) {
const customer = customers.data[0];
@@ -1,5 +1,10 @@
import { Database } from "@/types/supabase";
import { createClient } from "@supabase/supabase-js";
import {
createServerClient as _createServerClient,
type CookieOptions,
} from "@supabase/ssr";
import { type cookies } from "next/headers";
export function createAdminClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -13,3 +18,35 @@ export function createAdminClient() {
return client;
}
export function createServerClient(cookieStore: ReturnType<typeof cookies>) {
return _createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// The `set` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: "", ...options });
} catch (error) {
// The `delete` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { createClient } from "@supabase/supabase-js";
import type { Database } from "@/types/supabase";
import { env } from "@/env.mjs";
import { createBrowserClient } from "@supabase/ssr";
const supabaseUrl = env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
@@ -9,11 +9,11 @@ if (!supabaseKey) {
throw new Error("Missing supabaseKey");
}
export function getSupabaseClient() {
export function getSupabaseBrowserClient() {
if (!supabaseKey) {
throw new Error("Missing supabaseKey");
}
const supabase = createClient<Database>(supabaseUrl, supabaseKey);
const supabase = createBrowserClient<Database>(supabaseUrl, supabaseKey);
return supabase;
}
-7
View File
@@ -1,7 +0,0 @@
export type BlogImage = {
id: string;
name: string;
url: string;
createdAt: string;
updatedAt: string;
};
+6 -20
View File
@@ -8,12 +8,10 @@ import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "sonner";
import { useState } from "react";
import { createPagesBrowserClient } from "@supabase/auth-helpers-nextjs";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
import AppChecks from "@/components/AppChecks";
import AppChecks from "@/components/LoggedInUserChecks";
import { UserProvider } from "@/utils/supabase/browser";
// Fonts
const inter = Inter({
@@ -33,31 +31,19 @@ const ibmPlexMono = IBM_Plex_Mono({
function MyApp({ Component, pageProps }: AppProps) {
const { pathname } = useRouter();
const [queryClient] = useState(() => new QueryClient());
const [supabaseClient] = useState(() =>
createPagesBrowserClient({
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
})
);
return (
<div className={`${ibmPlexMono.variable} ${inter.variable}`}>
<SessionContextProvider
supabaseClient={supabaseClient}
initialSession={pageProps.initialSession}
>
<UserProvider>
<PlausibleProvider domain="zendo.blog">
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<AppChecks>
<Component key={pathname} {...pageProps} />
<Toaster />
</AppChecks>
<Component key={pathname} {...pageProps} />
<Toaster />
</Hydrate>
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider>
</PlausibleProvider>
</SessionContextProvider>
</UserProvider>
</div>
);
}
+206 -34
View File
@@ -1,41 +1,170 @@
import { Button } from "@/components/ui/button";
import { Tabs, TabsTrigger, TabsList } from "@/components/ui/tabs";
import AppLayout from "@/layouts/AppLayout";
import { usePricesQuery } from "@/queries/prices";
import { useProductsQuery } from "@/queries/products";
import { useSubscriptionQuery } from "@/queries/subscription";
import { useMutation } from "@tanstack/react-query";
import React from "react";
import { useTeamsQuery } from "@/queries/teams";
import { useUser } from "@/utils/supabase/browser";
import { Landmark, Loader } from "lucide-react";
import React, { useState } from "react";
import { toast } from "sonner";
import Stripe from "stripe";
type Props = {};
const AccountPage = (props: Props) => {
const [loading, setLoading] = React.useState(false);
export const SubscribeSection = () => {
const products = useProductsQuery();
const prices = usePricesQuery();
const [interval, setInterval] = React.useState<"year" | "month">("year");
const [isLoading, setIsLoading] = useState(false);
async function onSubscribeClick() {
setLoading(true);
const response = await fetch("/api/create-checkout-session", {
function openCheckoutPage(product_id: string) {
setIsLoading(true);
const pricesForProduct = prices.data?.filter(
(p) => p.price.product === product_id
);
if (!pricesForProduct) {
setIsLoading(false);
return;
}
const price = pricesForProduct.find(
(p) => p.price.recurring?.interval === interval
);
if (!price) {
setIsLoading(false);
return;
}
const res = fetch("/api/create-checkout-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ plan: "pro" }),
body: JSON.stringify({
price_id: price.price.id,
}),
});
const json = await response.json();
res.then(async (res) => {
const json = await res.json();
if (json.error) {
console.error(json.error);
setIsLoading(false);
return;
}
if (json.error) {
console.error(json.error);
if (json.url) {
window.location.href = json.url;
} else {
setIsLoading(false);
toast.error("Error creating session");
console.error("Error creating session");
}
});
}
const loading = products.isLoading || prices.isLoading || isLoading;
function formatAmount(price: number) {
if (!price) {
return "";
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price / 100);
}
function getAmountFromProduct(prodId: string) {
const price = prices.data?.find(
(p) =>
p.price.product === prodId && p.price.recurring?.interval === interval
);
if (!price) {
return;
}
console.log(json);
if (json.url) {
window.location.href = json.url;
} else {
console.error("Error creating session");
const amount = price.price.unit_amount;
if (!amount) {
return;
}
setLoading(false);
return formatAmount(amount);
}
if (loading) {
return (
<div className="flex w-full items-center justify-center py-24">
<Loader className="animate-spin text-orange-500" size={24} />
</div>
);
}
return (
<div>
<h2 className="text-xl font-medium">Pick a subscription</h2>
<p className="text-zinc-500">You can cancel anytime</p>
<Tabs
className="mt-4"
value={interval}
onValueChange={setInterval as any}
>
<TabsList>
<TabsTrigger value="year">
Yearly
<span className="ml-2 rounded-full bg-orange-100 px-1.5 py-0.5 text-xs text-orange-600">
Best
</span>
</TabsTrigger>
<TabsTrigger value="month">Monthly</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-4 ">
{products.data?.map((product) => (
<div
key={product.id}
className="relative max-w-sm rounded-xl border border-b-2 border-orange-500 p-3"
>
{interval === "year" && (
<span className="absolute -top-3 right-4 rounded-full border border-b-2 border-orange-500 bg-orange-100 px-2 py-1 text-xs font-medium text-orange-600">
2 Months Free!
</span>
)}
<h3 className="text-lg font-semibold">{product.product.name}</h3>
<p className="text-zinc-500">{product.product.description}</p>
<ul>
{product.product.features.map((feat, featIdx) => (
<li key={product.id + "-feat-" + featIdx}>{feat.name}</li>
))}
</ul>
<h4 className="mt-4 text-sm font-bold text-zinc-500">Price</h4>
<div className="text-xl font-semibold">
{getAmountFromProduct(product.product.id)}
<span className="text-sm text-zinc-400">/{interval}</span>
</div>
<div className="mt-4 flex">
<Button onClick={() => openCheckoutPage(product.product.id)}>
Subscribe to {product.product.name}
</Button>
</div>
</div>
))}
</div>
</div>
);
};
const AccountPage = (props: Props) => {
const [loading, setLoading] = React.useState(false);
const user = useUser();
const teams = useTeamsQuery();
async function onManageSubscriptionClick() {
setLoading(true);
const response = await fetch("/api/customer-portal", {
@@ -45,26 +174,55 @@ const AccountPage = (props: Props) => {
const json = await response.json();
if (json.error) {
toast.error(json.error);
console.error(json.error);
setLoading(false);
return;
}
if (json.session) {
window.location.href = json.session;
} else {
toast.error(json.error);
console.error("Error creating session");
setLoading(false);
}
setLoading(false);
}
const subscription = useSubscriptionQuery();
function formatDate(date: string) {
return new Date(date).toLocaleDateString();
}
return (
<AppLayout>
<AppLayout loading={loading || subscription.isLoading}>
<div className="mx-auto max-w-5xl px-4 py-12">
<h1 className="text-xl font-medium">Account settings</h1>
<section className="my-4 rounded-xl border bg-white p-4 shadow-sm">
<section className="my-4 rounded-xl border border-b-2 bg-white p-4">
<h2 className="text-lg font-medium">Account</h2>
<div className="mt-4 max-w-lg divide-y *:grid *:grid-cols-2 *:p-2">
<div>
<div>Email</div>
<div className="font-mono">{user?.email}</div>
</div>
<div>
<div>Created at</div>
<div className="font-mono">
{formatDate(user?.created_at || "")}
</div>
</div>
</div>
{/* <h3 className="mt-8 font-medium">Teams</h3>
<ul>
{teams.data?.map((team) => (
<li key={team.id}>{team.name}</li>
))}
</ul> */}
</section>
<section className="my-4 rounded-xl border border-b-2 bg-white p-4">
<h2 className="text-lg font-medium">Subscription details</h2>
{subscription.isLoading ? (
<></>
@@ -78,32 +236,46 @@ const AccountPage = (props: Props) => {
) : (
<span>
<span className="rounded-md bg-zinc-100 px-3 py-1 font-mono">
{subscription.data?.status}
{subscription.data?.status || "Not found"}
</span>
</span>
)}
</p>
)}
<div className="mt-8 flex gap-3">
<div className="">
{loading ? (
<pre>Loading...</pre>
) : (
<>
{subscription.data?.status !== "active" ? (
<Button onClick={onSubscribeClick}>
Subcribe to Pro plan
</Button>
<>
<hr className="my-8 max-w-lg" />
<SubscribeSection />
</>
) : (
<Button
variant="secondary"
onClick={onManageSubscriptionClick}
>
Manage subscription
</Button>
<>
{/* <pre>{JSON.stringify(subscription.data, null, 2)}</pre> */}
</>
)}
</>
)}
</div>
<hr className="my-6 max-w-lg" />
<h3 className="text-lg font-medium">Manage your subscription</h3>
<p className="text-zinc-500">
Check invoices, billing and payment information.
</p>
<Button
className="mt-4"
variant="secondary"
onClick={onManageSubscriptionClick}
>
<Landmark />
Manage subscription
</Button>
</section>
</div>
</AppLayout>
@@ -4,12 +4,13 @@ import {
createOrRetrieveCustomer,
createStripeClient,
} from "@/lib/server/stripe";
import { STRIPE_CONSTANTS } from "@/lib/server/stripe.constants";
import { BASE_URL } from "@/lib/config";
const handler: NextApiHandler = async (req, res) => {
try {
const stripe = createStripeClient();
const { user } = await getServerClient(req, res);
const price_id = req.body.price_id;
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
@@ -19,6 +20,10 @@ const handler: NextApiHandler = async (req, res) => {
return res.status(400).json({ error: "User email not found" });
}
if (!price_id) {
return res.status(400).json({ error: "Product id not found" });
}
const customer = await createOrRetrieveCustomer({
userId: user.id,
email: user.email,
@@ -28,14 +33,14 @@ const handler: NextApiHandler = async (req, res) => {
customer: customer.id,
line_items: [
{
price: STRIPE_CONSTANTS.products.proPlan.yearlyPriceId,
price: price_id,
quantity: 1,
},
],
mode: "subscription",
allow_promotion_codes: true,
success_url: `http://localhost:3000/account?success=true`,
cancel_url: `http://localhost:3000/account?canceled=true`,
success_url: `${BASE_URL}/account?success=true`,
cancel_url: `${BASE_URL}/account?canceled=true`,
});
if (!session.url) {
+27 -12
View File
@@ -4,12 +4,12 @@ import {
createOrRetrieveCustomer,
createStripeClient,
} from "@/lib/server/stripe";
import { STRIPE_CONSTANTS } from "@/lib/server/stripe.constants";
import Stripe from "stripe";
const handler: NextApiHandler = async (req, res) => {
try {
const stripe = createStripeClient();
const { user } = await getServerClient(req, res);
const { user, db } = await getServerClient(req, res);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
@@ -24,6 +24,26 @@ const handler: NextApiHandler = async (req, res) => {
email: user.email,
});
const products = await db.from("products").select("*");
const prices = await db.from("prices").select("*");
if (!products.data || !prices.data) {
return res
.status(500)
.json({ error: "Error fetching products and prices" });
}
const subscriptionUpdateConfig = products.data?.map((product) => ({
product: product.stripe_product_id,
prices: prices.data
?.filter(
(price) =>
(price.price as unknown as Stripe.Price).product ===
product.stripe_product_id
)
.map((price) => price.stripe_price_id),
}));
const configuration = await stripe.billingPortal.configurations.create({
features: {
customer_update: {
@@ -56,16 +76,11 @@ const handler: NextApiHandler = async (req, res) => {
subscription_update: {
enabled: true,
proration_behavior: "create_prorations",
default_allowed_updates: ["price"],
products: [
{
product: STRIPE_CONSTANTS.products.proPlan.productId,
prices: [
STRIPE_CONSTANTS.products.proPlan.monthlyPriceId,
STRIPE_CONSTANTS.products.proPlan.yearlyPriceId,
],
},
],
default_allowed_updates: ["price", "promotion_code"],
products: subscriptionUpdateConfig.map((config) => ({
product: config.product!,
prices: config.prices,
})),
},
},
business_profile: {
@@ -1,5 +1,4 @@
import { createAdminClient } from "@/lib/server/supabase";
import { getSupabaseClient } from "@/lib/supabase";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
@@ -1,5 +1,4 @@
import { createAdminClient } from "@/lib/server/supabase";
import { getSupabaseClient } from "@/lib/supabase";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
@@ -9,6 +9,9 @@ export default async function handler(
const { db, user } = await getServerClient(req, res);
const blogId = req.query.blogId as string;
if (!user?.id) {
return res.status(401).json({ error: "Unauthorized" });
}
if (req.method === "PATCH") {
const data = PatchBlog.safeParse(JSON.parse(req.body));
@@ -11,23 +11,34 @@ export default async function handler(
const { db, user } = await getServerClient(req, res);
const blogId = req.query.blogId as string;
const postSlug = req.query.postSlug as string;
const userId = user?.id;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
if (req.method === "PATCH") {
const data = PatchPost.safeParse(JSON.parse(req.body));
console.log("----> data", data);
if (!data.success) {
return res.status(400).json({ error: data.error.message });
}
const { title, slug, content, published, cover_image } = data.data;
const { title, slug, content, published, cover_image, metadata } =
data.data;
const updated_at = new Date().toISOString();
const { data: post, error } = await db
.from("posts")
.update({ title, content, slug, published, cover_image, updated_at })
.update({
title,
content,
slug,
published,
cover_image,
updated_at,
metadata,
})
.eq("blog_id", blogId)
.eq("user_id", user?.id)
.eq("slug", postSlug)
@@ -25,7 +25,7 @@ export default async function handler(
) {
const { db } = await getServerClient(req, res);
// const { userId } = getAuth(req);
const blogId = req.query.blogId;
const blogId = req.query.blogId as string;
if (!db) return res.status(401).json({ error: "Unauthorized" });
+110 -67
View File
@@ -1,57 +1,74 @@
import { env } from "@/env.mjs";
import { NextApiHandler } from "next";
import { createStripeClient } from "@/lib/server/stripe";
import getRawBody from "raw-body";
import { createAdminClient } from "@/lib/server/supabase";
import Stripe from "stripe";
const stripe = createStripeClient();
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
async function updateSubscription(event: Stripe.Event) {
const stripe = createStripeClient();
const supabase = createAdminClient();
async function upsertSubscription(event: Stripe.Event) {
if (
event.type !== "customer.subscription.updated" &&
event.type !== "customer.subscription.deleted"
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const cusId = event.data.object.customer;
if (typeof cusId !== "string" || !cusId) {
console.error("Invalid customer id");
throw new Error("Invalid customer id");
}
const customer = await stripe.customers.retrieve(cusId);
const userId = customer.deleted ? null : customer.metadata.userId;
if (!userId) {
console.error("Invalid user id");
throw new Error("Invalid user id");
}
const res = await supabase.from("subscriptions").upsert(
{
stripe_subscription_id: event.data.object.id,
user_id: userId,
status: event.data.object.status,
subscription: event.data.object as any,
},
{
onConflict: "user_id",
}
);
console.log(res);
}
}
async function upsertProduct(event: Stripe.Event) {
if (
event.type !== "product.created" &&
event.type !== "product.updated" &&
event.type !== "product.deleted"
) {
throw new Error("Invalid event type");
}
const cusId = event.data.object.customer;
if (typeof cusId !== "string" || !cusId) {
throw new Error("Invalid customer id");
}
const customer = await stripe.customers.retrieve(cusId);
const userId = customer.deleted ? null : customer.metadata.userId;
if (!userId) {
throw new Error("Invalid user id");
}
// Update customer in database
const supabase = createAdminClient();
console.log(
"🟢 UPDATING SUBSCRIPTION STATUS: ",
event.data.object.cancellation_details,
userId
);
await supabase.from("subscriptions").upsert({
user_id: userId,
status: event.data.object.status,
event: event,
});
}
async function upsertProduct(event: Stripe.Event) {
if (event.type !== "product.created") {
throw new Error("Invalid event type");
}
const product = event.data.object;
if (!product.active) {
return;
}
if (typeof product.id !== "string" || !product.id) {
throw new Error("Invalid product id");
}
@@ -59,12 +76,27 @@ async function upsertProduct(event: Stripe.Event) {
// Update product in database
const supabase = createAdminClient();
console.log("🟢 CREATING PRODUCT: ", product.id);
await supabase.from("products").upsert({
id: product.id,
name: product.name,
active: product.active,
stripe_product_id: product.id,
product: product as any,
});
}
async function upserCustomer(event: Stripe.Event) {
if (event.type !== "customer.created") {
throw new Error("Invalid event type");
}
const customer = event.data.object;
if (typeof customer.id !== "string" || !customer.id) {
throw new Error("Invalid customer id");
}
// Update customer in database
await supabase.from("customers").upsert({
id: customer.id,
email: customer.email,
});
}
@@ -75,6 +107,10 @@ async function upsertPrice(event: Stripe.Event) {
const price = event.data.object;
if (!price.active) {
return;
}
if (typeof price.id !== "string" || !price.id) {
throw new Error("Invalid price id");
}
@@ -82,14 +118,9 @@ async function upsertPrice(event: Stripe.Event) {
// Update price in database
const supabase = createAdminClient();
console.log("🟢 CREATING PRICE: ", price.id);
await supabase.from("prices").upsert({
id: price.id,
product_id: price.product,
currency: price.currency,
unit_amount: price.unit_amount,
active: price.active,
stripe_price_id: price.id,
price: price as any,
});
}
@@ -126,23 +157,35 @@ const handler: NextApiHandler = async (req, res) => {
return;
}
if (event.type === "customer.subscription.updated") {
console.log("🟢 UPDATED");
await updateSubscription(event);
res.status(200).send("success");
} else if (event.type === "customer.subscription.deleted") {
console.log("🟢 DELETED");
await updateSubscription(event);
res.status(200).send("success");
} else if (event.type === "product.created") {
console.log("🟢 PRODUCT CREATED");
type EventHandler = (event: Stripe.Event) => Promise<void>;
type EventKey = Stripe.Event["type"];
type EventMap = {
[key in EventKey]?: EventHandler;
};
res.status(200).send("success");
} else {
return res
.status(200)
.send(`Unhandled event type: ${event.type} ${event.id}`);
const eventMap: EventMap = {
"customer.subscription.created": upsertSubscription,
"customer.subscription.updated": upsertSubscription,
"customer.subscription.deleted": upsertSubscription,
"product.created": upsertProduct,
"product.updated": upsertProduct,
"product.deleted": upsertProduct,
"price.created": upsertPrice,
"price.updated": upsertPrice,
"price.deleted": upsertPrice,
};
const handler = eventMap[event.type];
if (!handler) {
// If there isnt a handler just return 200, we dont need to handle this event
res.status(200).send("OK");
return;
}
console.log("🟢 Stripe: ", event.type);
await handler(event);
res.status(200).send("OK");
} catch (error: any) {
console.error(error.message);
res.status(500).json({ error: "Error updating subscription" });
+1 -1
View File
@@ -1,5 +1,5 @@
import { BlogSelector } from "@/components/Blogs/BlogSelector";
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useRouter } from "next/router";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -1,12 +1,12 @@
import { useRouter } from "next/router";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { ZendoEditor } from "@/components/Editor/ZendoEditor";
import { toast } from "sonner";
import { getSupabaseBrowserClient } from "@/lib/supabase";
export default function CreatePost() {
const router = useRouter();
const blogId = router.query.blogId as string;
const supa = useSupabaseClient();
const supa = getSupabaseBrowserClient();
return (
<ZendoEditor
@@ -1,97 +1,10 @@
import { useRouter } from "next/router";
import { Editor, EditorContent, JSONContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useState } from "react";
import {
BoldIcon,
CodeIcon,
ItalicIcon,
PenLine,
Pencil,
SaveIcon,
Strikethrough,
Trash,
Trash2Icon,
Undo2,
} from "lucide-react";
import { PiArrowBendUpLeftBold, PiCodeBlock } from "react-icons/pi";
import Heading from "@tiptap/extension-heading";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createAPIClient } from "@/lib/app/api";
import { ContentRenderer } from "@/components/Content/ContentRenderer";
import { createAPIClient } from "@/lib/http/api";
import Spinner from "@/components/Spinner";
import { CgArrowTopLeft, CgWebsite } from "react-icons/cg";
import { BsFillImageFill } from "react-icons/bs";
import Link from "next/link";
import { ImagePicker } from "@/components/Images/ImagePicker";
import { Button } from "@/components/ui/button";
import { BlogImage } from "@/lib/types/BlogImage";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ZendoEditor } from "@/components/Editor/ZendoEditor";
import { toast } from "sonner";
// function EditorMenuButton({
// children,
// active,
// ...props
// }: {
// children: React.ReactNode;
// active: boolean;
// } & React.ComponentPropsWithoutRef<"button">) {
// const className = `p-2 rounded-md hover:bg-slate-100/80 text-slate-400 hover:text-slate-600 ${
// active ? "text-orange-500" : ""
// }`;
// return (
// <button type="button" className={className} {...props}>
// {children}
// </button>
// );
// }
// function EditorMenu({ editor }: { editor: Editor | null }) {
// const SIZE = 18;
// const menuButtons = [
// {
// icon: <BoldIcon size={SIZE} />,
// command: () => editor?.chain().focus().toggleBold().run(),
// },
// {
// icon: <ItalicIcon size={SIZE} />,
// command: () => editor?.chain().focus().toggleItalic().run(),
// },
// {
// icon: <Strikethrough size={SIZE} />,
// command: () => editor?.chain().focus().toggleStrike().run(),
// },
// {
// icon: <CodeIcon size={SIZE} />,
// command: () => editor?.chain().focus().toggleCode().run(),
// },
// {
// icon: <PiCodeBlock size={SIZE} />,
// command: () => editor?.chain().focus().toggleCodeBlock().run(),
// },
// ];
// return (
// <div className="flex rounded-2xl bg-white p-1">
// {menuButtons.map(({ icon, command }, i) => (
// <EditorMenuButton
// active={editor?.isActive(command) || false}
// key={i}
// onClick={() => command()}
// >
// {icon}
// </EditorMenuButton>
// ))}
// </div>
// );
// }
import { getSupabaseBrowserClient } from "@/lib/supabase";
export default function Post() {
const api = createAPIClient();
@@ -101,21 +14,11 @@ export default function Post() {
const blogId = router.query.blogId as string;
const postSlug = router.query.postSlug as string;
const {
data: post,
isLoading,
error: postError,
} = useQuery(["posts", router.query.blogId, router.query.postSlug], () =>
api.posts.get(blogId, postSlug)
const { data: post, isLoading } = useQuery(
["posts", router.query.blogId, router.query.postSlug],
() => api.posts.get(blogId, postSlug)
);
const deletePost = useMutation({
mutationFn: () => api.posts.delete(blogId, postSlug),
onSuccess: () => {
window.location.reload();
},
});
const updatePost = useMutation({
mutationFn: (
data: Partial<{
@@ -124,6 +27,7 @@ export default function Post() {
slug: string;
cover_image?: string;
content?: any;
metadata?: any;
}>
) => api.posts.update(blogId, postSlug, data),
onSuccess: () => {
@@ -143,6 +47,7 @@ export default function Post() {
<ZendoEditor
onSave={async (content) => {
try {
console.log(content.metadata);
await updatePost.mutateAsync(content);
toast.success("Post saved!");
} catch (error) {
@@ -150,7 +55,7 @@ export default function Post() {
console.error(error);
}
}}
post={post as any} // TODO: rm any
post={post}
/>
</div>
);
+38 -14
View File
@@ -1,10 +1,9 @@
/* eslint-disable @next/next/no-img-element */
import Spinner from "@/components/Spinner";
import AppLayout from "@/layouts/AppLayout";
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useRouter } from "next/router";
import { IoSettingsSharp } from "react-icons/io5";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { MoreVertical, Trash } from "lucide-react";
@@ -14,6 +13,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { toast } from "sonner";
export function StatePill({ published }: { published: boolean }) {
const text = published ? "Published" : "Draft";
@@ -33,12 +34,27 @@ export function StatePill({ published }: { published: boolean }) {
export default function BlogPosts() {
const router = useRouter();
const blogId = router.query.blogId as string;
const queryClient = useQueryClient();
const api = createAPIClient();
const { isLoading, data, error } = useQuery(["posts", blogId], () =>
api.posts.getAll(blogId)
);
const supabase = getSupabaseBrowserClient();
const deletePostMutation = useMutation({
mutationFn: async (postId: string) => {
await supabase.from("posts").update({ deleted: true }).eq("id", postId);
},
onMutate: async (postId: string) => {
await queryClient.cancelQueries(["posts", blogId]);
},
onSettled: () => {
queryClient.invalidateQueries(["posts", blogId]);
},
});
function getFormattedPosts() {
if (!data || !data.posts) return [];
@@ -60,14 +76,6 @@ export default function BlogPosts() {
return sortedPosts;
}
if (isLoading) {
return (
<AppLayout>
<Spinner />
</AppLayout>
);
}
if (error) {
return (
<AppLayout>
@@ -79,7 +87,7 @@ export default function BlogPosts() {
if (data) {
const { blog, posts } = data;
return (
<AppLayout>
<AppLayout loading={isLoading}>
<div className="mx-auto mt-8 max-w-5xl p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">
@@ -128,7 +136,7 @@ export default function BlogPosts() {
return (
<Link
href={`/blogs/${blogId}/post/${post.slug}`}
className="flex items-center gap-4 rounded-sm p-3 hover:bg-slate-100/60"
className="flex items-center gap-4 rounded-sm p-3 hover:bg-zinc-100/60"
key={post.slug}
>
{post.cover_image && (
@@ -160,7 +168,23 @@ export default function BlogPosts() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => {}}>
<DropdownMenuItem
onClick={async (e) => {
e.stopPropagation();
e.preventDefault();
const confirmed = window.confirm(
"Are you sure you want to delete this post?"
);
if (!confirmed) return;
try {
toast.success("Post deleted");
await deletePostMutation.mutateAsync(post.id);
} catch (error) {
toast.error("Failed to delete post");
console.error(error);
}
}}
>
<Trash size="16" />
<span className="ml-2">Delete</span>
</DropdownMenuItem>
@@ -1,5 +1,5 @@
import AppLayout from "@/layouts/AppLayout";
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { PatchBlog } from "@/lib/models/blogs/Blogs";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
@@ -188,11 +188,14 @@ const cms = createClient({
</h3>
</section>
<section className="section border border-red-300 bg-gradient-to-b from-white to-red-50 p-3 text-red-600">
<h2 className="mb-4 text-lg font-medium">🚨 Danger zone</h2>
<p className="text-sm">
This action cannot be undone. This will permanently delete the blog.
This will also delete all posts in the blog.
<section className="section border border-red-500 bg-white p-3 ">
<h2 className="mb-4 text-lg font-medium text-red-600">
🚨 Danger zone
</h2>
<p className="space-y-4">
<div>This action cannot be undone.</div>
<div>This will permanently delete the blog.</div>
<div>This will also delete all posts in the blog.</div>
</p>
<div className="actions">
<Button
+1 -1
View File
@@ -84,7 +84,7 @@ export default function CreateBlog() {
if (!isSubscribed) {
return (
<AppLayout>
<div className="section mx-8 my-12 py-12">
<div className="section mx-8 mx-auto my-12 max-w-xl py-12">
<div className="text-center text-4xl">🚫</div>
<h2 className="mt-2">
<span className="block text-center text-3xl font-semibold">
+2 -10
View File
@@ -1,6 +1,6 @@
import Spinner from "@/components/Spinner";
import AppLayout from "@/layouts/AppLayout";
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useBlogsQuery } from "@/queries/blogs";
import Link from "next/link";
import { IoSettingsSharp, IoAdd } from "react-icons/io5";
@@ -11,17 +11,9 @@ import { useEffect } from "react";
import { Plus } from "lucide-react";
export default function Dashboard() {
const api = createAPIClient();
const { data, error, isLoading } = useBlogsQuery();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
if (data?.length === 0) {
router.push("/blogs/create");
}
}, [router, data, isLoading]);
return (
<AppLayout>
<div className="mt-8 min-h-screen">
@@ -64,7 +56,7 @@ export default function Dashboard() {
>
<div className="flex items-center gap-4">
<div>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-orange-100 text-3xl transition-all group-hover:scale-105">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-orange-100 text-3xl transition-all group-hover:scale-110">
{blog.emoji}
</div>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { BlogSelector } from "@/components/Blogs/BlogSelector";
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useRouter } from "next/router";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
+41 -43
View File
@@ -3,30 +3,20 @@ import Link from "next/link";
import { FaTwitter } from "react-icons/fa";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { getSupabaseClient } from "@/lib/supabase";
import { useEffect, useState } from "react";
import { useState } from "react";
import ZendoLogo from "@/components/ZendoLogo";
import { useUser } from "@supabase/auth-helpers-react";
import { LoggedInUser } from "@/components/LoggedInUser";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { StarIcon } from "lucide-react";
import Footer from "@/components/Footer";
import { useUser } from "@/utils/supabase/browser";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { SubscribeSection } from "./account";
const Home = () => {
const user = useUser();
useEffect(() => {
const client = getSupabaseClient();
client.auth.getSession().then((res) => {
console.log("sess", res);
});
client.auth.getUser().then((res) => {
console.log("user", res);
});
}, []);
const [hasSubmitted, setHasSubmitted] = useState(false);
const formSchema = z.object({
@@ -41,7 +31,7 @@ const Home = () => {
const onSubmit = handleSubmit(async (data) => {
const formData = formSchema.parse(data);
const sb = getSupabaseClient();
const sb = getSupabaseBrowserClient();
await sb.from("homepage_signup").insert(formData);
@@ -65,7 +55,7 @@ const Home = () => {
</div>
<div className="flex flex-grow items-center justify-end gap-4 font-medium text-zinc-600">
<Link href="/blog">Blog</Link>
{/* <Link href="/blog">Blog</Link> */}
<LoggedInUser>
<Link
className="rounded-full px-3 py-1.5 hover:text-zinc-800"
@@ -85,16 +75,18 @@ const Home = () => {
</Link>
{!user && (
<Button asChild>
<Link
href="/sign-in"
className="btn btn-primary inline-block"
title="Sign in"
aria-label="Sign in"
>
Sign in
</Link>
</Button>
<div className="space-x-1.5">
<Button asChild variant={"outline"}>
<Link href="/sign-in" title="Sign in" aria-label="Sign in">
Log in
</Link>
</Button>
<Button asChild>
<Link href="/sign-up" title="Sign up" aria-label="Sign up">
Sign up
</Link>
</Button>
</div>
)}
{user && (
@@ -133,15 +125,28 @@ const Home = () => {
in 2 minutes
</span>
</h1>
<p className="mt-2 text-lg font-light text-zinc-500">
Open source, headless, blogging CMS.
</p>
<div className="mt-4 text-lg font-light text-zinc-500">
<ul className="space-y-3">
<li>Open source.</li>
<li>Headless, works with any stack.</li>
<li>Type safe content.</li>
<li>Great editing experience.</li>
<li>Easy to extend.</li>
<li>Gets you up and running in 2 minutes.</li>
</ul>
</div>
</div>
<hr className="my-12" />
{!hasSubmitted && (
<form
className="mt-6 flex max-w-sm flex-col gap-2"
className="mt-6 flex max-w-sm flex-col gap-2 pb-12"
onSubmit={onSubmit}
>
<h2>
<span className="font-serif text-2xl font-extralight italic text-zinc-800">
Be the first to try it.
</span>
</h2>
<div className="flex gap-2 [&>*]:w-full">
<label htmlFor="name">
<Input
@@ -182,20 +187,13 @@ const Home = () => {
</p>
</div>
)}
</main>
<div className="mx-4 mt-12 flex max-w-xl flex-col gap-4 py-6 font-mono text-zinc-800">
<h2 className="text-lg font-medium"># what you get</h2>
<ul className="flex flex-col gap-3">
<li>- Open source.</li>
<li>- Type safe content with the TS client.</li>
<li>- Great editing experience.</li>
<li>- Works with any stack.</li>
<li>- Easy to extend.</li>
<li>- Gets you up and running in 2 minutes.</li>
<li>- Have as many blogs as you want.</li>
</ul>
</div>
<hr className="my-12" />
<section className="pb-8">
<SubscribeSection />
</section>
</main>
</div>
<Footer />
</div>
@@ -1,13 +1,13 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useState } from "react";
export default function ResetPasswordConfirmation() {
const [loading, setLoading] = useState(false);
const supabase = useSupabaseClient();
const supabase = getSupabaseBrowserClient();
const router = useRouter();
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
+21 -8
View File
@@ -1,14 +1,16 @@
import Spinner from "@/components/Spinner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useState } from "react";
export default function ResetPassword() {
const [loading, setLoading] = useState(false);
const [step1Success, setStep1Success] = useState(false);
const supabase = useSupabaseClient();
const supabase = getSupabaseBrowserClient();
const router = useRouter();
async function onSubmitStep1(e: React.FormEvent<HTMLFormElement>) {
@@ -26,6 +28,8 @@ export default function ResetPassword() {
if (error) {
alert(error.message);
setLoading(false);
return;
}
setStep1Success(true);
@@ -38,8 +42,9 @@ export default function ResetPassword() {
<div className="py-40 text-center">
<h2 className="text-2xl font-medium">Reset password</h2>
<p className="text-slate-500">
We`ve sent you a link to reset your password.
We have sent you a link to reset your password.
</p>
<Link href="https://gmail.com">Open gmail</Link>
</div>
</>
);
@@ -53,11 +58,19 @@ export default function ResetPassword() {
>
<h2 className="text-2xl font-medium">Reset password</h2>
<p className="text-slate-500">
We`ll send you a link to reset your password.
We will send you a link to reset your password.
</p>
<Label htmlFor="email">Email</Label>
<Input type="email" name="email" id="email" />
<Button type="submit">Send reset link</Button>
{loading ? (
<p>
<Spinner />
</p>
) : (
<>
<Label htmlFor="email">Email</Label>
<Input type="email" name="email" id="email" />
<Button type="submit">Send reset link</Button>
</>
)}
</form>
</>
);
+36 -48
View File
@@ -1,26 +1,29 @@
import ZendoLogo from "@/components/ZendoLogo";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useUser } from "@/utils/supabase/browser";
import { TabsContent } from "@radix-ui/react-tabs";
import { useSupabaseClient, useUser } from "@supabase/auth-helpers-react";
import { CornerUpLeft } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { HiArrowLeft } from "react-icons/hi";
export default function SignIn() {
const [loading, setLoading] = useState(false);
const supabase = useSupabaseClient();
const supabase = getSupabaseBrowserClient();
const user = useUser();
const router = useRouter();
useEffect(() => {
if (user?.email) {
router.push("/blogs");
}
}, [user, router]);
supabase.auth.getSession().then((res) => {
if (res.data.session?.user) {
router.push("/blogs");
}
});
}, [router, supabase]);
async function onSubmitMagicLink(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -49,9 +52,9 @@ export default function SignIn() {
e.preventDefault();
setLoading(true);
const form = e.currentTarget;
const email = form.email.value;
const password = form.password.value;
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const { data, error } = await supabase.auth.signInWithPassword({
email,
@@ -70,48 +73,33 @@ export default function SignIn() {
return (
<div className="mx-auto my-32 flex max-w-sm flex-col gap-4">
<div>
<Link className="text-slate-400" href="/">
<Link className="text-zinc-400" href="/">
<CornerUpLeft size={18} />
</Link>
</div>
<Tabs defaultValue="password">
<TabsList>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="magic-link">Magic Link</TabsTrigger>
</TabsList>
<form className="mt-4 flex flex-col gap-2" onSubmit={onSubmit}>
<h1 className="text-2xl font-medium">Sign in with Password</h1>
<TabsContent value="password">
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit}>
<h1 className="text-2xl font-medium">Sign in with Password</h1>
<div>
<Label htmlFor="password">Email</Label>
<Input required type="email" name="email" />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input required type="password" name="password" />
</div>
<Button type="submit">Sign in</Button>
</form>
</TabsContent>
<TabsContent value="magic-link">
<form
className="mt-4 flex flex-col gap-4"
onSubmit={onSubmitMagicLink}
>
<h1 className="text-2xl font-medium">Sign in with Magic Link</h1>
<div>
<Label htmlFor="email">Email</Label>
<Input required type="email" name="email" />
</div>
<Button type="submit">Sign in</Button>
</form>
</TabsContent>
</Tabs>
<Link className="link" href="/reset-password">
Forgot your password?
</Link>
<div className="mt-4">
<Label htmlFor="email">Email</Label>
<Input required type="email" name="email" />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input required type="password" name="password" />
</div>
<div className="mt-2 flex flex-col">
<Button type="submit">Sign in</Button>
</div>
</form>
<div className="grid gap-4">
<Link className="text-zinc-500" href="/reset-password">
Forgot your password?
</Link>
<Link className="text-zinc-500" href="/sign-up">
Don&apos;t have an account?
</Link>
</div>
</div>
);
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useEffect } from "react";
export default function SignOut() {
const supa = useSupabaseClient();
const supa = getSupabaseBrowserClient();
useEffect(() => {
supa.auth.signOut().then((res) => {
@@ -12,5 +12,5 @@ export default function SignOut() {
});
}, [supa]);
return <>Signing out...</>;
return <></>;
}
+106
View File
@@ -0,0 +1,106 @@
import Spinner from "@/components/Spinner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { CornerUpLeft } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { toast } from "sonner";
export default function SignIn() {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const supabase = getSupabaseBrowserClient();
const router = useRouter();
useEffect(() => {
supabase.auth.getSession().then((res) => {
if (res.data.session?.user) {
router.push("/blogs");
}
});
}, [router, supabase]);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
try {
const form = e.currentTarget;
const email = form.email.value;
const password = form.password.value;
const sb = getSupabaseBrowserClient();
const { data, error } = await sb.auth.signUp({
email,
password,
});
if (error) {
console.error(error);
throw error;
}
setSuccess(true);
} catch (error) {
console.error("Error creating account", error);
toast.error("Error creating account");
}
setLoading(false);
}
if (success) {
return (
<div className="mx-auto my-32 flex max-w-sm flex-col gap-4">
<p className="text-4xl">🚀</p>
<h1 className="text-2xl font-medium">Account created!</h1>
<p className="bg-white">
Please, <span className="underline">check your email</span> 📧 to
confirm your account.
</p>
<div className="">
<p className="text-sm">You can close this tab.</p>
</div>
<div className="text-zinc-200">
<p className="text-xs">or not, i don&apos;t care.</p>
<p className="text-xs">it&apos;s your computer.</p>
</div>
</div>
);
}
return (
<div className="mx-auto my-32 flex max-w-sm flex-col gap-4">
<div>
<Link className="text-slate-400" href="/">
<CornerUpLeft size={18} />
</Link>
</div>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit}>
<h1 className="text-2xl font-medium">Create your account</h1>
<div>
<Label htmlFor="email">Email</Label>
<Input required type="email" name="email" />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input required type="password" name="password" />
</div>
{loading ? (
<Spinner />
) : (
<>
<Button type="submit">Create account</Button>
</>
)}
</form>
<Link className="text-zinc-500" href="/sign-in">
Already have an account?
</Link>
</div>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import Spinner from "@/components/Spinner";
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useRouter } from "next/router";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
+1 -1
View File
@@ -1,4 +1,4 @@
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const api = createAPIClient();
+1 -1
View File
@@ -1,4 +1,4 @@
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useQuery } from "@tanstack/react-query";
const api = createAPIClient();
+1 -1
View File
@@ -1,4 +1,4 @@
import { createAPIClient } from "@/lib/app/api";
import { createAPIClient } from "@/lib/http/api";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/router";
+22
View File
@@ -0,0 +1,22 @@
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useQuery } from "@tanstack/react-query";
import Stripe from "stripe";
const PRICES_KEYS = ["prices"];
export function usePricesQuery() {
const sb = getSupabaseBrowserClient();
return useQuery(PRICES_KEYS, async () => {
const { data, error } = await sb.from("prices").select("*");
if (error) {
console.error(error);
throw error;
}
type DataItemType = (typeof data)[0] & { price: Stripe.Price };
return data as DataItemType[];
});
}
+20
View File
@@ -0,0 +1,20 @@
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useQuery } from "@tanstack/react-query";
import Stripe from "stripe";
export function useProductsQuery() {
const sb = getSupabaseBrowserClient();
return useQuery(["products"], async () => {
const { data, error } = await sb.from("products").select("*");
if (error) {
console.error(error);
throw error;
}
type DataItemType = (typeof data)[0] & { product: Stripe.Product };
return data as DataItemType[];
});
}
+4 -22
View File
@@ -1,38 +1,20 @@
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useQuery } from "@tanstack/react-query";
const SUBSCRIPTION_KEYS = ["subscription"];
export function useSubscriptionQuery() {
const sb = useSupabaseClient();
const sb = getSupabaseBrowserClient();
return useQuery(SUBSCRIPTION_KEYS, async () => {
const { data, error } = await sb
.from("subscriptions")
.select("status")
.single();
const { data, error } = await sb.from("subscriptions").select("*").limit(1);
if (error) {
console.error(error);
throw error;
}
return { ...data, keys: SUBSCRIPTION_KEYS };
});
}
export function useProductsQuery() {
const sb = useSupabaseClient();
return useQuery(["products"], async () => {
const { data, error } = await sb.from("products").select("*");
if (error) {
console.error(error);
throw error;
}
return data;
return data[0];
});
}
+42
View File
@@ -0,0 +1,42 @@
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { useMutation, useQuery } from "@tanstack/react-query";
const keys = {
teams: () => ["teams"],
team: (teamId: string) => ["team", teamId],
};
export function useTeamsQuery() {
const sb = getSupabaseBrowserClient();
return useQuery(keys.teams(), async () => {
const { data, error } = await sb.from("teams").select("*");
if (error) {
throw error;
}
return data;
});
}
export function useCreateTeamMutation() {
const sb = getSupabaseBrowserClient();
return useMutation(
async ({ owner_id, name }: { owner_id: string; name: string }) => {
const { data, error } = await sb.from("teams").insert({
owner_id,
name,
});
console.log("create team", data, error);
if (error) {
throw error;
}
return data;
}
);
}
+56
View File
@@ -0,0 +1,56 @@
// Sync stripe data with supabase database
import "dotenv/config";
import { createStripeClient } from "@/lib/server/stripe";
import { createAdminClient } from "@/lib/server/supabase";
const supabase = createAdminClient();
const stripe = createStripeClient();
async function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// async function upsertSubscriptions() {
// const subscriptions = await stripe.subscriptions.list({ limit: 100 });
// for (const subscription of subscriptions.data) {
// await supabase.from("subscriptions").upsert({
// user_id: subscription.customer,
// status: subscription.status,
// stripe_subscription_id: subscription.id,
// });
// }
// }
async function upsertProducts() {
const products = await stripe.products.list({ limit: 100, active: true });
for (const product of products.data) {
await supabase.from("products").upsert({
stripe_product_id: product.id,
product: product as any,
});
}
}
async function upsertPrices() {
const prices = await stripe.prices.list({ limit: 100, active: true });
for (const price of prices.data) {
await supabase.from("prices").upsert({
price: price as any,
stripe_price_id: price.id,
});
}
}
async function syncStripe() {
await upsertProducts();
console.log("Updated products");
await wait(500);
await upsertPrices();
console.log("Updated prices");
}
syncStripe().catch((error) => {
console.error(error);
process.exit(1);
});
+228 -122
View File
@@ -6,35 +6,9 @@ export type Json =
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
export interface Database {
public: {
Tables: {
admin_users: {
Row: {
created_at: string
id: number
user_id: string | null
}
Insert: {
created_at?: string
id?: number
user_id?: string | null
}
Update: {
created_at?: string
id?: number
user_id?: string | null
}
Relationships: [
{
foreignKeyName: "admin_users_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
blogs: {
Row: {
created_at: string
@@ -94,7 +68,6 @@ export type Database = {
{
foreignKeyName: "categories_blog_id_fkey"
columns: ["blog_id"]
isOneToOne: false
referencedRelation: "blogs"
referencedColumns: ["id"]
}
@@ -168,37 +141,41 @@ export type Database = {
{
foreignKeyName: "invitations_blog_id_fkey"
columns: ["blog_id"]
isOneToOne: false
referencedRelation: "blogs"
referencedColumns: ["id"]
}
]
}
members: {
post_categories: {
Row: {
blog_id: string
category_id: string
created_at: string
id: number
user_id: string
post_id: string
}
Insert: {
blog_id: string
category_id: string
created_at?: string
id?: number
user_id: string
post_id: string
}
Update: {
blog_id?: string
category_id?: string
created_at?: string
id?: number
user_id?: string
post_id?: string
}
Relationships: [
{
foreignKeyName: "members_blog_id_fkey"
columns: ["blog_id"]
isOneToOne: false
referencedRelation: "blogs"
foreignKeyName: "post_categories_category_id_fkey"
columns: ["category_id"]
referencedRelation: "categories"
referencedColumns: ["id"]
},
{
foreignKeyName: "post_categories_post_id_fkey"
columns: ["post_id"]
referencedRelation: "posts"
referencedColumns: ["id"]
}
]
@@ -209,6 +186,7 @@ export type Database = {
content: Json
cover_image: string | null
created_at: string
deleted: boolean | null
id: string
metadata: Json[] | null
published: boolean
@@ -222,6 +200,7 @@ export type Database = {
content?: Json
cover_image?: string | null
created_at?: string
deleted?: boolean | null
id?: string
metadata?: Json[] | null
published?: boolean
@@ -235,6 +214,7 @@ export type Database = {
content?: Json
cover_image?: string | null
created_at?: string
deleted?: boolean | null
id?: string
metadata?: Json[] | null
published?: boolean
@@ -247,33 +227,122 @@ export type Database = {
{
foreignKeyName: "posts_blog_id_fkey"
columns: ["blog_id"]
isOneToOne: false
referencedRelation: "blogs"
referencedColumns: ["id"]
}
]
}
prices: {
Row: {
created_at: string
id: number
price: Json
stripe_price_id: string
}
Insert: {
created_at?: string
id?: number
price: Json
stripe_price_id: string
}
Update: {
created_at?: string
id?: number
price?: Json
stripe_price_id?: string
}
Relationships: []
}
products: {
Row: {
created_at: string
id: number
product: Json
stripe_product_id: string
}
Insert: {
created_at?: string
id?: number
product: Json
stripe_product_id: string
}
Update: {
created_at?: string
id?: number
product?: Json
stripe_product_id?: string
}
Relationships: []
}
subscriptions: {
Row: {
created_at: string
status: string
stripe_subscription_id: string
subscription: Json
user_id: string
}
Insert: {
created_at?: string
status: string
stripe_subscription_id: string
subscription: Json
user_id: string
}
Update: {
created_at?: string
status?: string
stripe_subscription_id?: string
subscription?: Json
user_id?: string
}
Relationships: [
{
foreignKeyName: "subscriptions_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "users"
referencedColumns: ["id"]
},
{
foreignKeyName: "subscriptions_user_id_fkey"
columns: ["user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
teams: {
Row: {
created_at: string
id: number
name: string
owner_id: string | null
ref: string | null
}
Insert: {
created_at?: string
id?: number
name: string
owner_id?: string | null
ref?: string | null
}
Update: {
created_at?: string
id?: number
name?: string
owner_id?: string | null
ref?: string | null
}
Relationships: [
{
foreignKeyName: "public_teams_owner_id_fkey"
columns: ["owner_id"]
referencedRelation: "users"
referencedColumns: ["id"]
},
{
foreignKeyName: "public_teams_owner_id_fkey"
columns: ["owner_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
@@ -281,9 +350,125 @@ export type Database = {
}
}
Views: {
[_ in never]: never
users: {
Row: {
aud: string | null
banned_until: string | null
confirmation_sent_at: string | null
confirmation_token: string | null
confirmed_at: string | null
created_at: string | null
deleted_at: string | null
email: string | null
email_change: string | null
email_change_confirm_status: number | null
email_change_sent_at: string | null
email_change_token_current: string | null
email_change_token_new: string | null
email_confirmed_at: string | null
encrypted_password: string | null
id: string | null
instance_id: string | null
invited_at: string | null
is_sso_user: boolean | null
is_super_admin: boolean | null
last_sign_in_at: string | null
phone: string | null
phone_change: string | null
phone_change_sent_at: string | null
phone_change_token: string | null
phone_confirmed_at: string | null
raw_app_meta_data: Json | null
raw_user_meta_data: Json | null
reauthentication_sent_at: string | null
reauthentication_token: string | null
recovery_sent_at: string | null
recovery_token: string | null
role: string | null
updated_at: string | null
}
Insert: {
aud?: string | null
banned_until?: string | null
confirmation_sent_at?: string | null
confirmation_token?: string | null
confirmed_at?: string | null
created_at?: string | null
deleted_at?: string | null
email?: string | null
email_change?: string | null
email_change_confirm_status?: number | null
email_change_sent_at?: string | null
email_change_token_current?: string | null
email_change_token_new?: string | null
email_confirmed_at?: string | null
encrypted_password?: string | null
id?: string | null
instance_id?: string | null
invited_at?: string | null
is_sso_user?: boolean | null
is_super_admin?: boolean | null
last_sign_in_at?: string | null
phone?: string | null
phone_change?: string | null
phone_change_sent_at?: string | null
phone_change_token?: string | null
phone_confirmed_at?: string | null
raw_app_meta_data?: Json | null
raw_user_meta_data?: Json | null
reauthentication_sent_at?: string | null
reauthentication_token?: string | null
recovery_sent_at?: string | null
recovery_token?: string | null
role?: string | null
updated_at?: string | null
}
Update: {
aud?: string | null
banned_until?: string | null
confirmation_sent_at?: string | null
confirmation_token?: string | null
confirmed_at?: string | null
created_at?: string | null
deleted_at?: string | null
email?: string | null
email_change?: string | null
email_change_confirm_status?: number | null
email_change_sent_at?: string | null
email_change_token_current?: string | null
email_change_token_new?: string | null
email_confirmed_at?: string | null
encrypted_password?: string | null
id?: string | null
instance_id?: string | null
invited_at?: string | null
is_sso_user?: boolean | null
is_super_admin?: boolean | null
last_sign_in_at?: string | null
phone?: string | null
phone_change?: string | null
phone_change_sent_at?: string | null
phone_change_token?: string | null
phone_confirmed_at?: string | null
raw_app_meta_data?: Json | null
raw_user_meta_data?: Json | null
reauthentication_sent_at?: string | null
reauthentication_token?: string | null
recovery_sent_at?: string | null
recovery_token?: string | null
role?: string | null
updated_at?: string | null
}
Relationships: []
}
}
Functions: {
generate_random_string: {
Args: {
length: number
}
Returns: string
}
generate_slug: {
Args: {
title: string
@@ -304,82 +489,3 @@ export type Database = {
}
}
export type Tables<
PublicTableNameOrOptions extends
| keyof (Database["public"]["Tables"] & Database["public"]["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (Database["public"]["Tables"] &
Database["public"]["Views"])
? (Database["public"]["Tables"] &
Database["public"]["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof Database["public"]["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof Database["public"]["Tables"]
? Database["public"]["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof Database["public"]["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof Database["public"]["Tables"]
? Database["public"]["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof Database["public"]["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof Database["public"]["Enums"]
? Database["public"]["Enums"][PublicEnumNameOrOptions]
: never
+32
View File
@@ -0,0 +1,32 @@
import { getSupabaseBrowserClient } from "@/lib/supabase";
import { UserResponse } from "@supabase/supabase-js";
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
} from "react";
type User = UserResponse["data"]["user"];
const UserContext = createContext<User | null>(null);
const supabase = getSupabaseBrowserClient();
export function UserProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
supabase.auth.onAuthStateChange((_, session) => {
setUser(session?.user ?? null);
});
}, []);
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}
export function useUser() {
const user = useContext(UserContext);
return user;
}
+434 -67
View File
@@ -12,7 +12,8 @@
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@types/inquirer": "^9.0.3",
"next": "^14.1.0"
"next": "^14.1.0",
"tsx": "^4.7.1"
},
"devDependencies": {
"@turbo/gen": "^1.9.7",
@@ -179,9 +180,8 @@
"@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^2.4.0",
"@stripe/stripe-js": "^2.4.0",
"@supabase/auth-helpers-nextjs": "^0.8.1",
"@supabase/auth-helpers-react": "^0.3.1",
"@supabase/supabase-js": "^2.21.0",
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.39.6",
"@t3-oss/env-nextjs": "^0.2.1",
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query": "^4.28.0",
@@ -204,6 +204,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dotenv": "^16.4.4",
"formidable": "^3.5.1",
"formik": "^2.2.9",
"framer-motion": "^10.12.12",
@@ -1396,6 +1397,21 @@
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz",
"integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA=="
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.17.19",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
@@ -4809,41 +4825,10 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.4.0.tgz",
"integrity": "sha512-WFkQx1mbs2b5+7looI9IV1BLa3bIApuN3ehp9FP58xGg7KL9hCHDECgW3BwO9l9L+xBPVAD7Yjn1EhGe6EDTeA=="
},
"node_modules/@supabase/auth-helpers-nextjs": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-helpers-nextjs/-/auth-helpers-nextjs-0.8.1.tgz",
"integrity": "sha512-ZO/UGDE9oaVXwPL4gpzGu2NpPwfKOgxTUnuK6no51So8Ah9BUuUUsTj+BdZlx7jJoZEIAkHWdRIx65WuQgWAiQ==",
"dependencies": {
"@supabase/auth-helpers-shared": "0.5.0",
"set-cookie-parser": "^2.6.0"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.19.0"
}
},
"node_modules/@supabase/auth-helpers-react": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-helpers-react/-/auth-helpers-react-0.3.1.tgz",
"integrity": "sha512-g3SFv08Dz9FapNif/ZY1b7qKGlMJDyTLSayHBz3kb3FuYxg7aLWgQtydDhm5AGbc0XtvpIBuhGTIOVevwpdosA==",
"peerDependencies": {
"@supabase/supabase-js": "^2.0.4"
}
},
"node_modules/@supabase/auth-helpers-shared": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-helpers-shared/-/auth-helpers-shared-0.5.0.tgz",
"integrity": "sha512-kioUeYDBZ89cfqOTZFOLEFIO7kGczL0XJYZdVv/bKt5efXqhiY14NbzgGWiMvvD9o9Iluo0+YEV2tG3ZZ5W06A==",
"dependencies": {
"jose": "^4.14.4"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.19.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.2.0.tgz",
"integrity": "sha512-lAmxD/mZ8vk2mg1CmXQWzK5mOHk7kDxAnxoyqUj2BVPvacEZ52P8nFkInEuSMqx6P6FKy64selW1Vyhui9racA==",
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.1.5.tgz",
"integrity": "sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
@@ -4868,9 +4853,9 @@
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.11.0.tgz",
"integrity": "sha512-Q2WwEid0I2rZfSZ0hDjmI20Fhu31Hzl4bwYTGJjLNMGbUOLoAQSaw0Hv/yHLZnl6OmOrH/trZqDiE5WocHN0oQ==",
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.9.2.tgz",
"integrity": "sha512-I6yHo8CC9cxhOo6DouDMy9uOfW7hjdsnCxZiaJuIVZm1dBGTFiQPgfMa9zXCamEWzNyWRjZvupAUuX+tqcl5Sw==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
@@ -4907,16 +4892,16 @@
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.39.3",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.39.3.tgz",
"integrity": "sha512-NoltJSaJNKDJNutO5sJPAAi5RIWrn1z2XH+ig1+cHDojT6BTN7TvZPNa3Kq3gFQWfO5H1N9El/bCTZJ3iFW2kQ==",
"version": "2.39.6",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.39.6.tgz",
"integrity": "sha512-HlflDzem0+l3KYYTqHV0UsqkDooV9my5UcBCV2zvvTrl77UtW97uKTZWn9lSWMuiy+ZvRLsiuG+WTiBuKMQl0Q==",
"dependencies": {
"@supabase/functions-js": "^2.1.5",
"@supabase/gotrue-js": "^2.60.0",
"@supabase/node-fetch": "^2.6.14",
"@supabase/postgrest-js": "^1.9.0",
"@supabase/realtime-js": "^2.9.3",
"@supabase/storage-js": "^2.5.4"
"@supabase/functions-js": "2.1.5",
"@supabase/gotrue-js": "2.62.2",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.9.2",
"@supabase/realtime-js": "2.9.3",
"@supabase/storage-js": "2.5.5"
}
},
"node_modules/@swc/core": {
@@ -7940,6 +7925,17 @@
"no-case": "^2.2.0"
}
},
"node_modules/dotenv": {
"version": "16.4.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.4.tgz",
"integrity": "sha512-XvPXc8XAQThSjAbY6cQ/9PcBXmFoWuw1sQ3b8HqUCR6ziGXjkTi//kB9SWa2UwqlgdAIuRqAa/9hVljzPehbYg==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -9404,9 +9400,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.6.2",
"dev": true,
"license": "MIT",
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz",
"integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
@@ -10498,14 +10494,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "4.14.4",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz",
"integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-beautify": {
"version": "1.14.11",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.11.tgz",
@@ -12896,7 +12884,6 @@
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -13219,11 +13206,6 @@
"randombytes": "^2.1.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ=="
},
"node_modules/set-function-name": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz",
@@ -14175,6 +14157,391 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz",
"integrity": "sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==",
"dependencies": {
"esbuild": "~0.19.10",
"get-tsconfig": "^4.7.2"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"cpu": [
"loong64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"cpu": [
"mips64el"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
}
},
"node_modules/turbo": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/turbo/-/turbo-1.10.5.tgz",
+10 -5
View File
@@ -6,12 +6,16 @@
"build:web": "turbo run build --filter=website",
"dev": "turbo run dev",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"supa:start": "npx supabase start",
"supa:stop": "npx supabase stop",
"supa:open": "open http://localhost:54323",
"db:typegen": "npx supabase gen types typescript --local --schema public > apps/zendo/src/types/supabase.ts",
"db:start": "npx supabase start",
"db:stop": "npx supabase stop",
"db:open": "open http://localhost:54323",
"db:pull": "npx supabase db pull",
"db:local:diff": "npx supabase db diff",
"qs": "git add . && git commit -m \"quick save\" && git push",
"stripe:webhook": "stripe listen --forward-to localhost:3000/api/webhooks/stripe",
"stripe:event": "stripe trigger payment_intent.succeeded"
"stripe:event": "stripe trigger payment_intent.succeeded",
"stripe:sync": "turbo run stripe:sync --filter=website"
},
"devDependencies": {
"@turbo/gen": "^1.9.7",
@@ -26,7 +30,8 @@
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@types/inquirer": "^9.0.3",
"next": "^14.1.0"
"next": "^14.1.0",
"tsx": "^4.7.1"
},
"engines": {
"node": ">=18.0.0",
+1 -1
View File
@@ -87,7 +87,7 @@ enable_signup = true
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
enable_confirmations = true
# Uncomment to customize email template
# [auth.email.template.invite]
@@ -12,8 +12,14 @@ SET row_security = off;
CREATE EXTENSION IF NOT EXISTS "timescaledb" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";
CREATE EXTENSION IF NOT EXISTS "pgsodium" WITH SCHEMA "pgsodium";
CREATE SCHEMA IF NOT EXISTS "stripe";
ALTER SCHEMA "stripe" OWNER TO "postgres";
CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql";
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions";
@@ -26,6 +32,23 @@ CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault";
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";
CREATE OR REPLACE FUNCTION "public"."generate_random_string"("length" integer) RETURNS "text"
LANGUAGE "plpgsql"
AS $$
DECLARE
chars text := 'abcdefghijklmnopqrstuvwxyz';
result text := '';
i int := 1;
BEGIN
FOR i IN 1..length LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
END LOOP;
RETURN result;
END;
$$;
ALTER FUNCTION "public"."generate_random_string"("length" integer) OWNER TO "postgres";
CREATE OR REPLACE FUNCTION "public"."generate_slug"("title" "text") RETURNS "text"
LANGUAGE "plpgsql"
AS $$
@@ -62,6 +85,23 @@ SET default_tablespace = '';
SET default_table_access_method = "heap";
CREATE TABLE IF NOT EXISTS "public"."admin_users" (
"id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"user_id" "uuid"
);
ALTER TABLE "public"."admin_users" OWNER TO "postgres";
ALTER TABLE "public"."admin_users" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME "public"."admin_users_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE IF NOT EXISTS "public"."blogs" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
@@ -74,6 +114,35 @@ CREATE TABLE IF NOT EXISTS "public"."blogs" (
ALTER TABLE "public"."blogs" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."categories" (
"id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
"blog_id" "uuid" NOT NULL,
"name" "text" NOT NULL,
"slug" "text" NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"updated_at" timestamp with time zone DEFAULT "now"() NOT NULL
);
ALTER TABLE "public"."categories" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."feedback" (
"id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"feedback" "text",
"user_email" "text"
);
ALTER TABLE "public"."feedback" OWNER TO "postgres";
ALTER TABLE "public"."feedback" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME "public"."feedback_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE IF NOT EXISTS "public"."homepage_signup" (
"id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"(),
@@ -102,24 +171,6 @@ CREATE TABLE IF NOT EXISTS "public"."invitations" (
ALTER TABLE "public"."invitations" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."members" (
"id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"user_id" "text" NOT NULL,
"blog_id" "uuid" NOT NULL
);
ALTER TABLE "public"."members" OWNER TO "postgres";
ALTER TABLE "public"."members" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME "public"."members_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE IF NOT EXISTS "public"."posts" (
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"user_id" "text" DEFAULT "public"."requesting_user_id"() NOT NULL,
@@ -136,21 +187,132 @@ CREATE TABLE IF NOT EXISTS "public"."posts" (
ALTER TABLE "public"."posts" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."products" (
"id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"product" "jsonb" NOT NULL,
"stripe_product_id" "text"
);
ALTER TABLE "public"."products" OWNER TO "postgres";
ALTER TABLE "public"."products" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME "public"."products_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE IF NOT EXISTS "public"."subscriptions" (
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"user_id" "uuid" NOT NULL,
"status" "text" NOT NULL
);
ALTER TABLE "public"."subscriptions" OWNER TO "postgres";
CREATE TABLE IF NOT EXISTS "public"."teams" (
"created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
"owner_id" "uuid",
"name" "text" NOT NULL,
"ref" "text" DEFAULT "public"."generate_random_string"(20),
"id" bigint NOT NULL
);
ALTER TABLE "public"."teams" OWNER TO "postgres";
ALTER TABLE "public"."teams" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY (
SEQUENCE NAME "public"."teams_id_seq"
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE OR REPLACE VIEW "public"."users" AS
SELECT "users"."instance_id",
"users"."id",
"users"."aud",
"users"."role",
"users"."email",
"users"."encrypted_password",
"users"."email_confirmed_at",
"users"."invited_at",
"users"."confirmation_token",
"users"."confirmation_sent_at",
"users"."recovery_token",
"users"."recovery_sent_at",
"users"."email_change_token_new",
"users"."email_change",
"users"."email_change_sent_at",
"users"."last_sign_in_at",
"users"."raw_app_meta_data",
"users"."raw_user_meta_data",
"users"."is_super_admin",
"users"."created_at",
"users"."updated_at",
"users"."phone",
"users"."phone_confirmed_at",
"users"."phone_change",
"users"."phone_change_token",
"users"."phone_change_sent_at",
"users"."confirmed_at",
"users"."email_change_token_current",
"users"."email_change_confirm_status",
"users"."banned_until",
"users"."reauthentication_token",
"users"."reauthentication_sent_at",
"users"."is_sso_user",
"users"."deleted_at"
FROM "auth"."users";
ALTER TABLE "public"."users" OWNER TO "postgres";
ALTER TABLE ONLY "public"."admin_users"
ADD CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."blogs"
ADD CONSTRAINT "blogs_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."categories"
ADD CONSTRAINT "categories_blog_id_slug_key" UNIQUE ("blog_id", "slug");
ALTER TABLE ONLY "public"."categories"
ADD CONSTRAINT "categories_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."feedback"
ADD CONSTRAINT "feedback_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."homepage_signup"
ADD CONSTRAINT "homepage_signup_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."invitations"
ADD CONSTRAINT "invitations_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."members"
ADD CONSTRAINT "members_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."posts"
ADD CONSTRAINT "posts_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."products"
ADD CONSTRAINT "products_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."subscriptions"
ADD CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("user_id");
ALTER TABLE ONLY "public"."subscriptions"
ADD CONSTRAINT "subscriptions_user_id_key" UNIQUE ("user_id");
ALTER TABLE ONLY "public"."teams"
ADD CONSTRAINT "teams_id_key" UNIQUE ("id");
ALTER TABLE ONLY "public"."teams"
ADD CONSTRAINT "teams_pkey" PRIMARY KEY ("id");
ALTER TABLE ONLY "public"."teams"
ADD CONSTRAINT "teams_ref_key" UNIQUE ("ref");
ALTER TABLE ONLY "public"."posts"
ADD CONSTRAINT "unique_slug_per_user_post_constraint" UNIQUE ("slug", "user_id", "blog_id");
@@ -158,34 +320,61 @@ CREATE INDEX "posts_slug_blog_id_idx" ON "public"."posts" USING "btree" ("slug",
CREATE INDEX "posts_slug_idx" ON "public"."posts" USING "btree" ("slug");
ALTER TABLE ONLY "public"."admin_users"
ADD CONSTRAINT "admin_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id");
ALTER TABLE ONLY "public"."categories"
ADD CONSTRAINT "categories_blog_id_fkey" FOREIGN KEY ("blog_id") REFERENCES "public"."blogs"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."invitations"
ADD CONSTRAINT "invitations_blog_id_fkey" FOREIGN KEY ("blog_id") REFERENCES "public"."blogs"("id");
ALTER TABLE ONLY "public"."members"
ADD CONSTRAINT "members_blog_id_fkey" FOREIGN KEY ("blog_id") REFERENCES "public"."blogs"("id");
ALTER TABLE ONLY "public"."posts"
ADD CONSTRAINT "posts_blog_id_fkey" FOREIGN KEY ("blog_id") REFERENCES "public"."blogs"("id") ON DELETE CASCADE;
ALTER TABLE ONLY "public"."teams"
ADD CONSTRAINT "public_teams_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id");
ALTER TABLE ONLY "public"."subscriptions"
ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id");
CREATE POLICY "Allow AUTHED users to query ALL their POSTS" ON "public"."posts" FOR SELECT TO "authenticated" USING ((("auth"."uid"())::"text" = "user_id"));
CREATE POLICY "Enable INSERT access for all users" ON "public"."homepage_signup" FOR INSERT TO "anon" WITH CHECK (true);
CREATE POLICY "Enable delete for users based on user_id" ON "public"."posts" FOR DELETE USING ((("auth"."uid"())::"text" = "user_id"));
CREATE POLICY "Enable insert for authenticated users only" ON "public"."feedback" FOR INSERT TO "authenticated" WITH CHECK (true);
CREATE POLICY "Enable insert for authenticated users only" ON "public"."posts" FOR INSERT TO "authenticated" WITH CHECK ((("auth"."uid"())::"text" = "user_id"));
CREATE POLICY "Enable insert for owners" ON "public"."teams" FOR INSERT TO "authenticated" WITH CHECK (("auth"."uid"() = "owner_id"));
CREATE POLICY "Enable read access for all users" ON "public"."teams" FOR SELECT USING (true);
CREATE POLICY "Enable read access for all users if published" ON "public"."posts" FOR SELECT TO "anon" USING (("published" = true));
CREATE POLICY "Enable update for users based on user_id" ON "public"."posts" FOR UPDATE USING ((("auth"."uid"())::"text" = "user_id"));
ALTER TABLE "public"."admin_users" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."blogs" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."categories" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "delete_categories_policy" ON "public"."categories" FOR DELETE USING ((EXISTS ( SELECT 1
FROM "public"."blogs"
WHERE (("blogs"."id" = "categories"."blog_id") AND ("blogs"."user_id" = CURRENT_USER)))));
ALTER TABLE "public"."feedback" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."homepage_signup" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."invitations" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "insert_categories_policy" ON "public"."categories" FOR INSERT WITH CHECK ((EXISTS ( SELECT 1
FROM "public"."blogs"
WHERE (("blogs"."id" = "categories"."blog_id") AND ("blogs"."user_id" = CURRENT_USER)))));
ALTER TABLE "public"."members" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."invitations" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "owners of a blog can create invitations" ON "public"."invitations" FOR INSERT TO "authenticated" WITH CHECK ((EXISTS ( SELECT 1
FROM "public"."blogs"
@@ -207,6 +396,22 @@ CREATE POLICY "owners of the blog can see invitations" ON "public"."invitations"
ALTER TABLE "public"."posts" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."products" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "select subscription" ON "public"."subscriptions" FOR SELECT TO "authenticated" USING (("auth"."uid"() = "user_id"));
CREATE POLICY "select_categories_policy" ON "public"."categories" FOR SELECT USING ((EXISTS ( SELECT 1
FROM "public"."blogs"
WHERE (("blogs"."id" = "categories"."blog_id") AND ("blogs"."user_id" = CURRENT_USER)))));
ALTER TABLE "public"."subscriptions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "public"."teams" ENABLE ROW LEVEL SECURITY;
CREATE POLICY "update_categories_policy" ON "public"."categories" FOR UPDATE USING ((EXISTS ( SELECT 1
FROM "public"."blogs"
WHERE (("blogs"."id" = "categories"."blog_id") AND ("blogs"."user_id" = CURRENT_USER)))));
CREATE POLICY "users can crud their blogs" ON "public"."blogs" TO "authenticated" USING ((("auth"."uid"())::"text" = "user_id"));
REVOKE USAGE ON SCHEMA "public" FROM PUBLIC;
@@ -215,6 +420,10 @@ GRANT USAGE ON SCHEMA "public" TO "anon";
GRANT USAGE ON SCHEMA "public" TO "authenticated";
GRANT USAGE ON SCHEMA "public" TO "service_role";
GRANT ALL ON FUNCTION "public"."generate_random_string"("length" integer) TO "anon";
GRANT ALL ON FUNCTION "public"."generate_random_string"("length" integer) TO "authenticated";
GRANT ALL ON FUNCTION "public"."generate_random_string"("length" integer) TO "service_role";
GRANT ALL ON FUNCTION "public"."generate_slug"("title" "text") TO "anon";
GRANT ALL ON FUNCTION "public"."generate_slug"("title" "text") TO "authenticated";
GRANT ALL ON FUNCTION "public"."generate_slug"("title" "text") TO "service_role";
@@ -223,10 +432,30 @@ GRANT ALL ON FUNCTION "public"."requesting_user_id"() TO "anon";
GRANT ALL ON FUNCTION "public"."requesting_user_id"() TO "authenticated";
GRANT ALL ON FUNCTION "public"."requesting_user_id"() TO "service_role";
GRANT ALL ON TABLE "public"."admin_users" TO "anon";
GRANT ALL ON TABLE "public"."admin_users" TO "authenticated";
GRANT ALL ON TABLE "public"."admin_users" TO "service_role";
GRANT ALL ON SEQUENCE "public"."admin_users_id_seq" TO "anon";
GRANT ALL ON SEQUENCE "public"."admin_users_id_seq" TO "authenticated";
GRANT ALL ON SEQUENCE "public"."admin_users_id_seq" TO "service_role";
GRANT ALL ON TABLE "public"."blogs" TO "anon";
GRANT ALL ON TABLE "public"."blogs" TO "authenticated";
GRANT ALL ON TABLE "public"."blogs" TO "service_role";
GRANT ALL ON TABLE "public"."categories" TO "anon";
GRANT ALL ON TABLE "public"."categories" TO "authenticated";
GRANT ALL ON TABLE "public"."categories" TO "service_role";
GRANT ALL ON TABLE "public"."feedback" TO "anon";
GRANT ALL ON TABLE "public"."feedback" TO "authenticated";
GRANT ALL ON TABLE "public"."feedback" TO "service_role";
GRANT ALL ON SEQUENCE "public"."feedback_id_seq" TO "anon";
GRANT ALL ON SEQUENCE "public"."feedback_id_seq" TO "authenticated";
GRANT ALL ON SEQUENCE "public"."feedback_id_seq" TO "service_role";
GRANT ALL ON TABLE "public"."homepage_signup" TO "anon";
GRANT ALL ON TABLE "public"."homepage_signup" TO "authenticated";
GRANT ALL ON TABLE "public"."homepage_signup" TO "service_role";
@@ -239,18 +468,34 @@ GRANT ALL ON TABLE "public"."invitations" TO "anon";
GRANT ALL ON TABLE "public"."invitations" TO "authenticated";
GRANT ALL ON TABLE "public"."invitations" TO "service_role";
GRANT ALL ON TABLE "public"."members" TO "anon";
GRANT ALL ON TABLE "public"."members" TO "authenticated";
GRANT ALL ON TABLE "public"."members" TO "service_role";
GRANT ALL ON SEQUENCE "public"."members_id_seq" TO "anon";
GRANT ALL ON SEQUENCE "public"."members_id_seq" TO "authenticated";
GRANT ALL ON SEQUENCE "public"."members_id_seq" TO "service_role";
GRANT ALL ON TABLE "public"."posts" TO "anon";
GRANT ALL ON TABLE "public"."posts" TO "authenticated";
GRANT ALL ON TABLE "public"."posts" TO "service_role";
GRANT ALL ON TABLE "public"."products" TO "anon";
GRANT ALL ON TABLE "public"."products" TO "authenticated";
GRANT ALL ON TABLE "public"."products" TO "service_role";
GRANT ALL ON SEQUENCE "public"."products_id_seq" TO "anon";
GRANT ALL ON SEQUENCE "public"."products_id_seq" TO "authenticated";
GRANT ALL ON SEQUENCE "public"."products_id_seq" TO "service_role";
GRANT ALL ON TABLE "public"."subscriptions" TO "anon";
GRANT ALL ON TABLE "public"."subscriptions" TO "authenticated";
GRANT ALL ON TABLE "public"."subscriptions" TO "service_role";
GRANT ALL ON TABLE "public"."teams" TO "anon";
GRANT ALL ON TABLE "public"."teams" TO "authenticated";
GRANT ALL ON TABLE "public"."teams" TO "service_role";
GRANT ALL ON SEQUENCE "public"."teams_id_seq" TO "anon";
GRANT ALL ON SEQUENCE "public"."teams_id_seq" TO "authenticated";
GRANT ALL ON SEQUENCE "public"."teams_id_seq" TO "service_role";
GRANT ALL ON TABLE "public"."users" TO "anon";
GRANT ALL ON TABLE "public"."users" TO "authenticated";
GRANT ALL ON TABLE "public"."users" TO "service_role";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated";
+6 -16
View File
@@ -6,31 +6,21 @@
- [] FAQ on homepage
- [] Update README
- [] Homepage use cases examples: personal blog, docs, careers page, product listing pages, help center, changelogs, etc.
- [] email provider
- [] supabase spend cap
## Bugs & Improvements
- [] First time a user logs in, they should be prompted to add a password and reload the session.
- [x] Fix email redirect when navigating to protected routes
- [] Force email confirmation
- [] Make layout work on mobile
- [] Make inputs not zoom in on mobile
## Teams
- [] When a user logs in, a team is created for them.
- [] Billing is per team.
- [] Blogs are owned by a team.
## Pricing
- [] Add pricing page
- [] Make pricing work
### Pro plan
- $50/year (2 months free) or $5/month
- 1 editor
- Unlimited blogs
- Unlimited posts
- Unlimited categories
- [x] Sync products between Stripe and Supabase
- [x] Sync subscriptions between Stripe and Supabase
## Blogs
+12
View File
@@ -2,6 +2,12 @@
## Ideas
- [] Allow to fetch list of posts with content.
```typescript
const posts = await client.posts.list({ withContent: true, limit: 10 });
```
- [] Allow users to add default metadata for posts in a blog.
- [] Add PolyScale cache to Supabase for faster queries?
- [] Featured Images
@@ -13,6 +19,12 @@
- [] Use a generic on createClient to pass the custom metadata
- [] Add files to custom metadata
## Teams
- [] When a user logs in, a team is created for them.
- [] Billing is per team.
- [] Blogs are owned by a team.
## Hosted blogs
- [] Let zenblog host your blog for you
+10
View File
@@ -0,0 +1,10 @@
# Template ideas for Zenblog
- [] Simple blog template
- [] News website
- [] Personal blog
- [] Portfolio
- [] Company blog
- [] Documentation
- [] Careers page
- [] Multilanguage blog