trial choice

This commit is contained in:
dev.daminik00 2025-10-23 01:06:28 +02:00
parent adfe8830d4
commit 123e105987
14 changed files with 399 additions and 8 deletions

View File

@ -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,

View File

@ -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>
);
}

View 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: {},
};

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { TrialOption } from "./TrialOption";
export type { TrialOptionProps, TrialOptionState } from "./TrialOption";

View File

@ -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>

View File

@ -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 },
],
},
};

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { TrialOptionsGrid } from "./TrialOptionsGrid";
export type { TrialOptionsGridProps, TrialOptionsItem } from "./TrialOptionsGrid";

View File

@ -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({

View File

@ -16,7 +16,7 @@ export function buildTrialChoiceDefaults(id: string): BuilderScreen {
}),
title: buildDefaultTitle({
show: false,
text: undefined,
text: "Trial Choice",
}),
subtitle: buildDefaultSubtitle({
show: false,

View File

@ -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,
};
}

View File

@ -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}

View File

@ -24,6 +24,7 @@ export interface ActionButtonConfig {
defaultText: string;
disabled: boolean;
onClick: () => void;
onDisabledClick?: () => void;
}
/**