Merge pull request #1 from deptrai/develop

feat(story-5.1): Subscription Pricing UI with Stripe Checkout
This commit is contained in:
Vonic 2026-04-14 23:37:41 +07:00 committed by GitHub
commit 45ce8b9e0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 246 additions and 99 deletions

View file

@ -1,6 +1,6 @@
# Story 5.1: Giao diện Bảng giá & Lựa chọn Gói Cước (Pricing & Plan Selection UI)
Status: ready-for-dev
Status: done
## Story
@ -29,20 +29,20 @@ so that tôi biết chính xác số lượng file/tin nhắn mình nhận đư
## Tasks / Subtasks
- [ ] Task 1: Thiết kế lại Pricing Tiers (Frontend)
- [ ] Subtask 1.1: Cập nhật `demoPlans` constant → đổi sang subscription tiers:
- [x] Task 1: Thiết kế lại Pricing Tiers (Frontend)
- [x] Subtask 1.1: Cập nhật `demoPlans` constant → đổi sang subscription tiers:
- **Free**: 500 pages ETL, 50 LLM messages/day, basic models (GPT-3.5), $0/mo
- **Pro**: 5,000 pages ETL, 1,000 LLM messages/day, premium models (GPT-4, Claude), $X/mo hoặc $Y/year
- **Team/Enterprise**: Unlimited, custom pricing, SSO, audit logs
- [ ] Subtask 1.2: Thêm Monthly/Yearly toggle switch — hiển thị `price` vs `yearlyPrice` tương ứng.
- [x] Subtask 1.2: Thêm Monthly/Yearly toggle switch — hiển thị `price` vs `yearlyPrice` tương ứng.
- [ ] Task 2: Kết nối nút CTA với Stripe Checkout (Frontend)
- [ ] Subtask 2.1: Nút "Get Started" cho tier Free → redirect `/login` (giữ nguyên).
- [ ] Subtask 2.2: Nút "Upgrade to Pro" → gọi `POST /api/v1/stripe/create-subscription-checkout` (Story 5.2) với `plan_id` tương ứng. Nếu user chưa login → redirect `/login` trước.
- [ ] Subtask 2.3: Nút "Contact Sales" cho Enterprise → giữ nguyên `/contact`.
- [x] Task 2: Kết nối nút CTA với Stripe Checkout (Frontend)
- [x] Subtask 2.1: Nút "Get Started" cho tier Free → redirect `/login` (giữ nguyên).
- [x] Subtask 2.2: Nút "Upgrade to Pro" → gọi `POST /api/v1/stripe/create-subscription-checkout` (Story 5.2) với `plan_id` tương ứng. Nếu user chưa login → redirect `/login` trước.
- [x] Subtask 2.3: Nút "Contact Sales" cho Enterprise → giữ nguyên `/contact`.
- [ ] Task 3: Graceful degradation khi Offline
- [ ] Subtask 3.1: Pricing data dùng static constant (load được offline). Disable nút "Upgrade" khi offline để tránh lỗi network request.
- [x] Task 3: Graceful degradation khi Offline
- [x] Subtask 3.1: Pricing data dùng static constant (load được offline). Disable nút "Upgrade" khi offline để tránh lỗi network request.
## Dev Notes
@ -59,3 +59,35 @@ Mỗi tier subscription cần 1 Stripe Price ID (tạo trên Stripe Dashboard):
### References
- `surfsense_web/components/pricing/pricing-section.tsx` — pricing UI hiện tại
- `surfsense_web/app/(home)/pricing/page.tsx` — pricing page route
## Dev Agent Record
### Implementation Notes
- Chuyển `pricing-section.tsx` thành Client Component (`"use client"`) để xử lý online state và Stripe CTA.
- Cập nhật `PricingPlan` interface trong `pricing.tsx`: thêm `onAction?: () => void``disabled?: boolean`.
- Bật lại Monthly/Yearly toggle (đã bị comment), refactor để nhận `isYearly``onToggleBilling` props từ parent.
- Pro plan dùng `onAction``handleUpgradePro()`: check `isAuthenticated()` rồi gọi `POST /api/v1/stripe/create-subscription-checkout`.
- Graceful degradation: `useEffect` lắng nghe `online`/`offline` events, disable nút Upgrade + thay text khi offline.
- Pricing data là static constant → luôn render được kể cả offline.
- Quyết định xóa PAYG tier cũ, thay bằng Free/Pro/Enterprise subscription tiers theo PRD.
### Completion Notes
✅ Tất cả tasks/subtasks hoàn thành. AC 1-4 đều được đáp ứng.
### File List
- `surfsense_web/components/pricing/pricing-section.tsx` — rewritten: subscription tiers, Stripe CTA, offline degradation
- `surfsense_web/components/pricing.tsx` — updated: PricingPlan interface, toggle enabled, button renders onAction/Link
### Review Findings
- [x] [Review][Decision] Gate Stripe checkout khi `isSelfHosted()` — DISMISSED: dự án chuyển sang SaaS-only, không còn self-hosted
- [x] [Review][Patch] Dùng `authenticatedFetch` thay `fetch` raw — xử lý 401/token refresh [pricing-section.tsx:43]
- [x] [Review][Patch] Hiện toast/error cho user khi Stripe checkout fail — hiện chỉ `console.error` [pricing-section.tsx:49-65]
- [x] [Review][Patch] Thêm `if (isLoading) return;` early guard chống double-click [pricing-section.tsx:33]
- [x] [Review][Patch] Xóa `useRouter` import không dùng [pricing-section.tsx:4,15]
- [x] [Review][Patch] Validate `checkout_url` bắt đầu bằng `https://` trước khi redirect [pricing-section.tsx:57]
- [x] [Review][Patch] Fix "Save 20%" → "Save 25%" hoặc tính dynamic từ giá [pricing.tsx:104]
- [x] [Review][Defer] `ref` cast `as any` trên Switch component — deferred, pre-existing [pricing.tsx:99]
### Change Log
- 2026-04-14: Chuyển sang subscription tiers (Free/Pro/Enterprise), bật toggle Monthly/Yearly, kết nối Stripe Checkout, graceful offline degradation.

