mirror of
https://github.com/jordienr/zenblog.git
synced 2026-09-28 01:56:07 -04:00
wip
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user