diff --git a/__tests__/components/Pricing.test.tsx b/__tests__/components/Pricing.test.tsx new file mode 100644 index 0000000..d18c7f3 --- /dev/null +++ b/__tests__/components/Pricing.test.tsx @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Pricing from "@/components/Pricing"; + +vi.mock("gsap", () => ({ + default: { + registerPlugin: vi.fn(), + fromTo: vi.fn(() => ({ kill: vi.fn() })), + context: vi.fn((fn) => { + fn(); + return { revert: vi.fn() }; + }), + }, + ScrollTrigger: { + create: vi.fn(), + }, +})); + +const originalEnv = process.env; + +describe("Pricing Component", () => { + beforeEach(() => { + process.env = { + ...originalEnv, + NEXT_PUBLIC_STRIPE_CHECKOUT_URL: "https://checkout.stripe.com", + }; + vi.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("Rendering", () => { + it("renders all three pricing plans", () => { + render(); + expect(screen.getByText("Free")).toBeInTheDocument(); + expect(screen.getByText("Pro")).toBeInTheDocument(); + expect(screen.getByText("Enterprise")).toBeInTheDocument(); + }); + + it("displays correct prices for monthly billing (default)", () => { + render(); + expect(screen.getByText("$0")).toBeInTheDocument(); + expect(screen.getByText("$49")).toBeInTheDocument(); + expect(screen.getByText("$499")).toBeInTheDocument(); + + const monthTexts = screen.getAllByText("/month"); + expect(monthTexts.length).toBe(2); + }); + + it("displays correct badges", () => { + render(); + expect(screen.getByText("Most Popular")).toBeInTheDocument(); + expect(screen.getByText("Best Value")).toBeInTheDocument(); + }); + + it("renders all features for Free plan", () => { + render(); + expect( + screen.getByText(/1,000 monthly tracked users/i), + ).toBeInTheDocument(); + expect(screen.getByText(/100,000 events \/ month/i)).toBeInTheDocument(); + expect(screen.getByText(/30-day data retention/i)).toBeInTheDocument(); + expect( + screen.getByText(/Core analytics: funnels, retention/i), + ).toBeInTheDocument(); + expect(screen.getByText(/3 dashboards/i)).toBeInTheDocument(); + expect(screen.getByText(/5 reports/i)).toBeInTheDocument(); + expect(screen.getByText(/Community support/i)).toBeInTheDocument(); + }); + + it("renders limit indicators for Pro plan", () => { + render(); + const proElements = screen.getAllByText(/Pro/); + const proCard = proElements[0].closest(".relative"); + + expect(proCard?.textContent).toContain("API Rate Limit"); + expect(proCard?.textContent).toContain("Data Retention"); + expect(proCard?.textContent).toContain("Team Seats"); + expect(proCard?.textContent).toContain("1,000 req/min"); + expect(proCard?.textContent).toContain("180 days"); + expect(proCard?.textContent).toContain("10"); + }); + }); + + describe("Billing Toggle", () => { + it("switches to yearly pricing when toggle is clicked", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText("$49")).toBeInTheDocument(); + + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + await waitFor(() => { + expect(screen.getByText("$500")).toBeInTheDocument(); + }); + + await waitFor(() => { + const yearTexts = screen.getAllByText("/year"); + expect(yearTexts.length).toBeGreaterThan(0); + }); + }); + + it("displays savings message for Pro plan when yearly is selected", async () => { + const user = userEvent.setup(); + render(); + + // Updated: Get by role "switch" + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + await waitFor(() => { + expect(screen.getByText(/Save \$88\/year/i)).toBeInTheDocument(); + }); + }); + + it("maintains correct price after toggling back to monthly", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("switch"); + + await user.click(toggle); + await waitFor(() => { + expect(screen.getByText("$500")).toBeInTheDocument(); + }); + + await user.click(toggle); + await waitFor(() => { + expect(screen.getByText("$49")).toBeInTheDocument(); + }); + + await waitFor(() => { + const monthTexts = screen.getAllByText("/month"); + expect(monthTexts.length).toBe(2); + }); + }); + }); + + describe("CTA Buttons", () => { + it("renders correct button text for each plan", () => { + render(); + expect(screen.getByText("Get Started")).toBeInTheDocument(); + expect(screen.getByText("Start Pro Trial")).toBeInTheDocument(); + expect(screen.getByText("Contact Sales")).toBeInTheDocument(); + }); + + it("has correct href for Free plan", () => { + render(); + const freeButton = screen.getByText("Get Started").closest("a"); + expect(freeButton).toHaveAttribute( + "href", + expect.stringContaining("?plan=free&billing=monthly"), + ); + }); + + it("has correct href for Pro plan with monthly billing", () => { + render(); + const proButton = screen.getByText("Start Pro Trial").closest("a"); + expect(proButton).toHaveAttribute( + "href", + expect.stringContaining("?plan=pro&billing=monthly"), + ); + }); + + it("updates Pro plan href when billing changes to yearly", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + await waitFor(() => { + const proButton = screen.getByText("Start Pro Trial").closest("a"); + expect(proButton).toHaveAttribute( + "href", + expect.stringContaining("?plan=pro&billing=yearly"), + ); + }); + }); + + it("has mailto link for Enterprise plan", () => { + render(); + const enterpriseButton = screen.getByText("Contact Sales").closest("a"); + expect(enterpriseButton).toHaveAttribute( + "href", + "mailto:sales@example.com", + ); + }); + }); + + describe("Visual Elements", () => { + it("applies popular badge styling to Pro plan card", () => { + render(); + const badge = screen.getByText("Most Popular"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass( + "absolute", + "-top-3", + "left-1/2", + "-translate-x-1/2", + ); + }); + + it("renders checkmark icons for all features", () => { + render(); + const svgs = document.querySelectorAll("svg"); + expect(svgs.length).toBeGreaterThan(10); + + const path = svgs[0]?.querySelector("path"); + expect(path).toHaveAttribute( + "d", + expect.stringContaining("M5 13l4 4L19 7"), + ); + }); + }); + + describe("Responsive Behavior", () => { + it("applies responsive text classes to heading", () => { + render(); + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toHaveClass("text-4xl", "sm:text-5xl", "lg:text-6xl"); + }); + + it("applies responsive grid classes", () => { + render(); + const grid = document.querySelector(".grid"); + expect(grid).toHaveClass("grid-cols-1", "md:grid-cols-3"); + }); + }); + + describe("Edge Cases", () => { + it("handles correct path redirection", () => { + const SC = process.env.NEXT_PUBLIC_STRIPE_CHECKOUT_URL; + render(); + const proButton = screen.getByText("Start Pro Trial").closest("a"); + expect(proButton).toHaveAttribute( + "href", + `${SC}?plan=pro&billing=monthly`, + ); + }); + + it("does not show savings message for Free plan when yearly is selected", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + const allSavingsMessages = screen.queryAllByText(/Save \$/i); + expect(allSavingsMessages.length).toBe(2); + }); + + it("displays 'Unlimited' text for unlimited seats in Enterprise", () => { + render(); + const unlimitedTexts = screen.getAllByText("Unlimited"); + expect(unlimitedTexts.length).toBeGreaterThan(0); + + const enterpriseCard = screen + .getByText("Enterprise") + .closest(".relative"); + expect(enterpriseCard).toBeTruthy(); + }); + }); +}); + +describe("Pricing Component - Accessibility", () => { + it("has proper heading hierarchy", () => { + render(); + const headings = document.querySelectorAll("h1, h2"); + expect(headings.length).toBeGreaterThan(0); + expect(headings[0].tagName).toBe("H1"); + + const h2s = Array.from(document.querySelectorAll("h2")); + expect(h2s.length).toBe(3); + }); + + it("toggle button has focus styles", () => { + render(); + const toggle = screen.getByRole("switch"); + expect(toggle.className).toContain("focus:outline-none"); + expect(toggle.className).toContain("focus:ring-2"); + }); +}); diff --git a/app/(root)/pricing/page.tsx b/app/(root)/pricing/page.tsx new file mode 100644 index 0000000..660460b --- /dev/null +++ b/app/(root)/pricing/page.tsx @@ -0,0 +1,9 @@ +import Pricing from "@/components/Pricing" + +const page = () => { + return ( + + ) +} + +export default page \ No newline at end of file diff --git a/components/Pricing.tsx b/components/Pricing.tsx new file mode 100644 index 0000000..df6b9c5 --- /dev/null +++ b/components/Pricing.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useRef, useState } from "react"; +import gsap from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { PLANS, type Plan } from "@/lib/constants"; +import { useGSAP } from "@gsap/react"; +import Link from "next/link"; + +// Register GSAP plugins +if (typeof window !== "undefined") { + gsap.registerPlugin(ScrollTrigger); +} + +const Pricing = () => { + const sectionRef = useRef(null); + const headingRef = useRef(null); + const cardsRef = useRef<(HTMLDivElement | null)[]>([]); + const [billingCycle, setBillingCycle] = useState<"monthly" | "yearly">( + "monthly", + ); + const checkoutBaseUrl = process.env.NEXT_PUBLIC_STRIPE_CHECKOUT_URL; + + // Set up GSAP animations + useGSAP(() => { + const ctx = gsap.context(() => { + // Heading animation + gsap.fromTo( + headingRef.current, + { opacity: 0, y: 40 }, + { + opacity: 1, + y: 0, + duration: 0.8, + ease: "power3.out", + scrollTrigger: { + trigger: headingRef.current, + start: "top 85%", + toggleActions: "play none none reverse", + }, + }, + ); + + // Cards staggered animation + cardsRef.current.forEach((card, index) => { + if (!card) return; + gsap.fromTo( + card, + { opacity: 0, y: 60, scale: 0.95 }, + { + opacity: 1, + y: 0, + scale: 1, + duration: 0.6, + delay: index * 0.15, + ease: "back.out(0.4)", + scrollTrigger: { + trigger: card, + start: "top 90%", + toggleActions: "play none none reverse", + }, + }, + ); + }); + }, sectionRef); + + return () => ctx.revert(); + }, []); + + // Helper: Format price with optional yearly discount + const getDisplayPrice = (plan: Plan) => { + if (plan.id === "free") return "$0"; + const monthlyPrice = plan.price.monthly; + if (billingCycle === "yearly") { + const yearlyPrice = monthlyPrice * 12 * 0.85; // 15% discount + return `$${yearlyPrice.toFixed(0)}`; + } + return `$${monthlyPrice}`; + }; + + const getPricePeriod = (plan: Plan) => { + if (plan.id === "free") return ""; + return billingCycle === "yearly" ? "/year" : "/month"; + }; + + const getYearlySavings = (plan: Plan) => { + if (plan.id === "free" || billingCycle === "monthly") return null; + const monthlyTotal = plan.price.monthly * 12; + const yearlyTotal = monthlyTotal * 0.85; + const savings = monthlyTotal - yearlyTotal; + return `Save $${savings.toFixed(0)}/year`; + }; + + return ( +
+ {/* Background decorative elements */} +
+
+
+
+ +
+ {/* Header Section */} +
+

+ Privacy-first{" "} + + product analytics + {" "} + for modern SaaS +

+

+ Understand user behavior across your entire multi-tenant + architecture. +

+ + {/* Billing Toggle */} +
+ + Monthly + + + + Yearly{" "} + + Save 15% + + +
+
+ + {/* Pricing Cards Grid */} +
+ {PLANS.map((plan: any, index: number) => { + const isPopular = plan.badge === "Most Popular"; + const isEnterprise = plan.id === "enterprise"; + + return ( +
{ + cardsRef.current[index] = el; + }} + className="relative rounded-2xl transition-all duration-300 hover:-translate-y-2" + style={{ + backgroundColor: "var(--color-card)", + borderWidth: isPopular ? "2px" : "1px", + borderColor: isPopular + ? "var(--color-primary-500)" + : "var(--color-border-light)", + boxShadow: "var(--shadow-lg)", + }} + > + {/* Badge */} + {plan.badge && ( +
+ {plan.badge} +
+ )} + +
+ {/* Plan Name */} +

+ {plan.name} +

+ + {/* Price */} +
+ + {getDisplayPrice(plan)} + + + {getPricePeriod(plan)} + + {getYearlySavings(plan) && ( +
+ {getYearlySavings(plan)} +
+ )} +
+ + {/* CTA Button */} + + {plan.cta} + + + {/* Divider */} +
+ + {/* Features List */} +
    + {plan.features.map((feature: string, idx: number) => ( +
  • + + + + + {feature} + +
  • + ))} +
+ + {/* Limit Indicators for Pro/Enterprise */} + {plan.id !== "free" && ( +
+
+ + API Rate Limit + + + {plan.limits.apiRateLimit.toLocaleString()} req/min + +
+
+ + Data Retention + + + {plan.limits.dataRetentionDays} days + +
+
+ + Team Seats + + + {plan.limits.seats === -1 + ? "Unlimited" + : plan.limits.seats} + +
+
+ )} +
+
+ ); + })} +
+ + {/* Footer Note */} +

+ No credit card required for the Free plan. +

+
+
+ ); +}; + +export default Pricing; diff --git a/lib/config/index.ts b/lib/config/index.ts index 1107f2a..20763f6 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -33,6 +33,7 @@ const clientSchema = z.object({ .default("false") .transform((v) => v === "true"), NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_STRIPE_CHECKOUT_URL: z.string() }); // Export types