View file

@ -4,3 +4,7 @@
- **stripe_subscription_id has no unique constraint** [surfsense_backend/app/db.py] — Column added without UNIQUE constraint. Should be enforced once Stripe integration (Epic 5) is implemented to prevent duplicate subscription mappings.
- **load_llm_config_from_yaml reads API keys directly from YAML file, not env vars** [surfsense_backend/app/config.py] — Pre-existing: YAML config stores API keys inline. Spec Task 1.2 says "đọc API keys từ env vars" but this is the existing pattern used throughout the project. To be refactored when security hardening is prioritized.
## Deferred from: code review of story 5-1 (2026-04-14)
- `ref` cast `as any` on Switch component in `pricing.tsx:99` — pre-existing issue, not introduced by this change. Should use proper `React.ComponentRef<typeof Switch>` type.

View file

@ -65,9 +65,9 @@ development_status:
4-2-graceful-degradation-offline-ui: done
4-3-global-network-sync-indicators: done
epic-4-retrospective: optional
epic-5: backlog
5-1-pricing-plan-selection-ui: backlog
5-2-stripe-payment-integration: backlog
5-3-stripe-webhook-sync: backlog
5-4-usage-tracking-rate-limit-enforcement: backlog
epic-5: in-progress
5-1-pricing-plan-selection-ui: done
5-2-stripe-payment-integration: ready-for-dev
5-3-stripe-webhook-sync: ready-for-dev
5-4-usage-tracking-rate-limit-enforcement: ready-for-dev
epic-5-retrospective: optional

View file

