Design Updates (#379)
* Enhance Marketing Pages and UI Components - Updated the marketing homepage to include an Ecosystem Showcase component, improving the presentation of the SaaS Starter Kit. - Refined various UI components, including adjustments to spacing, typography, and layout for better visual consistency. - Improved accessibility by adding aria-labels and ensuring proper semantic structure in components. - Adjusted styles across multiple components to enhance responsiveness and user experience. - Updated the pricing table and feature cards to align with the new design standards, ensuring a cohesive look and feel throughout the application. - Updated plan picker design
This commit is contained in:
committed by
GitHub
parent
d8bb7f56df
commit
54d6b4897f
@@ -24,7 +24,7 @@ export function BillingSessionStatus({
|
||||
className={
|
||||
'fade-in dark:border-border mx-auto max-w-xl rounded-xl border border-transparent p-16 xl:drop-shadow-2xl' +
|
||||
' bg-background animate-in slide-in-from-bottom-8 ease-out' +
|
||||
' zoom-in-50 dark:shadow-primary/20 duration-1000 dark:shadow-2xl'
|
||||
' zoom-in-50 dark:shadow-primary/5 duration-1000 dark:shadow-2xl'
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDate } from 'date-fns';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
import { BadgeCheck, InfoIcon, MessageCircleWarning } from 'lucide-react';
|
||||
|
||||
import { PlanSchema, type ProductSchema } from '@kit/billing';
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
@@ -58,15 +58,15 @@ export function CurrentSubscriptionCard({
|
||||
|
||||
<CardContent className={'space-y-4 border-t pt-4 text-sm'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center gap-x-3 text-lg font-semibold'}>
|
||||
<BadgeCheck
|
||||
className={
|
||||
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
|
||||
}
|
||||
/>
|
||||
<div className={'flex items-center gap-x-4 text-lg font-semibold'}>
|
||||
<span className={'flex items-center gap-x-1.5'}>
|
||||
<BadgeCheck
|
||||
className={'s-6 fill-green-500 text-white dark:text-stone-900'}
|
||||
/>
|
||||
|
||||
<span data-test={'current-plan-card-product-name'}>
|
||||
<Trans i18nKey={product.name} defaults={product.name} />
|
||||
<span data-test={'current-plan-card-product-name'}>
|
||||
<Trans i18nKey={product.name} defaults={product.name} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<CurrentPlanBadge status={subscription.status} />
|
||||
@@ -92,38 +92,7 @@ export function CurrentSubscriptionCard({
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={subscription.status === 'trialing'}>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:trialEndsOn" />
|
||||
</span>
|
||||
|
||||
<div className={'text-muted-foreground'}>
|
||||
<span>
|
||||
{subscription.trial_ends_at
|
||||
? formatDate(subscription.trial_ends_at, 'P')
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={subscription.cancel_at_period_end}>
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="billing:subscriptionCancelled" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="billing:cancelSubscriptionDate" />:
|
||||
<span className={'ml-1'}>
|
||||
{formatDate(subscription.period_ends_at ?? '', 'P')}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div className="flex flex-col gap-y-1 border-y border-dashed py-4">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
@@ -134,6 +103,54 @@ export function CurrentSubscriptionCard({
|
||||
selectedInterval={firstLineItem.interval}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<If condition={subscription.status === 'trialing'}>
|
||||
{() => (
|
||||
<Alert variant={'info'}>
|
||||
<InfoIcon className={'h-4 w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="billing:trialAlertTitle" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="billing:trialAlertDescription"
|
||||
values={{
|
||||
date: formatDate(
|
||||
subscription.trial_ends_at ?? '',
|
||||
'MMMM d, yyyy',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<If condition={subscription.cancel_at_period_end}>
|
||||
{() => (
|
||||
<Alert variant={'warning'}>
|
||||
<MessageCircleWarning className={'h-4 w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="billing:subscriptionCancelled" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="billing:cancelSubscriptionDate"
|
||||
values={{
|
||||
date: formatDate(
|
||||
subscription.period_ends_at ?? '',
|
||||
'MMMM d, yyyy',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</If>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
RadioGroupItem,
|
||||
RadioGroupItemLabel,
|
||||
} from '@kit/ui/radio-group';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
@@ -125,7 +123,7 @@ export function PlanPicker(
|
||||
className={'flex flex-col gap-y-4 lg:flex-row lg:gap-x-4 lg:gap-y-0'}
|
||||
>
|
||||
<form
|
||||
className={'flex w-full max-w-xl flex-col gap-y-8'}
|
||||
className={'flex w-full flex-col gap-y-4'}
|
||||
onSubmit={form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<If condition={intervals.length}>
|
||||
@@ -139,13 +137,9 @@ export function PlanPicker(
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'flex flex-col gap-4'}>
|
||||
<FormLabel htmlFor={'plan-picker-id'}>
|
||||
<Trans i18nKey={'common:billingInterval.label'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl id={'plan-picker-id'}>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
<div className={'flex space-x-1'}>
|
||||
{intervals.map((interval) => {
|
||||
const selected = field.value === interval;
|
||||
|
||||
@@ -154,11 +148,10 @@ export function PlanPicker(
|
||||
htmlFor={interval}
|
||||
key={interval}
|
||||
className={cn(
|
||||
'focus-within:border-primary flex items-center gap-x-2.5 rounded-md border px-2.5 py-2 transition-colors',
|
||||
'focus-within:border-primary flex items-center gap-x-2.5 rounded-md px-2.5 py-2 transition-colors',
|
||||
{
|
||||
['bg-muted border-input']: selected,
|
||||
['hover:border-input border-transparent']:
|
||||
!selected,
|
||||
['bg-muted']: selected,
|
||||
['hover:bg-muted/50']: !selected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
@@ -216,12 +209,12 @@ export function PlanPicker(
|
||||
name={'planId'}
|
||||
render={({ field }) => (
|
||||
<FormItem className={'flex flex-col gap-4'}>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:planPickerLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup value={field.value} name={field.name}>
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
name={field.name}
|
||||
className="gap-y-0.5"
|
||||
>
|
||||
{visibleProducts.map((product) => {
|
||||
const plan = product.plans.find((item) => {
|
||||
if (item.paymentType === 'one-time') {
|
||||
@@ -251,27 +244,8 @@ export function PlanPicker(
|
||||
<RadioGroupItemLabel
|
||||
selected={selected}
|
||||
key={primaryLineItem.id}
|
||||
className="rounded-md !border-transparent"
|
||||
>
|
||||
<RadioGroupItem
|
||||
data-test-plan={plan.id}
|
||||
key={plan.id + selected}
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('planId', planId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col content-center gap-y-3 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
|
||||
@@ -280,10 +254,30 @@ export function PlanPicker(
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-2'
|
||||
'flex flex-col justify-center space-y-2.5'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center space-x-2.5'}>
|
||||
<RadioGroupItem
|
||||
data-test-plan={plan.id}
|
||||
key={plan.id + selected}
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('planId', planId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="font-semibold">
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${product.id}.name`}
|
||||
@@ -363,6 +357,14 @@ export function PlanPicker(
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedPlan && selectedInterval && selectedProduct ? (
|
||||
<PlanDetails
|
||||
selectedInterval={selectedInterval}
|
||||
selectedPlan={selectedPlan}
|
||||
selectedProduct={selectedProduct}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-test="checkout-submit-button"
|
||||
@@ -385,14 +387,6 @@ export function PlanPicker(
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{selectedPlan && selectedInterval && selectedProduct ? (
|
||||
<PlanDetails
|
||||
selectedInterval={selectedInterval}
|
||||
selectedPlan={selectedPlan}
|
||||
selectedProduct={selectedProduct}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
@@ -427,78 +421,49 @@ function PlanDetails({
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
'fade-in animate-in zoom-in-95 flex w-full flex-col space-y-4 py-2 lg:px-8'
|
||||
'fade-in animate-in flex w-full flex-col space-y-2 rounded-md border p-4'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-0.5'}>
|
||||
<span className={'text-sm font-medium'}>
|
||||
<b>
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${selectedProduct.id}.name`}
|
||||
defaults={selectedProduct.name}
|
||||
/>
|
||||
</b>{' '}
|
||||
<If condition={isRecurring}>
|
||||
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
|
||||
</If>
|
||||
</span>
|
||||
|
||||
<p>
|
||||
<span className={'text-muted-foreground text-sm'}>
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${selectedProduct.id}.description`}
|
||||
defaults={selectedProduct.description}
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm font-semibold'}>{selectedProduct.name}</span>
|
||||
</div>
|
||||
|
||||
<If condition={selectedPlan.lineItems.length > 0}>
|
||||
<Separator />
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<LineItemDetails
|
||||
lineItems={selectedPlan.lineItems ?? []}
|
||||
selectedInterval={isRecurring ? selectedInterval : undefined}
|
||||
currency={selectedProduct.currency}
|
||||
/>
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<span className={'text-sm font-semibold'}>
|
||||
<Trans i18nKey={'billing:detailsLabel'} />
|
||||
</span>
|
||||
<div className={'flex flex-wrap gap-1'}>
|
||||
{selectedProduct.features.map((item) => {
|
||||
return (
|
||||
<Badge
|
||||
key={item}
|
||||
className={'flex items-center gap-x-2'}
|
||||
variant={'outline'}
|
||||
>
|
||||
<CheckCircle className={'h-3 w-3 text-green-500'} />
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={selectedPlan.lineItems ?? []}
|
||||
selectedInterval={isRecurring ? selectedInterval : undefined}
|
||||
currency={selectedProduct.currency}
|
||||
/>
|
||||
<span className={'text-muted-foreground'}>
|
||||
<Trans i18nKey={item} defaults={item} />
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<span className={'text-sm font-semibold'}>
|
||||
<Trans i18nKey={'billing:featuresLabel'} />
|
||||
</span>
|
||||
|
||||
{selectedProduct.features.map((item) => {
|
||||
return (
|
||||
<div key={item} className={'flex items-center gap-x-2 text-sm'}>
|
||||
<CheckCircle className={'h-4 text-green-500'} />
|
||||
|
||||
<span className={'text-secondary-foreground'}>
|
||||
<Trans i18nKey={item} defaults={item} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Price(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
'animate-in slide-in-from-left-4 fade-in text-xl font-semibold tracking-tight duration-500'
|
||||
}
|
||||
>
|
||||
<span className={'animate-in fade-in text-xl font-medium tracking-tight'}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ export function PricingTable({
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-start space-y-6 lg:space-y-0' +
|
||||
' justify-center lg:flex-row lg:space-x-4'
|
||||
' justify-center lg:flex-row lg:gap-x-2.5'
|
||||
}
|
||||
>
|
||||
{visibleProducts.map((product) => {
|
||||
@@ -171,17 +171,13 @@ function PricingItem(
|
||||
data-cy={'subscription-plan'}
|
||||
className={cn(
|
||||
props.className,
|
||||
`s-full relative flex flex-1 grow flex-col items-stretch justify-between self-stretch rounded-lg border px-6 py-5 lg:w-4/12 xl:max-w-[20rem]`,
|
||||
{
|
||||
['border-primary']: highlighted,
|
||||
['border-border']: !highlighted,
|
||||
},
|
||||
`s-full bg-muted/50 relative flex flex-1 grow flex-col items-stretch justify-between self-stretch rounded px-6 py-5 lg:w-4/12 xl:max-w-[20rem]`,
|
||||
)}
|
||||
>
|
||||
<If condition={props.product.badge}>
|
||||
<div className={'absolute -top-2.5 left-0 flex w-full justify-center'}>
|
||||
<Badge
|
||||
className={highlighted ? '' : 'bg-background'}
|
||||
className={highlighted ? '' : 'bg-muted'}
|
||||
variant={highlighted ? 'default' : 'outline'}
|
||||
>
|
||||
<span>
|
||||
@@ -194,12 +190,12 @@ function PricingItem(
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col gap-y-5'}>
|
||||
<div className={'flex flex-col gap-y-1'}>
|
||||
<div className={'flex flex-col gap-y-4'}>
|
||||
<div className={'flex flex-col'}>
|
||||
<div className={'flex items-center space-x-6'}>
|
||||
<b
|
||||
className={
|
||||
'text-secondary-foreground font-heading text-xl font-medium tracking-tight'
|
||||
'text-secondary-foreground font-heading text-xl font-medium tracking-tight text-orange-800'
|
||||
}
|
||||
>
|
||||
<Trans
|
||||
@@ -208,9 +204,20 @@ function PricingItem(
|
||||
/>
|
||||
</b>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(`text-muted-foreground text-base tracking-tight`)}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={props.product.description}
|
||||
defaults={props.product.description}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'mt-6 flex flex-col gap-y-1'}>
|
||||
<div className={'h-px w-full border border-dashed'} />
|
||||
|
||||
<div className={'flex flex-col gap-y-1'}>
|
||||
<Price
|
||||
isMonthlyPrice={props.alwaysDisplayMonthlyPrice}
|
||||
displayBillingPeriod={!props.plan.label}
|
||||
@@ -296,13 +303,6 @@ function PricingItem(
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<span className={cn(`text-muted-foreground text-base tracking-tight`)}>
|
||||
<Trans
|
||||
i18nKey={props.product.description}
|
||||
defaults={props.product.description}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className={'h-px w-full border border-dashed'} />
|
||||
|
||||
<div className={'flex flex-col'}>
|
||||
@@ -389,9 +389,9 @@ function ListItem({
|
||||
highlighted: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<li className={'flex items-center gap-x-2.5'}>
|
||||
<li className={'flex items-center gap-x-2'}>
|
||||
<CheckCircle
|
||||
className={cn('h-4 min-h-4 w-4 min-w-4', {
|
||||
className={cn('h-3.5 min-h-3.5 w-3.5 min-w-3.5', {
|
||||
'text-secondary-foreground': highlighted,
|
||||
'text-muted-foreground': !highlighted,
|
||||
})}
|
||||
@@ -417,7 +417,7 @@ function PlanIntervalSwitcher(
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex gap-x-1 rounded-full border p-1.5'}>
|
||||
<div className={'flex gap-x-1 rounded-full border'}>
|
||||
{props.intervals.map((plan, index) => {
|
||||
const selected = plan === props.interval;
|
||||
|
||||
@@ -434,8 +434,7 @@ function PlanIntervalSwitcher(
|
||||
return (
|
||||
<Button
|
||||
key={plan}
|
||||
size={'sm'}
|
||||
variant={selected ? 'default' : 'ghost'}
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
className={className}
|
||||
onClick={() => props.setInterval(plan)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'server-only';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user