Skip to content

Commit ef41af0

Browse files
committed
feat: small pricing section
1 parent c2237a9 commit ef41af0

12 files changed

Lines changed: 934 additions & 70 deletions

src/blocks/PricingBlock.ts

Lines changed: 5 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,6 @@
11
import { sectionFields } from "@/fields/sectionFields"
2-
import type { Block, Field } from "payload"
3-
4-
const packageFields = (name: "pro" | "max" | "custom", label: string): Field => ({
5-
name,
6-
label,
7-
type: "group",
8-
fields: [
9-
{
10-
name: "features",
11-
label: "Features",
12-
type: "array",
13-
required: false,
14-
fields: [
15-
{
16-
name: "text",
17-
type: "text",
18-
required: true,
19-
localized: true,
20-
},
21-
],
22-
},
23-
{
24-
name: "missingFeatures",
25-
label: "Missing Features",
26-
type: "array",
27-
required: false,
28-
fields: [
29-
{
30-
name: "text",
31-
type: "text",
32-
required: true,
33-
localized: true,
34-
},
35-
],
36-
},
37-
{
38-
name: "button",
39-
label: "Button",
40-
type: "group",
41-
fields: [
42-
{
43-
name: "label",
44-
type: "text",
45-
required: false,
46-
localized: true,
47-
},
48-
{
49-
name: "url",
50-
type: "text",
51-
required: false,
52-
},
53-
{
54-
name: "variant",
55-
type: "select",
56-
required: false,
57-
defaultValue: "normal",
58-
options: [
59-
{ label: "None", value: "none" },
60-
{ label: "Normal", value: "normal" },
61-
{ label: "Outlined", value: "outlined" },
62-
{ label: "Filled", value: "filled" },
63-
],
64-
},
65-
],
66-
},
67-
],
68-
})
2+
import { pricingPackageFields } from "@/fields/pricingPackageFields"
3+
import type { Block } from "payload"
694

705
export const PricingBlock: Block = {
716
slug: "pricing",
@@ -75,8 +10,8 @@ export const PricingBlock: Block = {
7510
},
7611
fields: [
7712
sectionFields(),
78-
packageFields("pro", "Pro"),
79-
packageFields("max", "Max"),
80-
packageFields("custom", "Custom"),
13+
pricingPackageFields("pro", "Pro"),
14+
pricingPackageFields("max", "Max"),
15+
pricingPackageFields("custom", "Custom"),
8116
],
8217
}

src/blocks/SmallPricingBlock.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { pricingPackageFields } from "@/fields/pricingPackageFields"
2+
import { sectionFields } from "@/fields/sectionFields"
3+
import type { Block } from "payload"
4+
5+
export const SmallPricingBlock: Block = {
6+
slug: "smallPricing",
7+
labels: {
8+
singular: "Small Pricing",
9+
plural: "Small Pricing Blocks",
10+
},
11+
fields: [
12+
sectionFields(),
13+
{
14+
name: "pricingPeriod",
15+
label: "Pricing Period",
16+
type: "select",
17+
required: true,
18+
defaultValue: "monthly",
19+
options: [
20+
{ label: "Monthly", value: "monthly" },
21+
{ label: "Quarterly", value: "quarterly" },
22+
{ label: "Yearly", value: "yearly" },
23+
],
24+
},
25+
{
26+
name: "gradient",
27+
label: "Gradient",
28+
type: "select",
29+
required: false,
30+
defaultValue: "blue",
31+
options: [
32+
{ label: "Blue", value: "blue" },
33+
{ label: "Yellow", value: "yellow" },
34+
{ label: "Pink", value: "pink" },
35+
{ label: "Aqua", value: "aqua" },
36+
{ label: "Brand", value: "brand" },
37+
{ label: "Lime", value: "lime" },
38+
{ label: "Magenta", value: "magenta" },
39+
{ label: "Neutral", value: "neutral" },
40+
],
41+
},
42+
{
43+
name: "gradientDirection",
44+
label: "Gradient Direction",
45+
type: "select",
46+
required: false,
47+
defaultValue: "topLeft",
48+
options: [
49+
{ label: "Top left", value: "topLeft" },
50+
{ label: "Top right", value: "topRight" },
51+
{ label: "Bottom left", value: "bottomLeft" },
52+
{ label: "Bottom right", value: "bottomRight" },
53+
],
54+
},
55+
pricingPackageFields("pro", "Pro"),
56+
pricingPackageFields("max", "Max"),
57+
pricingPackageFields("custom", "Custom"),
58+
],
59+
}

