This commit is contained in:
Jordi Enric
2024-10-26 12:35:10 +02:00
parent e9086983a0
commit ec76b613b2
9 changed files with 293 additions and 109 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ const Footer = (props: Props) => {
];
return (
<footer className="mt-6 border-t bg-zinc-50 p-4 text-xs text-zinc-700 md:p-12">
<footer className="mt-6 border-t bg-zinc-50 p-6 text-xs text-zinc-700 md:p-12">
<div className="mx-auto flex max-w-3xl justify-between">
<div>
<ZendoLogo />
@@ -12,44 +12,51 @@ const Navigation = (props: Props) => {
const user = useUser();
return (
<nav className="flex items-center justify-between p-6">
<div className="px-4">
<nav className="mx-auto flex w-full max-w-4xl items-center justify-between py-8">
<div className="flex-grow cursor-default">
<Link href="/">
<ZendoLogo />
</Link>
<ZendoLogo className="" size={31} />
</div>
<div className="flex flex-grow items-center justify-end gap-4 font-medium text-zinc-600">
<Link href="/">Home</Link>
<div className="flex flex-grow items-center justify-end gap-1 font-medium text-zinc-500">
{/* <Link
className="rounded-lg px-2 py-1 hover:text-zinc-800"
href="/pricing"
>
Pricing
</Link> */}
<Link
className="rounded-full px-3 py-1.5 hover:text-zinc-800"
className="rounded-lg px-2 py-1 hover:text-zinc-800"
href="/docs"
>
Docs
</Link>
<Link
className="rounded-lg px-2 py-1 hover:text-zinc-800"
href="/blog"
>
Blog
</Link>
<Link
target="_blank"
href="https://twitter.com/zenbloghq"
className="flex items-center justify-center rounded-full p-2 text-lg text-blue-500 transition-all hover:bg-blue-100"
className="flex items-center justify-center rounded-full p-2 text-lg transition-all hover:text-zinc-800"
title="Follow us on Twitter"
aria-label="Follow us on Twitter"
>
<FaTwitter size="18" />
</Link>
{/* <Button asChild>
<Link
href="/sign-in"
className="btn btn-primary inline-block"
title="Sign in"
aria-label="Sign in"
>
Sign in
</Link>
</Button> */}
{user && (
<div className="ml-2 flex">
<Button asChild variant={"secondary"} size="default">
<Link href="/blogs">Dashboard</Link>
</Button>
</div>
)}
</div>
</nav>
</div>
);
};
+11 -14
View File
@@ -60,18 +60,14 @@ export default function AppLayout({
label: "Media",
href: `/blogs/${selectedBlog?.id}/media`,
},
// {
// label: "Authors",
// href: `/blogs/${selectedBlog?.id}/authors`,
// },
{
label: "Tags",
href: `/blogs/${selectedBlog?.id}/tags`,
},
{
label: "Categories",
href: `/blogs/${selectedBlog?.id}/categories`,
},
{
label: "Tags",
href: `/blogs/${selectedBlog?.id}/tags`,
},
{
label: "Settings",
href: `/blogs/${selectedBlog?.id}/settings`,
@@ -81,7 +77,7 @@ export default function AppLayout({
return (
<div className={`flex min-h-screen flex-col border-b bg-zinc-50 font-sans`}>
<Head>
<title>zenblog</title>
<title>Zenblog</title>
<meta
name="description"
content="Simple, open source, headless, blogging CMS."
@@ -117,15 +113,11 @@ export default function AppLayout({
{selectedBlog?.emoji}
</span>
{selectedBlog?.title}
{blogsLoading ? (
{blogsLoading && (
<Loader
className="animate-spin text-orange-500"
size={16}
/>
) : (
<div className="rounded-full text-xs font-medium text-blue-500">
Pro
</div>
)}
{plan === "free" && (
<Link
@@ -136,6 +128,11 @@ export default function AppLayout({
Free
</Link>
)}
{plan === "pro" && (
<div className="rounded-full text-xs font-medium text-blue-500">
Pro
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
+31
View File
@@ -0,0 +1,31 @@
type PricingPlan = {
title: string;
monthlyPrice: number;
yearlyPrice: number;
features: string[];
};
export const PRICING_PLANS: PricingPlan[] = [
{
title: "Hobby",
monthlyPrice: 0,
yearlyPrice: 0,
features: [
"1 blog",
"Unlimited posts",
"Limited API access",
"Limited media files",
],
},
{
title: "Pro",
monthlyPrice: 9,
yearlyPrice: 90,
features: [
"Unlimited blogs",
"Unlimited posts",
"Unlimited API access",
"Unlimited media files",
],
},
];
+19 -11
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ImageIcon, Plus } from "lucide-react";
import { ImageIcon, MoreVertical, Plus, Trash } from "lucide-react";
import { createSupabaseBrowserClient } from "@/lib/supabase";
import { toast } from "sonner";
@@ -27,6 +27,12 @@ import { Image, ImageSelector } from "@/components/Images/ImagePicker";
import { useRouterTabs } from "@/hooks/useRouterTabs";
import { getHostedBlogUrl } from "@/utils/get-hosted-blog-url";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function StatePill({ published }: { published: boolean }) {
const text = published ? "Published" : "Draft";
@@ -108,6 +114,8 @@ export default function BlogPosts() {
const hostedBlogUrl = getHostedBlogUrl(blog?.slug || "");
const noPosts = posts?.pages.flatMap((page) => page).length === 0;
if (blog && posts) {
return (
<AppLayout
@@ -124,17 +132,17 @@ export default function BlogPosts() {
</>
}
>
<Section>
{posts.pages.length === 0 && (
<Section className="overflow-hidden py-0">
{noPosts && (
<div className="p-12 py-32 text-center">
<div className="text-2xl"></div>
<div className="text-lg text-zinc-500">Nothing here yet</div>
<div className="text-4xl"></div>
<div className="mt-2 text-lg text-zinc-500">Nothing here yet</div>
<Button asChild>
<Link
href={`/blogs/${blog.id}/create`}
className="btn btn-primary mx-auto mt-4 max-w-xs"
className="mt-4 inline-flex"
>
Publish your first post
Write your first post
</Link>
</Button>
</div>
@@ -164,7 +172,7 @@ export default function BlogPosts() {
/>
);
})}
<div className="flex items-center justify-end gap-4 px-3 pt-2 text-center font-mono text-xs text-zinc-500">
<div className="flex items-center justify-end gap-4 p-4 text-center font-mono text-xs text-zinc-500">
Showing {posts.pages.flatMap((page) => page).length} posts
<Button
size="xs"
@@ -199,7 +207,7 @@ function PostItem({
<Link
key={post.slug}
href={`/blogs/${blogId}/post/${post.slug}`}
className="group flex flex-col gap-4 border-b border-zinc-200 px-3 py-2 transition-all hover:bg-slate-50 md:flex-row md:items-center"
className="group flex flex-col gap-4 border-b border-zinc-200 p-4 transition-all hover:bg-slate-50 md:flex-row md:items-center"
>
<div className="hidden h-16 w-24 rounded-md bg-zinc-100 md:block ">
{post.cover_image ? (
@@ -236,7 +244,7 @@ function PostItem({
</div>
<span className="font-mono">{formatDate(post.published_at || "")}</span>
</div>
{/* <DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={"ghost"}
@@ -259,7 +267,7 @@ function PostItem({
<span className="ml-2">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu> */}
</DropdownMenu>
</Link>
);
}
+5 -40
View File
@@ -19,6 +19,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import Navigation from "@/components/marketing/Navigation";
const Home = () => {
const user = useUser();
@@ -50,7 +51,7 @@ const Home = () => {
return (
<>
<Head>
<title>zenblog - a tiny blogging cms</title>
<title>Zenblog - A tiny blogging CMS</title>
<meta
name="description"
content="Simple, open source, headless, blogging CMS."
@@ -58,46 +59,10 @@ const Home = () => {
<link rel="icon" href="/static/favicon.ico" />
</Head>
<div className="">
<div className="flex flex-col px-4">
<nav className="mx-auto flex w-full max-w-4xl items-center justify-between py-8">
<div className="flex-grow cursor-default">
<ZendoLogo className="" size={31} />
</div>
<div className="flex flex-col">
<Navigation />
<div className="flex flex-grow items-center justify-end gap-1 font-medium text-zinc-500">
<Link
className="rounded-lg px-2 py-1 hover:text-zinc-800"
href="/docs"
>
Docs
</Link>
<Link
className="rounded-lg px-2 py-1 hover:text-zinc-800"
href="/blog"
>
Blog
</Link>
<Link
target="_blank"
href="https://twitter.com/zenbloghq"
className="flex items-center justify-center rounded-full p-2 text-lg transition-all hover:text-zinc-800"
title="Follow us on Twitter"
aria-label="Follow us on Twitter"
>
<FaTwitter size="18" />
</Link>
{user && (
<div className="ml-2 flex">
<Button asChild variant={"secondary"} size="default">
<Link href="/blogs">Dashboard</Link>
</Button>
</div>
)}
</div>
</nav>
<main className="mt-12 px-4 pb-24 font-sans">
<main className="mt-12 px-6 pb-24 font-sans">
<div className="mx-auto max-w-4xl">
<h1
className={`mt-2 text-4xl font-medium tracking-tight text-zinc-800`}
+178
View File
@@ -0,0 +1,178 @@
import Footer from "@/components/Footer";
import Navigation from "@/components/marketing/Navigation";
import { Button } from "@/components/ui/button";
import { Section, SectionHeader, SectionTitle } from "@/layouts/AppLayout";
import { PRICING_PLANS } from "@/lib/pricing.constants";
import { CheckIcon } from "lucide-react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
export default function Pricing() {
const [subscriptionType, setSubscriptionType] = useState<
"monthly" | "yearly"
>("yearly");
const router = useRouter();
return (
<div>
<Head>
<title>Zenblog - Pricing</title>
<meta
name="description"
content="Simple, open source, headless, blogging CMS."
/>
<link rel="icon" href="/static/favicon.ico" />
</Head>
<Navigation />
<main className="mx-auto max-w-4xl px-4">
<h1 className="text-2xl font-medium">Zenblog Pricing</h1>
<p className="mt-2 text-slate-500">
Simple pricing, cancel anytime, no questions asked.
</p>
<div className="mt-8">
<div className="flex items-center gap-1">
<Button
variant={subscriptionType === "monthly" ? "secondary" : "ghost"}
onClick={() => setSubscriptionType("monthly")}
>
Monthly
</Button>
<Button
variant={subscriptionType === "yearly" ? "secondary" : "ghost"}
onClick={() => setSubscriptionType("yearly")}
>
Yearly (2 months free)
</Button>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{PRICING_PLANS.map((plan) => (
<PricingCard
key={plan.title}
{...plan}
type={subscriptionType}
onClick={() => {
router.push(`/sign-in`);
}}
/>
))}
</div>
</div>
<Section className="mt-24 px-4 py-4">
<h2 className="text-xl font-medium">FAQ</h2>
<p className="mt-2 text-slate-500">
Frequently asked questions about Zenblog pricing.
</p>
<FAQItem
question="What is your refund policy?"
answer={
<>
You can request a refund within 14 days of your initial purchase
or renewal date. Email us at support@zenblog.com with your
account details and reason for the refund request.
<br />
<Link className="underline" href="/terms">
Read more about our refund policy here
</Link>
</>
}
/>
<FAQItem
question="Can I cancel my subscription anytime?"
answer="Yes, you can cancel your subscription at any time. No questions asked."
/>
<FAQItem
question="Do you offer discounts for non-profits or educational institutions?"
answer="Yes, we do. Please contact us at support@zenblog.com for more information."
/>
</Section>
</main>
<div className="mt-24">
<Footer />
</div>
</div>
);
}
function PricingCard({
title,
monthlyPrice,
yearlyPrice,
features,
onClick,
type,
}: {
title: string;
monthlyPrice: number;
yearlyPrice: number;
features: string[];
onClick: () => void;
type: "monthly" | "yearly";
}) {
const yearlyToMonth = (yearlyPrice / 12).toFixed(2);
const PricingText = () => {
if (monthlyPrice === 0) {
return <p className="text-2xl font-medium">Free</p>;
}
if (type === "monthly") {
return (
<p className="text-slate-500">
<span className="mr-1 text-xl">$</span>
<span className="text-2xl font-medium text-slate-900">
{monthlyPrice}
</span>{" "}
per month <br />
billed monthly
</p>
);
}
return (
<p className="text-slate-500">
<span className="mr-1 text-xl">$</span>
<span className="text-2xl font-medium text-slate-900">
{yearlyToMonth}
</span>{" "}
per month <br />
billed ${yearlyPrice} yearly
</p>
);
};
return (
<div className="rounded-2xl border p-4">
<h3 className="text-lg font-medium">{title}</h3>
<div className="h-16">
<PricingText />
</div>
<ul className="mt-5 space-y-2 font-medium">
{features.map((feature) => (
<li key={feature} className="flex items-center">
<CheckIcon className="mr-2 h-4 w-4" />
{feature}
</li>
))}
</ul>
<div className="mt-5">
<Button>Get started</Button>
</div>
</div>
);
}
function FAQItem({
question,
answer,
}: {
question: string;
answer: React.ReactNode;
}) {
return (
<div className="mt-4">
<h3 className="text-lg font-medium">{question}</h3>
<p className="mt-2 text-slate-500">{answer}</p>
</div>
);
}
+5 -2
View File
@@ -1,10 +1,12 @@
import { createSupabaseBrowserClient } from "@/lib/supabase";
import { useUser } from "@/utils/supabase/browser";
import { QueryOptions, useQuery } from "@tanstack/react-query";
const SUBSCRIPTION_KEYS = ["subscription"];
export function useSubscriptionQuery() {
const sb = createSupabaseBrowserClient();
const user = useUser();
return useQuery({
queryKey: SUBSCRIPTION_KEYS,
@@ -12,6 +14,7 @@ export function useSubscriptionQuery() {
const { data, error } = await sb
.from("subscriptions")
.select("status")
.eq("user_id", user?.id || "")
.limit(1);
if (error || !data[0]) {
@@ -24,7 +27,7 @@ export function useSubscriptionQuery() {
return data[0];
},
initialData: {
status: "active",
status: "",
},
staleTime: 5 * 60 * 1000,
});
@@ -34,7 +37,7 @@ export function usePlan() {
const { data, isLoading } = useSubscriptionQuery();
if (isLoading) {
return "loading";
return "";
}
if (data?.status === "active") {
+1 -6
View File
@@ -3,17 +3,12 @@
## Backlog
- [] Paddle for subscriptions. I aint risking it, need a MoR.
- [] finish pagination in usePostsQuery
- [] Category management page
- [] Categories in API + API Client
- [] Nextjs example in homepage
- [] Add tags to posts API
- [] Fix deleting tags with posts
- [] Use a JWT with the blog id to authenticate the API, should make api faster by removing one req to get the blog id
- [] Add categories to posts API
- [] fix creating tags in tags tab
- [] fix metadata format so that its {key:value} not [{key:foo,value:bar}]
- [] pagination to media query
- [] fix reset password
## Blogs