From f0a2bae7ae6776e51b9362e24e30b96a0a1c63e2 Mon Sep 17 00:00:00 2001 From: Zen0space Date: Tue, 7 Apr 2026 11:21:50 +0800 Subject: [PATCH 1/5] feat: add Stripe subscription billing integration - Add Stripe checkout session and customer portal via tRPC - Add webhook handler for checkout.completed, subscription.updated/deleted, invoice.payment_failed - Add pricing page with 3-tier cards (Free, Tier 1 RM45/mo, Tier 2 RM75/mo) - Add upgrade/manage billing buttons to dashboard settings - Add Stripe fields to User type (stripeCustomerId, stripeSubscriptionId, stripeCurrentPeriodEnd) - Add TIER_PRICING constants to kal-shared - Add DB migration for Stripe user fields - Fix email unique index bug (E11000 duplicate key on null emails) - Migration 2: sparse index (insufficient) - Migration 3: partialFilterExpression on string type (correct fix) - Fix context.ts to not set email: null explicitly on upsert - Add stripe and @stripe/stripe-js dependencies --- packages/kal-backend/package.json | 1 + packages/kal-backend/src/lib/context.ts | 3 +- packages/kal-backend/src/lib/stripe.ts | 29 ++ packages/kal-backend/src/routers/index.ts | 2 + .../kal-backend/src/routers/subscription.ts | 205 +++++++++++++ .../kal-backend/src/routes/stripe-webhook.ts | 275 +++++++++++++++++ .../20260407000001_add_stripe_fields.js | 45 +++ .../20260407000002_fix_email_index_sparse.js | 35 +++ .../20260407000003_fix_email_index_partial.js | 46 +++ packages/kal-frontend/package.json | 1 + .../src/app/dashboard/settings/client.tsx | 202 ++++++++++-- .../kal-frontend/src/app/pricing/client.tsx | 290 ++++++++++++++++++ .../kal-frontend/src/app/pricing/page.tsx | 28 ++ packages/kal-shared/src/constants/index.ts | 26 ++ packages/kal-shared/src/types/index.ts | 3 + pnpm-lock.yaml | 21 ++ 16 files changed, 1185 insertions(+), 27 deletions(-) create mode 100644 packages/kal-backend/src/lib/stripe.ts create mode 100644 packages/kal-backend/src/routers/subscription.ts create mode 100644 packages/kal-backend/src/routes/stripe-webhook.ts create mode 100644 packages/kal-db/migrations/20260407000001_add_stripe_fields.js create mode 100644 packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js create mode 100644 packages/kal-db/migrations/20260407000003_fix_email_index_partial.js create mode 100644 packages/kal-frontend/src/app/pricing/client.tsx create mode 100644 packages/kal-frontend/src/app/pricing/page.tsx diff --git a/packages/kal-backend/package.json b/packages/kal-backend/package.json index a831616..fa89024 100644 --- a/packages/kal-backend/package.json +++ b/packages/kal-backend/package.json @@ -42,6 +42,7 @@ "mongodb": "^6.12.0", "swagger-ui-express": "^5.0.1", "ws": "^8.18.3", + "stripe": "^17.7.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/kal-backend/src/lib/context.ts b/packages/kal-backend/src/lib/context.ts index 1a529de..d34d277 100644 --- a/packages/kal-backend/src/lib/context.ts +++ b/packages/kal-backend/src/lib/context.ts @@ -32,7 +32,8 @@ async function syncUserFromLogto( { logtoId: claims.sub }, { $set: { - email: claims.email || null, + // Only set email when we actually have one (avoids null duplicate key issues) + ...(claims.email ? { email: claims.email } : {}), // Only update name if we have a value (don't overwrite with empty) ...(displayName ? { name: displayName } : {}), updatedAt: now, diff --git a/packages/kal-backend/src/lib/stripe.ts b/packages/kal-backend/src/lib/stripe.ts new file mode 100644 index 0000000..5b6a3b7 --- /dev/null +++ b/packages/kal-backend/src/lib/stripe.ts @@ -0,0 +1,29 @@ +import Stripe from "stripe"; + +if (!process.env.STRIPE_SECRET_KEY) { + console.warn( + "⚠️ STRIPE_SECRET_KEY is not set. Stripe functionality will not work." + ); +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || ""); + +/** + * Maps a Stripe Price ID to a UserTier. + * Returns "free" if the price ID doesn't match any known tier. + */ +export function priceIdToTier(priceId: string): "free" | "tier_1" | "tier_2" { + if (priceId === process.env.STRIPE_TIER1_PRICE_ID) return "tier_1"; + if (priceId === process.env.STRIPE_TIER2_PRICE_ID) return "tier_2"; + return "free"; +} + +/** + * Maps a UserTier to a Stripe Price ID. + * Returns null for the free tier (no Stripe price). + */ +export function tierToPriceId(tier: "tier_1" | "tier_2"): string | null { + if (tier === "tier_1") return process.env.STRIPE_TIER1_PRICE_ID || null; + if (tier === "tier_2") return process.env.STRIPE_TIER2_PRICE_ID || null; + return null; +} diff --git a/packages/kal-backend/src/routers/index.ts b/packages/kal-backend/src/routers/index.ts index e45751f..3ef4d64 100644 --- a/packages/kal-backend/src/routers/index.ts +++ b/packages/kal-backend/src/routers/index.ts @@ -8,6 +8,7 @@ import { foodRouter } from "./food.js"; import { halalRouter } from "./halal.js"; import { platformSettingsRouter } from "./platform-settings.js"; import { requestLogsRouter } from "./request-logs.js"; +import { subscriptionRouter } from "./subscription.js"; import { userRouter } from "./user.js"; export const appRouter = router({ @@ -20,6 +21,7 @@ export const appRouter = router({ platformSettings: platformSettingsRouter, requestLogs: requestLogsRouter, adminLogs: adminLogsRouter, + subscription: subscriptionRouter, }); // Export type for client diff --git a/packages/kal-backend/src/routers/subscription.ts b/packages/kal-backend/src/routers/subscription.ts new file mode 100644 index 0000000..ae2837a --- /dev/null +++ b/packages/kal-backend/src/routers/subscription.ts @@ -0,0 +1,205 @@ +import { TRPCError } from "@trpc/server"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; + +import type { User } from "kal-shared"; + +import { stripe, tierToPriceId } from "../lib/stripe.js"; +import { protectedProcedure, router } from "../lib/trpc.js"; + +export const subscriptionRouter = router({ + /** + * Create a Stripe Checkout session for subscribing to a tier. + * Returns the Checkout URL for the frontend to redirect to. + */ + createCheckoutSession: protectedProcedure + .input( + z.object({ + tier: z.enum(["tier_1", "tier_2"]), + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, userId } = ctx; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const user = await db + .collection("users") + .findOne({ _id: new ObjectId(userId) as any }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + // Don't allow subscribing if already on the requested tier + if (user.tier === input.tier) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `You are already on ${input.tier}`, + }); + } + + // If user already has a subscription, redirect to portal for plan changes + if (user.stripeSubscriptionId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "You already have an active subscription. Use the billing portal to change your plan.", + }); + } + + const priceId = tierToPriceId(input.tier); + + if (!priceId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Price ID not configured for this tier", + }); + } + + // Create or reuse a Stripe customer + let stripeCustomerId = user.stripeCustomerId; + + if (!stripeCustomerId) { + const customer = await stripe.customers.create({ + email: user.email || undefined, + name: user.name || undefined, + metadata: { + userId: String(user._id), + logtoId: user.logtoId, + platform: "kal", + }, + }); + stripeCustomerId = customer.id; + + // Save customer ID to user record + await db + .collection("users") + .updateOne( + { _id: new ObjectId(userId) as any }, + { $set: { stripeCustomerId: customer.id, updatedAt: new Date() } } + ); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const session = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + client_reference_id: String(user._id), + mode: "subscription", + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + success_url: `${frontendUrl}/dashboard/settings?subscription=success`, + cancel_url: `${frontendUrl}/dashboard/settings?subscription=cancelled`, + metadata: { + userId: String(user._id), + tier: input.tier, + platform: "kal", + }, + }); + + if (!session.url) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create checkout session", + }); + } + + return { url: session.url }; + }), + + /** + * Create a Stripe Customer Portal session for managing billing. + * Returns the portal URL for the frontend to redirect to. + */ + createPortalSession: protectedProcedure.mutation(async ({ ctx }) => { + const { db, userId } = ctx; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const user = await db + .collection("users") + .findOne({ _id: new ObjectId(userId) as any }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + if (!user.stripeCustomerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "No billing account found. You need an active subscription first.", + }); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: user.stripeCustomerId, + return_url: `${frontendUrl}/dashboard/settings`, + }); + + return { url: portalSession.url }; + }), + + /** + * Get the current user's subscription status. + */ + getSubscriptionStatus: protectedProcedure.query(async ({ ctx }) => { + const { db, userId } = ctx; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const user = await db + .collection("users") + .findOne({ _id: new ObjectId(userId) as any }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const isActive = + user.tier !== "free" && + !!user.stripeSubscriptionId && + !!user.stripeCurrentPeriodEnd && + new Date(user.stripeCurrentPeriodEnd) > new Date(); + + return { + tier: user.tier, + stripeCustomerId: user.stripeCustomerId || null, + stripeSubscriptionId: user.stripeSubscriptionId || null, + stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd || null, + isActive, + }; + }), +}); diff --git a/packages/kal-backend/src/routes/stripe-webhook.ts b/packages/kal-backend/src/routes/stripe-webhook.ts new file mode 100644 index 0000000..684462d --- /dev/null +++ b/packages/kal-backend/src/routes/stripe-webhook.ts @@ -0,0 +1,275 @@ +import express, { Router } from "express"; +import type { Collection } from "mongodb"; +import { ObjectId } from "mongodb"; +import type Stripe from "stripe"; + +import { getDB } from "../lib/db.js"; +import { stripe, priceIdToTier } from "../lib/stripe.js"; + +import type { User } from "kal-shared"; + +const router: Router = Router(); + +/** + * Stripe Webhook Handler + * + * IMPORTANT: This route must be mounted BEFORE express.json() middleware + * because Stripe requires the raw body for signature verification. + */ +router.post( + "/", + express.raw({ type: "application/json" }), + async (req, res) => { + const sig = req.headers["stripe-signature"]; + + if (!sig) { + console.error("[Stripe Webhook] Missing stripe-signature header"); + return res.status(400).json({ error: "Missing stripe-signature header" }); + } + + if (!process.env.STRIPE_WEBHOOK_SECRET) { + console.error("[Stripe Webhook] STRIPE_WEBHOOK_SECRET is not configured"); + return res.status(500).json({ error: "Webhook secret not configured" }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + req.body, + sig, + process.env.STRIPE_WEBHOOK_SECRET + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `[Stripe Webhook] Signature verification failed: ${message}` + ); + return res + .status(400) + .json({ error: `Webhook signature verification failed` }); + } + + const db = getDB(); + const users = db.collection("users"); + + try { + switch (event.type) { + case "checkout.session.completed": { + await handleCheckoutCompleted( + event.data.object as Stripe.Checkout.Session, + users + ); + break; + } + case "customer.subscription.updated": { + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription, + users + ); + break; + } + case "customer.subscription.deleted": { + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription, + users + ); + break; + } + case "invoice.payment_failed": { + await handlePaymentFailed(event.data.object as Stripe.Invoice); + break; + } + default: { + console.log(`[Stripe Webhook] Unhandled event type: ${event.type}`); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `[Stripe Webhook] Error handling ${event.type}: ${message}` + ); + // Return 200 anyway to prevent Stripe from retrying + // (we log the error and can investigate manually) + } + + // Always return 200 to acknowledge receipt + res.status(200).json({ received: true }); + } +); + +/** + * Handle checkout.session.completed + * Triggered when a customer successfully completes the Stripe Checkout flow. + */ +async function handleCheckoutCompleted( + session: Stripe.Checkout.Session, + users: Collection +) { + const userId = session.client_reference_id; + + if (!userId) { + console.error( + "[Stripe Webhook] checkout.session.completed: No client_reference_id" + ); + return; + } + + const subscriptionId = + typeof session.subscription === "string" + ? session.subscription + : session.subscription?.id; + + const customerId = + typeof session.customer === "string" + ? session.customer + : session.customer?.id; + + if (!subscriptionId || !customerId) { + console.error( + "[Stripe Webhook] checkout.session.completed: Missing subscription or customer ID" + ); + return; + } + + // Retrieve the subscription to get the price ID and period end + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const priceId = subscription.items.data[0]?.price?.id; + + if (!priceId) { + console.error( + "[Stripe Webhook] checkout.session.completed: No price ID found in subscription" + ); + return; + } + + const tier = priceIdToTier(priceId); + + await users.updateOne( + { _id: new ObjectId(userId) as any }, + { + $set: { + tier, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + updatedAt: new Date(), + }, + } + ); + + console.log( + `[Stripe Webhook] checkout.session.completed: User ${userId} upgraded to ${tier}` + ); +} + +/** + * Handle customer.subscription.updated + * Triggered when a subscription is changed (plan upgrade/downgrade, renewal, etc.) + */ +async function handleSubscriptionUpdated( + subscription: Stripe.Subscription, + users: Collection +) { + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer?.id; + + if (!customerId) { + console.error("[Stripe Webhook] subscription.updated: No customer ID"); + return; + } + + const priceId = subscription.items.data[0]?.price?.id; + if (!priceId) { + console.error("[Stripe Webhook] subscription.updated: No price ID found"); + return; + } + + const tier = priceIdToTier(priceId); + + // If the subscription is set to cancel at period end, keep the current tier + // but update the period end so we know when to downgrade + const updateFields: Record = { + stripeSubscriptionId: subscription.id, + stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), + updatedAt: new Date(), + }; + + // Only update tier if subscription is active (not pending cancellation) + if (subscription.status === "active" && !subscription.cancel_at_period_end) { + updateFields.tier = tier; + } + + await users.updateOne( + { stripeCustomerId: customerId }, + { $set: updateFields } + ); + + console.log( + `[Stripe Webhook] subscription.updated: Customer ${customerId} → tier ${tier} (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})` + ); +} + +/** + * Handle customer.subscription.deleted + * Triggered when a subscription is fully cancelled (end of billing period or immediate). + */ +async function handleSubscriptionDeleted( + subscription: Stripe.Subscription, + users: Collection +) { + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer?.id; + + if (!customerId) { + console.error("[Stripe Webhook] subscription.deleted: No customer ID"); + return; + } + + await users.updateOne( + { stripeCustomerId: customerId }, + { + $set: { + tier: "free", + stripeSubscriptionId: null, + stripeCurrentPeriodEnd: null, + updatedAt: new Date(), + }, + } + ); + + console.log( + `[Stripe Webhook] subscription.deleted: Customer ${customerId} downgraded to free` + ); +} + +/** + * Handle invoice.payment_failed + * Triggered when a subscription payment fails. + */ +async function handlePaymentFailed(invoice: Stripe.Invoice) { + const customerId = + typeof invoice.customer === "string" + ? invoice.customer + : invoice.customer?.id; + + if (!customerId) { + console.error("[Stripe Webhook] invoice.payment_failed: No customer ID"); + return; + } + + // Log the payment failure — we don't downgrade immediately because + // Stripe will retry the payment. The subscription.deleted event + // will fire if all retries fail and the subscription is cancelled. + console.warn( + `[Stripe Webhook] invoice.payment_failed: Customer ${customerId} — payment failed for invoice ${invoice.id}` + ); +} + +export { router as stripeWebhookRouter }; diff --git a/packages/kal-db/migrations/20260407000001_add_stripe_fields.js b/packages/kal-db/migrations/20260407000001_add_stripe_fields.js new file mode 100644 index 0000000..8deb3cf --- /dev/null +++ b/packages/kal-db/migrations/20260407000001_add_stripe_fields.js @@ -0,0 +1,45 @@ +/** + * Migration: Add Stripe subscription fields to users collection + * + * Adds stripeCustomerId, stripeSubscriptionId, and stripeCurrentPeriodEnd + * to support Stripe subscription billing. + */ + +export const up = async (db, client) => { + // Add Stripe fields to all existing users (default to null) + await db.collection("users").updateMany( + { stripeCustomerId: { $exists: false } }, + { + $set: { + stripeCustomerId: null, + stripeSubscriptionId: null, + stripeCurrentPeriodEnd: null, + }, + } + ); + + // Create index on stripeCustomerId for webhook lookups + await db + .collection("users") + .createIndex({ stripeCustomerId: 1 }, { sparse: true, unique: true }); +}; + +export const down = async (db, client) => { + // Drop the Stripe customer ID index + await db + .collection("users") + .dropIndex("stripeCustomerId_1") + .catch(() => {}); + + // Remove Stripe fields from all users + await db.collection("users").updateMany( + {}, + { + $unset: { + stripeCustomerId: "", + stripeSubscriptionId: "", + stripeCurrentPeriodEnd: "", + }, + } + ); +}; diff --git a/packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js b/packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js new file mode 100644 index 0000000..2025455 --- /dev/null +++ b/packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js @@ -0,0 +1,35 @@ +/** + * Migration: Fix email index to allow multiple null emails + * + * The original email_1 index was { unique: true } which prevents + * multiple users from having email: null (e.g. social/phone login users). + * This changes it to { unique: true, sparse: true } so null values are + * excluded from the uniqueness constraint. + */ + +export const up = async (db, client) => { + // Drop the old non-sparse unique index + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + // Recreate as sparse unique — allows multiple null emails + await db + .collection("users") + .createIndex({ email: 1 }, { unique: true, sparse: true }); + + console.log("✅ Recreated email index as sparse unique"); +}; + +export const down = async (db, client) => { + // Revert to original non-sparse unique index + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + await db.collection("users").createIndex({ email: 1 }, { unique: true }); + + console.log("✅ Reverted email index to non-sparse unique"); +}; diff --git a/packages/kal-db/migrations/20260407000003_fix_email_index_partial.js b/packages/kal-db/migrations/20260407000003_fix_email_index_partial.js new file mode 100644 index 0000000..267774b --- /dev/null +++ b/packages/kal-db/migrations/20260407000003_fix_email_index_partial.js @@ -0,0 +1,46 @@ +/** + * Migration: Fix email index using partialFilterExpression + * + * The previous sparse:true fix doesn't actually work because MongoDB sparse + * indexes still include documents where the field is explicitly set to null. + * Sparse only skips documents where the field is completely absent. + * + * This replaces the index with a partialFilterExpression that only indexes + * documents where email is a string — completely excluding null/missing values + * from the uniqueness constraint. + */ + +export const up = async (db, client) => { + // Drop the current sparse unique index + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + // Recreate with partialFilterExpression — only enforce uniqueness on actual string emails + await db.collection("users").createIndex( + { email: 1 }, + { + unique: true, + partialFilterExpression: { email: { $type: "string" } }, + } + ); + + console.log( + "✅ Recreated email index with partialFilterExpression (string only)" + ); +}; + +export const down = async (db, client) => { + // Revert to the sparse unique index from the previous migration + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + await db + .collection("users") + .createIndex({ email: 1 }, { unique: true, sparse: true }); + + console.log("✅ Reverted email index to sparse unique"); +}; diff --git a/packages/kal-frontend/package.json b/packages/kal-frontend/package.json index 1432baf..1e386db 100644 --- a/packages/kal-frontend/package.json +++ b/packages/kal-frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@logto/next": "^4.2.7", + "@stripe/stripe-js": "^5.5.0", "@tanstack/react-query": "^5.62.11", "@trpc/client": "^11.0.0-rc.682", "@trpc/react-query": "^11.0.0-rc.682", diff --git a/packages/kal-frontend/src/app/dashboard/settings/client.tsx b/packages/kal-frontend/src/app/dashboard/settings/client.tsx index b553aed..eabd11b 100644 --- a/packages/kal-frontend/src/app/dashboard/settings/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/settings/client.tsx @@ -1,7 +1,17 @@ "use client"; import { RATE_LIMITS } from "kal-shared"; -import { Mail, Shield, User } from "react-feather"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { + CreditCard, + ExternalLink, + Mail, + Shield, + User, + Zap, +} from "react-feather"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { AuthUpdater, useAuth } from "@/lib/auth-context"; @@ -13,26 +23,34 @@ interface SettingsClientProps { name?: string | null; } -export default function SettingsClient({ logtoId, email, name }: SettingsClientProps) { +export default function SettingsClient({ + logtoId, + email, + name, +}: SettingsClientProps) { return ( <> - + ); } -function SettingsContentWrapper({ - expectedLogtoId, +function SettingsContentWrapper({ + expectedLogtoId, nameProp, - emailProp -}: { - expectedLogtoId?: string; + emailProp, +}: { + expectedLogtoId?: string; nameProp?: string | null; emailProp?: string | null; }) { const { logtoId } = useAuth(); - + if (expectedLogtoId && logtoId !== expectedLogtoId) { return (
@@ -47,23 +65,92 @@ function SettingsContentWrapper({ return ; } -function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; emailProp?: string | null }) { +function SettingsContent({ + nameProp, + emailProp, +}: { + nameProp?: string | null; + emailProp?: string | null; +}) { const { isMobile } = useBreakpoint(); const { data: userInfo, isLoading } = trpc.apiKeys.getMe.useQuery(); const { data: stats } = trpc.apiKeys.getUsageStats.useQuery(); + const { data: subscriptionStatus } = + trpc.subscription.getSubscriptionStatus.useQuery(); + const createPortal = trpc.subscription.createPortalSession.useMutation({ + onSuccess: (data) => { + window.location.href = data.url; + }, + }); + + const searchParams = useSearchParams(); + const subscriptionResult = searchParams.get("subscription"); + + // Clean up URL params after showing success/cancelled message + useEffect(() => { + if (subscriptionResult) { + const timer = setTimeout(() => { + const url = new URL(window.location.href); + url.searchParams.delete("subscription"); + window.history.replaceState({}, "", url.toString()); + }, 5000); + return () => clearTimeout(timer); + } + }, [subscriptionResult]); const displayName = userInfo?.name || nameProp || "Developer"; const displayEmail = userInfo?.email || emailProp || ""; const tier = stats?.tier || "free"; const limits = RATE_LIMITS[tier]; + const hasStripeCustomer = !!subscriptionStatus?.stripeCustomerId; + + const handleManageBilling = () => { + createPortal.mutate(); + }; return (
-

Account Settings

-

Manage your account information

+

+ Account Settings +

+

+ Manage your account information +

+ {/* Subscription Success/Cancelled Banner */} + {subscriptionResult === "success" && ( +
+
+ +
+
+

+ Subscription activated! +

+

+ Your plan has been upgraded. It may take a moment to reflect. +

+
+
+ )} + {subscriptionResult === "cancelled" && ( +
+
+ +
+
+

+ Checkout cancelled +

+

+ No charges were made. You can try again anytime. +

+
+
+ )} + {/* Profile Section */}

@@ -71,7 +158,9 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em

- +

{isLoading ? ( @@ -84,7 +173,9 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em -

{displayEmail}

+

+ {displayEmail} +

@@ -97,40 +188,99 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em
-

Current Plan

+

+ Current Plan +

- {tier === "free" ? "Free" : tier === "tier_1" ? "Tier 1" : "Tier 2"} + {tier === "free" + ? "Free" + : tier === "tier_1" + ? "Tier 1" + : "Tier 2"}

- + {tier.toUpperCase().replace("_", " ")}
-

Daily Limit

-

{limits.dailyLimit.toLocaleString()}

+

+ Daily Limit +

+

+ {limits.dailyLimit.toLocaleString()} +

-

Monthly Limit

-

{limits.monthlyLimit.toLocaleString()}

+

+ Monthly Limit +

+

+ {limits.monthlyLimit.toLocaleString()} +

-

Per Minute

-

{limits.minuteLimit}/min

+

+ Per Minute +

+

+ {limits.minuteLimit}/min +

+ + {/* Subscription Actions */} +
+ {tier === "free" ? ( + + + Upgrade Plan + + ) : ( + + + Change Plan + + )} + {hasStripeCustomer && ( + + )} +
+ {createPortal.error && ( +

+ {createPortal.error.message} +

+ )}
{/* Danger Zone */}
-

Danger Zone

+

+ Danger Zone +

- Account deletion is not available through this interface. Please contact support if you need to delete your account. + Account deletion is not available through this interface. Please + contact support if you need to delete your account.

- + + {/* Header */} +
+

+ Choose Your Plan +

+

+ Scale your application with higher rate limits and priority support. + All plans include full access to the Malaysian food nutrition + database. +

+
+ + {/* Pricing Cards */} +
+ {tiers.map((tier) => { + const pricing = TIER_PRICING[tier.key]; + const isCurrent = currentTier === tier.key; + const isHigherTier = + (tier.key === "tier_1" && currentTier === "free") || + (tier.key === "tier_2" && + (currentTier === "free" || currentTier === "tier_1")); + const isPaid = tier.key !== "free"; + + return ( +
+ {/* Popular badge */} + {tier.highlighted && ( +
+ + POPULAR + +
+ )} + + {/* Tier name */} +

+ {pricing.label} +

+

+ {pricing.description} +

+ + {/* Price */} +
+ {pricing.price === 0 ? ( +
+ + Free + +
+ ) : ( +
+ RM + + {pricing.price} + + /month +
+ )} +
+ + {/* Features */} +
    + {tier.features.map((feature, i) => ( +
  • + + + {feature} + +
  • + ))} +
+ + {/* CTA Button */} + {isLoading ? ( +
+ ) : isCurrent ? ( +
+ + Current Plan + + {isPaid && subscriptionStatus?.stripeCustomerId && ( + + )} +
+ ) : isHigherTier ? ( + + ) : ( + // Lower tier than current — show manage billing + + )} +
+ ); + })} +
+ + {/* Footer note */} +

+ All prices are in Malaysian Ringgit (MYR). Subscriptions are billed + monthly and can be cancelled anytime. +

+ + {/* Error display */} + {(createCheckout.error || createPortal.error) && ( +
+

+ {createCheckout.error?.message || createPortal.error?.message} +

+
+ )} +
+
+ ); +} diff --git a/packages/kal-frontend/src/app/pricing/page.tsx b/packages/kal-frontend/src/app/pricing/page.tsx new file mode 100644 index 0000000..44e6293 --- /dev/null +++ b/packages/kal-frontend/src/app/pricing/page.tsx @@ -0,0 +1,28 @@ +import { getLogtoContext } from "@logto/next/server-actions"; +import { redirect } from "next/navigation"; + +import PricingClient from "./client"; + +import { getLogtoConfig } from "@/lib/logto"; + +export const metadata = { + title: "Pricing - Kalori API", + description: "Choose the right plan for your application", +}; + +export default async function PricingPage() { + const config = getLogtoConfig(); + const { isAuthenticated, claims } = await getLogtoContext(config); + + if (!isAuthenticated) { + redirect("/"); + } + + return ( + + ); +} diff --git a/packages/kal-shared/src/constants/index.ts b/packages/kal-shared/src/constants/index.ts index 08c5ad2..261750c 100644 --- a/packages/kal-shared/src/constants/index.ts +++ b/packages/kal-shared/src/constants/index.ts @@ -1,3 +1,29 @@ export const API_VERSION = "v1" as const; export const API_BASE_PATH = `/api/${API_VERSION}` as const; + +// =================== +// Subscription Pricing +// =================== +export const TIER_PRICING = { + free: { + price: 0, + currency: "MYR", + label: "Free", + description: "Get started with generous free limits", + }, + tier_1: { + price: 45, + currency: "MYR", + label: "Tier 1", + description: "For growing applications", + }, + tier_2: { + price: 75, + currency: "MYR", + label: "Tier 2", + description: "For high-traffic production apps", + }, +} as const; + +export type TierPricing = typeof TIER_PRICING; diff --git a/packages/kal-shared/src/types/index.ts b/packages/kal-shared/src/types/index.ts index 554bb78..debe301 100644 --- a/packages/kal-shared/src/types/index.ts +++ b/packages/kal-shared/src/types/index.ts @@ -9,6 +9,9 @@ export interface User { email: string | null; name?: string; tier: UserTier; + stripeCustomerId?: string | null; + stripeSubscriptionId?: string | null; + stripeCurrentPeriodEnd?: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd1b36f..a6a9aff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: mongodb: specifier: ^6.12.0 version: 6.21.0 + stripe: + specifier: ^17.7.0 + version: 17.7.0 swagger-ui-express: specifier: ^5.0.1 version: 5.0.1(express@4.22.1) @@ -234,6 +237,9 @@ importers: '@logto/next': specifier: ^4.2.7 version: 4.2.7(next@15.5.9(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + '@stripe/stripe-js': + specifier: ^5.5.0 + version: 5.10.0 '@tanstack/react-query': specifier: ^5.62.11 version: 5.90.12(react@19.2.3) @@ -1038,6 +1044,10 @@ packages: resolution: {integrity: sha512-bD+82D9Dfa1F5xX1kfdR5ODIoJS41NOxTuHx4shVS5A4/ayEG+ZplpDDjB19fsa7kZXgSgD75R4sUCXjm88x6w==} engines: {node: ^18.12.0 || ^20.9.0 || ^22.0.0, pnpm: ^9.0.0} + '@stripe/stripe-js@5.10.0': + resolution: {integrity: sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==} + engines: {node: '>=12.16'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3027,6 +3037,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3972,6 +3986,8 @@ snapshots: '@silverhand/essentials@2.9.2': {} + '@stripe/stripe-js@5.10.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -6516,6 +6532,11 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@17.7.0: + dependencies: + '@types/node': 22.19.3 + qs: 6.14.0 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 From a74f5b5de1d1de25e3109cf6fb0fac725325e00d Mon Sep 17 00:00:00 2001 From: Zen0space Date: Tue, 7 Apr 2026 11:22:27 +0800 Subject: [PATCH 2/5] fix: replace hardcoded domain URLs with env vars - Create site-config.ts with centralized env var helpers (SITE_URL, SITE_DOMAIN, API_URL_DISPLAY, SUPPORT_EMAIL, PRIVACY_EMAIL) - Replace all hardcoded kalori-api.my references with env vars in layout, robots, sitemap, terms, privacy pages - Replace all hardcoded api.kalori-api.my in docs, setup, and api-docs code examples - Add production env var validation to backend (exits if critical vars missing) - Restrict CORS localhost origins to development only - Fix Logto port mismatch in backend .env.example (3301 -> 3001) - Add BACKEND_BASE_URL, PUBLIC_URL to backend .env.example - Add NEXT_PUBLIC_SITE_URL, SITE_DOMAIN, API_DOMAIN, SUPPORT_EMAIL, PRIVACY_EMAIL to frontend .env.example - All .env.example defaults use localhost (safe for public repo) --- packages/kal-backend/.env.example | 13 ++++- packages/kal-backend/src/index.ts | 51 +++++++++++++++++-- packages/kal-frontend/.env.example | 10 ++++ .../kal-frontend/src/app/api-docs/client.tsx | 3 +- .../src/app/dashboard/docs/client.tsx | 11 ++-- .../src/app/dashboard/setup/client.tsx | 39 +++++++------- packages/kal-frontend/src/app/layout.tsx | 6 ++- .../kal-frontend/src/app/privacy/page.tsx | 48 ++++++++++------- packages/kal-frontend/src/app/robots.ts | 2 +- packages/kal-frontend/src/app/sitemap.ts | 2 +- packages/kal-frontend/src/app/terms/page.tsx | 34 +++++++------ packages/kal-frontend/src/lib/site-config.ts | 34 +++++++++++++ 12 files changed, 185 insertions(+), 68 deletions(-) create mode 100644 packages/kal-frontend/src/lib/site-config.ts diff --git a/packages/kal-backend/.env.example b/packages/kal-backend/.env.example index 33b49cd..97efa32 100644 --- a/packages/kal-backend/.env.example +++ b/packages/kal-backend/.env.example @@ -7,11 +7,13 @@ MONGODB_DATABASE=kalori_db # Server BACKEND_PORT=4000 +PUBLIC_URL=http://localhost:4000 # Logto Auth (for protected routes) -LOGTO_ENDPOINT=http://localhost:3301 +LOGTO_ENDPOINT=http://localhost:3001 LOGTO_APP_ID=your-app-id LOGTO_APP_SECRET=your-app-secret +BACKEND_BASE_URL=http://localhost:4000 # =================== # GLM 4.6 (Zhipu AI) - BAML Chat API @@ -22,4 +24,11 @@ GLM_API_KEY=your-glm-api-key INTERNAL_API_KEY=kal_660e99df8cb78a60d8cc90dcb1228b1a812283d1393f0e49918ca3ca82363820 REDIS_URL=redis://localhost:6379 -ADMIN_SECRET=replace_with_secure_random_string \ No newline at end of file +ADMIN_SECRET=replace_with_secure_random_string + +# Stripe +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_TIER1_PRICE_ID=price_... +STRIPE_TIER2_PRICE_ID=price_... +FRONTEND_URL=http://localhost:3000 \ No newline at end of file diff --git a/packages/kal-backend/src/index.ts b/packages/kal-backend/src/index.ts index 190d287..e49396f 100644 --- a/packages/kal-backend/src/index.ts +++ b/packages/kal-backend/src/index.ts @@ -28,11 +28,47 @@ import { } from "./middleware/timeout.js"; import { apiRouter } from "./routers/api.js"; import { chatStreamRouter } from "./routes/chat-stream.js"; +import { stripeWebhookRouter } from "./routes/stripe-webhook.js"; import { appRouter } from "./routers/index.js"; const PORT = process.env.BACKEND_PORT || 3000; +const isProduction = process.env.NODE_ENV === "production"; + +/** + * Validate that critical environment variables are set in production. + * Prevents silent fallback to localhost URLs that would break the app. + */ +function validateProductionEnv() { + if (!isProduction) return; + + const required: Record = { + FRONTEND_URL: process.env.FRONTEND_URL, + LOGTO_ENDPOINT: process.env.LOGTO_ENDPOINT, + BACKEND_BASE_URL: process.env.BACKEND_BASE_URL, + LOGTO_APP_ID: process.env.LOGTO_APP_ID, + LOGTO_APP_SECRET: process.env.LOGTO_APP_SECRET, + SESSION_SECRET: process.env.SESSION_SECRET, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + }; + + const missing = Object.entries(required) + .filter(([, value]) => !value) + .map(([key]) => key); + + if (missing.length > 0) { + console.error( + `\n❌ FATAL: Missing required environment variables in production:\n` + + missing.map((k) => ` - ${k}`).join("\n") + + `\n\nSet these in your .env or deployment environment.\n` + ); + process.exit(1); + } +} async function main() { + // Validate production environment before anything else + validateProductionEnv(); // Connect to MongoDB await connectDB(); console.log("✅ Connected to MongoDB"); @@ -78,9 +114,14 @@ async function main() { // Private CORS: Strict whitelist for your own apps (tRPC, auth, sessions) const privateCorsOptions = { origin: [ - "http://localhost:3000", - "http://localhost:3003", // Chat frontend - "http://localhost:3005", // Admin frontend + // Only include localhost origins in development + ...(isProduction + ? [] + : [ + "http://localhost:3000", + "http://localhost:3003", // Chat frontend + "http://localhost:3005", // Admin frontend + ]), process.env.NEXT_PUBLIC_APP_URL, process.env.FRONTEND_URL, process.env.CHAT_FRONTEND_URL, @@ -96,6 +137,10 @@ async function main() { credentials: false, // No cookies for public API }; + // Stripe webhook route — MUST be before express.json() because + // Stripe needs the raw request body for signature verification + app.use("/stripe/webhook", stripeWebhookRouter); + // Core middleware app.use(express.json()); app.use(cookieParser()); diff --git a/packages/kal-frontend/.env.example b/packages/kal-frontend/.env.example index 725bc81..8f7d18d 100644 --- a/packages/kal-frontend/.env.example +++ b/packages/kal-frontend/.env.example @@ -1,6 +1,13 @@ # API NEXT_PUBLIC_API_URL=http://localhost:4000 +# Site Configuration (used for SEO metadata, robots.txt, sitemap, legal pages, docs) +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_SITE_DOMAIN=localhost:3000 +NEXT_PUBLIC_API_DOMAIN=localhost:4000 +NEXT_PUBLIC_SUPPORT_EMAIL=support@localhost +NEXT_PUBLIC_PRIVACY_EMAIL=privacy@localhost + # Logto Auth NEXT_PUBLIC_LOGTO_ENDPOINT=http://localhost:3001 NEXT_PUBLIC_LOGTO_APP_ID=8hr723agloaw142cbag0g @@ -9,3 +16,6 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 SESSION_SECRET=AyQ4erWsxYn6HTmSmkEz7Ih3j1qUg3TG NEXT_PUBLIC_DEMO_API_KEY=kal_660e99df8cb78a60d8cc90dcb1228b1a812283d1393f0e49918ca3ca82363820 + +# Stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... diff --git a/packages/kal-frontend/src/app/api-docs/client.tsx b/packages/kal-frontend/src/app/api-docs/client.tsx index 5951d24..1f251dd 100644 --- a/packages/kal-frontend/src/app/api-docs/client.tsx +++ b/packages/kal-frontend/src/app/api-docs/client.tsx @@ -6,6 +6,7 @@ import { Check, FileText, Heart, Lock, Menu, Star, X } from "react-feather"; import { Button } from "@/components/ui/Button"; import { Container } from "@/components/ui/Container"; +import { API_URL_DISPLAY } from "@/lib/site-config"; import { trpc } from "@/lib/trpc"; // Get the API base URL from environment @@ -563,7 +564,7 @@ export default function APIDocsClient({

Base URL

- https://api.kalori-api.my + {API_URL_DISPLAY}
diff --git a/packages/kal-frontend/src/app/dashboard/docs/client.tsx b/packages/kal-frontend/src/app/dashboard/docs/client.tsx index e22558a..1bb424c 100644 --- a/packages/kal-frontend/src/app/dashboard/docs/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/docs/client.tsx @@ -20,6 +20,7 @@ import { } from "react-feather"; import { AuthUpdater, useAuth } from "@/lib/auth-context"; +import { API_URL_DISPLAY } from "@/lib/site-config"; import { trpc } from "@/lib/trpc"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -572,7 +573,7 @@ async function getFoodCached(query) { const hit = cache.get(key); if (hit && Date.now() - hit.ts < 5 * 60 * 1000) return hit.data; - const res = await fetch(\`https://api.kalori-api.my/api/v1/foods/search?q=\${encodeURIComponent(query)}\`, + const res = await fetch(\`${API_URL_DISPLAY}/api/v1/foods/search?q=\${encodeURIComponent(query)}\`, { headers: { 'X-API-Key': process.env.KAL_API_KEY } }); const data = await res.json(); cache.set(key, { data, ts: Date.now() }); @@ -616,7 +617,7 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = searchParams.get('q') ?? ''; const res = await fetch( - \`https://api.kalori-api.my/api/v1/foods/search?q=\${encodeURIComponent(q)}\`, + \`${API_URL_DISPLAY}/api/v1/foods/search?q=\${encodeURIComponent(q)}\`, { headers: { 'X-API-Key': process.env.KAL_API_KEY! } } ); return Response.json(await res.json()); @@ -663,7 +664,7 @@ data.data.forEach(food => console.log(food.name));`, } // Usage -for await (const page of paginate('https://api.kalori-api.my/api/v1/foods', key)) { +for await (const page of paginate('${API_URL_DISPLAY}/api/v1/foods', key)) { console.log(\`Got \${page.length} foods\`); }`, lang: "javascript", @@ -727,7 +728,7 @@ function ApiKeysTab({ header. Query-string based auth is not supported.

- kalori-api.my + + {SITE_DOMAIN} {" "} and our API services (collectively, the "Service"). @@ -120,8 +123,8 @@ export default function PrivacyPolicyPage() { Data Controller

Kalori API

-

Email: privacy@kalori-api.my

-

Website: https://kalori-api.my

+

Email: {PRIVACY_EMAIL}

+

Website: {SITE_URL}

@@ -160,7 +163,9 @@ export default function PrivacyPolicyPage() {

3.2 Information Collected Automatically

-

When you access our Service, we automatically collect:

+

+ When you access our Service, we automatically collect: +

  • Usage Data: API call logs, endpoints @@ -346,7 +351,9 @@ export default function PrivacyPolicyPage() { Adequate safeguards are in place (e.g., Standard Contractual Clauses for EU transfers)
  • -
  • Compliance with PDPA cross-border transfer requirements
  • +
  • + Compliance with PDPA cross-border transfer requirements +
  • Data protection standards equivalent to those in Malaysia
  • @@ -450,10 +457,10 @@ export default function PrivacyPolicyPage() {

    To exercise these rights, contact us at{" "} - privacy@kalori-api.my + {PRIVACY_EMAIL} . We will respond within 21 days (PDPA) or 30 days (GDPR).

    @@ -530,7 +537,9 @@ export default function PrivacyPolicyPage() { 11. Third-Party Services
    -

    Our Service integrates with the following third parties:

    +

    + Our Service integrates with the following third parties: +

    • Logto: Authentication service @@ -569,10 +578,10 @@ export default function PrivacyPolicyPage() { If you believe we have collected data from a child, please contact us immediately at{" "} - privacy@kalori-api.my + {PRIVACY_EMAIL} , and we will take steps to delete such information.

      @@ -649,9 +658,9 @@ export default function PrivacyPolicyPage() {

      Kalori API - Privacy Team

      -

      Email: privacy@kalori-api.my

      -

      General Inquiries: support@kalori-api.my

      -

      Website: https://kalori-api.my

      +

      Email: {PRIVACY_EMAIL}

      +

      General Inquiries: {SUPPORT_EMAIL}

      +

      Website: {SITE_URL}

    @@ -686,7 +695,10 @@ export default function PrivacyPolicyPage() { Privacy Policy - + Terms of Service diff --git a/packages/kal-frontend/src/app/robots.ts b/packages/kal-frontend/src/app/robots.ts index 5619c79..a7023b6 100644 --- a/packages/kal-frontend/src/app/robots.ts +++ b/packages/kal-frontend/src/app/robots.ts @@ -7,7 +7,7 @@ import type { MetadataRoute } from "next"; * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots */ export default function robots(): MetadataRoute.Robots { - const baseUrl = "https://kalori-api.my"; + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; return { rules: [ diff --git a/packages/kal-frontend/src/app/sitemap.ts b/packages/kal-frontend/src/app/sitemap.ts index 901f02f..34fb647 100644 --- a/packages/kal-frontend/src/app/sitemap.ts +++ b/packages/kal-frontend/src/app/sitemap.ts @@ -7,7 +7,7 @@ import type { MetadataRoute } from "next"; * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap */ export default function sitemap(): MetadataRoute.Sitemap { - const baseUrl = "https://kalori-api.my"; + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; const currentDate = new Date(); // Main pages with their priorities and change frequencies diff --git a/packages/kal-frontend/src/app/terms/page.tsx b/packages/kal-frontend/src/app/terms/page.tsx index 6626275..4fe4e68 100644 --- a/packages/kal-frontend/src/app/terms/page.tsx +++ b/packages/kal-frontend/src/app/terms/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Container } from "@/components/ui/Container"; +import { SITE_URL, SITE_DOMAIN, SUPPORT_EMAIL } from "@/lib/site-config"; export const metadata: Metadata = { title: "Terms of Service", @@ -72,11 +73,8 @@ export default function TermsOfServicePage() { "Platform", "we", "us", or "our"). These Terms of Service ("Terms") govern your access to and use of our website at{" "} - - kalori-api.my + + {SITE_DOMAIN} {" "} and our Application Programming Interface (API) services.

    @@ -198,7 +196,9 @@ export default function TermsOfServicePage() {
    • Keep your API keys confidential and secure
    • -
    • Do not share API keys publicly or in client-side code
    • +
    • + Do not share API keys publicly or in client-side code +
    • Regenerate keys immediately if you suspect unauthorized access @@ -252,8 +252,9 @@ export default function TermsOfServicePage() { or entity
    • - Violate any third-party platform terms (including X/Twitter - Developer Agreement) when integrating with our Service + Violate any third-party platform terms (including + X/Twitter Developer Agreement) when integrating with our + Service
    @@ -291,9 +292,7 @@ export default function TermsOfServicePage() { third-party services, including:

      -
    • - Authentication providers (Google, Apple, X/Twitter) -
    • +
    • Authentication providers (Google, Apple, X/Twitter)
    • Analytics services
    • Payment processors
    @@ -372,7 +371,9 @@ export default function TermsOfServicePage() {
  • Your use of the Service
  • Your violation of these Terms
  • Your violation of any third-party rights
  • -
  • Any applications or content you create using our API
  • +
  • + Any applications or content you create using our API +
@@ -473,8 +474,8 @@ export default function TermsOfServicePage() {

Kalori API

-

Email: support@kalori-api.my

-

Website: https://kalori-api.my

+

Email: {SUPPORT_EMAIL}

+

Website: {SITE_URL}

@@ -504,7 +505,10 @@ export default function TermsOfServicePage() {
- + Privacy Policy diff --git a/packages/kal-frontend/src/lib/site-config.ts b/packages/kal-frontend/src/lib/site-config.ts new file mode 100644 index 0000000..08c5687 --- /dev/null +++ b/packages/kal-frontend/src/lib/site-config.ts @@ -0,0 +1,34 @@ +/** + * Site configuration derived from environment variables. + * + * All domain references in the app should use these helpers + * so nothing is hardcoded and forks can configure via .env. + */ + +/** Site URL, e.g. http://localhost:3000 */ +export const SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + +/** Site domain for display, e.g. localhost:3000 */ +export const SITE_DOMAIN = + process.env.NEXT_PUBLIC_SITE_DOMAIN || "localhost:3000"; + +/** API base URL, e.g. http://localhost:4000 */ +export const API_URL = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; + +/** API domain for display in docs/examples, e.g. localhost:4000 */ +export const API_DOMAIN = + process.env.NEXT_PUBLIC_API_DOMAIN || "localhost:4000"; + +/** API base URL with https for code examples shown to users */ +export const API_URL_DISPLAY = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; + +/** Support email address */ +export const SUPPORT_EMAIL = + process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@localhost"; + +/** Privacy email address */ +export const PRIVACY_EMAIL = + process.env.NEXT_PUBLIC_PRIVACY_EMAIL || "privacy@localhost"; From b0c8c7378ddae9c0fb1901aec3d99cec2fb569f8 Mon Sep 17 00:00:00 2001 From: Zen0space Date: Tue, 7 Apr 2026 11:23:41 +0800 Subject: [PATCH 3/5] chore: remove unused docs --- docs/github-actions.md | 184 --------------- docs/migration.md | 515 ----------------------------------------- 2 files changed, 699 deletions(-) delete mode 100644 docs/github-actions.md delete mode 100644 docs/migration.md diff --git a/docs/github-actions.md b/docs/github-actions.md deleted file mode 100644 index 6c1e28e..0000000 --- a/docs/github-actions.md +++ /dev/null @@ -1,184 +0,0 @@ -# GitHub Actions CI/CD - -This project uses GitHub Actions for continuous integration and deployment. - ---- - -## Workflow Overview - -The CI workflow is located at `.github/workflows/ci.yml` and runs automatically on: - -- **Push** to `main` or `dev` branches -- **Pull requests** targeting `main` or `dev` branches - ---- - -## Pipeline Stages - -### 1. Lint & Type Check - -The first stage validates code quality: - -```yaml -steps: - - pnpm install --frozen-lockfile - - pnpm lint - - pnpm typecheck -``` - -**What it checks:** - -- ESLint rules compliance -- TypeScript type errors -- Import ordering and consistency - -### 2. Build - -After linting passes, the build stage compiles all packages: - -```yaml -steps: - - pnpm install --frozen-lockfile - - pnpm build -``` - -This ensures: - -- All TypeScript compiles successfully -- Next.js builds without errors -- No missing dependencies - -### 3. Docker Build Validation (Main Branch Only) - -On pushes to `main`, the workflow also validates Docker builds: - -```yaml -- Build Backend Docker Image - - context: ./packages/kal-backend -``` - ---- - -## Deployment - -Deployment is handled automatically by [Coolify](https://coolify.io/) when changes are merged to `main`: - -| Branch | Action | -| ------ | ----------------------------------- | -| `dev` | CI runs (lint, typecheck, build) | -| `main` | CI runs + Auto-deploy to production | - -### Deployment URL - -🌐 **Production:** [https://kalori-api.my](https://kalori-api.my) - ---- - -## Workflow Configuration - -### Environment Variables - -The workflow uses these environment variables: - -| Variable | Value | Description | -| -------------- | ----- | -------------------- | -| `NODE_VERSION` | 18 | Node.js version | -| `PNPM_VERSION` | 8 | pnpm package manager | - -### Concurrency - -The workflow uses concurrency groups to prevent duplicate runs: - -```yaml -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true -``` - -This means: - -- Only one CI run per branch at a time -- New pushes cancel in-progress runs - ---- - -## Running CI Locally - -Before pushing, you can run the same checks locally: - -```bash -# Install dependencies -pnpm install - -# Lint check -pnpm lint - -# Auto-fix lint issues -pnpm lint:fix - -# Type check -pnpm typecheck - -# Build all packages -pnpm build -``` - ---- - -## Troubleshooting - -### Common CI Failures - -| Error | Solution | -| -------------------- | -------------------------------------------------------------- | -| `pnpm install` fails | Run `pnpm install --frozen-lockfile` locally to check lockfile | -| Lint errors | Run `pnpm lint:fix` to auto-fix | -| Type errors | Check TypeScript errors with `pnpm typecheck` | -| Build fails | Check for missing environment variables | - -### Viewing Logs - -1. Go to the repository on GitHub -2. Click on **Actions** tab -3. Select the failed workflow run -4. Click on the failed job to view logs - ---- - -## Adding New Workflows - -To add new workflows: - -1. Create a new file in `.github/workflows/` -2. Use YAML format with the workflow definition -3. Push to trigger the workflow - -### Example: Adding a Test Workflow - -```yaml -# .github/workflows/test.yml -name: Test - -on: - pull_request: - branches: [dev] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 - with: - version: 8 - - run: pnpm install - - run: pnpm test -``` - ---- - -## Related Documentation - -- [Contributing Guidelines](./contributing.md) -- [Linting Guide](./linting.md) -- [VPS Setup](./vps-setup-selfhosted.md) diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index 3362ed0..0000000 --- a/docs/migration.md +++ /dev/null @@ -1,515 +0,0 @@ -# Deployment Architecture Migration - -> **Goal:** Move frontend deployments to Vercel, keep only backend on VPS with pre-built Docker images from GitHub Actions. - -**Current State:** - -- All services (backend, frontend, frontend-chat, admin) built and run on VPS via Docker Compose -- Coolify orchestrates deployments by pulling repo and building on VPS - -**Target State:** - -- `kal-frontend` → Vercel -- `kal-admin` → Vercel -- `kal-frontend-chat` → **DELETED** -- `kal-backend` → VPS (Docker image built in CI, pushed to GHCR, pulled on VPS) - ---- - -## Prerequisites - -Before starting, gather these values: - -| Value | Description | How to get | -| ---------------------------- | ----------------------------------------- | --------------------------------------------- | -| GitHub org/user | Your GitHub username or organization name | Check `github.com/` | -| `VERCEL_TOKEN` | Vercel API token | Vercel Dashboard → Settings → Tokens → Create | -| `VERCEL_ORG_ID` | Vercel team/org ID | Run `vercel whoami` or check dashboard URL | -| `VERCEL_PROJECT_ID_FRONTEND` | Vercel project ID for kal-frontend | Create project first, then get from dashboard | -| `VERCEL_PROJECT_ID_ADMIN` | Vercel project ID for kal-admin | Create project first, then get from dashboard | - ---- - -## Phase 1 — Root `.dockerignore` - -**File:** `/.dockerignore` (create at monorepo root) - -All Dockerfiles use `context: ..` (monorepo root). Without a root `.dockerignore`, Docker sends the entire repo including all `node_modules`, `.next`, and `dist` folders to the build daemon. - -**Create file with contents:** - -``` -.git -.github -**/.env* -!**/.env.example -**/node_modules -**/.next -**/dist -**/baml_client -**/*.log -**/.DS_Store -packages/kal-frontend-chat -``` - -**Verification:** - -```bash -ls -la .dockerignore -``` - ---- - -## Phase 2 — Backend: CI builds and pushes to GHCR - -**File:** `.github/workflows/ci.yml` - -Replace the existing `docker-build` job (validate-only, `push: false`) with `docker-publish` that actually pushes to GitHub Container Registry. - -**Replace the `docker-build` job with:** - -```yaml -docker-publish: - name: Build & Push Backend Image - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: ./packages/kal-backend/Dockerfile - push: true - tags: | - ghcr.io/${{ github.repository }}/kal-backend:latest - ghcr.io/${{ github.repository }}/kal-backend:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max -``` - -**No new secrets needed** — `GITHUB_TOKEN` is automatically provided by GitHub Actions. - -**Verification:** - -- Push to `main` branch -- Check Actions tab for successful `docker-publish` job -- Verify image at `ghcr.io//kal-monorepo/kal-backend` - ---- - -## Phase 3 — Backend: docker-compose uses pre-built GHCR image - -**File:** `docker/docker-compose.yml` - -1. Remove `frontend`, `frontend-chat`, `admin` service blocks -2. Replace `backend.build:` with `image:` -3. Remove `profiles: ["apps"]` from backend -4. Remove `CHAT_FRONTEND_URL` from backend environment - -**New backend service block:** - -```yaml -backend: - image: ghcr.io//kal-monorepo/kal-backend:latest - container_name: kal-backend - ports: - - "${BACKEND_PORT:-4000}:3000" - environment: - - NODE_ENV=production - - MONGODB_URI=mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}?authSource=admin - - REDIS_URL=redis://redis:6379 - - LOGTO_ENDPOINT=${LOGTO_ENDPOINT} - - LOGTO_APP_ID=${LOGTO_APP_ID} - - LOGTO_APP_SECRET=${LOGTO_APP_SECRET} - - SESSION_SECRET=${SESSION_SECRET} - - GLM_API_BASE_URL=${GLM_API_BASE_URL} - - GLM_API_KEY=${GLM_API_KEY} - - INTERNAL_API_KEY=${INTERNAL_API_KEY} - - FRONTEND_URL=${FRONTEND_URL} - depends_on: - - mongodb - - logto - - redis - networks: - - kal-network -``` - -**Remove these service blocks entirely:** - -- `frontend` -- `frontend-chat` -- `admin` - -**VPS update workflow (manual, after merge to main):** - -```bash -docker compose -f docker/docker-compose.yml pull backend -docker compose -f docker/docker-compose.yml up -d backend -``` - -**Verification:** - -```bash -docker compose -f docker/docker-compose.yml config -docker compose -f docker/docker-compose.yml pull backend -``` - ---- - -## Phase 4 — Remove `kal-frontend-chat` - -Full cleanup of the chat frontend package. - -**Steps:** - -1. Delete `packages/kal-frontend-chat/` directory -2. No changes needed to `pnpm-workspace.yaml` (uses `packages/*` glob) -3. No changes needed to `turbo.json` (no package-specific overrides) - -**Commands:** - -```bash -rm -rf packages/kal-frontend-chat -``` - -**Verification:** - -```bash -ls packages/ # Should NOT show kal-frontend-chat -pnpm install # Should succeed without errors -``` - ---- - -## Phase 5 — Prepare `kal-frontend` for Vercel - -**File:** `packages/kal-frontend/vercel.json` (create) - -This configures Vercel to build from monorepo root with correct dependency order. - -**Create file with contents:** - -```json -{ - "framework": "nextjs", - "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "buildCommand": "cd ../.. && pnpm turbo build --filter=kal-frontend", - "outputDirectory": ".next" -} -``` - -**Note:** `kal-frontend/next.config.mjs` requires NO changes — it's already using default Next.js output mode (compatible with Vercel). - -**Verification:** - -```bash -cat packages/kal-frontend/vercel.json -``` - ---- - -## Phase 6 — Prepare `kal-admin` for Vercel - -**File:** `packages/kal-admin/next.config.ts` (modify) - -Remove `output: "standalone"` and `outputFileTracingRoot` — these are for Docker self-hosted deployments and conflict with Vercel's build system. - -**Change FROM:** - -```typescript -import type { NextConfig } from "next"; -import path from "path"; - -const nextConfig: NextConfig = { - reactCompiler: true, - output: "standalone", - outputFileTracingRoot: path.join(__dirname, "../../"), -}; - -export default nextConfig; -``` - -**Change TO:** - -```typescript -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - reactCompiler: true, -}; - -export default nextConfig; -``` - -**File:** `packages/kal-admin/vercel.json` (create) - -**Create file with contents:** - -```json -{ - "framework": "nextjs", - "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "buildCommand": "cd ../.. && pnpm turbo build --filter=kal-admin", - "outputDirectory": ".next" -} -``` - -**Verification:** - -```bash -cat packages/kal-admin/next.config.ts -cat packages/kal-admin/vercel.json -``` - ---- - -## Phase 7 — Vercel Auto-Deploy (no CI needed) - -Vercel deploys are handled automatically by connecting the repo directly in the Vercel dashboard. -No CI changes are needed — no Vercel secrets in GitHub required. - -**How it works:** - -- Push to `dev` → Vercel builds a **preview deployment** -- Push/merge to `main` → Vercel builds a **production deployment** -- Vercel reads `vercel.json` in each package automatically and runs the correct build command - -**CI pipeline (final state):** - -``` -lint-and-typecheck - │ - build - │ - docker-publish - (GHCR — backend only) -``` - -Vercel watches the GitHub repo independently and deploys `kal-frontend` and `kal-admin` on its own. - ---- - -## Phase 8 — Vercel Project Setup (Manual — Dashboard Only) - -### 8.1 Create Project for `kal-frontend` - -1. Go to [vercel.com](https://vercel.com) → Add New → Project -2. Import `Kal-Monorepo` from GitHub -3. Set **Root Directory** to `packages/kal-frontend` -4. Framework Preset: Next.js (auto-detected) -5. Leave Build Command and Output Directory as default — `vercel.json` overrides them -6. Click Deploy - -### 8.2 Create Project for `kal-admin` - -1. Go to [vercel.com](https://vercel.com) → Add New → Project -2. Import the **same** `Kal-Monorepo` repo -3. Set **Root Directory** to `packages/kal-admin` -4. Framework Preset: Next.js (auto-detected) -5. Click Deploy - -### 8.3 Add Environment Variables - -In each Vercel project: Project → Settings → Environment Variables - -**kal-frontend:** - -| Variable | Description | -| ---------------------------- | -------------------------------------------------- | -| `NEXT_PUBLIC_LOGTO_ENDPOINT` | Logto endpoint URL | -| `NEXT_PUBLIC_LOGTO_APP_ID` | Logto app ID | -| `NEXT_PUBLIC_APP_URL` | Frontend production URL | -| `NEXT_PUBLIC_API_URL` | Backend API URL (e.g. `https://kalori-api.my/api`) | -| `LOGTO_APP_SECRET` | Logto app secret (server-side only) | -| `SESSION_SECRET` | Session secret | - -**kal-admin:** - -| Variable | Description | -| --------------------- | -------------------------------------------------- | -| `NEXT_PUBLIC_API_URL` | Backend API URL (e.g. `https://kalori-api.my/api`) | -| `ADMIN_SECRET` | Admin auth secret | -| `ADMIN_USERNAME` | Admin username | -| `ADMIN_PASSWORD` | Admin password | - -### 8.4 Branch Behaviour - -Vercel will automatically: - -- Deploy `main` → **Production** -- Deploy `dev` and any other branch → **Preview** - -No GitHub secrets needed. No additional CI configuration needed. - ---- - -## Phase 9 — VPS: Coolify + GHCR (Manual — VPS Dashboard) - -This replaces the old Coolify Git-based backend (which built the image on the VPS) with a new -Docker image resource that pulls directly from GHCR. - -> **Do this after merging to `main`** so the image already exists in GHCR before Coolify tries to pull it. - -### 9.1 Merge to Main (Trigger First Image Push) - -1. Merge `chore/deployment-migration` PR → `dev` -2. Merge `dev` → `main` -3. Wait for `Backend / Build & Push Docker Image` GitHub Actions job to succeed -4. Verify the image exists at: - `https://github.com/Zen0space/Kal-Monorepo/pkgs/container/kal-monorepo%2Fkal-backend` - -### 9.2 Create New Coolify Resource (Docker Image) - -In Coolify dashboard → your Project → **Add New Resource**: - -1. Select **Docker Image** (not Git Repository) -2. Image: `ghcr.io/zen0space/kal-monorepo/kal-backend:latest` -3. Name: `kal-backend` -4. No registry credentials needed — image is public - -### 9.3 Configure Environment Variables - -In the new resource settings, add all environment variables: - -| Variable | Value | -| ------------------ | ------------------------------------------------------------------------ | -| `NODE_ENV` | `production` | -| `MONGODB_URI` | `mongodb://:@:27017/?authSource=admin` | -| `REDIS_URL` | `redis://:6379` | -| `LOGTO_ENDPOINT` | Your Logto URL | -| `LOGTO_APP_ID` | Your Logto app ID | -| `LOGTO_APP_SECRET` | Your Logto app secret | -| `SESSION_SECRET` | Your session secret | -| `GLM_API_BASE_URL` | `https://api.z.ai/api/paas/v4` | -| `GLM_API_KEY` | Your GLM API key | -| `INTERNAL_API_KEY` | Your internal API key | -| `FRONTEND_URL` | Your Vercel frontend URL | - -> **Note:** Use the Coolify container hostnames (not `localhost`) for `MONGODB_URI` and `REDIS_URL` -> since all services run on the same Docker network in Coolify. - -### 9.4 Configure Port - -- Container port: `3000` -- Exposed port: `4000` (or whatever your current backend port is) - -### 9.5 Deploy - -Click **Deploy** — Coolify will pull `ghcr.io/zen0space/kal-monorepo/kal-backend:latest` and start the container. - -### 9.6 Verify - -```bash -# SSH into VPS and check the container is running -docker ps | grep kal-backend - -# Check logs for startup errors -docker logs --tail 50 - -# Quick API health check -curl http://localhost:4000 -``` - -### 9.7 Remove Old Resources - -Once the new backend is confirmed working: - -1. In Coolify → delete the old Git-based `kal-backend` resource -2. Delete old `kal-frontend`, `kal-frontend-chat`, `kal-admin` resources (moving to Vercel) -3. Clean up dangling images on VPS: - -```bash -docker image prune -f -``` - -### 9.8 Redeployment Workflow (Going Forward) - -When you want to deploy a new backend version: - -1. Merge to `main` -2. Wait for `Backend / Build & Push Docker Image` to succeed in GitHub Actions -3. Go to Coolify dashboard → `kal-backend` resource → click **Redeploy** -4. Coolify pulls the new `:latest` image and restarts the container - ---- - -## Final Architecture - -``` - GitHub push to dev/main - │ - ├─────────────────────────────────┐ - │ │ - ▼ ▼ - GitHub Actions Vercel (auto) - ┌──────────────┐ ┌──────────────────┐ - │ lint & │ │ kal-frontend │ - │ typecheck │ │ (preview/prod) │ - └──────┬───────┘ └──────────────────┘ - │ ┌──────────────────┐ - build │ kal-admin │ - │ │ (preview/prod) │ - docker-publish └──────────────────┘ - (kal-backend → GHCR) │ - │ │ API calls - ▼ │ - VPS ◄──────────────────────────────┘ - ┌──────────────┐ - │ backend │ ◄── docker pull from GHCR - │ mongodb │ - │ logto │ - │ redis │ - │ postgres │ - └──────────────┘ -``` - ---- - -## Rollback Plan - -If anything goes wrong: - -| Phase | Rollback | -| ----- | ------------------------------------------------------------------------- | -| 1 | Delete `.dockerignore` | -| 2 | Revert CI workflow, restore `docker-build` job | -| 3 | Revert `docker-compose.yml`, restore `build:` blocks | -| 4 | Cannot rollback (files deleted) — restore from git | -| 5-6 | Delete `vercel.json` files, revert `kal-admin/next.config.ts` | -| 7-8 | Delete Vercel projects | -| 9 | In Coolify, delete new Docker Image resource, keep old Git-based resource | - ---- - -## Checklist - -- [x] Phase 1: Create root `.dockerignore` -- [x] Phase 2: Update CI to push backend image to GHCR -- [x] Phase 3: Update docker-compose to use GHCR image -- [x] Phase 4: Delete `kal-frontend-chat` package -- [x] Phase 5: Create `kal-frontend/vercel.json` -- [x] Phase 6: Update `kal-admin/next.config.ts`, create `kal-admin/vercel.json` -- [x] Phase 7: Vercel auto-deploy (no CI jobs needed) -- [ ] Phase 8: Create Vercel projects and add env vars in dashboard -- [ ] Phase 9: Create new Coolify Docker Image resource pointing to GHCR -- [ ] Verify: Merge to main, check `Backend / Build & Push Docker Image` passes -- [ ] Verify: New backend container running on VPS via Coolify -- [ ] Verify: Old Coolify Git-based resources removed (backend, frontend-chat, frontend, admin) -- [ ] Verify: Frontend and admin deploy to Vercel From d4393369787733624a576c27e5bd4d4d75cedf8a Mon Sep 17 00:00:00 2001 From: Zen0space Date: Tue, 7 Apr 2026 11:38:15 +0800 Subject: [PATCH 4/5] feat: public pricing page and changelog - Make pricing page accessible without authentication - Visitors see plans with sign-in CTAs, authenticated users can subscribe/manage billing - Add standalone navbar and footer to pricing page - Add pricing link to landing page navbar - Add v1.1.0 changelog entry --- docs/changelog.md | 16 + .../kal-frontend/src/app/pricing/client.tsx | 491 ++++++++++++------ .../kal-frontend/src/app/pricing/page.tsx | 18 +- .../src/components/landing/Navbar.tsx | 60 ++- 4 files changed, 398 insertions(+), 187 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 4c728c2..e82e471 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,22 @@ All notable changes to Kal will be documented in this file. --- +## [1.1.0] - 2026-04-07 + +### Added + +- **Subscription billing** — upgrade to Tier 1 (RM45/mo) or Tier 2 (RM75/mo) for higher rate limits and priority support +- **Public pricing page** (`/pricing`) — view all plans and pricing without signing in; sign in to subscribe directly +- Manage your subscription, update payment methods, and view invoices from the billing portal +- Pricing link in the landing page navbar +- Upgrade and billing management buttons in dashboard settings + +### Fixed + +- Fixed an issue where accounts with no email could fail to be created + +--- + ## [1.0.2] - 2026-04-07 ### Added diff --git a/packages/kal-frontend/src/app/pricing/client.tsx b/packages/kal-frontend/src/app/pricing/client.tsx index 94a35b1..fe15a68 100644 --- a/packages/kal-frontend/src/app/pricing/client.tsx +++ b/packages/kal-frontend/src/app/pricing/client.tsx @@ -2,73 +2,204 @@ import { RATE_LIMITS, TIER_PRICING } from "kal-shared"; import type { UserTier } from "kal-shared"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { ArrowLeft, Check, Zap } from "react-feather"; +import { Container } from "@/components/ui/Container"; import { AuthUpdater, useAuth } from "@/lib/auth-context"; import { trpc } from "@/lib/trpc"; interface PricingClientProps { + isAuthenticated: boolean; logtoId?: string; email?: string | null; name?: string | null; + onSignIn?: () => Promise; } export default function PricingClient({ + isAuthenticated, logtoId, email, name, + onSignIn, }: PricingClientProps) { return ( <> - - + {isAuthenticated && ( + + )} + ); } -function PricingContentWrapper({ +function PricingPage({ + isAuthenticated, expectedLogtoId, + onSignIn, }: { + isAuthenticated: boolean; expectedLogtoId?: string; + onSignIn?: () => Promise; }) { const { logtoId } = useAuth(); - if (expectedLogtoId && logtoId !== expectedLogtoId) { - return ( -
-
-
-
-
+ // If authenticated, wait for auth context to sync before showing interactive content + const authReady = + !isAuthenticated || !expectedLogtoId || logtoId === expectedLogtoId; + + return ( +
+ {/* Background Effects */} +
+
+
+
+ + {/* Navbar */} + + + {/* Content */} +
+
- ); - } - return ; + {/* Footer */} +
+ +
+ +
+ + Kal + + +
+ + Privacy Policy + + + Terms of Service + +
+

+ © {new Date().getFullYear()} Kal. All rights reserved. +

+
+ +
+
+ ); } -function PricingContent() { +function PricingNavbar({ + isAuthenticated, + onSignIn, +}: { + isAuthenticated: boolean; + onSignIn?: () => Promise; +}) { + return ( + + ); +} + +function PricingContent({ + isAuthenticated, + authReady, + onSignIn, +}: { + isAuthenticated: boolean; + authReady: boolean; + onSignIn?: () => Promise; +}) { const router = useRouter(); + + // Only fetch subscription status if authenticated const { data: subscriptionStatus, isLoading } = - trpc.subscription.getSubscriptionStatus.useQuery(); + trpc.subscription.getSubscriptionStatus.useQuery(undefined, { + enabled: isAuthenticated && authReady, + }); + const createCheckout = trpc.subscription.createCheckoutSession.useMutation({ onSuccess: (data) => { - // Redirect to Stripe Checkout window.location.href = data.url; }, }); const createPortal = trpc.subscription.createPortalSession.useMutation({ onSuccess: (data) => { - // Redirect to Stripe Customer Portal window.location.href = data.url; }, }); - const currentTier = subscriptionStatus?.tier || "free"; + const currentTier = isAuthenticated + ? subscriptionStatus?.tier || "free" + : null; const handleSubscribe = (tier: "tier_1" | "tier_2") => { + if (!isAuthenticated) { + onSignIn?.(); + return; + } createCheckout.mutate({ tier }); }; @@ -121,9 +252,9 @@ function PricingContent() { ]; return ( -
-
- {/* Back button */} +
+ {/* Back to home */} + {isAuthenticated && ( + )} - {/* Header */} -
-

- Choose Your Plan -

-

- Scale your application with higher rate limits and priority support. - All plans include full access to the Malaysian food nutrition - database. -

-
+ {/* Header */} +
+

+ Simple, Transparent Pricing +

+

+ Scale your application with higher rate limits and priority support. + All plans include full access to the Malaysian food nutrition + database. +

+
- {/* Pricing Cards */} -
- {tiers.map((tier) => { - const pricing = TIER_PRICING[tier.key]; - const isCurrent = currentTier === tier.key; - const isHigherTier = - (tier.key === "tier_1" && currentTier === "free") || + {/* Pricing Cards */} +
+ {tiers.map((tier) => { + const pricing = TIER_PRICING[tier.key]; + const isCurrent = currentTier === tier.key; + const isHigherTier = + currentTier !== null && + ((tier.key === "tier_1" && currentTier === "free") || (tier.key === "tier_2" && - (currentTier === "free" || currentTier === "tier_1")); - const isPaid = tier.key !== "free"; - - return ( -
- {/* Popular badge */} - {tier.highlighted && ( -
- - POPULAR - -
- )} + (currentTier === "free" || currentTier === "tier_1"))); + const isPaid = tier.key !== "free"; - {/* Tier name */} -

- {pricing.label} -

-

- {pricing.description} -

- - {/* Price */} -
- {pricing.price === 0 ? ( -
- - Free - -
- ) : ( -
- RM - - {pricing.price} - - /month -
- )} + return ( +
+ {/* Popular badge */} + {tier.highlighted && ( +
+ + POPULAR +
+ )} - {/* Features */} -
    - {tier.features.map((feature, i) => ( -
  • - - - {feature} - -
  • - ))} -
- - {/* CTA Button */} - {isLoading ? ( -
- ) : isCurrent ? ( -
- - Current Plan + {/* Tier name */} +

+ {pricing.label} +

+

+ {pricing.description} +

+ + {/* Price */} +
+ {pricing.price === 0 ? ( +
+ + Free - {isPaid && subscriptionStatus?.stripeCustomerId && ( - - )}
- ) : isHigherTier ? ( - ) : ( - // Lower tier than current — show manage billing - +
+ RM + + {pricing.price} + + /month +
)}
- ); - })} -
- {/* Footer note */} -

- All prices are in Malaysian Ringgit (MYR). Subscriptions are billed - monthly and can be cancelled anytime. -

+ {/* Features */} +
    + {tier.features.map((feature, i) => ( +
  • + + + {feature} + +
  • + ))} +
- {/* Error display */} - {(createCheckout.error || createPortal.error) && ( -
-

- {createCheckout.error?.message || createPortal.error?.message} -

-
- )} + {/* CTA Button */} + {!isAuthenticated ? ( + // Visitor — not signed in + + ) : !authReady || isLoading ? ( +
+ ) : isCurrent ? ( +
+ + Current Plan + + {isPaid && subscriptionStatus?.stripeCustomerId && ( + + )} +
+ ) : isHigherTier ? ( + + ) : ( + // Lower tier than current — show manage billing + + )} +
+ ); + })}
+ + {/* Footer note */} +

+ All prices are in Malaysian Ringgit (MYR). Subscriptions are billed + monthly and can be cancelled anytime. +

+ + {/* Error display */} + {(createCheckout.error || createPortal.error) && ( +
+

+ {createCheckout.error?.message || createPortal.error?.message} +

+
+ )}
); } + +function VisitorButton({ + tier, + highlighted, + onSignIn, +}: { + tier: UserTier; + highlighted?: boolean; + onSignIn?: () => Promise; +}) { + if (tier === "free") { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/kal-frontend/src/app/pricing/page.tsx b/packages/kal-frontend/src/app/pricing/page.tsx index 44e6293..3d553ba 100644 --- a/packages/kal-frontend/src/app/pricing/page.tsx +++ b/packages/kal-frontend/src/app/pricing/page.tsx @@ -1,5 +1,5 @@ +import { signIn } from "@logto/next/server-actions"; import { getLogtoContext } from "@logto/next/server-actions"; -import { redirect } from "next/navigation"; import PricingClient from "./client"; @@ -14,15 +14,19 @@ export default async function PricingPage() { const config = getLogtoConfig(); const { isAuthenticated, claims } = await getLogtoContext(config); - if (!isAuthenticated) { - redirect("/"); - } + const onSignIn = async () => { + "use server"; + const cfg = getLogtoConfig(); + await signIn(cfg); + }; return ( ); } diff --git a/packages/kal-frontend/src/components/landing/Navbar.tsx b/packages/kal-frontend/src/components/landing/Navbar.tsx index b14dd05..7293706 100644 --- a/packages/kal-frontend/src/components/landing/Navbar.tsx +++ b/packages/kal-frontend/src/components/landing/Navbar.tsx @@ -10,6 +10,7 @@ import { Container } from "@/components/ui/Container"; const navLinks = [ { label: "Features", href: "#features" }, { label: "How It Works", href: "#how-it-works" }, + { label: "Pricing", href: "/pricing" }, { label: "FAQ", href: "#faq" }, { label: "API", href: "/api-docs" }, ]; @@ -33,15 +34,25 @@ export function Navbar({ onSignIn }: NavbarProps) { {/* Desktop Navigation */}
- {navLinks.map((link) => ( - - {link.label} - - ))} + {navLinks.map((link) => + link.href.startsWith("/") ? ( + + {link.label} + + ) : ( + + {link.label} + + ) + )}
{/* CTA Button */} @@ -67,16 +78,27 @@ export function Navbar({ onSignIn }: NavbarProps) { {/* Mobile Menu */} {mobileMenuOpen && (
- {navLinks.map((link) => ( - setMobileMenuOpen(false)} - className="block py-2 text-content-secondary hover:text-content-primary transition-colors" - > - {link.label} - - ))} + {navLinks.map((link) => + link.href.startsWith("/") ? ( + setMobileMenuOpen(false)} + className="block py-2 text-content-secondary hover:text-content-primary transition-colors" + > + {link.label} + + ) : ( + setMobileMenuOpen(false)} + className="block py-2 text-content-secondary hover:text-content-primary transition-colors" + > + {link.label} + + ) + )}