This commit is contained in:
Jordi Enric
2024-10-26 23:43:13 +02:00
parent 566e1b5011
commit d59564418d
21 changed files with 424 additions and 556 deletions
+144 -3
View File
@@ -8,7 +8,19 @@ import { createClient } from "@/lib/server/supabase";
import { createId } from "@/lib/create-id";
export const dynamic = "force-dynamic";
import bcrypt from "bcrypt";
import { axiom } from "lib/axiom";
import { axiom, AXIOM_DATASETS } from "lib/axiom";
import {
createOrRetrieveCustomer,
createStripeClient,
} from "@/lib/server/stripe";
import { BASE_URL } from "@/lib/config";
import {
isPricingPlanInterval,
isPricingPlanTier,
PRICING_PLANS,
PricingPlanInterval,
PricingPlanTier,
} from "@/lib/pricing.constants";
const UnauthorizedError = (c: Context) => {
return c.json({ message: "Unauthorized" }, { status: 401 });
@@ -30,7 +42,7 @@ const errors = {
const handleError = (c: Context, error: keyof typeof errors, rawLog: any) => {
console.log("🔴", error);
axiom.ingest("api", {
axiom.ingest(AXIOM_DATASETS.api, {
message: error,
error: true,
blogId: c.req.param("blogId"),
@@ -93,6 +105,135 @@ const api = new Hono()
// Return the new API key to the client for use / storage
return c.json({ message: "success", apiKey: newAPIKey }, 200);
})
.get(
"/accounts/:user_id/checkout",
zValidator(
"query",
z.object({
plan: PricingPlanTier,
interval: PricingPlanInterval,
})
),
async (c) => {
try {
const stripe = createStripeClient();
const { user, error } = await getUser();
const userId = c.req.param("user_id");
const plan = c.req.query("plan");
const interval = c.req.query("interval");
if (error || !user || !user.email || !plan || !interval) {
axiom.ingest(AXIOM_DATASETS.stripe, {
message: "Error loading checkout session",
payload: { userId, plan, error, user },
error: true,
});
return c.json(
{
error: "Error loading checkout session. Please contact support.",
},
{ status: 500 }
);
}
if (!isPricingPlanTier(plan)) {
return c.json({ error: "Invalid plan" }, { status: 400 });
}
if (!isPricingPlanInterval(interval)) {
return c.json({ error: "Invalid interval" }, { status: 400 });
}
const customer = await createOrRetrieveCustomer({
userId: user.id,
email: user.email,
});
const selectedPlan = PRICING_PLANS.find((p) => p.id === plan);
if (!selectedPlan) {
return c.json({ error: "Invalid plan" }, { status: 400 });
}
const price =
selectedPlan?.[interval === "month" ? "monthlyPrice" : "yearlyPrice"];
const session = await stripe.checkout.sessions.create({
customer: customer.id,
mode: "subscription",
allow_promotion_codes: true,
success_url: `${BASE_URL}/account?success=true`,
cancel_url: `${BASE_URL}/account?canceled=true`,
line_items: [
{
quantity: 1,
price_data: {
product_data: {
name: selectedPlan.title,
description: selectedPlan.description,
metadata: {
plan_id: selectedPlan.id,
interval: interval,
},
},
currency: "usd",
unit_amount: price * 100,
recurring: {
interval,
},
},
},
],
});
if (!session.url) {
return c.json({ error: "Error creating session" }, { status: 500 });
}
console.log("session", session);
return c.json({ url: session.url }, { status: 200 });
} catch (error) {
console.error(error);
return c.json(
{ errorMessage: "Error creating session", error },
{ status: 500 }
);
}
}
)
.get("/accounts/:user_id/customer-portal", async (c) => {
try {
const stripe = createStripeClient();
const { user } = await getUser();
if (!user) {
return c.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.email) {
return c.json({ error: "User email not found" }, { status: 400 });
}
const customer = await createOrRetrieveCustomer({
userId: user.id,
email: user.email,
});
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: process.env.NEXT_PUBLIC_BASE_URL + "/account",
});
return c.json({ url: session.url }, { status: 200 });
} catch (error) {
console.error(error);
return c.json(
{ error: "Error creating customer portal" },
{ status: 500 }
);
}
});
const app = new Hono()
@@ -104,7 +245,7 @@ const app = new Hono()
const start = Date.now();
await next();
const duration = Date.now() - start;
axiom.ingest("api", {
axiom.ingest(AXIOM_DATASETS.api, {
message: "Request completed",
duration,
method: c.req.method,
@@ -1,75 +0,0 @@
import {
createOrRetrieveCustomer,
createStripeClient,
} from "@/lib/server/stripe";
import { BASE_URL } from "@/lib/config";
import { createClient } from "app/supa";
import { type NextRequest, NextResponse as res } from "next/server";
export async function POST(req: NextRequest) {
try {
const stripe = createStripeClient();
const supa = createClient();
const {
data: { user },
error,
} = await supa.auth.getUser();
if (error) {
console.error(error);
return res.json({ error: "Error loading user" }, { status: 500 });
}
console.log("user --->>>", user?.email);
const bodyJson = await req.json();
const price_id = bodyJson.price_id;
console.log(user?.email, price_id);
if (!user) {
return res.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.email) {
return res.json({ error: "User email not found" }, { status: 400 });
}
if (!price_id) {
return res.json({ error: "Product id not found" }, { status: 400 });
}
const customer = await createOrRetrieveCustomer({
userId: user.id,
email: user.email,
});
const session = await stripe.checkout.sessions.create({
customer: customer.id,
line_items: [
{
price: price_id,
quantity: 1,
},
],
mode: "subscription",
allow_promotion_codes: true,
success_url: `${BASE_URL}/account?success=true`,
cancel_url: `${BASE_URL}/account?canceled=true`,
});
if (!session.url) {
return res.json({ error: "Error creating session" }, { status: 500 });
}
return res.json({ url: session.url }, { status: 200 });
} catch (error) {
console.error(error);
return res.json(
{ errorMessage: "Error creating session", error },
{ status: 500 }
);
}
}
@@ -1,5 +1,5 @@
import { Context } from "hono";
import { axiom } from "lib/axiom";
import { axiom, AXIOM_DATASETS } from "lib/axiom";
const ERROR_TABLE = "zenblog-errors";
@@ -18,7 +18,7 @@ const ERROR_MAP: Record<string, ErrorItem> = {
export const throwError = (ctx: Context, error: keyof typeof ERROR_MAP) => {
console.log(`🔴 ${ERROR_MAP[error]?.message}`);
axiom.ingest(ERROR_TABLE, {
axiom.ingest(AXIOM_DATASETS.api, {
error: ERROR_MAP[error]?.message,
request: ctx.req,
});
+5
View File
@@ -1,5 +1,10 @@
import { Axiom } from "@axiomhq/js";
export const AXIOM_DATASETS = {
api: "api",
stripe: "stripe",
};
const axiom = new Axiom({
token: process.env.AXIOM_TOKEN!,
});
@@ -41,10 +41,8 @@ export const useCreateBlogTag = () => {
if (error) {
throw error;
}
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
return data;
},
});
+21 -6
View File
@@ -5,7 +5,6 @@ import { motion } from "framer-motion";
import Notifications from "@/components/Notifications";
import Feedback from "@/components/Feedback";
import Footer from "@/components/Footer";
import { usePlan } from "@/queries/subscription";
import AppChecks from "@/components/LoggedInUserChecks";
import { Loader } from "lucide-react";
import { useUser } from "@/utils/supabase/browser";
@@ -21,20 +20,23 @@ import {
import { useBlogsQuery } from "@/queries/blogs";
import { cn } from "@/lib/utils";
import Head from "next/head";
import { useSubscriptionQuery } from "@/queries/subscription";
type Props = {
children?: React.ReactNode;
loading?: boolean;
title?: string;
actions?: React.ReactNode;
description?: string;
};
export default function AppLayout({
children,
loading = false,
title,
actions,
description,
}: Props) {
const plan = usePlan();
const { data: subscription } = useSubscriptionQuery();
const user = useUser();
const router = useRouter();
const { data: blogs, isLoading: blogsLoading } = useBlogsQuery({
@@ -119,16 +121,17 @@ export default function AppLayout({
size={16}
/>
)}
{plan === "free" && (
{subscription?.plan}
{subscription?.plan === "hobby" && (
<Link
title="Upgrade to Pro"
href="/account"
className=" rounded-full p-1 text-center text-xs font-medium text-emerald-500"
>
Free
Hobby
</Link>
)}
{plan === "pro" && (
{subscription?.plan === "pro" && (
<div className="rounded-full text-xs font-medium text-blue-500">
Pro
</div>
@@ -206,7 +209,12 @@ export default function AppLayout({
) : (
<div className="">
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<SectionTitle>{title}</SectionTitle>
<div className="flex flex-col">
<SectionTitle>{title}</SectionTitle>
{description && (
<SectionDescription>{description}</SectionDescription>
)}
</div>
<SectionActions>{actions}</SectionActions>
</div>
@@ -280,3 +288,10 @@ export function SectionTitle({ children }: { children: React.ReactNode }) {
export function SectionActions({ children }: { children: React.ReactNode }) {
return <div className="flex items-center gap-2">{children}</div>;
}
export function SectionDescription({
children,
}: {
children: React.ReactNode;
}) {
return <p className="text-sm text-zinc-500">{children}</p>;
}
+31 -7
View File
@@ -1,15 +1,37 @@
type PricingPlan = {
import { z } from "zod";
export const PricingPlanTier = z.enum(["hobby", "pro"]);
export type PricingPlanTier = z.infer<typeof PricingPlanTier>;
export const isPricingPlanTier = (value: string): value is PricingPlanTier =>
PricingPlanTier.safeParse(value).success;
export const PricingPlanInterval = z.enum(["month", "year"]);
export type PricingPlanInterval = z.infer<typeof PricingPlanInterval>;
export const isPricingPlanInterval = (
value: string
): value is PricingPlanInterval => PricingPlanInterval.safeParse(value).success;
export type PricingPlan = {
id: PricingPlanTier;
title: string;
description: string;
monthlyPrice: number;
yearlyPrice: number;
features: string[];
};
/**
* ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
* ! Changing these values will change the pricing of the plans in Stripe.
* ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
*/
export const PRICING_PLANS: PricingPlan[] = [
{
title: "Hobby",
monthlyPrice: 0,
yearlyPrice: 0,
id: "hobby",
title: "Zenblog Hobby Plan",
description: "For personal blogs and small projects",
monthlyPrice: 8,
yearlyPrice: 72,
features: [
"1 blog",
"Unlimited posts",
@@ -18,9 +40,11 @@ export const PRICING_PLANS: PricingPlan[] = [
],
},
{
title: "Pro",
monthlyPrice: 9,
yearlyPrice: 90,
id: "pro",
title: "Zenblog Pro Plan",
description: "For professional blogs and projects",
monthlyPrice: 20,
yearlyPrice: 192,
features: [
"Unlimited blogs",
"Unlimited posts",
+132 -193
View File
@@ -1,103 +1,61 @@
import { IsDevMode } from "@/components/is-dev-mode";
import { Button } from "@/components/ui/button";
import { Tabs, TabsTrigger, TabsList } from "@/components/ui/tabs";
import AppLayout from "@/layouts/AppLayout";
import AppLayout, { Section, SectionTitle } from "@/layouts/AppLayout";
import { PRICING_PLANS, PricingPlan } from "@/lib/pricing.constants";
import { usePricesQuery } from "@/queries/prices";
import { useProductsQuery } from "@/queries/products";
import { useIsSubscribed, useSubscriptionQuery } from "@/queries/subscription";
import { useSubscriptionQuery } from "@/queries/subscription";
import { useUser } from "@/utils/supabase/browser";
import { Landmark, Loader } from "lucide-react";
import React, { useState } from "react";
import { toast } from "sonner";
import { PricingCard } from "../pricing";
import { API } from "app/utils/api-client";
type Props = {};
export const SubscribeSection = () => {
const products = useProductsQuery();
const prices = usePricesQuery();
const user = useUser();
const [interval, setInterval] = React.useState<"year" | "month">("year");
const [isLoading, setIsLoading] = useState(false);
async function openCheckoutPage(product_id: string) {
async function openCheckoutPage(plan: PricingPlan) {
setIsLoading(true);
toast.info("Redirecting to Stripe...");
const pricesForProduct = prices.data?.filter(
(p) => p.price.product === product_id
);
if (!pricesForProduct) {
if (!user || !user.id) {
toast.error("User not found");
setIsLoading(false);
return;
}
const price = pricesForProduct.find(
(p) => p.price.recurring?.interval === interval
);
if (!price) {
setIsLoading(false);
return;
}
const res = await fetch("/api/create-checkout-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await API().v2.accounts[":user_id"].checkout.$get({
param: {
user_id: user.id,
},
query: {
plan: plan.id,
interval,
},
body: JSON.stringify({
price_id: price.price.id,
}),
});
if (!res.ok) {
setIsLoading(false);
console.error(res.statusText);
toast.error("Error creating checkout session, please try again.");
return;
}
const resJson = await response.json();
const json = await res.json();
if (json.error) {
console.error(json.error);
console.log(resJson);
if ("error" in resJson) {
toast.error("Error creating checkout session");
console.error(resJson.error);
setIsLoading(false);
return;
}
if (json.url) {
window.location.href = json.url;
} else {
setIsLoading(false);
toast.error("Error creating session");
console.error("Error creating session");
}
window.location.href = resJson.url;
}
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;
}
const amount = price.price.unit_amount;
if (!amount) {
return;
}
return formatAmount(amount);
}
if (loading) {
return (
<div className="flex w-full items-center justify-center py-24">
@@ -108,47 +66,31 @@ export const SubscribeSection = () => {
return (
<div>
{/* <IsDevMode>
<pre>
Run this to sync stripe with the local database:
<br />
`npm run stripe:webhook` // listen for stripe events
<br />
`npm run stripe:sync` // sync stripe products and prices
<br />
Then refresh the page and subscribe to a plan
</pre>
</IsDevMode> */}
<h2 className="text-lg font-medium">Pricing</h2>
<p className="font-mono text-sm text-zinc-500">Cancel anytime</p>
<div className="mt-4 flex gap-2">
<Button
variant={interval === "month" ? "default" : "outline"}
onClick={() => setInterval("month")}
>
Monthly
</Button>
<Button
variant={interval === "year" ? "default" : "outline"}
onClick={() => setInterval("year")}
>
Yearly
</Button>
</div>
<div className="mt-4 ">
{products.data?.map((product) => (
<div
key={product.id}
className="relative max-w-sm rounded-xl border border-b-2 bg-zinc-100/70 p-3"
>
<h3 className="text-lg font-semibold">{product.product.name}</h3>
<div className="mt-2 text-3xl font-semibold">
{getAmountFromProduct(product.product.id)}
<span className="text-sm text-zinc-400">/{interval}</span>
</div>
<div className="mt-2 flex">
<Button
size="default"
className="w-full"
onClick={() => openCheckoutPage(product.product.id)}
>
Subscribe →
</Button>
</div>
<ul className="mt-4 grid gap-1.5 px-4 py-4 font-mono">
<li>✔︎ Unlimited blogs</li>
<li>✔︎ API access</li>
<li>✔︎ Supports development</li>
<li>🔜 More soon</li>
</ul>
<div className="mt-4 grid max-w-xl grid-cols-2 gap-4">
{PRICING_PLANS.map((plan) => (
<div key={plan.title}>
<PricingCard
{...plan}
type={interval}
onClick={() => openCheckoutPage(plan)}
/>
</div>
))}
</div>
@@ -162,32 +104,36 @@ const AccountPage = (props: Props) => {
async function onManageSubscriptionClick() {
setLoading(true);
const response = await fetch("/api/customer-portal", {
method: "POST",
toast.info("Redirecting to Stripe...");
const response = await API().v2.accounts[":user_id"][
"customer-portal"
].$get({
param: {
user_id: user?.id || "",
},
});
const json = await response.json();
if (json.error) {
toast.error(json.error);
console.error(json.error);
if ("error" in json) {
toast.error("Error creating customer portal");
console.error(json);
setLoading(false);
return;
}
if (json.session) {
window.location.href = json.session;
if (json.url) {
console.log("redirecting to", json.url);
window.location.href = json.url;
} else {
toast.error(json.error);
console.error("Error creating session");
toast.error("Error creating customer portal");
console.error(json);
setLoading(false);
}
}
const subscription = useSubscriptionQuery();
const isSubbed = useIsSubscribed();
function formatDate(date: string) {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
@@ -199,92 +145,85 @@ const AccountPage = (props: Props) => {
}
return (
<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 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>
<AppLayout
loading={loading || subscription.isLoading}
title="Account settings"
description="Manage your account and subscription"
>
<Section className="px-4">
<SectionTitle>Account</SectionTitle>
<div className="mt-4 max-w-xl 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>
{/* <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 ? (
<></>
) : (
<p className="mt-4">
Subscription status:{" "}
{subscription.data?.status === "active" ? (
<span className="rounded-md bg-emerald-100 px-3 py-1 font-mono text-emerald-700">
{subscription.data?.status}
</span>
) : (
<span>
<span className="rounded-md bg-yellow-100 px-3 py-1 font-mono text-yellow-600">
{subscription.data?.status || "Not found"}
</span>
</span>
)}
</p>
)}
<div className="">
{loading ? (
<pre>Loading...</pre>
</div>
</Section>
<Section className="my-4 rounded-xl border border-b-2 bg-white p-4">
<SectionTitle>Subscription details</SectionTitle>
{subscription.isLoading ? (
<></>
) : (
<p className="mt-4 grid max-w-xl grid-cols-2">
Subscription status:{" "}
{subscription.data?.status === "active" ? (
<span className="inline-flex items-center gap-1 rounded-md px-3 py-1 font-mono text-emerald-700">
{subscription.data?.status}
</span>
) : (
<>
{subscription.data?.status !== "active" ? (
<>
<hr className="my-8 max-w-lg" />
<SubscribeSection />
</>
) : (
<>
{/* <pre>{JSON.stringify(subscription.data, null, 2)}</pre> */}
</>
)}
</>
<span>
<span className="rounded-md bg-yellow-100 px-3 py-1 font-mono text-yellow-600">
{subscription.data?.status || "Free"}
</span>
</span>
)}
</div>
</p>
)}
{isSubbed && (
<div className="">
{loading ? (
<pre>Loading...</pre>
) : (
<>
<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>
{subscription.data?.status !== "active" ? (
<>
<hr className="my-8 max-w-xl" />
<SubscribeSection />
</>
) : (
<>
{/* <pre>{JSON.stringify(subscription.data, null, 2)}</pre> */}
</>
)}
</>
)}
</section>
</div>
</div>
{subscription.data?.status === "active" && (
<>
<hr className="my-6 max-w-xl" />
<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>
</AppLayout>
);
};
@@ -1,67 +0,0 @@
import { NextApiHandler } from "next";
import {
createOrRetrieveCustomer,
createStripeClient,
} from "@/lib/server/stripe";
import { BASE_URL } from "@/lib/config";
import { createClient } from "app/supa";
const handler: NextApiHandler = async (req, res) => {
return;
// try {
// const stripe = createStripeClient();
// const supa = createClient();
// const jwt = req.headers.authorization?.replace("Bearer ", "");
// console.log(jwt);
// const {
// data: { user },
// error,
// } = await supa.auth.getUser(jwt);
// const price_id = req.body.price_id;
// console.log(user);
// if (!user) {
// return res.status(401).json({ error: "Unauthorized" });
// }
// if (!user.email) {
// 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,
// });
// const session = await stripe.checkout.sessions.create({
// customer: customer.id,
// line_items: [
// {
// price: price_id,
// quantity: 1,
// },
// ],
// mode: "subscription",
// allow_promotion_codes: true,
// success_url: `${BASE_URL}/account?success=true`,
// cancel_url: `${BASE_URL}/account?canceled=true`,
// });
// if (!session.url) {
// return res.status(500).json({ error: "Error creating session" });
// }
// res.status(200).json({ url: session.url });
// } catch (error) {
// console.error(error);
// res.status(500).json({ errorMessage: "Error creating session", error });
// }
};
export default handler;
-104
View File
@@ -1,104 +0,0 @@
import { NextApiHandler } from "next";
import { getServerClient } from "@/lib/server/deprecated/supabase";
import {
createOrRetrieveCustomer,
createStripeClient,
} from "@/lib/server/stripe";
import Stripe from "stripe";
const handler: NextApiHandler = async (req, res) => {
try {
const stripe = createStripeClient();
const { user, db } = await getServerClient(req, res);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!user.email) {
return res.status(400).json({ error: "User email not found" });
}
const customer = await createOrRetrieveCustomer({
userId: user.id,
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: {
enabled: true,
allowed_updates: ["address", "name", "phone", "tax_id"],
},
invoice_history: {
enabled: true,
},
payment_method_update: {
enabled: true,
},
subscription_cancel: {
enabled: true,
mode: "immediately",
cancellation_reason: {
enabled: true,
options: [
"customer_service",
"low_quality",
"missing_features",
"other",
"unused",
"too_expensive",
"too_complex",
"switched_service",
],
},
},
subscription_update: {
enabled: true,
proration_behavior: "create_prorations",
default_allowed_updates: ["price", "promotion_code"],
products: subscriptionUpdateConfig.map((config) => ({
product: config.product!,
prices: config.prices,
})),
},
},
business_profile: {
headline: "Zenblog partners with Stripe for simplified billing",
},
});
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
configuration: configuration.id,
return_url: process.env.NEXT_PUBLIC_API_URL + "/account",
});
res.status(200).json({ session: session.url });
} catch (error) {
console.error(error);
res.status(500).json({ error: "Error creating session" });
}
};
export default handler;
-9
View File
@@ -1,9 +0,0 @@
import { appRouter } from "@/trpc/server";
import { createTRPCContext } from "@/trpc/server/context";
import { createNextApiHandler } from "@trpc/server/adapters/next";
// @link https://nextjs.org/docs/api-routes/introduction
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
});
+12 -16
View File
@@ -3,15 +3,7 @@ import { createStripeClient } from "@/lib/server/stripe";
import getRawBody from "raw-body";
import Stripe from "stripe";
import { createAdminClient } from "@/lib/server/supabase/admin";
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
console.log("----");
import { axiom, AXIOM_DATASETS } from "lib/axiom";
const stripe = createStripeClient();
const supabase = createAdminClient();
@@ -38,11 +30,16 @@ async function upsertSubscription(event: Stripe.Event) {
throw new Error("Invalid user id");
}
const isCancelled = event.data.object.status === "canceled";
const plan = isCancelled ? null : event.data.object.metadata.plan_id;
const res = await supabase.from("subscriptions").upsert(
{
stripe_subscription_id: event.data.object.id,
user_id: userId,
status: event.data.object.status,
plan,
subscription: event.data.object as any,
},
{
@@ -50,7 +47,12 @@ async function upsertSubscription(event: Stripe.Event) {
}
);
console.log(res);
console.log("error: ", res.error);
console.log("data: ", res.data);
axiom.ingest(AXIOM_DATASETS.stripe, {
message: "Stripe subscription upserted",
payload: { event, res },
});
}
}
@@ -167,12 +169,6 @@ const handler: NextApiHandler = async (req, res) => {
"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];
@@ -109,6 +109,7 @@ export default function CategoriesPage() {
title="Categories"
loading={isLoading}
actions={<CreateCategoryDialog />}
description="Categories help you group your posts. Posts can have one category."
>
<Section>
<Table>
@@ -125,9 +126,13 @@ export default function CategoriesPage() {
<TableBody>
{categories?.data?.map((category) => (
<TableRow key={category.category_id}>
<TableCell>{category.category_name}</TableCell>
<TableCell>{category.category_slug}</TableCell>
<TableCell className="text-right">
<TableCell className="font-medium">
{category.category_name}
</TableCell>
<TableCell className="font-mono text-slate-500">
{category.category_slug}
</TableCell>
<TableCell className="text-right font-mono">
{category.post_count}
</TableCell>
<TableCell className="text-right">
@@ -25,9 +25,9 @@ export default function Post() {
const tagsQuery = usePostTags({ blogId, postId: post?.data?.id || "" });
const tags = tagsQuery.data?.map((tagRes) => ({
name: tagRes.blog_tags!.name,
id: tagRes.blog_tags!.id,
slug: tagRes.blog_tags!.slug,
name: tagRes.blog_tags?.name || "",
id: tagRes.blog_tags?.id || "",
slug: tagRes.blog_tags?.slug || "",
}));
if (isLoading || tagsQuery.isLoading || isRefetching) {
+17 -1
View File
@@ -1,4 +1,8 @@
import AppLayout, { Section } from "@/layouts/AppLayout";
import AppLayout, {
Section,
SectionDescription,
SectionTitle,
} from "@/layouts/AppLayout";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
@@ -265,6 +269,18 @@ export default function BlogSettings() {
</Dialog>
</Section>
<Section className="p-4">
<SectionTitle>Manage subscription</SectionTitle>
<SectionDescription>
You can manage your subscription from the account page.
</SectionDescription>
<div className="mt-4">
<Link href="/account">
<Button variant={"outline"}>Go to account</Button>
</Link>
</div>
</Section>
<div className="py-8 text-center text-zinc-400">~</div>
<Section className="p-4">
+3 -2
View File
@@ -38,6 +38,7 @@ export default function TagsPage() {
<AppLayout
title="Tags"
actions={<CreateTagDialog blogId={blogId} />}
description="Tags help you group your posts. Posts can have multiple tags."
loading={tags.isLoading}
>
<Section>
@@ -62,10 +63,10 @@ export default function TagsPage() {
return (
<div
key={tag.tag_id}
className="grid grid-cols-4 items-center px-2 py-1.5 hover:bg-zinc-50"
className="grid grid-cols-4 items-center p-4 hover:bg-zinc-50"
>
<div className="flex items-center gap-2">
<PiTag className="text-orange-500" size="16" />
{/* <PiTag className="text-orange-500" size="16" /> */}
<span className="font-medium">{tag.tag_name}</span>
</div>
+3 -3
View File
@@ -7,17 +7,17 @@ import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useIsSubscribed } from "@/queries/subscription";
import Link from "next/link";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Smile } from "lucide-react";
import { RESERVED_SLUGS } from "@/lib/constants";
import { useSubscriptionQuery } from "@/queries/subscription";
export default function CreateBlog() {
const DEFAULT_EMOJI = "📝";
const isSubscribed = useIsSubscribed();
const { data: subscription, isLoading } = useSubscriptionQuery();
type FormData = {
title: string;
@@ -92,7 +92,7 @@ export default function CreateBlog() {
}
};
if (!isSubscribed && hasOneBlogAlready) {
if (subscription?.status !== "active" && hasOneBlogAlready && !isLoading) {
return (
<AppLayout loading={createBlog.isPending || createBlog.isSuccess}>
<div className="section mx-auto my-12 max-w-xl py-12">
+23 -12
View File
@@ -10,9 +10,9 @@ import { useRouter } from "next/router";
import { useState } from "react";
export default function Pricing() {
const [subscriptionType, setSubscriptionType] = useState<
"monthly" | "yearly"
>("yearly");
const [subscriptionType, setSubscriptionType] = useState<"month" | "year">(
"year"
);
const router = useRouter();
return (
@@ -34,14 +34,14 @@ export default function Pricing() {
<div className="mt-8">
<div className="flex items-center gap-1">
<Button
variant={subscriptionType === "monthly" ? "secondary" : "ghost"}
onClick={() => setSubscriptionType("monthly")}
variant={subscriptionType === "month" ? "secondary" : "ghost"}
onClick={() => setSubscriptionType("month")}
>
Monthly
</Button>
<Button
variant={subscriptionType === "yearly" ? "secondary" : "ghost"}
onClick={() => setSubscriptionType("yearly")}
variant={subscriptionType === "year" ? "secondary" : "ghost"}
onClick={() => setSubscriptionType("year")}
>
Yearly (2 months free)
</Button>
@@ -96,7 +96,8 @@ export default function Pricing() {
);
}
function PricingCard({
export function PricingCard({
id,
title,
monthlyPrice,
yearlyPrice,
@@ -104,20 +105,21 @@ function PricingCard({
onClick,
type,
}: {
id: string;
title: string;
monthlyPrice: number;
yearlyPrice: number;
features: string[];
onClick: () => void;
type: "monthly" | "yearly";
type: "month" | "year";
}) {
const yearlyToMonth = (yearlyPrice / 12).toFixed(2);
const yearlyToMonth = yearlyPrice / 12;
const PricingText = () => {
if (monthlyPrice === 0) {
return <p className="text-2xl font-medium">Free</p>;
}
if (type === "monthly") {
if (type === "month") {
return (
<p className="text-slate-500">
<span className="mr-1 text-xl">$</span>
@@ -141,6 +143,13 @@ function PricingCard({
);
};
function getButtonVariant() {
if (id === "pro") {
return "default";
}
return "outline";
}
return (
<div className="rounded-2xl border p-4">
<h3 className="text-lg font-medium">{title}</h3>
@@ -156,7 +165,9 @@ function PricingCard({
))}
</ul>
<div className="mt-5">
<Button>Get started</Button>
<Button variant={getButtonVariant()} onClick={onClick}>
Get started
</Button>
</div>
</div>
);
+10 -41
View File
@@ -1,6 +1,7 @@
import { createSupabaseBrowserClient } from "@/lib/supabase";
import { useUser } from "@/utils/supabase/browser";
import { QueryOptions, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
const SUBSCRIPTION_KEYS = ["subscription"];
@@ -10,51 +11,19 @@ export function useSubscriptionQuery() {
return useQuery({
queryKey: SUBSCRIPTION_KEYS,
enabled: !!user,
queryFn: async () => {
const { data, error } = await sb
const { data } = await sb
.from("subscriptions")
.select("status")
.select("status, plan")
.eq("user_id", user?.id || "")
.limit(1);
.limit(1)
.throwOnError();
if (error || !data[0]) {
console.error(error);
return {
status: "inactive",
};
}
return data[0];
return {
status: data?.[0]?.status,
plan: data?.[0]?.plan,
};
},
initialData: {
status: "",
},
staleTime: 5 * 60 * 1000,
});
}
export function usePlan() {
const { data, isLoading } = useSubscriptionQuery();
if (isLoading) {
return "";
}
if (data?.status === "active") {
return "pro";
} else {
return "free";
}
}
export function useIsSubscribed() {
const { isFetching, data } = useSubscriptionQuery();
if (isFetching) {
return false; // Assume subscribed to avoid flicker
}
const isSubscribed = data?.status === "active";
return isSubscribed;
}
+6 -6
View File
@@ -2,8 +2,8 @@ import { createSupabaseBrowserClient } from "@/lib/supabase";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export const tagKeys = {
tags: (keys: string[]) => ["blog-tags", ...keys],
tag: (tagId: string) => ["blog-tag", tagId],
tags: () => ["tags"],
tag: (tagId: string) => ["tags", tagId],
};
export function useTagsWithUsageQuery(
@@ -17,7 +17,7 @@ export function useTagsWithUsageQuery(
const supa = createSupabaseBrowserClient();
return useQuery({
queryKey: tagKeys.tags([blogId]),
queryKey: tagKeys.tags(),
enabled: !!blogId && enabled,
queryFn: async () => {
const { data } = await supa
@@ -49,7 +49,7 @@ export function useDeleteTagMutation(blogId: string) {
return res;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: tagKeys.tags([blogId]) });
queryClient.invalidateQueries({ queryKey: tagKeys.tags() });
},
});
}
@@ -69,7 +69,7 @@ export function useUpdateTagMutation(blogId: string) {
return res;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: tagKeys.tags([blogId]) });
queryClient.invalidateQueries({ queryKey: tagKeys.tags() });
},
});
}
@@ -84,7 +84,7 @@ export function usePostTags({
const supa = createSupabaseBrowserClient();
return useQuery({
queryKey: tagKeys.tags([postId, blogId]),
queryKey: tagKeys.tags(),
enabled: !!postId,
queryFn: async () => {
const { data } = await supa
+3
View File
@@ -496,6 +496,7 @@ export type Database = {
subscriptions: {
Row: {
created_at: string
plan: string | null
status: string
stripe_subscription_id: string
subscription: Json
@@ -503,6 +504,7 @@ export type Database = {
}
Insert: {
created_at?: string
plan?: string | null
status: string
stripe_subscription_id: string
subscription: Json
@@ -510,6 +512,7 @@ export type Database = {
}
Update: {
created_at?: string
plan?: string | null
status?: string
stripe_subscription_id?: string
subscription?: Json