22
33import { StaggerContainer , StaggerItem } from "@/components/animations/Stagger"
44import { HapticButtonLink } from "@/components/ui/HapticButtonLink"
5+ import { getIcon } from "@/components/ui/IconRenderer"
56import { Section } from "@/components/ui/Section"
67import { StableBadge } from "@/components/ui/StableBadge"
78import { Switch } from "@/components/ui/Switch"
@@ -11,12 +12,21 @@ import { cn } from "@/lib/utils"
1112import NumberFlow from "@number-flow/react"
1213import { IconCheck , IconX } from "@tabler/icons-react"
1314import { BorderBeam } from "border-beam"
14- import { AnimatePresence , m as motion } from "motion/react"
1515import { useState } from "react"
1616
1717type PricingPeriod = "monthly" | "quarterly" | "yearly"
1818type PackageContent = NonNullable < PricingLayoutBlock [ "pro" ] >
1919
20+ const popularPillColorClasses = {
21+ brand : "border-brand/10! bg-brand/10! text-brand!" ,
22+ pink : "border-pink/10! bg-pink/10! text-pink!" ,
23+ yellow : "border-yellow/10! bg-yellow/10! text-yellow!" ,
24+ aqua : "border-aqua/10! bg-aqua/10! text-aqua!" ,
25+ blue : "border-blue/10! bg-blue/10! text-blue!" ,
26+ lime : "border-lime/10! bg-lime/10! text-lime!" ,
27+ magenta : "border-magenta/10! bg-magenta/10! text-magenta!" ,
28+ } as const
29+
2030interface PricingSectionProps {
2131 content ?: PricingLayoutBlock | null
2232 locale : AppLocale
@@ -36,9 +46,9 @@ export function PricingSection({ content, locale, packages, paymentPeriod }: Pri
3646 { value : "yearly" , label : paymentPeriod . yearlyText , badge : yearlyDiscount > 0 ? `-${ yearlyDiscount } %` : null } ,
3747 ] as const
3848 const periodSuffix = {
39- monthly : "/mo" ,
40- quarterly : "/qtr" ,
41- yearly : "/yr" ,
49+ monthly : paymentPeriod . monthlyPeriodSuffix ,
50+ quarterly : paymentPeriod . quarterlyPeriodSuffix ,
51+ yearly : paymentPeriod . yearlyPeriodSuffix ,
4252 } [ selectedPeriod ]
4353 const pricingPackages = [
4454 {
@@ -94,13 +104,15 @@ export function PricingSection({ content, locale, packages, paymentPeriod }: Pri
94104 const buttonLabel = pricingPackage . content ?. button ?. label ?. trim ( )
95105 const buttonUrl = pricingPackage . content ?. button ?. url ?. trim ( )
96106 const highlighted = pricingPackage . key === "max"
107+ const popularPillColor = content . popularPill ?. color ?? "brand"
97108 const periodMonths = selectedPeriod === "quarterly" ? 3 : selectedPeriod === "yearly" ? 12 : 1
98- const calculatedDiscount =
99- selectedPeriod !== "monthly" && pricingPackage . price !== null && pricingPackage . price > 0 && pricingPackage . monthlyPrice !== null && pricingPackage . monthlyPrice > 0
100- ? Math . max ( 0 , Math . round ( ( 1 - pricingPackage . price / ( pricingPackage . monthlyPrice * periodMonths ) ) * 100 ) )
101- : 0
102109 const configuredDiscount = selectedPeriod === "quarterly" ? paymentPeriod . quarterlyDiscount : selectedPeriod === "yearly" ? paymentPeriod . yearlyDiscount : 0
103- const discount = pricingPackage . price === null ? 0 : calculatedDiscount || Math . round ( ( configuredDiscount ?? 0 ) * 100 )
110+ const priceWithoutPeriodDiscount = pricingPackage . price !== null && configuredDiscount > 0 && configuredDiscount < 1 ? pricingPackage . price / ( 1 - configuredDiscount ) : null
111+ const priceBasedOnMonthlyRate =
112+ selectedPeriod !== "monthly" && pricingPackage . monthlyPrice !== null && pricingPackage . monthlyPrice > 0 ? pricingPackage . monthlyPrice * periodMonths : null
113+ const regularPrice =
114+ priceBasedOnMonthlyRate !== null && pricingPackage . price !== null && priceBasedOnMonthlyRate > pricingPackage . price ? priceBasedOnMonthlyRate : priceWithoutPeriodDiscount
115+ const hasDiscount = regularPrice !== null && pricingPackage . price !== null && pricingPackage . price < regularPrice
104116
105117 const card = (
106118 < StaggerItem
@@ -111,55 +123,61 @@ export function PricingSection({ content, locale, packages, paymentPeriod }: Pri
111123 >
112124 { highlighted && < div aria-hidden = "true" className = "pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,oklch(1_0_0/0.1),transparent_36%)]" /> }
113125 < div className = "pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-white/30 to-transparent" />
114- < AnimatePresence initial = { false } >
115- { discount > 0 && (
116- < motion . div
117- initial = { { opacity : 0 , scale : 0.88 , y : - 4 } }
118- animate = { { opacity : 1 , scale : 1 , y : 0 } }
119- exit = { { opacity : 0 , scale : 0.88 , y : - 4 } }
120- transition = { { duration : 0.22 , ease : [ 0.22 , 1 , 0.36 , 1 ] } }
121- className = "absolute right-4 top-4 z-20 md:right-6 md:top-6"
122- >
123- < StableBadge border className = "border! border-brand/10! bg-brand/10! px-3 py-1 text-sm font-medium text-brand!" >
124- < span className = "inline-flex items-baseline gap-0" >
125- -< NumberFlow value = { discount } /> %
126- </ span >
127- </ StableBadge >
128- </ motion . div >
129- ) }
130- </ AnimatePresence >
126+ { highlighted && content . popularPill ?. text && (
127+ < div className = "absolute right-4 top-4 z-20 md:right-6 md:top-6" >
128+ < StableBadge border className = { cn ( "border! py-0.5 pr-3 pl-2 text-sm font-medium" , popularPillColorClasses [ popularPillColor ] ) } >
129+ < span className = "inline-flex items-center gap-1.5" >
130+ { getIcon ( content . popularPill . icon , 14 ) }
131+ { content . popularPill . text }
132+ </ span >
133+ </ StableBadge >
134+ </ div >
135+ ) }
131136 < h3 className = { cn ( "relative z-10 text-2xl font-semibold text-white" , pricingPackage . price !== null && "pr-20" ) } > { pricingPackage . title } </ h3 >
137+ { pricingPackage . description && < p className = "relative z-10 mt-1 leading-6 text-secondary" > { pricingPackage . description } </ p > }
132138
133139 { pricingPackage . price !== null && (
134- < div className = "relative z-10 mt-2" >
140+ < div className = "relative z-10 mt-6 flex flex-col items-start gap-0" >
141+ { hasDiscount && (
142+ < span className = "relative -mb-1 inline-flex text-sm leading-none font-medium text-tertiary after:absolute after:top-1/2 after:right-0 after:left-0 after:z-10 after:h-px after:bg-current after:content-['']" >
143+ { regularPrice . toLocaleString ( locale === "de" ? "de-DE" : "en-US" , {
144+ style : "currency" ,
145+ currency : "EUR" ,
146+ minimumFractionDigits : 0 ,
147+ maximumFractionDigits : 2 ,
148+ } ) }
149+ </ span >
150+ ) }
135151 < div className = "flex flex-wrap items-center gap-2" >
136152 < NumberFlow
137153 value = { pricingPackage . price }
138154 locales = { locale === "de" ? "de-DE" : "en-US" }
139155 format = { { style : "currency" , currency : "EUR" , trailingZeroDisplay : "stripIfInteger" } }
140156 className = "text-4xl font-semibold text-white"
141157 />
142- < span className = "mt-2 text-lg font-semibold text-tertiary" > { periodSuffix } </ span >
158+ < span className = "mt-2 text-base text-tertiary" > { periodSuffix } </ span >
143159 </ div >
144160 </ div >
145161 ) }
146- { pricingPackage . description && < p className = "relative z-10 leading-6 text-secondary" > { pricingPackage . description } </ p > }
147162
148163 { ( features . length > 0 || missingFeatures . length > 0 ) && (
149- < ul className = "relative z-10 mt-8 flex flex-col gap-2" >
150- { features . map ( ( feature , featureIndex ) => (
151- < li key = { feature . id ?? `feature-${ featureIndex } ` } className = "flex items-start gap-3 text-sm text-white" >
152- < IconCheck size = { 18 } className = "mt-0.5 shrink-0 text-brand" />
153- < span > { feature . text } </ span >
154- </ li >
155- ) ) }
156- { missingFeatures . map ( ( feature , featureIndex ) => (
157- < li key = { feature . id ?? `missing-feature-${ featureIndex } ` } className = "flex items-start gap-3 text-sm text-tertiary" >
158- < IconX size = { 18 } className = "mt-0.5 shrink-0" />
159- < span > { feature . text } </ span >
160- </ li >
161- ) ) }
162- </ ul >
164+ < div className = "relative z-10 mt-8" >
165+ { content . whatsIncludedText && < p className = "text-sm text-secondary" > { content . whatsIncludedText } </ p > }
166+ < ul className = { cn ( "flex flex-col gap-2" , content . whatsIncludedText && "mt-3" ) } >
167+ { features . map ( ( feature , featureIndex ) => (
168+ < li key = { feature . id ?? `feature-${ featureIndex } ` } className = "flex items-start gap-3 text-sm text-white" >
169+ < IconCheck size = { 18 } className = "mt-0.5 shrink-0 text-brand" />
170+ < span > { feature . text } </ span >
171+ </ li >
172+ ) ) }
173+ { missingFeatures . map ( ( feature , featureIndex ) => (
174+ < li key = { feature . id ?? `missing-feature-${ featureIndex } ` } className = "flex items-start gap-3 text-sm text-tertiary" >
175+ < IconX size = { 18 } className = "mt-0.5 shrink-0" />
176+ < span > { feature . text } </ span >
177+ </ li >
178+ ) ) }
179+ </ ul >
180+ </ div >
163181 ) }
164182
165183 { buttonLabel && buttonUrl && (
@@ -177,7 +195,7 @@ export function PricingSection({ content, locale, packages, paymentPeriod }: Pri
177195 )
178196
179197 return highlighted ? (
180- < BorderBeam key = { pricingPackage . key } size = "md" colorVariant = "colorful" strength = { 0.7 } className = "h-full" >
198+ < BorderBeam key = { pricingPackage . key } size = "md" colorVariant = "colorful" strength = { 0.7 } duration = { 3.5 } className = "h-full" >
181199 { card }
182200 </ BorderBeam >
183201 ) : (
0 commit comments