Skip to content

Commit 06184d6

Browse files
committed
feat: add pricing widget to product landing page
See: FE-123
1 parent 2dcd539 commit 06184d6

File tree

13 files changed

+597
-69
lines changed

13 files changed

+597
-69
lines changed

frontend/i18n/locales/en.json

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,7 +1131,11 @@
11311131
},
11321132
"products": {
11331133
"api": "API",
1134+
"business": "Business",
1135+
"enterprise": "Enterprise",
11341136
"explorer": "Explorer",
1137+
"free": "Free",
1138+
"hobbyist": "Hobbyist",
11351139
"landing_page": {
11361140
"api": {
11371141
"action": {
@@ -1174,10 +1178,20 @@
11741178
},
11751179
"api_pricing_plan": {
11761180
"action": {
1177-
"compare_plans": "Compare Plans"
1181+
"compare_plans": "Compare Plans",
1182+
"contact_sales": "Contact Sales"
11781183
},
1184+
"billing_cycle": "Billing Cycle",
1185+
"details": {
1186+
"contact_sales_explainer": "This is our Premium offering please contact our sales department to set your requirements",
1187+
"cost_per_month": "Cost per Month",
1188+
"cost_per_year": "Cost per Year",
1189+
"requests_per_second": "Requests per Second"
1190+
},
1191+
"monthly": "Monthly",
11791192
"subtitle": "Start for free or choose a plan that suits you best",
1180-
"title": "API Pricing Plan"
1193+
"title": "API Pricing Plan",
1194+
"yearly": "Yearly"
11811195
}
11821196
},
11831197
"description": "Power your apps with unified blockchain data—access real-time and historical insights across execution and consensus layers.",
@@ -1258,6 +1272,7 @@
12581272
"title": "Trusted By"
12591273
}
12601274
},
1275+
"scale": "Scale",
12611276
"staking_hub": "StakingHub"
12621277
},
12631278
"search_bar": {

frontend/layers/base/app/components/BaseButton.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const {
1010
{
1111
full?: boolean,
1212
leadingIcon?: IconName,
13-
size?: 'md' | 'xl',
13+
size?: 'lg' | 'md' | 'xl',
1414
trailingIcon?: IconName,
1515
variant?: 'branded' | 'primary' | 'quaternary' | 'secondary',
1616
}
@@ -33,8 +33,9 @@ const {
3333
variant === 'branded' && 'from-brand-500 to-brand-700 text-white hover:opacity-90',
3434
variant === 'quaternary' && 'text-black dark:text-white hover:opacity-95',
3535
size === 'md' && 'text-sm py-md px-sm',
36+
size === 'lg' && 'text-md py-lg px-2xl',
3637
size === 'xl' && 'text-md py-xl px-3xl',
37-
full ? 'w-full' : 'w-fit',
38+
full ? 'w-full' : 'min-w-fit',
3839
]"
3940
>
4041
<LazyBaseIcon
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<script setup lang="ts" generic="T extends SwitchValue[]">
2+
export type SwitchValue = {
3+
key: string,
4+
label: string,
5+
}
6+
const { values } = defineProps<{
7+
classList?: {
8+
thumb?: string,
9+
track?: string,
10+
trackItem?: string,
11+
},
12+
screenreaderTitle: TranslationInput,
13+
values: T,
14+
}>()
15+
const idFirstValue = useId()
16+
const idSecondValue = useId()
17+
const name = useId()
18+
const modelValue = defineModel<typeof values[number]['key']>()
19+
const { t: $t } = useTranslation()
20+
const thumb = useTemplateRef('thumb')
21+
const track = useTemplateRef('track')
22+
23+
const moveThumb = () => {
24+
const activeTrackItem = track.value?.querySelector(':has(input[type="radio"]:checked)')
25+
if (!activeTrackItem) return
26+
if (!thumb.value) return
27+
28+
const { x: initialX } = thumb.value.getBoundingClientRect()
29+
const { x } = activeTrackItem.getBoundingClientRect()
30+
31+
// this should have rather have been done via view transition api
32+
// but it currently lacks `firefox support`
33+
// and also there was a flickering issue which might be solved via
34+
const animation = thumb.value.animate([ {
35+
transform: `translateX(${x - initialX}px)`,
36+
} ],
37+
{ duration: 180 },
38+
)
39+
return animation.finished.then(() => {
40+
activeTrackItem.appendChild(thumb.value!)
41+
})
42+
}
43+
watch(modelValue, () => {
44+
moveThumb()
45+
})
46+
</script>
47+
48+
<template>
49+
<fieldset class="isolate">
50+
<legend class="sr-only">
51+
{{ $t(screenreaderTitle as string) }}
52+
</legend>
53+
<div
54+
ref="track"
55+
:class="classList?.track"
56+
v-bind="$attrs"
57+
>
58+
<label
59+
:for="idFirstValue"
60+
:class="classList?.trackItem"
61+
class="relative"
62+
>
63+
<span
64+
class="relative z-10"
65+
>
66+
{{ values[0]?.label }}
67+
</span>
68+
<input
69+
:id="idFirstValue"
70+
v-model="modelValue"
71+
:value="values[0]?.key"
72+
type="radio"
73+
class="appearance-none"
74+
:name
75+
>
76+
<span
77+
ref="thumb"
78+
class="absolute inset-[0] z-0"
79+
aria-hidden="true"
80+
:class="classList?.thumb"
81+
/>
82+
</label>
83+
<label
84+
class="relative"
85+
:class="classList?.trackItem"
86+
:for="idSecondValue"
87+
>
88+
<span
89+
class="relative z-10"
90+
>
91+
{{ values[1]?.label }}
92+
</span>
93+
<input
94+
:id="idSecondValue"
95+
v-model="modelValue"
96+
:value="values[1]?.key"
97+
class="appearance-none"
98+
type="radio"
99+
:name
100+
>
101+
</label>
102+
</div>
103+
</fieldset>
104+
</template>
105+
106+
<style scoped></style>
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<script setup lang="ts" generic="T extends Tab[]">
2+
export type Tab = {
3+
key: string,
4+
label: TranslationInput,
5+
}
6+
const props = defineProps<{
7+
classList?: {
8+
activeTab?: string,
9+
activeTabIndicator?: string,
10+
tab?: string,
11+
tablist?: string,
12+
},
13+
defaultSelectedTab?: number,
14+
hasFocusableElement?: boolean,
15+
screenreaderTitle: TranslationInput,
16+
tabs: T,
17+
}>()
18+
19+
const { t: $t } = useTranslation()
20+
21+
// otherwise syntax highlighting gets confused by typecasting 🫤
22+
const ariaLabel = computed(() => $t(props.screenreaderTitle as string))
23+
24+
const idTab = useId()
25+
const idTabPanel = useId()
26+
27+
const selectedTab = ref(props.defaultSelectedTab ?? 0)
28+
29+
const activeTabIndicators = useTemplateRef('activeTabIndicator')
30+
const tabButtons = useTemplateRef('tab')
31+
32+
const moveIndicator = async (index: number) => {
33+
const activeTabIndicator = activeTabIndicators.value?.[0]
34+
const tabButton = tabButtons.value?.[index]
35+
if (!activeTabIndicator) return
36+
if (!tabButton) return
37+
38+
const { x: initialX } = activeTabIndicator.getBoundingClientRect()
39+
const {
40+
width,
41+
x,
42+
} = tabButton.getBoundingClientRect()
43+
// this should have rather have been done via view transition api
44+
// but it currently lacks `firefox support`
45+
// and also there was a flickering issue with the activeTabIndicators height
46+
const animation = activeTabIndicator.animate([ {
47+
transform: `translateX(${x - initialX}px)`,
48+
width: `${width}px`,
49+
} ],
50+
{
51+
duration: 180,
52+
})
53+
return await animation.finished.then(() => {
54+
tabButton.appendChild(activeTabIndicator)
55+
})
56+
}
57+
const addActiveTabClassList = (index: number) => {
58+
const tabButton = tabButtons.value?.[index]
59+
if (!tabButton) return
60+
tabButton.classList.add(...(props.classList?.activeTab?.split(' ') ?? []))
61+
}
62+
const removeActiveTabClassList = (index: number) => {
63+
const tabButton = tabButtons.value?.[index]
64+
if (!tabButton) return
65+
tabButton.classList.remove(...(props.classList?.activeTab?.split(' ') ?? []))
66+
}
67+
68+
const handleRight = () => {
69+
selectedTab.value = (selectedTab.value + 1) % (props.tabs.length)
70+
}
71+
const handleLeft = () => {
72+
selectedTab.value = (selectedTab.value - 1 + (props.tabs.length)) % (props.tabs.length)
73+
}
74+
const handleClick = async (index: number) => {
75+
selectedTab.value = index
76+
}
77+
watch(selectedTab, (newValue, oldValue) => {
78+
moveIndicator(newValue)
79+
.then(() => {
80+
removeActiveTabClassList(oldValue ?? 0)
81+
})
82+
.then(() => {
83+
addActiveTabClassList(newValue)
84+
})
85+
.then(() => {
86+
const nextTab = tabButtons.value?.[newValue]
87+
nextTab?.focus()
88+
})
89+
}, { immediate: true })
90+
</script>
91+
92+
<template>
93+
<div class="isolate">
94+
<section
95+
role="tablist"
96+
:aria-label
97+
:class="classList?.tablist"
98+
@keydown.right="handleRight"
99+
@keydown.left="handleLeft"
100+
@keydown.home.prevent="selectedTab = 0"
101+
@keydown.end.prevent="selectedTab = tabs.length - 1"
102+
>
103+
<button
104+
v-for="(tab, index) in tabs"
105+
:id="`${idTab}-${index}`"
106+
:key="tab.key"
107+
ref="tab"
108+
class="relative"
109+
:tabindex="selectedTab === index ? 0 : -1"
110+
type="button"
111+
role="tab"
112+
:aria-selected="selectedTab === index"
113+
:aria-controls="`${idTabPanel}-${index}`"
114+
:class="[classList?.tab, selectedTab === index && classList?.activeTab]"
115+
@click="handleClick(index)"
116+
>
117+
<span
118+
v-if="index === defaultSelectedTab"
119+
ref="activeTabIndicator"
120+
class="absolute inset-[0] activeTabIndicator z-0"
121+
aria-hidden="true"
122+
:class="classList?.activeTabIndicator"
123+
/>
124+
<span
125+
:style="`view-transition-name: tabText-${index};`"
126+
class="tabText relative z-10"
127+
>
128+
{{ $t(tab.label as string) }}
129+
</span>
130+
</button>
131+
</section>
132+
<section>
133+
<template
134+
v-for="(tab, index) in tabs"
135+
:key="tab.key"
136+
>
137+
<article
138+
v-if="selectedTab === index"
139+
:id="`${idTabPanel}-${index}`"
140+
tabindex="0"
141+
role="tabpanel"
142+
:aria-labelledby="`${idTab}-${index}`"
143+
>
144+
<slot :name="`tabpanel-${tab.key}`" />
145+
</article>
146+
</template>
147+
</section>
148+
</div>
149+
</template>

frontend/layers/base/app/components/BaseText.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const {
44
size = 'md',
55
} = defineProps<{
66
is?:
7+
| 'p'
78
| 'span',
89
size?:
910
| '2xl'
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Locale } from '~/i18n/i18n.config.ts'
2+
3+
export function formatFiatCurrency(
4+
value: number | string,
5+
options: {
6+
currency?: CurrencyCodeFiat,
7+
locale?: Locale,
8+
maximumFractionDigits?: number,
9+
minimumFractionDigits?: number,
10+
trailingZeroDisplay?: 'auto' | 'stripIfInteger',
11+
} = {},
12+
) {
13+
const {
14+
currency = 'EUR',
15+
locale = 'en-US',
16+
maximumFractionDigits,
17+
minimumFractionDigits,
18+
trailingZeroDisplay = 'auto',
19+
} = options
20+
21+
return new Intl.NumberFormat(locale, {
22+
currency,
23+
maximumFractionDigits,
24+
minimumFractionDigits,
25+
style: 'currency',
26+
trailingZeroDisplay,
27+
}).format(value as `${number}`)
28+
}
29+
30+
export function formatNumber(value: number | string, {
31+
hasRoundingIndication,
32+
locale = 'en-US',
33+
maximumFractionDigits,
34+
minimumFractionDigits,
35+
scaleBy = 0,
36+
signDisplay,
37+
useGrouping,
38+
}: {
39+
hasRoundingIndication?: boolean,
40+
locale?: Locale,
41+
maximumFractionDigits?: number,
42+
minimumFractionDigits?: number,
43+
scaleBy?: number,
44+
signDisplay?: Intl.NumberFormatOptions['signDisplay'],
45+
useGrouping?: Intl.NumberFormatOptions['useGrouping'],
46+
} = {}) {
47+
const [
48+
number,
49+
exponent = 0,
50+
] = `${value}`.toLowerCase().split('e')
51+
const numberInScientificNotation = `${number}e${Number(exponent) + scaleBy}`
52+
const numberInScientificNotationAbsolute = Number(numberInScientificNotation)
53+
const isPositive = numberInScientificNotationAbsolute > 0
54+
const isRoundedToZero = numberInScientificNotationAbsolute < Number(`1e-${maximumFractionDigits || 1}`)
55+
const shouldShowRoundingIndication = hasRoundingIndication && isPositive && isRoundedToZero
56+
const formattedValue = new Intl.NumberFormat(locale, {
57+
maximumFractionDigits,
58+
minimumFractionDigits,
59+
roundingMode: shouldShowRoundingIndication
60+
? 'expand'
61+
: 'halfExpand',
62+
signDisplay,
63+
useGrouping,
64+
}).format(numberInScientificNotation as `${number}`)
65+
return `${shouldShowRoundingIndication ? '<' : ''}${formattedValue}`
66+
}

0 commit comments

Comments
 (0)