@ -23,25 +23,38 @@ interface PricingPlan {
buttonText: string;
href: string;
isPopular: boolean;
onAction?: () => void;
disabled?: boolean;
}
interface PricingProps {
plans: PricingPlan[];
title?: string;
description?: string;
isYearly?: boolean;
onToggleBilling?: (yearly: boolean) => void;
}
export function Pricing({
plans,
title = "Simple, Transparent Pricing",
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
isYearly: isYearlyProp,
onToggleBilling,
}: PricingProps) {
const [isMonthly, setIsMonthly] = useState(false);
const [isYearlyLocal, setIsYearlyLocal] = useState(false);
const isYearly = isYearlyProp !== undefined ? isYearlyProp : isYearlyLocal;
const isMonthly = !isYearly;
const isDesktop = useMediaQuery("(min-width: 768px)");
const switchRef = useRef<HTMLButtonElement>(null);
const handleToggle = (checked: boolean) => {
setIsMonthly(!checked);
const yearly = checked;
if (onToggleBilling) {
onToggleBilling(yearly);
} else {
setIsYearlyLocal(yearly);
}
if (checked && switchRef.current) {
const rect = switchRef.current.getBoundingClientRect();
const x = rect.left + rect.width / 2;
@ -76,24 +89,27 @@ export function Pricing({
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
</div>
{/* <div className="flex justify-center mb-10">
<div className="flex justify-center mb-10">
<label
htmlFor="billing-toggle"
className="relative inline-flex items-center cursor-pointer"
className="relative inline-flex items-center cursor-pointer gap-3"
>
<span className={`font-semibold ${isMonthly ? "text-foreground" : "text-muted-foreground"}`}>
Monthly
</span>
<Label>
<Switch
ref={switchRef as any}
checked={!isMonthly}
checked={isYearly}
onCheckedChange={handleToggle}
className="relative"
/>
</Label>
<span className={`font-semibold ${isYearly ? "text-foreground" : "text-muted-foreground"}`}>
Annual <span className="text-primary">(Save 25%)</span>
</span>
</label>
<span className="ml-2 font-semibold">
Annual billing <span className="text-primary">(Save 20%)</span>
</span>
</div> */}
</div>
<div
className={cn(
@ -203,21 +219,42 @@ export function Pricing({
<hr className="w-full my-4" />
<Link
href={plan.href}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground"
)}
>
{plan.buttonText}
</Link>
{plan.onAction ? (
<button
type="button"
onClick={plan.onAction}
disabled={plan.disabled}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground",
plan.disabled && "opacity-50 cursor-not-allowed hover:ring-0 hover:bg-inherit hover:text-inherit"
)}
>
{plan.buttonText}
</button>
) : (
<Link
href={plan.href}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground"
)}
>
{plan.buttonText}
</Link>
)}
<p className="mt-6 text-xs leading-5 text-muted-foreground">{plan.description}</p>
</div>
</motion.div>

View file

@ -1,70 +1,144 @@
import { Pricing } from "@/components/pricing";
"use client";
const demoPlans = [
{
name: "FREE",
price: "0",
yearlyPrice: "0",
period: "",
billingText: "500 pages included",
features: [
"Self Hostable",
"500 pages included to start",
"Earn up to 3,000+ bonus pages for free",
"Includes access to OpenAI text, audio and image models",
"Realtime Collaborative Group Chats with teammates",
"Community support on Discord",
],
description: "",
buttonText: "Get Started",
href: "/login",
isPopular: false,
},
{
name: "PAY AS YOU GO",
price: "1",
yearlyPrice: "1",
period: "1,000 pages",
billingText: "No subscription, buy only when you need more",
features: [
"Everything in Free",
"Buy 1,000-page packs at $1 each",
"Priority support on Discord",
],
description: "",
buttonText: "Get Started",
href: "/login",
isPopular: false,
},
{
name: "ENTERPRISE",
price: "Contact Us",
yearlyPrice: "Contact Us",
period: "",
billingText: "",
features: [
"Everything in Pay As You Go",
"On-prem or VPC deployment",
"Audit logs and compliance",
"SSO, OIDC & SAML",
"White-glove setup and deployment",
"Monthly managed updates and maintenance",
"SLA commitments",
"Dedicated support",
],
description: "Customized setup for large organizations",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,
},
];
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Pricing } from "@/components/pricing";
import { isAuthenticated, redirectToLogin, authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
const PLAN_IDS = {
pro_monthly: "pro_monthly",
pro_yearly: "pro_yearly",
};
function PricingBasic() {
const [isOnline, setIsOnline] = useState(true);
const [isYearly, setIsYearly] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsOnline(navigator.onLine);
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
const handleUpgradePro = async () => {
if (!isOnline || isLoading) return;
if (!isAuthenticated()) {
redirectToLogin();
return;
}
setIsLoading(true);
try {
const planId = isYearly ? PLAN_IDS.pro_yearly : PLAN_IDS.pro_monthly;
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/stripe/create-subscription-checkout`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ plan_id: planId }),
}
);
if (!response.ok) {
toast.error("Unable to start checkout. Please try again later.");
return;
}
const data = await response.json();
const checkoutUrl = data.checkout_url;
if (typeof checkoutUrl === "string" && checkoutUrl.startsWith("https://")) {
window.location.href = checkoutUrl;
} else {
toast.error("Invalid checkout response. Please try again.");
}
} catch (error) {
toast.error("Something went wrong. Please check your connection and try again.");
} finally {
setIsLoading(false);
}
};
// Pricing plans — static constant (loads offline)
const demoPlans = [
{
name: "FREE",
price: "0",
yearlyPrice: "0",
period: "month",
billingText: "No credit card required",
features: [
"Self hostable",
"500 pages ETL / month",
"50 LLM messages / day",
"Basic models (GPT-3.5 Turbo)",
"Community support on Discord",
],
description: "Perfect for personal use and exploration",
buttonText: "Get Started",
href: "/login",
isPopular: false,
},
{
name: "PRO",
price: "12",
yearlyPrice: "9",
period: "month",
billingText: isYearly ? "billed annually ($108/yr)" : "billed monthly",
features: [
"Everything in Free",
"5,000 pages ETL / month",
"1,000 LLM messages / day",
"Premium models (GPT-4, Claude, Gemini)",
"Priority support on Discord",
],
description: "For power users and professionals",
buttonText: isLoading ? "Redirecting…" : isOnline ? "Upgrade to Pro" : "Offline — unavailable",
href: "#",
isPopular: true,
onAction: handleUpgradePro,
disabled: !isOnline || isLoading,
},
{
name: "ENTERPRISE",
price: "Contact Us",
yearlyPrice: "Contact Us",
period: "",
billingText: "",
features: [
"Everything in Pro",
"Unlimited pages ETL",
"Unlimited LLM messages",
"All models including latest releases",
"On-prem or VPC deployment",
"SSO, OIDC & SAML",
"Audit logs and compliance",
"Dedicated support & SLA",
],
description: "Custom setup for large organisations",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,
},
];
return (
<Pricing
plans={demoPlans}
title="SurfSense Pricing"
description="Start free with 500 pages and pay as you go."
description="Start free. Upgrade when you need more power."
isYearly={isYearly}
onToggleBilling={setIsYearly}
/>
);
}