Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default function SettingsPage() {
const [maxPages, setMaxPages] = useState(300);
const [savingWindow, setSavingWindow] = useState(false);
const [authOutcome, setAuthOutcome] = useState<string | null>(null);
const [upgradePrompt, setUpgradePrompt] = useState<string | null>(null);
const { stores, reload: reloadStores } = useConnectedStores();
const [busyStore, setBusyStore] = useState<string | null>(null);
const [section, setSection] = useState<SectionId>("store");
Expand Down Expand Up @@ -116,6 +117,7 @@ export default function SettingsPage() {
const params = new URLSearchParams(window.location.search);
// eslint-disable-next-line react-hooks/set-state-in-effect
setAuthOutcome(params.get("auth"));
setUpgradePrompt(params.get("upgrade"));
const requested = params.get("section");
if (SECTIONS.some((s) => s.id === requested)) {
setSection(requested as SectionId);
Expand Down Expand Up @@ -202,6 +204,17 @@ export default function SettingsPage() {
</Alert>
) : null}

{upgradePrompt === "whatsapp" ? (
<Alert>
<Info />
<AlertTitle>That page needs Go or Plus</AlertTitle>
<AlertDescription>
The Lite plan is analytics only. Upgrade below to use WhatsApp campaigns, flows, the
inbox and order confirmations.
</AlertDescription>
</Alert>
) : null}

<div className="grid gap-6 lg:grid-cols-[200px_1fr] lg:items-start">
<nav className="flex gap-1 overflow-x-auto lg:sticky lg:top-6 lg:flex-col lg:overflow-visible">
{SECTIONS.map(({ id, label, icon: Icon }) => (
Expand Down
39 changes: 29 additions & 10 deletions src/app/(marketing)/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import { BrandMark, BRANDS } from "@/components/marketing/brand-mark";
import { Cta } from "@/components/marketing/landing/cta";
import { Pricing as LandingPricing } from "@/components/marketing/landing/pricing";
import { FAQ } from "@/lib/marketing/faq";
import { INCLUDED } from "@/lib/marketing/plans";
import { ANALYTICS_MODULES, WHATSAPP_MODULES } from "@/lib/marketing/plans";
import { cn } from "@/lib/utils";

export const metadata: Metadata = {
title: "Pricing",
description:
"One price for all thirteen modules. Choose how messages are carried: your own WhatsApp host with no per-message fee, or the official Cloud API with delivery backed by Meta.",
"Lite for analytics alone, or Go/Plus to add WhatsApp campaigns, flows and the inbox. Choose how messages are carried: your own WhatsApp host with no per-message fee, or the official Cloud API with delivery backed by Meta.",
alternates: { canonical: "/pricing" },
};

Expand Down Expand Up @@ -95,24 +95,25 @@ export default function PricingPage() {
<BlurFade inView>
<Badge variant="outline" className="gap-1.5">
<span className="size-1.5 rounded-full bg-primary" />
Every module, every tier
Analytics, or analytics plus WhatsApp
</Badge>
</BlurFade>
<BlurFade delay={0.08} inView>
<h1 className="mx-auto mt-6 max-w-3xl text-4xl leading-[1.1] font-semibold tracking-tight text-balance sm:text-5xl">
One price.{" "}
Pay for{" "}
<AuroraText
colors={["var(--chart-7)", "var(--primary)", "var(--chart-3)", "var(--primary)"]}
speed={0.6}
>
No feature gates.
what you use.
</AuroraText>
</h1>
</BlurFade>
<BlurFade delay={0.16} inView>
<p className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-pretty text-muted-foreground sm:text-lg">
Tiers are sized by how many WhatsApp messages you send a month, not by what the
product will tell you about your store. Go and Plus get the same thirteen modules.
Lite is every analytics module, nothing else. Go and Plus add WhatsApp campaigns,
flows and the inbox, sized by how many messages you send a month — not by what the
product will tell you about your store.
</p>
</BlurFade>
</div>
Expand All @@ -130,11 +131,29 @@ export default function PricingPage() {
<Section className="bg-muted/20">
<SectionHeading
eyebrow="Included"
title="What every tier includes"
body="All of it. The list is here so nobody has to go looking for the asterisk."
title="Every analytics module. Every tier."
body="Lite, Go and Plus all get the same analytics — the list is here so nobody has to go looking for the asterisk."
/>
<div className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{INCLUDED.map((item, i) => (
{ANALYTICS_MODULES.map((item, i) => (
<BlurFade key={item} delay={Math.min(i * 0.03, 0.3)} inView>
<div className="flex items-start gap-2.5 rounded-xl bg-card p-4 ring-1 ring-foreground/10">
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="text-sm">{item}</span>
</div>
</BlurFade>
))}
</div>
</Section>

<Section>
<SectionHeading
eyebrow="Go & Plus add"
title="WhatsApp, on top of the same analytics"
body="Lite doesn't include any of this — upgrade when campaigns, flows or a shared inbox actually become worth carrying WhatsApp for."
/>
<div className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{WHATSAPP_MODULES.map((item, i) => (
<BlurFade key={item} delay={Math.min(i * 0.03, 0.3)} inView>
<div className="flex items-start gap-2.5 rounded-xl bg-card p-4 ring-1 ring-foreground/10">
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
Expand Down
2 changes: 1 addition & 1 deletion src/app/(marketing)/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default function TermsPage() {

<LegalSection title="4. Billing and plans">
<p>
Paid plans (Go and Plus) bill monthly via Razorpay UPI Autopay. New subscriptions
Paid plans (Lite, Go and Plus) bill monthly via Razorpay UPI Autopay. New subscriptions
include a one-time 14-day free trial; your payment mandate is authorized immediately,
and the first charge is deferred to the end of the trial. You can cancel from Settings
→ Billing at any time; cancelling stops future billing but doesn&apos;t refund the
Expand Down
9 changes: 5 additions & 4 deletions src/app/api/billing/checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ export const dynamic = "force-dynamic";
* (src/app/api/keys/route.ts), a leaked API key must not be able to change
* what the account is billed, so this is not reachable with one.
*/
const bodySchema = z.object({ plan: z.enum(["go", "plus"]) });
const bodySchema = z.object({ plan: z.enum(["go", "plus", "lite"]) });

const PLAN_ENV: Record<"go" | "plus", string | undefined> = {
const PLAN_ENV: Record<"go" | "plus" | "lite", string | undefined> = {
go: process.env.RAZORPAY_PLAN_ID_GO,
plus: process.env.RAZORPAY_PLAN_ID_PLUS,
lite: process.env.RAZORPAY_PLAN_ID_LITE,
};

/**
Expand All @@ -43,7 +44,7 @@ export async function POST(request: Request) {
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "A plan of \"go\" or \"plus\" is required." }, { status: 422 });
return NextResponse.json({ error: "A plan of \"lite\", \"go\" or \"plus\" is required." }, { status: 422 });
}

const planId = PLAN_ENV[parsed.data.plan];
Expand All @@ -58,7 +59,7 @@ export async function POST(request: Request) {
{
razorpay_customer_id: string | null;
razorpay_subscription_id: string | null;
plan: "go" | "plus" | null;
plan: "go" | "plus" | "lite" | null;
subscription_status: string;
trial_used_at: Date | null;
}[]
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/billing/invoices/[id]/pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const dynamic = "force-dynamic";

interface InvoiceRow {
id: string;
plan: "go" | "plus";
plan: "go" | "plus" | "lite";
amount_paise: number;
currency: string;
status: "paid" | "failed" | "refunded";
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/billing/invoices/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";

interface InvoiceRow {
id: string;
plan: "go" | "plus";
plan: "go" | "plus" | "lite";
amount_paise: number;
currency: string;
status: "paid" | "failed" | "refunded";
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/billing/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function POST(request: Request) {
const payment = event.payload.payment?.entity;
if (!sub || !payment) break;

const [profile] = await db()<{ id: string; plan: "go" | "plus" | null }[]>`
const [profile] = await db()<{ id: string; plan: "go" | "plus" | "lite" | null }[]>`
select id, plan from profiles where razorpay_subscription_id = ${sub.id}
`;
if (!profile?.plan) break;
Expand Down
25 changes: 22 additions & 3 deletions src/components/layout/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";

import { Activity, ExternalLink } from "lucide-react";
import { Activity, ExternalLink, Lock } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AccountMenu } from "@/components/auth/account-menu";
import { StoreSwitcher } from "@/components/layout/store-switcher";
import { useAnalytics } from "@/components/providers/analytics-provider";
import { useBilling } from "@/components/providers/billing-provider";
import {
Sidebar,
SidebarContent,
Expand All @@ -15,6 +16,7 @@ import {
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
Expand All @@ -24,6 +26,17 @@ import { NAV_GROUPS } from "./nav-items";
export function AppSidebar() {
const pathname = usePathname();
const { data } = useAnalytics();
const { status } = useBilling();
/*
* Lite still sees every item -- clicking one is how it finds out WhatsApp
* needs an upgrade, via src/proxy.ts's WHATSAPP_PAGES redirect to
* /settings?section=billing&upgrade=whatsapp, same destination this lock
* icon links toward. Hiding the item instead would hide the upsell along
* with it. Only true once the plan is actually known to be Lite -- while
* status is still loading (null) or on Go/Plus/legacy-unlimited, nothing
* is locked.
*/
const isLite = status !== null && !status.legacyUnlimited && status.plan === "lite";

return (
<Sidebar collapsible="icon">
Expand All @@ -47,14 +60,20 @@ export function AppSidebar() {
<SidebarMenu>
{group.items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
const locked = isLite && item.whatsapp;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={active} tooltip={item.label}>
<Link href={item.href}>
<SidebarMenuButton asChild isActive={active} tooltip={locked ? `${item.label} — needs Go or Plus` : item.label}>
<Link href={locked ? "/settings?section=billing&upgrade=whatsapp" : item.href}>
<item.icon />
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
{locked ? (
<SidebarMenuBadge>
<Lock className="size-3" />
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
);
})}
Expand Down
9 changes: 9 additions & 0 deletions src/components/layout/nav-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface NavItem {
label: string;
icon: typeof LayoutDashboard;
description: string;
/** Hidden for a Lite (analytics-only) plan — matches src/proxy.ts's WHATSAPP_PAGES. */
whatsapp?: boolean;
}

export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [
Expand Down Expand Up @@ -97,42 +99,49 @@ export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [
label: "Assistant",
icon: Sparkles,
description: "Ask about the store, and have messages drafted for approval",
whatsapp: true,
},
{
href: "/campaigns",
label: "Campaigns",
icon: Megaphone,
description: "Build audiences and measure campaign and coupon performance",
whatsapp: true,
},
{
href: "/flows",
label: "Flows",
icon: Workflow,
description: "Multi-step campaigns that send themselves over days",
whatsapp: true,
},
{
href: "/abandoned-checkouts",
label: "Abandoned checkouts",
icon: ShoppingCart,
description: "WhatsApp reminders for checkouts left pending",
whatsapp: true,
},
{
href: "/order-confirmations",
label: "Order confirmations",
icon: PackageCheck,
description: "A WhatsApp thank-you, with a product photo, the moment an order comes in",
whatsapp: true,
},
{
href: "/menu",
label: "Auto-reply",
icon: MessageSquare,
description: "The menu a customer gets when they message you first",
whatsapp: true,
},
{
href: "/inbox",
label: "Inbox",
icon: MessageCircle,
description: "WhatsApp conversations, with the customer behind each number",
whatsapp: true,
},
],
},
Expand Down
10 changes: 10 additions & 0 deletions src/components/layout/subscription-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@ import { Button } from "@/components/ui/button";
* while the real gate already refused it). `usage.limit` is null for
* anything actually unlimited (legacy_unlimited, or an active/trialing
* Plus), so it can never misfire for those.
*
* Lite is a third, deliberate exception: its `usage.limit` is also 0, but
* not because anything is broken or unpaid -- Lite simply doesn't include
* WhatsApp, on purpose, at any subscription status. Telling a Lite
* subscriber their subscription "isn't active" would be false, and "Add a
* subscription" would be telling someone who already has one to add another.
* WHATSAPP_PAGES in src/proxy.ts is what actually keeps a Lite account off
* the pages this would matter on; this banner has nothing useful to say on
* the analytics pages Lite does use.
*/
export function SubscriptionBanner() {
const { status, initialising } = useBilling();

if (initialising || !status) return null;
if (status.plan === "lite") return null;

const blocked = status.usage.limit === 0;
if (!blocked) return null;
Expand Down
6 changes: 3 additions & 3 deletions src/components/marketing/landing/pricing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export function Pricing({ heading = true, footer = heading }: PricingProps = {})
<SectionHeading
align="center"
eyebrow="Pricing"
title="Every feature, on both plans."
body="Tiers are sized by how many WhatsApp messages you send a month, not by what the product will tell you about your store. A store on Go gets the same analytics, flows and assistant as one on Plus."
title="Analytics alone, or analytics plus WhatsApp."
body="Lite is every analytics module and nothing else. Go and Plus add WhatsApp campaigns, flows and the inbox, sized by how many messages you send a monthnot by what the product will tell you about your store."
/>
) : null}

<div className={cn("mx-auto grid max-w-3xl items-stretch gap-6 sm:grid-cols-2", heading && "mt-12")}>
<div className={cn("mx-auto grid max-w-5xl items-stretch gap-6 sm:grid-cols-3", heading && "mt-12")}>
{PLANS.map((plan, i) => (
<BlurFade key={plan.name} delay={i * 0.08} inView className="h-full">
<div
Expand Down
2 changes: 1 addition & 1 deletion src/components/providers/billing-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";

export interface BillingStatus {
plan: "go" | "plus" | null;
plan: "go" | "plus" | "lite" | null;
subscriptionStatus: "none" | "created" | "authenticated" | "active" | "past_due" | "halted" | "cancelled";
currentPeriodEnd: string | null;
graceUntil: string | null;
Expand Down
9 changes: 5 additions & 4 deletions src/components/settings/billing-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {

interface Invoice {
id: string;
plan: "go" | "plus";
plan: "go" | "plus" | "lite";
amountPaise: number;
currency: string;
status: "paid" | "failed" | "refunded";
Expand All @@ -31,6 +31,7 @@ interface Invoice {
}

const PLANS = [
{ id: "lite" as const, label: "Lite", priceLabel: "₹1,999/mo", detail: "Analytics only — no WhatsApp" },
{ id: "go" as const, label: "Go", priceLabel: "₹3,999/mo", detail: "10,000 WhatsApp messages/month" },
{ id: "plus" as const, label: "Plus", priceLabel: "₹5,999/mo", detail: "Unlimited WhatsApp messages" },
];
Expand Down Expand Up @@ -61,7 +62,7 @@ declare global {
export function BillingCard() {
const { status, refresh: refreshStatus } = useBilling();
const [invoices, setInvoices] = useState<Invoice[] | null>(null);
const [subscribing, setSubscribing] = useState<"go" | "plus" | null>(null);
const [subscribing, setSubscribing] = useState<"go" | "plus" | "lite" | null>(null);
const [error, setError] = useState<string | null>(null);
const [checkoutReady, setCheckoutReady] = useState(false);

Expand All @@ -79,7 +80,7 @@ export function BillingCard() {
void loadInvoices();
}, [loadInvoices]);

const subscribe = async (plan: "go" | "plus") => {
const subscribe = async (plan: "go" | "plus" | "lite") => {
setSubscribing(plan);
setError(null);
try {
Expand Down Expand Up @@ -229,7 +230,7 @@ export function BillingCard() {
{status.usage.limit ? <Progress value={usagePct} /> : null}
</div>

<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
{PLANS.map((plan) => {
const isCurrent =
status.plan === plan.id &&
Expand Down
Loading
Loading