src/collections/pages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { StatsBlock } from "@/blocks/StatsBlock"
3030
import { FlowExampleBlock } from "@/blocks/FlowExampleBlock"
3131
import { SubscriptionConfiguratorBlock } from "@/blocks/SubscriptionConfiguratorBlock"
3232
import { PricingBlock } from "@/blocks/PricingBlock"
33+
import { SmallPricingBlock } from "@/blocks/SmallPricingBlock"
3334

3435
const RESERVED_PAGE_SLUGS = [
3536
"main",
@@ -187,6 +188,7 @@ export const Pages: CollectionConfig = {
187188
FlowExampleBlock,
188189
SubscriptionConfiguratorBlock,
189190
PricingBlock,
191+
SmallPricingBlock,
190192
],
191193
required: false,
192194
localized: true,

src/components/PageBlockRenderer.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { ActionReferencesSection } from "./sections/ActionReferencesSection"
2828
import { ActionListSection } from "./sections/ActionListSection"
2929
import { SubscriptionConfiguratorSection, type SubscriptionIcons } from "./sections/SubscriptionConfiguratorSection"
3030
import { PricingSection } from "./sections/PricingSection"
31+
import { SmallPricingSection } from "./sections/SmallPricingSection"
3132
import { getIcon } from "@/components/IconRenderer"
3233
import type { ActionItem, SubscriptionConfigData, SubscriptionConfiguratorBlockData } from "@/lib/cms"
3334

@@ -188,6 +189,19 @@ const pageBlockRenderers: Partial<Record<PageBlock["blockType"], BlockRenderer>>
188189
/>
189190
)
190191
},
192+
smallPricing: (block, options) => {
193+
const config = options.subscriptionConfig
194+
if (!config?.packages) return null
195+
196+
return (
197+
<SmallPricingSection
198+
content={block as Extract<PageBlock, { blockType: "smallPricing" }>}
199+
locale={options.locale ?? "en"}
200+
packages={config.packages}
201+
paymentPeriod={config.paymentPeriod}
202+
/>
203+
)
204+
},
191205
}
192206

