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
-
Name
+
+ Name
+
{isLoading ? (
@@ -84,7 +173,9 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em
Email
-
{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.isPending ? "Loading..." : "Manage Billing"}
+
+ )}
+
+ {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.
-
diff --git a/packages/kal-frontend/src/app/pricing/client.tsx b/packages/kal-frontend/src/app/pricing/client.tsx
new file mode 100644
index 0000000..94a35b1
--- /dev/null
+++ b/packages/kal-frontend/src/app/pricing/client.tsx
@@ -0,0 +1,290 @@
+"use client";
+
+import { RATE_LIMITS, TIER_PRICING } from "kal-shared";
+import type { UserTier } from "kal-shared";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { ArrowLeft, Check, Zap } from "react-feather";
+
+import { AuthUpdater, useAuth } from "@/lib/auth-context";
+import { trpc } from "@/lib/trpc";
+
+interface PricingClientProps {
+ logtoId?: string;
+ email?: string | null;
+ name?: string | null;
+}
+
+export default function PricingClient({
+ logtoId,
+ email,
+ name,
+}: PricingClientProps) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function PricingContentWrapper({
+ expectedLogtoId,
+}: {
+ expectedLogtoId?: string;
+}) {
+ const { logtoId } = useAuth();
+
+ if (expectedLogtoId && logtoId !== expectedLogtoId) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+function PricingContent() {
+ const router = useRouter();
+ const { data: subscriptionStatus, isLoading } =
+ trpc.subscription.getSubscriptionStatus.useQuery();
+ 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 handleSubscribe = (tier: "tier_1" | "tier_2") => {
+ createCheckout.mutate({ tier });
+ };
+
+ const handleManageBilling = () => {
+ createPortal.mutate();
+ };
+
+ const tiers: {
+ key: UserTier;
+ features: string[];
+ highlighted?: boolean;
+ }[] = [
+ {
+ key: "free",
+ features: [
+ `${RATE_LIMITS.free.minuteLimit} requests/minute`,
+ `${RATE_LIMITS.free.dailyLimit.toLocaleString()} requests/day`,
+ `${RATE_LIMITS.free.monthlyLimit.toLocaleString()} requests/month`,
+ "Full food database access",
+ "AI-powered nutrition chat",
+ "Community support",
+ ],
+ },
+ {
+ key: "tier_1",
+ highlighted: true,
+ features: [
+ `${RATE_LIMITS.tier_1.minuteLimit} requests/minute`,
+ `${RATE_LIMITS.tier_1.dailyLimit.toLocaleString()} requests/day`,
+ `${RATE_LIMITS.tier_1.monthlyLimit.toLocaleString()} requests/month`,
+ "Full food database access",
+ "AI-powered nutrition chat",
+ "Priority support",
+ "Higher burst limits",
+ ],
+ },
+ {
+ key: "tier_2",
+ features: [
+ `${RATE_LIMITS.tier_2.minuteLimit} requests/minute`,
+ `${RATE_LIMITS.tier_2.dailyLimit.toLocaleString()} requests/day`,
+ `${RATE_LIMITS.tier_2.monthlyLimit.toLocaleString()} requests/month`,
+ "Full food database access",
+ "AI-powered nutrition chat",
+ "Priority support",
+ "Maximum burst limits",
+ "Dedicated support channel",
+ ],
+ },
+ ];
+
+ return (
+
+
+ {/* Back button */}
+
router.push("/dashboard/settings")}
+ className="flex items-center gap-2 text-content-secondary hover:text-content-primary transition-colors mb-8"
+ >
+
+ Back to Settings
+
+
+ {/* 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 && (
+
+ {createPortal.isPending
+ ? "Loading..."
+ : "Manage Billing"}
+
+ )}
+
+ ) : isHigherTier ? (
+
+ handleSubscribe(tier.key as "tier_1" | "tier_2")
+ }
+ disabled={createCheckout.isPending}
+ className={`w-full py-2.5 px-4 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
+ tier.highlighted
+ ? "bg-accent text-dark-bg hover:bg-accent/90"
+ : "bg-dark-elevated text-content-primary hover:bg-dark-border border border-dark-border"
+ }`}
+ >
+ {createCheckout.isPending
+ ? "Redirecting..."
+ : `Subscribe to ${pricing.label}`}
+
+ ) : (
+ // Lower tier than current — show manage billing
+
+ {createPortal.isPending ? "Loading..." : "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 (
+
+
+
+
+
+
Kal
+
+
+
+
+ Features
+
+
+ API
+
+
+
+
+ {isAuthenticated ? (
+
+ Dashboard
+
+ ) : (
+ onSignIn?.()}
+ className="inline-flex items-center px-4 py-2 rounded-lg text-sm font-medium bg-accent text-dark-bg hover:bg-accent/90 transition-colors"
+ >
+ Sign In
+
+ )}
+
+
+
+
+ );
+}
+
+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 && (
router.push("/dashboard/settings")}
className="flex items-center gap-2 text-content-secondary hover:text-content-primary transition-colors mb-8"
@@ -131,160 +262,198 @@ function PricingContent() {
Back to Settings
+ )}
- {/* 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 && (
-
- {createPortal.isPending
- ? "Loading..."
- : "Manage Billing"}
-
- )}
- ) : isHigherTier ? (
-
- handleSubscribe(tier.key as "tier_1" | "tier_2")
- }
- disabled={createCheckout.isPending}
- className={`w-full py-2.5 px-4 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
- tier.highlighted
- ? "bg-accent text-dark-bg hover:bg-accent/90"
- : "bg-dark-elevated text-content-primary hover:bg-dark-border border border-dark-border"
- }`}
- >
- {createCheckout.isPending
- ? "Redirecting..."
- : `Subscribe to ${pricing.label}`}
-
) : (
- // Lower tier than current — show manage billing
-
- {createPortal.isPending ? "Loading..." : "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 && (
+
+ {createPortal.isPending ? "Loading..." : "Manage Billing"}
+
+ )}
+
+ ) : isHigherTier ? (
+
+ handleSubscribe(tier.key as "tier_1" | "tier_2")
+ }
+ disabled={createCheckout.isPending}
+ className={`w-full py-2.5 px-4 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
+ tier.highlighted
+ ? "bg-accent text-dark-bg hover:bg-accent/90"
+ : "bg-dark-elevated text-content-primary hover:bg-dark-border border border-dark-border"
+ }`}
+ >
+ {createCheckout.isPending
+ ? "Redirecting..."
+ : `Subscribe to ${pricing.label}`}
+
+ ) : (
+ // Lower tier than current — show manage billing
+
+ {createPortal.isPending ? "Loading..." : "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 (
+ onSignIn?.()}
+ className="w-full py-2.5 px-4 rounded-lg text-sm font-medium bg-dark-elevated text-content-primary hover:bg-dark-border border border-dark-border transition-colors"
+ >
+ Get Started Free
+
+ );
+ }
+
+ return (
+ onSignIn?.()}
+ className={`w-full py-2.5 px-4 rounded-lg text-sm font-medium transition-colors ${
+ highlighted
+ ? "bg-accent text-dark-bg hover:bg-accent/90"
+ : "bg-dark-elevated text-content-primary hover:bg-dark-border border border-dark-border"
+ }`}
+ >
+ Sign In to Subscribe
+
+ );
+}
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}
+
+ )
+ )}
onSignIn?.()}
From b0e9b3de4097bda8c95581cabf4ffca9cc1d4f83 Mon Sep 17 00:00:00 2001
From: Zen0space
Date: Tue, 7 Apr 2026 11:41:20 +0800
Subject: [PATCH 5/5] fix: render bold markdown in changelog entries
---
.../kal-frontend/src/app/dashboard/changelog/client.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/kal-frontend/src/app/dashboard/changelog/client.tsx b/packages/kal-frontend/src/app/dashboard/changelog/client.tsx
index 1430b5c..23705aa 100644
--- a/packages/kal-frontend/src/app/dashboard/changelog/client.tsx
+++ b/packages/kal-frontend/src/app/dashboard/changelog/client.tsx
@@ -147,9 +147,9 @@ function VersionCard({
);
}
-/** Renders inline `code` segments with accent styling */
+/** Renders inline `code` and **bold** segments with styling */
function HighlightCode({ text }: { text: string }) {
- const parts = text.split(/(`[^`]+`)/g);
+ const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g);
return (
<>
{parts.map((part, i) =>
@@ -160,6 +160,10 @@ function HighlightCode({ text }: { text: string }) {
>
{part.slice(1, -1)}
+ ) : part.startsWith("**") && part.endsWith("**") ? (
+
+ {part.slice(2, -2)}
+
) : (
{part}
)