From 123e1059878c54a4c5b89859f90e3d07b1f0d697 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Thu, 23 Oct 2025 01:06:28 +0200 Subject: [PATCH] trial choice --- .../TrialChoiceTemplate.stories.tsx | 10 ++- .../TrialChoiceTemplate.tsx | 73 +++++++++++++-- .../ui/TrialOption/TrialOption.stories.tsx | 54 +++++++++++ src/components/ui/TrialOption/TrialOption.tsx | 89 +++++++++++++++++++ src/components/ui/TrialOption/index.ts | 2 + .../BottomActionButton/BottomActionButton.tsx | 28 +++++- .../TrialOptionsGrid.stories.tsx | 73 +++++++++++++++ .../TrialOptionsGrid/TrialOptionsGrid.tsx | 67 ++++++++++++++ .../widgets/TrialOptionsGrid/index.ts | 2 + src/entities/session/funnel/types.ts | 2 + .../builder/state/defaults/trialChoice.ts | 2 +- src/lib/funnel/mappers.tsx | 2 + src/lib/funnel/screenRenderer.tsx | 2 + src/lib/funnel/templateHelpers.ts | 1 + 14 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 src/components/ui/TrialOption/TrialOption.stories.tsx create mode 100644 src/components/ui/TrialOption/TrialOption.tsx create mode 100644 src/components/ui/TrialOption/index.ts create mode 100644 src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.stories.tsx create mode 100644 src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx create mode 100644 src/components/widgets/TrialOptionsGrid/index.ts diff --git a/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.stories.tsx b/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.stories.tsx index 64dbd0f..32156a4 100644 --- a/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.stories.tsx +++ b/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.stories.tsx @@ -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 = { title: "Funnel Templates/TrialChoiceTemplate", @@ -18,6 +25,7 @@ const meta: Meta = { layout: "fullscreen", }, args: { + funnel: mockFunnel, screen: defaultScreen, onContinue: fn(), canGoBack: true, diff --git a/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx b/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx index 7f9d337..2ffce10 100644 --- a/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx +++ b/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx @@ -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(null); + const [showError, setShowError] = useState(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 {null}; + return ( + + { + setSelectedId(id); + if (showError) setShowError(false); + }} + /> + + ); } diff --git a/src/components/ui/TrialOption/TrialOption.stories.tsx b/src/components/ui/TrialOption/TrialOption.stories.tsx new file mode 100644 index 0000000..8c31cb4 --- /dev/null +++ b/src/components/ui/TrialOption/TrialOption.stories.tsx @@ -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 = { + 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; + +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: {}, +}; diff --git a/src/components/ui/TrialOption/TrialOption.tsx b/src/components/ui/TrialOption/TrialOption.tsx new file mode 100644 index 0000000..c2adae7 --- /dev/null +++ b/src/components/ui/TrialOption/TrialOption.tsx @@ -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 ( + + ); +} diff --git a/src/components/ui/TrialOption/index.ts b/src/components/ui/TrialOption/index.ts new file mode 100644 index 0000000..a142213 --- /dev/null +++ b/src/components/ui/TrialOption/index.ts @@ -0,0 +1,2 @@ +export { TrialOption } from "./TrialOption"; +export type { TrialOptionProps, TrialOptionState } from "./TrialOption"; diff --git a/src/components/widgets/BottomActionButton/BottomActionButton.tsx b/src/components/widgets/BottomActionButton/BottomActionButton.tsx index b055df3..8aced14 100644 --- a/src/components/widgets/BottomActionButton/BottomActionButton.tsx +++ b/src/components/widgets/BottomActionButton/BottomActionButton.tsx @@ -23,6 +23,8 @@ export interface BottomActionButtonProps extends React.ComponentProps<"div"> { syncCssVar?: boolean; gradientBlurProps?: React.ComponentProps; + /** Вызывается при клике на отключенную кнопку действия */ + onDisabledClick?: () => void; } const BottomActionButton = forwardRef( @@ -35,6 +37,7 @@ const BottomActionButton = forwardRef( className, syncCssVar = true, gradientBlurProps, + onDisabledClick, ...props }, ref @@ -95,7 +98,30 @@ const BottomActionButton = forwardRef( {childrenAboveButton} )} - {hasButton ? : null} + {hasButton ? ( +
+ {/* Invisible overlay to capture clicks when button is disabled */} + {actionButtonProps?.disabled && onDisabledClick ? ( +
{ + e.preventDefault(); + onDisabledClick(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onDisabledClick(); + } + }} + tabIndex={0} + /> + ) : null} + +
+ ) : null} {childrenUnderButton}
diff --git a/src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.stories.tsx b/src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.stories.tsx new file mode 100644 index 0000000..0341af1 --- /dev/null +++ b/src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.stories.tsx @@ -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 = { + 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; + +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 }, + ], + }, +}; diff --git a/src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx b/src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx new file mode 100644 index 0000000..ff9b4ae --- /dev/null +++ b/src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx @@ -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 & { 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 ( +
+ {items.map((item, index) => { + const isLast = index === count - 1; + const shouldSpanFull = + count === 1 || (count % 2 !== 0 && isLast); + + return ( +
+ { + item.onClick?.(e); + if (!item.onClick && onItemClick) onItemClick(item.id); + }} + className={cn("w-full", item.className)} + /> +
+ ); + })} +
+ ); +} diff --git a/src/components/widgets/TrialOptionsGrid/index.ts b/src/components/widgets/TrialOptionsGrid/index.ts new file mode 100644 index 0000000..851de5e --- /dev/null +++ b/src/components/widgets/TrialOptionsGrid/index.ts @@ -0,0 +1,2 @@ +export { TrialOptionsGrid } from "./TrialOptionsGrid"; +export type { TrialOptionsGridProps, TrialOptionsItem } from "./TrialOptionsGrid"; diff --git a/src/entities/session/funnel/types.ts b/src/entities/session/funnel/types.ts index 067b4af..6a3e42c 100644 --- a/src/entities/session/funnel/types.ts +++ b/src/entities/session/funnel/types.ts @@ -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({ diff --git a/src/lib/admin/builder/state/defaults/trialChoice.ts b/src/lib/admin/builder/state/defaults/trialChoice.ts index f7018bf..6a8c39c 100644 --- a/src/lib/admin/builder/state/defaults/trialChoice.ts +++ b/src/lib/admin/builder/state/defaults/trialChoice.ts @@ -16,7 +16,7 @@ export function buildTrialChoiceDefaults(id: string): BuilderScreen { }), title: buildDefaultTitle({ show: false, - text: undefined, + text: "Trial Choice", }), subtitle: buildDefaultSubtitle({ show: false, diff --git a/src/lib/funnel/mappers.tsx b/src/lib/funnel/mappers.tsx index e5ff26b..bcb94bc 100644 --- a/src/lib/funnel/mappers.tsx +++ b/src/lib/funnel/mappers.tsx @@ -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, }; } diff --git a/src/lib/funnel/screenRenderer.tsx b/src/lib/funnel/screenRenderer.tsx index ed5be3f..9a85806 100644 --- a/src/lib/funnel/screenRenderer.tsx +++ b/src/lib/funnel/screenRenderer.tsx @@ -356,6 +356,7 @@ const TEMPLATE_REGISTRY: Record< ); }, trialChoice: ({ + funnel, screen, onContinue, canGoBack, @@ -368,6 +369,7 @@ const TEMPLATE_REGISTRY: Record< return ( void; + onDisabledClick?: () => void; } /**