mirror of
https://github.com/jordienr/zenblog.git
synced 2026-08-24 02:34:19 -05:00
wip
This commit is contained in:
@@ -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="flex-grow cursor-default">
|
||||
<Link href="/">
|
||||
<ZendoLogo />
|
||||
</Link>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<Link
|
||||
className="rounded-full px-3 py-1.5 hover:text-zinc-800"
|
||||
href="/docs"
|
||||
>
|
||||
Docs
|
||||
</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"
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
Sign in
|
||||
Pricing
|
||||
</Link> */}
|
||||
<Link
|
||||
className="rounded-lg px-2 py-1 hover:text-zinc-800"
|
||||
href="/docs"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
</Button> */}
|
||||
</div>
|
||||
</nav>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user