193207
function renderPageBlock(block: PageBlock, options: PageBlockRenderOptions) {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"use client"
2+
3+
import { StaggerContainer, StaggerItem } from "@/components/animations/Stagger"
4+
import { Card } from "@/components/ui/Card"
5+
import { HapticButtonLink } from "@/components/ui/HapticButtonLink"
6+
import { Section } from "@/components/ui/Section"
7+
import { StableBadge } from "@/components/ui/StableBadge"
8+
import type { SmallPricingLayoutBlock, SubscriptionConfigData } from "@/lib/cms"
9+
import type { AppLocale } from "@/lib/i18n"
10+
import { cn } from "@/lib/utils"
11+
import NumberFlow from "@number-flow/react"
12+
import { IconCheck, IconX } from "@tabler/icons-react"
13+
14+
type PackageContent = NonNullable<SmallPricingLayoutBlock["pro"]>
15+
16+
interface SmallPricingSectionProps {
17+
content?: SmallPricingLayoutBlock | null
18+
locale: AppLocale
19+
packages: SubscriptionConfigData["packages"]
20+
paymentPeriod: SubscriptionConfigData["paymentPeriod"]
21+
}
22+
23+
export function SmallPricingSection({ content, locale, packages, paymentPeriod }: SmallPricingSectionProps) {
24+
if (!content) return null
25+
26+
const selectedPeriod = content.pricingPeriod
27+
const periodMonths = selectedPeriod === "quarterly" ? 3 : selectedPeriod === "yearly" ? 12 : 1
28+
const periodSuffix = {
29+
monthly: paymentPeriod.monthlyPeriodSuffix,
30+
quarterly: paymentPeriod.quarterlyPeriodSuffix,
31+
yearly: paymentPeriod.yearlyPeriodSuffix,
32+
}[selectedPeriod]
33+
const pricingPackages = [
34+
{
35+
key: "pro",
36+
title: packages.pro.title || "Pro",
37+
description: packages.pro.description,
38+
price: packages.pro.prices[selectedPeriod],
39+
monthlyPrice: packages.pro.prices.monthly,
40+
content: content.pro,
41+
},
42+
{
43+
key: "max",
44+
title: packages.max.title || "Max",
45+
description: packages.max.description,
46+
price: packages.max.prices[selectedPeriod],
47+
monthlyPrice: packages.max.prices.monthly,
48+
content: content.max,
49+
},
50+
{
51+
key: "custom",
52+
title: packages.custom.title || "Custom",
53+
description: packages.custom.description,
54+
price: null,
55+
monthlyPrice: null,
56+
content: content.custom,
57+
},
58+
] satisfies {
59+
key: "pro" | "max" | "custom"
60+
title: string
61+
description: string
62+
price: number | null
63+
monthlyPrice: number | null
64+
content?: PackageContent
65+
}[]
66+
67+
return (
68+
<Section
69+
heading={content.sectionHeading}
70+
description={content.sectionDescription}
71+
linkButton={content.sectionLinkButton}
72+
funnelType={content.sectionLayout ?? "center"}
73+
animation={{ preset: "none" }}
74+
>
75+
<Card
76+
size="lg"
77+
variant="light"
78+
radialGradient={content.gradient}
79+
gradientDirection={content.gradientDirection}
80+
className="mx-auto w-full max-w-5xl p-2!"
81+
>
82+
<StaggerContainer className="relative z-10 grid grid-cols-1 md:grid-cols-3" delayChildren={0.04} staggerChildren={0.08}>
83+
{pricingPackages.map((pricingPackage) => {
84+
const features = pricingPackage.content?.features?.filter((feature) => Boolean(feature.text)) ?? []
85+
const missingFeatures = pricingPackage.content?.missingFeatures?.filter((feature) => Boolean(feature.text)) ?? []
86+
const buttonLabel = pricingPackage.content?.button?.label?.trim()
87+
const buttonUrl = pricingPackage.content?.button?.url?.trim()
88+
const discount =
89+
selectedPeriod !== "monthly" && pricingPackage.price !== null && pricingPackage.monthlyPrice !== null && pricingPackage.monthlyPrice > 0
90+
? Math.max(0, Math.round((1 - pricingPackage.price / (pricingPackage.monthlyPrice * periodMonths)) * 100))
91+
: 0
92+
93+
return (
94+
<StaggerItem
95+
key={pricingPackage.key}
96+
y={10}
97+
duration={0.36}
98+
className="flex min-w-0 flex-col p-5 sm:p-6"
99+
>
100+
<div className="flex items-start justify-between gap-3">
101+
<h3 className="text-xl font-semibold text-white">{pricingPackage.title}</h3>
102+
{discount > 0 && (
103+
<StableBadge border className="shrink-0 border-brand/25! bg-brand/15! px-2 py-1 text-xs font-semibold tracking-wider text-brand!">
104+
-{discount}%
105+
</StableBadge>
106+
)}
107+
</div>
108+
{pricingPackage.description && <p className="mt-2 text-sm leading-5 text-secondary">{pricingPackage.description}</p>}
109+
110+
{pricingPackage.price !== null && (
111+
<div className="mt-5">
112+
<NumberFlow
113+
value={pricingPackage.price}
114+
locales={locale === "de" ? "de-DE" : "en-US"}
115+
format={{ style: "currency", currency: "EUR", trailingZeroDisplay: "stripIfInteger" }}
116+
className="text-3xl font-semibold text-white"
117+
/>
118+
{periodSuffix && <p className="mt-1 text-xs text-tertiary">{periodSuffix}</p>}
119+
</div>
120+
)}
121+
122+
{(features.length > 0 || missingFeatures.length > 0) && (
123+
<ul className="mt-6 flex flex-col gap-2.5">
124+
{features.map((feature, featureIndex) => (
125+
<li key={feature.id ?? `feature-${featureIndex}`} className="flex items-start gap-2 text-sm text-white">
126+
<IconCheck size={16} className="mt-0.5 shrink-0 text-brand" />
127+
<span>{feature.text}</span>
128+
</li>
129+
))}
130+
{missingFeatures.map((feature, featureIndex) => (
131+
<li key={feature.id ?? `missing-feature-${featureIndex}`} className="flex items-start gap-2 text-sm text-tertiary">
132+
<IconX size={16} className="mt-0.5 shrink-0" />
133+
<span>{feature.text}</span>
134+
</li>
135+
))}
136+
</ul>
137+
)}
138+
139+
{buttonLabel && buttonUrl && (
140+
<div className="mt-auto pt-6">
141+
<HapticButtonLink
142+
href={buttonUrl}
143+
variant={pricingPackage.content?.button?.variant ?? "normal"}
144+
className={cn("w-full text-sm!", pricingPackage.content?.button?.variant === "filled" && "bg-white/80! text-primary! hover:bg-white!")}
145+
>
146+
{buttonLabel}
147+
</HapticButtonLink>
148+
</div>
149+
)}
150+
</StaggerItem>
151+
)
152+
})}
153+
</StaggerContainer>
154+
</Card>
155+
</Section>
156+
)
157+
}

0 commit comments

Comments
 (0)