trial choice
This commit is contained in:
parent
adfe8830d4
commit
123e105987
@ -2,13 +2,20 @@ import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { TrialChoiceTemplate } from "./TrialChoiceTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildTrialChoiceDefaults } from "@/lib/admin/builder/state/defaults/trialChoice";
|
||||
import type { TrialChoiceScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { TrialChoiceScreenDefinition, FunnelDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Используем дефолтные значения из builder, чтобы сторибук совпадал с админкой
|
||||
const defaultScreen = buildTrialChoiceDefaults(
|
||||
"trial-choice-story"
|
||||
) as TrialChoiceScreenDefinition;
|
||||
|
||||
// Minimal funnel mock for storybook (used to derive placement id in hooks)
|
||||
const mockFunnel: FunnelDefinition = {
|
||||
meta: { id: "storybook-funnel" },
|
||||
defaultTexts: { nextButton: "Next", continueButton: "Continue" },
|
||||
screens: [],
|
||||
};
|
||||
|
||||
/** TrialChoiceTemplate — базовый экран с выбором пробного периода */
|
||||
const meta: Meta<typeof TrialChoiceTemplate> = {
|
||||
title: "Funnel Templates/TrialChoiceTemplate",
|
||||
@ -18,6 +25,7 @@ const meta: Meta<typeof TrialChoiceTemplate> = {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
funnel: mockFunnel,
|
||||
screen: defaultScreen,
|
||||
onContinue: fn(),
|
||||
canGoBack: true,
|
||||
|
||||
@ -4,11 +4,18 @@ import type {
|
||||
TrialChoiceScreenDefinition,
|
||||
DefaultTexts,
|
||||
FunnelAnswers,
|
||||
FunnelDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "@/components/funnel/templates/layouts/TemplateLayout";
|
||||
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
|
||||
import { TrialOptionsGrid } from "@/components/widgets/TrialOptionsGrid";
|
||||
import { useState, useMemo } from "react";
|
||||
import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
|
||||
import { Currency } from "@/shared/types";
|
||||
import { getFormattedPrice } from "@/shared/utils/price";
|
||||
|
||||
interface TrialChoiceTemplateProps {
|
||||
funnel: FunnelDefinition;
|
||||
screen: TrialChoiceScreenDefinition;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
@ -18,10 +25,9 @@ interface TrialChoiceTemplateProps {
|
||||
answers: FunnelAnswers;
|
||||
}
|
||||
|
||||
export function TrialChoiceTemplate(
|
||||
props: TrialChoiceTemplateProps
|
||||
) {
|
||||
export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
||||
const {
|
||||
funnel,
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
@ -29,6 +35,49 @@ export function TrialChoiceTemplate(
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
} = props;
|
||||
// Load trial variants from placement API (same source as TrialPayment)
|
||||
const paymentId = "main"; // can be made configurable later
|
||||
const { placement } = usePaymentPlacement({ funnel, paymentId });
|
||||
|
||||
// Local selection state
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [showError, setShowError] = useState<boolean>(false);
|
||||
|
||||
// Map variant -> TrialOption items with server-provided English titles and accent (last as fallback)
|
||||
const items = useMemo(() => {
|
||||
const currency = placement?.currency || Currency.USD;
|
||||
const variants = placement?.variants ?? [];
|
||||
|
||||
const TITLES = ["Basic", "Standard", "Popular", "Premium"] as const;
|
||||
const list = (variants.length ? variants : new Array(4).fill(null)).map(
|
||||
(v, index) => {
|
||||
const id = v?.id ?? `stub-${index}`;
|
||||
const trialPrice = v?.trialPrice ?? (index + 1) * 100; // cents stub
|
||||
const value = getFormattedPrice(trialPrice, currency);
|
||||
const title = v?.title ?? TITLES[index] ?? TITLES[TITLES.length - 1];
|
||||
|
||||
return { id, value, title };
|
||||
}
|
||||
);
|
||||
|
||||
const lastIndex = Math.max(0, list.length - 1);
|
||||
return list.map((it, index) => {
|
||||
const variant = variants[index];
|
||||
const accentFromApi = variant?.accent === true;
|
||||
const state =
|
||||
selectedId === it.id
|
||||
? "selected"
|
||||
: accentFromApi || index === lastIndex
|
||||
? "accent"
|
||||
: "default";
|
||||
// When user clicks disabled button, we flag all options with error until a selection is made
|
||||
const error = showError && selectedId == null;
|
||||
return { ...it, state, error } as const;
|
||||
});
|
||||
}, [placement, selectedId, showError]);
|
||||
|
||||
const isActionDisabled = selectedId == null;
|
||||
|
||||
const layoutProps = createTemplateLayoutProps(
|
||||
screen,
|
||||
{ canGoBack, onBack },
|
||||
@ -40,11 +89,25 @@ export function TrialChoiceTemplate(
|
||||
screen.bottomActionButton?.text ||
|
||||
defaultTexts?.continueButton ||
|
||||
"Next",
|
||||
disabled: false,
|
||||
disabled: isActionDisabled,
|
||||
onClick: onContinue,
|
||||
onDisabledClick: () => {
|
||||
if (isActionDisabled) setShowError(true);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return <TemplateLayout {...layoutProps}>{null}</TemplateLayout>;
|
||||
return (
|
||||
<TemplateLayout {...layoutProps}>
|
||||
<TrialOptionsGrid
|
||||
className="w-full"
|
||||
items={items}
|
||||
onItemClick={(id) => {
|
||||
setSelectedId(id);
|
||||
if (showError) setShowError(false);
|
||||
}}
|
||||
/>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/ui/TrialOption/TrialOption.stories.tsx
Normal file
54
src/components/ui/TrialOption/TrialOption.stories.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { TrialOption } from "./TrialOption";
|
||||
import type { TrialOptionProps } from "./TrialOption";
|
||||
|
||||
/** Reusable TrialOption component matching Figma states */
|
||||
const meta: Meta<typeof TrialOption> = {
|
||||
title: "UI/TrialOption",
|
||||
component: TrialOption,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
value: "$1",
|
||||
title: "Basic",
|
||||
state: "default",
|
||||
error: false,
|
||||
onClick: fn(),
|
||||
} satisfies TrialOptionProps,
|
||||
argTypes: {
|
||||
state: {
|
||||
control: { type: "select" },
|
||||
options: ["default", "selected", "accent"],
|
||||
},
|
||||
error: { control: { type: "boolean" } },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: { state: "selected" },
|
||||
};
|
||||
|
||||
export const Accent: Story = {
|
||||
args: { state: "accent" },
|
||||
};
|
||||
|
||||
export const ErrorDefault: Story = {
|
||||
args: { state: "default", error: true },
|
||||
};
|
||||
|
||||
export const ErrorAccent: Story = {
|
||||
args: { state: "accent", error: true },
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
args: {},
|
||||
};
|
||||
89
src/components/ui/TrialOption/TrialOption.tsx
Normal file
89
src/components/ui/TrialOption/TrialOption.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TrialOptionState = "default" | "selected" | "accent";
|
||||
|
||||
export interface TrialOptionProps extends React.ComponentProps<"button"> {
|
||||
value?: string;
|
||||
title?: string;
|
||||
state?: TrialOptionState;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function TrialOption({
|
||||
className,
|
||||
value = "$1",
|
||||
title = "Basic",
|
||||
state = "default",
|
||||
error = false,
|
||||
...props
|
||||
}: TrialOptionProps) {
|
||||
const isSelected = state === "selected";
|
||||
const isAccent = state === "accent";
|
||||
// Error must apply only to default and accent (not to selected)
|
||||
const isError = error && !isSelected;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
className={cn(
|
||||
// Layout (flex to allow w-full expansion inside grid cells)
|
||||
"flex w-full select-none items-center justify-center p-[14px] gap-1 rounded-[12px] transition-all duration-150",
|
||||
// Focus ring (does not affect layout)
|
||||
"outline-none focus-visible:ring-[3px] focus-visible:ring-[#3A70B2]/30",
|
||||
"active:scale-[0.99]",
|
||||
// Backgrounds
|
||||
!isSelected && "bg-[#F9FAFB]",
|
||||
// Shadows
|
||||
!isSelected
|
||||
? "shadow-[0_2px_4px_rgba(0,0,0,0.10),_0_4px_6px_rgba(0,0,0,0.10)] hover:shadow-[0_4px_6px_rgba(0,0,0,0.10),_0_8px_12px_rgba(0,0,0,0.10)]"
|
||||
: "shadow-[0_4px_6px_rgba(0,0,0,0.10),_0_10px_15px_rgba(0,0,0,0.10)] hover:shadow-[0_6px_10px_rgba(0,0,0,0.10),_0_12px_18px_rgba(0,0,0,0.10)]",
|
||||
// Borders per state
|
||||
// Keep a constant 2px border to avoid layout shifts
|
||||
// Default state (2px #D1D5DB) unless error overrides
|
||||
state === "default" && !isError && "border-[2px] border-[#D1D5DB] hover:border-[#9CA3AF]",
|
||||
// Accent state: simulate 4px using inset ring (no layout shift, stays inside)
|
||||
isAccent && !isError && "border-[2px] border-[#9CA3AF] ring-inset ring-[2px] ring-[#9CA3AF] hover:border-[#6B7280] hover:ring-[#6B7280]",
|
||||
// Selected state (2px #3A70B2)
|
||||
isSelected && "border-[2px] border-[#3A70B2]",
|
||||
// Error overrides: default -> 2px red; accent -> 2px red + ring 2px red
|
||||
isError && !isAccent && "border-[2px] border-[#FF0D11]",
|
||||
isError && isAccent && "border-[2px] border-[#FF0D11] ring-inset ring-[2px] ring-[#FF0D11]",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
// background: var(--TrialGradient, linear-gradient(90deg, #5393DE 0%, #3A70B2 100%))
|
||||
background:
|
||||
"var(--TrialGradient, linear-gradient(90deg, #5393DE 0%, #3A70B2 100%))",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-inter font-bold text-[20px] leading-[28px]",
|
||||
// Default/Accent normal -> black, Selected -> #E5E7EB, Error -> #FF0D11
|
||||
isSelected ? "text-[#E5E7EB]" : isError ? "text-[#FF0D11]" : "text-[#000000]"
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"font-inter font-semibold text-[14px] leading-[20px]",
|
||||
// Default/Accent normal -> #6B7280, Selected -> #E5E7EB, Error -> #FF0D11
|
||||
isSelected ? "text-[#E5E7EB]" : isError ? "text-[#FF0D11]" : "text-[#6B7280]"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
2
src/components/ui/TrialOption/index.ts
Normal file
2
src/components/ui/TrialOption/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TrialOption } from "./TrialOption";
|
||||
export type { TrialOptionProps, TrialOptionState } from "./TrialOption";
|
||||
@ -23,6 +23,8 @@ export interface BottomActionButtonProps extends React.ComponentProps<"div"> {
|
||||
syncCssVar?: boolean;
|
||||
|
||||
gradientBlurProps?: React.ComponentProps<typeof GradientBlur>;
|
||||
/** Вызывается при клике на отключенную кнопку действия */
|
||||
onDisabledClick?: () => void;
|
||||
}
|
||||
|
||||
const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
|
||||
@ -35,6 +37,7 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
|
||||
className,
|
||||
syncCssVar = true,
|
||||
gradientBlurProps,
|
||||
onDisabledClick,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@ -95,7 +98,30 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
|
||||
{childrenAboveButton}
|
||||
</div>
|
||||
)}
|
||||
{hasButton ? <ActionButton {...actionButtonProps} /> : null}
|
||||
{hasButton ? (
|
||||
<div className="relative">
|
||||
{/* Invisible overlay to capture clicks when button is disabled */}
|
||||
{actionButtonProps?.disabled && onDisabledClick ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 cursor-pointer"
|
||||
role="button"
|
||||
aria-disabled="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDisabledClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onDisabledClick();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
/>
|
||||
) : null}
|
||||
<ActionButton {...actionButtonProps} />
|
||||
</div>
|
||||
) : null}
|
||||
{childrenUnderButton}
|
||||
</GradientBlur>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { TrialOptionsGrid } from "./TrialOptionsGrid";
|
||||
import type { TrialOptionsItem } from "./TrialOptionsGrid";
|
||||
|
||||
const buildItems = (n: number): TrialOptionsItem[] =>
|
||||
Array.from({ length: n }).map((_, i) => ({
|
||||
id: `opt-${i + 1}`,
|
||||
value: `$${i + 1}`,
|
||||
title: i % 2 === 0 ? "Basic" : "Pro",
|
||||
state: i === 1 ? "selected" : i === 2 ? "accent" : "default",
|
||||
error: false,
|
||||
onClick: fn(),
|
||||
}));
|
||||
|
||||
const meta: Meta<typeof TrialOptionsGrid> = {
|
||||
title: "Widgets/TrialOptionsGrid",
|
||||
component: TrialOptionsGrid,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
items: buildItems(2),
|
||||
onItemClick: fn(),
|
||||
className: "w-[360px]",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const One: Story = {
|
||||
args: {
|
||||
items: buildItems(1),
|
||||
},
|
||||
};
|
||||
|
||||
export const Two: Story = {
|
||||
args: {
|
||||
items: buildItems(2),
|
||||
},
|
||||
};
|
||||
|
||||
export const Three: Story = {
|
||||
args: {
|
||||
items: buildItems(3),
|
||||
},
|
||||
};
|
||||
|
||||
export const Four: Story = {
|
||||
args: {
|
||||
items: buildItems(4),
|
||||
},
|
||||
};
|
||||
|
||||
export const Five: Story = {
|
||||
args: {
|
||||
items: buildItems(5),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithErrors: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ id: "a", value: "$1", title: "Basic", state: "default", error: true },
|
||||
{ id: "b", value: "$2", title: "Pro", state: "selected", error: false },
|
||||
{ id: "c", value: "$3", title: "Plus", state: "accent", error: true },
|
||||
{ id: "d", value: "$4", title: "Max", state: "default", error: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
67
src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx
Normal file
67
src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TrialOption } from "@/components/ui/TrialOption";
|
||||
import type { TrialOptionProps } from "@/components/ui/TrialOption";
|
||||
|
||||
export type TrialOptionsItem = (Omit<TrialOptionProps, "children"> & { id: string });
|
||||
|
||||
export interface TrialOptionsGridProps extends React.ComponentProps<"div"> {
|
||||
items: TrialOptionsItem[];
|
||||
/** Fallback click handler if item doesn't provide onClick */
|
||||
onItemClick?: (id: string) => void;
|
||||
/** Extra class applied to each option wrapper */
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrialOptionsGrid — lays out TrialOption components in two columns with 12px gap.
|
||||
* - 1 item → full width
|
||||
* - 2 items → two columns
|
||||
* - Odd count (>=3) → last item spans full width
|
||||
* - Even count → pairs in two columns
|
||||
*/
|
||||
export function TrialOptionsGrid({
|
||||
className,
|
||||
items,
|
||||
onItemClick,
|
||||
itemClassName,
|
||||
...props
|
||||
}: TrialOptionsGridProps) {
|
||||
const count = items.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
// 2-column grid, 12px gap
|
||||
"grid grid-cols-2 gap-3 w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === count - 1;
|
||||
const shouldSpanFull =
|
||||
count === 1 || (count % 2 !== 0 && isLast);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(shouldSpanFull ? "col-span-2" : "col-span-1", itemClassName)}
|
||||
style={shouldSpanFull ? { gridColumn: "1 / -1" } : undefined}
|
||||
>
|
||||
<TrialOption
|
||||
{...item}
|
||||
onClick={(e) => {
|
||||
item.onClick?.(e);
|
||||
if (!item.onClick && onItemClick) onItemClick(item.id);
|
||||
}}
|
||||
className={cn("w-full", item.className)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/components/widgets/TrialOptionsGrid/index.ts
Normal file
2
src/components/widgets/TrialOptionsGrid/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TrialOptionsGrid } from "./TrialOptionsGrid";
|
||||
export type { TrialOptionsGridProps, TrialOptionsItem } from "./TrialOptionsGrid";
|
||||
@ -21,6 +21,8 @@ export const FunnelPaymentVariantSchema = z.object({
|
||||
price: z.number(),
|
||||
oldPrice: z.number().optional(),
|
||||
trialPrice: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
accent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const FunnelPaymentPlacementSchema = z.object({
|
||||
|
||||
@ -16,7 +16,7 @@ export function buildTrialChoiceDefaults(id: string): BuilderScreen {
|
||||
}),
|
||||
title: buildDefaultTitle({
|
||||
show: false,
|
||||
text: undefined,
|
||||
text: "Trial Choice",
|
||||
}),
|
||||
subtitle: buildDefaultSubtitle({
|
||||
show: false,
|
||||
|
||||
@ -150,6 +150,7 @@ interface BuildActionButtonOptions {
|
||||
defaultText?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
onDisabledClick?: () => void;
|
||||
}
|
||||
|
||||
export function buildActionButtonProps(
|
||||
@ -195,6 +196,7 @@ export function buildBottomActionButtonProps(
|
||||
return {
|
||||
actionButtonProps,
|
||||
showGradientBlur: buttonDef?.showGradientBlur ?? true, // Градиент по умолчанию включен
|
||||
onDisabledClick: options.onDisabledClick,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -356,6 +356,7 @@ const TEMPLATE_REGISTRY: Record<
|
||||
);
|
||||
},
|
||||
trialChoice: ({
|
||||
funnel,
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
@ -368,6 +369,7 @@ const TEMPLATE_REGISTRY: Record<
|
||||
|
||||
return (
|
||||
<TrialChoiceTemplate
|
||||
funnel={funnel}
|
||||
screen={trialChoiceScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
|
||||
@ -24,6 +24,7 @@ export interface ActionButtonConfig {
|
||||
defaultText: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
onDisabledClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user