diff --git a/_bmad-output/implementation-artifacts/5-1-pricing-plan-selection-ui.md b/_bmad-output/implementation-artifacts/5-1-pricing-plan-selection-ui.md index c287e918f..6e0c9b5f3 100644 --- a/_bmad-output/implementation-artifacts/5-1-pricing-plan-selection-ui.md +++ b/_bmad-output/implementation-artifacts/5-1-pricing-plan-selection-ui.md @@ -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` và `disabled?: boolean`. +- Bật lại Monthly/Yearly toggle (đã bị comment), refactor để nhận `isYearly` và `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. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index b043aeba6..c2c8c0211 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -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` type. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 821354c4f..8598953da 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -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 diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 1b0947e3a..1e8036fa8 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -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(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({

{description}

- {/*
+
- - Annual billing (Save 20%) - -
*/} +
- - {plan.buttonText} - + {plan.onAction ? ( + + ) : ( + + {plan.buttonText} + + )}

{plan.description}

diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 43177e383..2462096cd 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -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 ( ); }