From ac9bdaf316fb0a5f8817aa01b16b7ad6f4c4ccde Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 18 Feb 2026 21:58:58 +0000 Subject: [PATCH] feat: improve pricing tiers and add comparison table --- .../components/FeatureComparisonTable.tsx | 818 ++++++++++++------ .../views/pricing/components/PricingTiers.tsx | 306 ++++--- apps/web/src/views/pricing/index.tsx | 22 +- 3 files changed, 743 insertions(+), 403 deletions(-) diff --git a/apps/web/src/views/pricing/components/FeatureComparisonTable.tsx b/apps/web/src/views/pricing/components/FeatureComparisonTable.tsx index ac24c558..11005b2b 100644 --- a/apps/web/src/views/pricing/components/FeatureComparisonTable.tsx +++ b/apps/web/src/views/pricing/components/FeatureComparisonTable.tsx @@ -1,293 +1,587 @@ import { t } from "@lingui/core/macro"; +import React, { useEffect, useRef, useState } from "react"; import { HiCheckCircle, HiXCircle } from "react-icons/hi2"; - -interface Feature { - key: string; - label: string; - kan: boolean | string; - trello: boolean | string; -} - -interface Section { - name: string; - features: Feature[]; -} +import { twMerge } from "tailwind-merge"; type FrequencyValue = "monthly" | "annually"; -const CellValue = ({ value }: { value: boolean | string }) => { - if (typeof value === "string") { - return ( - {value} - ); - } - return value ? ( - - ) : ( - - ); -}; +interface PlanFeature { + key: string; + label: string; + free: string | boolean | { text: string; highlight?: string }; + teams: string | boolean | { text: string; highlight?: string }; + pro: string | boolean | { text: string; highlight?: string }; + enterprise: string | boolean | { text: string; highlight?: string }; +} + +interface FeatureSection { + name: string; + features: PlanFeature[]; +} const FeatureComparisonTable = ({ - frequencyValue, + frequencyValue: _frequencyValue, }: { frequencyValue: FrequencyValue; }) => { - const sections: Section[] = [ + // Keep pricing column headers visible as you scroll + const containerRef = useRef(null); + const [isHeaderFixed, setIsHeaderFixed] = useState(false); + const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({ + left: 0, + width: 0, + }); + + useEffect(() => { + const HEADER_OFFSET = 64; // px; matches fixed top nav height + + const handleScroll = () => { + if (!containerRef.current) return; + + // Hide fixed header on mobile (screens smaller than 620px) + if (window.innerWidth < 620) { + setIsHeaderFixed(false); + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + const pastHeader = rect.top <= HEADER_OFFSET; + const aboveBottom = rect.bottom > HEADER_OFFSET + 40; + + const shouldFix = pastHeader && aboveBottom; + setIsHeaderFixed(shouldFix); + + if (shouldFix) { + setHeaderRect({ + left: rect.left, + width: rect.width, + }); + } + }; + + handleScroll(); + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("resize", handleScroll); + return () => { + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", handleScroll); + }; + }, []); + const planSpecificLimits: PlanFeature[] = [ { - name: t`Core features`, - features: [ - { - key: "ulimited-workspaces", - label: t`Unlimited workspaces`, - kan: true, - trello: false, - }, - { - key: "unlimited-boards", - label: t`Unlimited boards`, - kan: true, - trello: false, - }, - { - key: "unlimited-lists", - label: t`Unlimited lists`, - kan: true, - trello: true, - }, - { - key: "unlimited-cards", - label: t`Unlimited cards`, - kan: true, - trello: true, - }, - { - key: "activity-log", - label: t`Activity log`, - kan: true, - trello: true, - }, - { - key: "templates", - label: t`Custom templates`, - kan: true, - trello: false, - }, - { key: "labels", label: t`Labels & filters`, kan: true, trello: true }, - { key: "checklists", label: t`Checklists`, kan: true, trello: true }, - { - key: "import", - label: t`Import from Trello`, - kan: true, - trello: false, - }, - { - key: "visibility", - label: t`Board visibility`, - kan: true, - trello: true, - }, - { - key: "search", - label: t`Intelligent search`, - kan: true, - trello: true, - }, - { - key: "workspace-url", - label: t`Custom workspace link`, - kan: true, - trello: false, - }, - ], + key: "boards", + label: t`Boards`, + free: { text: t`Unlimited boards`, highlight: "Unlimited" }, + teams: { text: t`Unlimited boards`, highlight: "Unlimited" }, + pro: { text: t`Unlimited boards`, highlight: "Unlimited" }, + enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" }, }, { - name: t`Teams`, - features: [ - { - key: "pricing", - label: t`Price per user/month`, - kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`, - trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`, - }, - { - key: "comments", - label: t`Comments & mentions`, - kan: true, - trello: true, - }, - { - key: "assignments", - label: t`Assignees`, - kan: true, - trello: true, - }, - { - key: "sharing", - label: t`Board sharing & invites`, - kan: true, - trello: true, - }, - { - key: "invite-links", - label: t`Invite links`, - kan: true, - trello: true, - }, - ], + key: "members", + label: t`Members`, + free: { text: t`1 user`, highlight: "1" }, + teams: { text: t`Per-seat pricing`, highlight: "Per-seat" }, + pro: { text: t`Unlimited members`, highlight: "Unlimited" }, + enterprise: { text: t`Unlimited members`, highlight: "Unlimited" }, + }, + { + key: "file-uploads", + label: t`File uploads`, + free: { text: t`10mb file uploads`, highlight: "10mb" }, + teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" }, + pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" }, + enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" }, + }, + { + key: "workspace-username", + label: t`Workspace username`, + free: { text: t`Default username`, highlight: "Default" }, + teams: { text: t`Default username`, highlight: "Default" }, + pro: { text: t`Custom username`, highlight: "Custom" }, + enterprise: { text: t`Custom username`, highlight: "Custom" }, + }, + ]; + + const coreFeatures: PlanFeature[] = [ + { + key: "checklists", + label: t`Checklists`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "custom-templates", + label: t`Board templates`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "labels-filters", + label: t`Labels & filters`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "board-visibility", + label: t`Board visibility`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "search", + label: t`Intelligent search`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "activity-log", + label: t`Activity log`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "comments", + label: t`Comments`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "roles-permissions", + label: t`Roles & permissions`, + free: false, + teams: false, + pro: true, + enterprise: true, + }, + { + key: "custom-branding", + label: t`Custom branding`, + free: false, + teams: false, + pro: true, + enterprise: true, + }, + ]; + + const collaborationFeatures: PlanFeature[] = [ + { + key: "mentions", + label: t`Mentions`, + free: false, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "email-notifications", + label: t`Email notifications`, + free: false, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "assignees", + label: t`Assignees`, + free: false, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "invite-links", + label: t`Invite links`, + free: false, + teams: true, + pro: true, + enterprise: true, + }, + ]; + + const importsFeatures: PlanFeature[] = [ + { + key: "import-export", + label: t`Trello`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + ]; + + const platformFeatures: PlanFeature[] = [ + { + key: "open-source", + label: t`Open source`, + free: true, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "rest-api", + label: t`API`, + free: { text: t`Limited API access` }, + teams: { text: t`Full API access` }, + pro: { text: t`Full API access` }, + enterprise: { text: t`Full API access` }, + }, + { + key: "self-hostable", + label: t`On-premise`, + free: false, + teams: false, + pro: false, + enterprise: true, + }, + ]; + + const securityFeatures: PlanFeature[] = [ + { + key: "sso", + label: t`SSO`, + free: { text: t`Google SSO` }, + teams: { text: t`Google SSO` }, + pro: { text: t`Google SSO` }, + enterprise: { text: t`Google SSO + SAML` }, + }, + { + key: "admin-roles", + label: t`Admin roles`, + free: { text: t`Admin roles` }, + teams: { text: t`Admin roles` }, + pro: { text: t`Admin roles` }, + enterprise: { text: t`Advanced admin roles` }, + }, + ]; + + const supportFeatures: PlanFeature[] = [ + { + key: "priority-email-support", + label: t`Priority email support`, + free: false, + teams: true, + pro: true, + enterprise: true, + }, + { + key: "account-manager", + label: t`Account manager`, + free: false, + teams: false, + pro: false, + enterprise: true, + }, + { + key: "sla", + label: t`SLA`, + free: false, + teams: false, + pro: false, + enterprise: true, + }, + ]; + + const sections: FeatureSection[] = [ + { + name: "", + features: planSpecificLimits, + }, + { + name: t`Core features`, + features: coreFeatures, + }, + { + name: t`Collaboration`, + features: collaborationFeatures, + }, + { + name: t`Imports`, + features: importsFeatures, }, { name: t`Platform`, - features: [ - { - key: "api", - label: t`REST API`, - kan: true, - trello: true, - }, - { - key: "open-source", - label: t`Open source`, - kan: true, - trello: false, - }, - { - key: "self-hostable", - label: t`Self-hostable`, - kan: true, - trello: false, - }, - { - key: "white-label", - label: t`White label`, - kan: true, - trello: false, - }, - { - key: "performance", - label: t`Fast & lightweight`, - kan: true, - trello: false, - }, - { - key: "this-is-a-joke", - label: t`Owned by Atlassian`, - kan: false, - trello: true, - }, - ], + features: platformFeatures, + }, + { + name: t`Security`, + features: securityFeatures, + }, + { + name: t`Support`, + features: supportFeatures, }, ]; - const products = [ - { - id: "kan", - name: t`Kan`, - featured: true, - }, - { - id: "trello", - name: t`Trello`, - featured: false, - }, + const plans = [ + { id: "free", name: t`Free`, tierId: "tier-free" }, + { id: "teams", name: t`Teams`, tierId: "tier-teams" }, + { id: "pro", name: t`Pro`, tierId: "tier-pro" }, + { id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" }, ]; + const isHighlightValue = ( + value: string | boolean | { text: string; highlight?: string }, + ): value is { text: string; highlight?: string } => { + return typeof value === "object" && value !== null && "text" in value; + }; + + const shouldHighlight = (text: string): boolean => { + const lowerText = text.toLowerCase(); + return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some( + (keyword) => lowerText.includes(keyword), + ); + }; + + const CellValue = ({ + value, + label, + }: { + value: string | boolean | { text: string; highlight?: string }; + label: string; + }) => { + // Handle object with text and highlight + if (isHighlightValue(value)) { + const { text, highlight } = value; + const isUnlimited = shouldHighlight(text); + + let displayText; + if (highlight) { + const parts = text.split(new RegExp(`(${highlight})`, "gi")); + displayText = ( + <> + {parts.map((part, index) => + part.toLowerCase() === highlight.toLowerCase() ? ( + + {part} + + ) : ( + + {part} + + ), + )} + + ); + } else { + displayText = text; + } + + return ( +
+ + + {displayText} + +
+ ); + } + + if (typeof value === "string") { + const isUnlimited = shouldHighlight(value); + return ( +
+ + + {value} + +
+ ); + } + if (value) { + // Boolean true - show checkmark icon + return ( +
+ + + {label} + +
+ ); + } + // Boolean false - show cross icon + return ( +
+ + + {label} + +
+ ); + }; + + const getFeatureValue = ( + feature: PlanFeature, + planId: string, + ): string | boolean | { text: string; highlight?: string } => { + switch (planId) { + case "free": + return feature.free; + case "teams": + return feature.teams; + case "pro": + return feature.pro; + case "enterprise": + return feature.enterprise; + default: + return false; + } + }; + return ( -
-
-
- {products.map((product) => ( -