Skip to content

Commit 9561627

Browse files
committed
feat: improve pricing section and add configurable badges
1 parent 2f6015d commit 9561627

11 files changed

Lines changed: 50785 additions & 45 deletions

src/blocks/PricingBlock.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { sectionFields } from "@/fields/sectionFields"
2+
import { iconField } from "@mvriu5/payload-icon-picker"
23
import { pricingPackageFields } from "@/fields/pricingPackageFields"
34
import type { Block } from "payload"
45

@@ -10,6 +11,56 @@ export const PricingBlock: Block = {
1011
},
1112
fields: [
1213
sectionFields(),
14+
{
15+
name: "popularPill",
16+
label: "Popular Pill",
17+
type: "group",
18+
fields: [
19+
iconField({
20+
name: "icon",
21+
label: "Icon",
22+
required: false,
23+
defaultValue: "tabler:IconSparkles",
24+
placeholder: "Search icons",
25+
noResultsLabel: "No icons found",
26+
admin: {
27+
position: "sidebar",
28+
},
29+
}),
30+
{
31+
name: "text",
32+
label: "Text",
33+
type: "text",
34+
required: false,
35+
localized: true,
36+
defaultValue: "Popular",
37+
},
38+
{
39+
name: "color",
40+
label: "Color",
41+
type: "select",
42+
required: false,
43+
defaultValue: "brand",
44+
options: [
45+
{ label: "Brand", value: "brand" },
46+
{ label: "Pink", value: "pink" },
47+
{ label: "Yellow", value: "yellow" },
48+
{ label: "Aqua", value: "aqua" },
49+
{ label: "Blue", value: "blue" },
50+
{ label: "Lime", value: "lime" },
51+
{ label: "Magenta", value: "magenta" },
52+
],
53+
},
54+
],
55+
},
56+
{
57+
name: "whatsIncludedText",
58+
label: "What's Included Text",
59+
type: "text",
60+
required: false,
61+
localized: true,
62+
defaultValue: "What's included",
63+
},
1364
pricingPackageFields("pro", "Pro"),
1465
pricingPackageFields("max", "Max"),
1566
pricingPackageFields("custom", "Custom"),

src/components/sections/PricingSection.tsx

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { StaggerContainer, StaggerItem } from "@/components/animations/Stagger"
44
import { HapticButtonLink } from "@/components/ui/HapticButtonLink"
5+
import { getIcon } from "@/components/ui/IconRenderer"
56
import { Section } from "@/components/ui/Section"
67
import { StableBadge } from "@/components/ui/StableBadge"
78
import { Switch } from "@/components/ui/Switch"
@@ -11,12 +12,21 @@ import { cn } from "@/lib/utils"
1112
import NumberFlow from "@number-flow/react"
1213
import { IconCheck, IconX } from "@tabler/icons-react"
1314
import { BorderBeam } from "border-beam"
14-
import { AnimatePresence, m as motion } from "motion/react"
1515
import { useState } from "react"
1616

1717
type PricingPeriod = "monthly" | "quarterly" | "yearly"
1818
type 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+
2030
interface 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

Comments
 (0)