La sección de planes: un toggle de periodo sobre una fila de planes, uno destacado. Es el primer block cuyas partes se COORDINAN de verdad (no solo se repiten como en feature-grid/split): el `Switch` escribe el periodo de facturación y cada `PlanPrice` lo lee. Esa coordinación es lo que gana un contexto compartido — la forma más fuerte de compound. `<Pricing bind:period>` + `.Header` + `.Switch` + `.Plans` + `.Plan`(featured, badge) + `.PlanName`/`.PlanDescription`/`.PlanPrice`/`.PlanFeatures`/ `.PlanFeature`/`.PlanAction`. - **Contexto reactivo** (`context.ts`): la raíz provee el periodo como getter sobre un `$bindable`; el `Switch` (un `ToggleGroup`) lo escribe, el `PlanPrice` lo lee y muestra el snippet `monthly` o `annual`. Mismo patrón que `CardGroup`. `period` es bindable por si la app quiere observarlo. - **El block NUNCA formatea moneda**: la app compone `FormatNumber` dentro de los snippets de precio (B-7). El block posee el switch, no el dinero. - `.PlanAction` fija el CTA al borde inferior de la tarjeta (`margin-block-start: auto`) para que una fila de planes alinee sus botones aunque tengan distinto nº de features; `.Plan` con `align="start"` deja los checks en columna limpia; `featured` da acento (borde primary) + elevación. Demo (`web/routes/blocks/pricing/`): full-bleed + ruta `preview`, tres planes (Pro destacado en el centro) con el toggle mensual/anual vivo. Hueco a decisión del usuario (Gaps del block): la **tabla de comparación** (features × planes) — la brecha recurrente del dossier en pricing. Es una tabla, no una fila de tarjetas: candidato a hermano `pricing-table`. Presentado, no resuelto. Verificado en navegador: el toggle cambia los TRES precios a la vez (0/29/99 → 0/23/79), tarjetas de igual alto con CTAs alineados, featured con acento, en claro/oscuro × LTR/RTL. `blocks:check` verde (5 blocks) · `svelte-check` sin errores propios. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>alpha-0.1-sec-dom
parent
cd399f7c7c
commit
594e5a5db4
@ -0,0 +1,27 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
/** The billing period the plans price against. */
|
||||
export type BillingPeriod = 'monthly' | 'annual';
|
||||
|
||||
/**
|
||||
* The reactive coordination `Pricing` shares with its parts: `Switch` writes the
|
||||
* period, `PlanPrice` reads it to show the right figure. This is the tier's first
|
||||
* COORDINATING compound — the parts talk to each other, which is exactly what
|
||||
* earns a context (vs feature-grid / feature-split, whose parts only repeat).
|
||||
*/
|
||||
export interface PricingContext {
|
||||
/** The active period — a getter, so reads track the reactive source. */
|
||||
readonly period: BillingPeriod;
|
||||
/** Set the period (the `Switch` calls this). */
|
||||
setPeriod(period: BillingPeriod): void;
|
||||
}
|
||||
|
||||
const KEY = Symbol('uix.pricing');
|
||||
|
||||
export function setPricingContext(ctx: PricingContext): void {
|
||||
setContext(KEY, ctx);
|
||||
}
|
||||
|
||||
export function getPricingContext(): PricingContext | undefined {
|
||||
return getContext<PricingContext | undefined>(KEY);
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
/** Pricing.Header — the section title + description, measure-capped and centered
|
||||
* by the parent. The app supplies a `Heading` (level 2) and a `Text`. */
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import type { PricingHeaderProps } from './types';
|
||||
|
||||
let { maxWidth = '48rem', children, ...rest }: PricingHeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<Box {maxWidth} width="100%" {...rest}>
|
||||
<Stack gap={3}>
|
||||
{@render children?.()}
|
||||
</Stack>
|
||||
</Box>
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Pricing.PlanAction — the plan's CTA, pushed to the card's bottom edge
|
||||
* (`margin-block-start: auto` in the height-100% column) so a row of plans
|
||||
* lines its buttons up regardless of feature count. The app puts a
|
||||
* `<Button block>` inside.
|
||||
*/
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import type { PricingPlanActionProps } from './types';
|
||||
|
||||
let { children, ...rest }: PricingPlanActionProps = $props();
|
||||
</script>
|
||||
|
||||
<Box width="100%" marginTop="auto" {...rest}>
|
||||
{@render children?.()}
|
||||
</Box>
|
||||
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
/** Pricing.PlanDescription — the plan's one-line tagline, muted. */
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import type { PricingPlanDescriptionProps } from './types';
|
||||
|
||||
let { children, ...rest }: PricingPlanDescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<Text size="sm" color="muted" {...rest}>
|
||||
{@render children?.()}
|
||||
</Text>
|
||||
@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
/** Pricing.PlanFeature — one checklist line: a check glyph (decorative,
|
||||
* aria-hidden) + the words. */
|
||||
import { Group } from '$uix/eidos/components/group';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import * as Icon from '$uix/eidos/components/icon';
|
||||
import type { PricingPlanFeatureProps } from './types';
|
||||
|
||||
let { color = 'primary', children, ...rest }: PricingPlanFeatureProps = $props();
|
||||
</script>
|
||||
|
||||
<Group gap={3} align="start" {...rest}>
|
||||
<Text {color} aria-hidden="true"><Icon.Check size="sm" /></Text>
|
||||
<Text size="sm">{@render children?.()}</Text>
|
||||
</Group>
|
||||
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
/** Pricing.PlanFeatures — the plan's checklist: a `Stack` of `PlanFeature`s. */
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import type { PricingPlanFeaturesProps } from './types';
|
||||
|
||||
let { gap = 3, children, ...rest }: PricingPlanFeaturesProps = $props();
|
||||
</script>
|
||||
|
||||
<Stack {gap} {...rest}>
|
||||
{@render children?.()}
|
||||
</Stack>
|
||||
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
/** Pricing.PlanName — the plan's name (a `Heading`, level 3 by default). */
|
||||
import { Heading } from '$uix/eidos/components/heading';
|
||||
import type { PricingPlanNameProps } from './types';
|
||||
|
||||
let { level = 3, children, ...rest }: PricingPlanNameProps = $props();
|
||||
</script>
|
||||
|
||||
<Heading {level} size="md" {...rest}>
|
||||
{@render children?.()}
|
||||
</Heading>
|
||||
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Pricing.PlanPrice — the figure, switched by the shared period. The app puts
|
||||
* the formatted amount in the `monthly` / `annual` snippets (composing
|
||||
* `FormatNumber` — the block never formats currency) and an optional `suffix`
|
||||
* ("/mes", "por usuario"). When there is no `annual`, the `monthly` figure
|
||||
* stands for both.
|
||||
*/
|
||||
import { Group } from '$uix/eidos/components/group';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import { getPricingContext } from './context';
|
||||
import type { PricingPlanPriceProps } from './types';
|
||||
|
||||
let { monthly, annual, suffix }: PricingPlanPriceProps = $props();
|
||||
|
||||
const ctx = getPricingContext();
|
||||
const showAnnual = $derived(ctx?.period === 'annual' && !!annual);
|
||||
</script>
|
||||
|
||||
<Group gap={2} align="baseline" justify="start">
|
||||
<Text size="xxxl" weight="bold">
|
||||
{#if showAnnual}
|
||||
{@render annual?.()}
|
||||
{:else}
|
||||
{@render monthly?.()}
|
||||
{/if}
|
||||
</Text>
|
||||
{#if suffix}
|
||||
<Text color="muted">{@render suffix()}</Text>
|
||||
{/if}
|
||||
</Group>
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Pricing.Plan — one plan, a `Card`. `featured` gives it the accent colour and
|
||||
* elevation that marks the recommended tier; a `badge` chip rides above the
|
||||
* name. The card fills the row's height (the grid stretches it), so a set of
|
||||
* plans lines up.
|
||||
*/
|
||||
import { Card } from '$uix/eidos/components/card';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import type { PricingPlanProps } from './types';
|
||||
|
||||
let { featured = false, badge, children }: PricingPlanProps = $props();
|
||||
</script>
|
||||
|
||||
<Card
|
||||
variant="outline"
|
||||
color={featured ? 'primary' : 'neutral'}
|
||||
size="lg"
|
||||
height="100%"
|
||||
style={featured ? 'box-shadow: var(--shadow-4);' : undefined}
|
||||
>
|
||||
<!-- Left-aligned so the feature checks form a clean column (centered rows make
|
||||
the checkmarks zigzag by text length). -->
|
||||
<Stack gap={5} height="100%" align="start">
|
||||
{#if badge}
|
||||
{@render badge()}
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</Stack>
|
||||
</Card>
|
||||
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
/** Pricing.Plans — the responsive row the plans lay out in. Fluid by default
|
||||
* (`AutoGrid minChildWidth`); the cards stretch to equal height. */
|
||||
import { AutoGrid } from '$uix/eidos/components/auto-grid';
|
||||
import type { PricingPlansProps } from './types';
|
||||
|
||||
let { minChildWidth = '17rem', columns, gap = 6, children }: PricingPlansProps = $props();
|
||||
</script>
|
||||
|
||||
<AutoGrid
|
||||
minChildWidth={columns ? undefined : minChildWidth}
|
||||
{columns}
|
||||
{gap}
|
||||
align="stretch"
|
||||
width="100%"
|
||||
>
|
||||
{@render children?.()}
|
||||
</AutoGrid>
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Pricing.Switch — the billing-period toggle. Controlled by the shared context:
|
||||
* it shows the active period and writes the new one, so every `PlanPrice`
|
||||
* updates in step. Composes the canon `ToggleGroup` (a segmented control).
|
||||
*/
|
||||
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
|
||||
import { getPricingContext, type BillingPeriod } from './context';
|
||||
import type { PricingSwitchProps } from './types';
|
||||
|
||||
let {
|
||||
monthlyLabel = 'Mensual',
|
||||
annualLabel = 'Anual',
|
||||
'aria-label': ariaLabel = 'Periodo de facturación'
|
||||
}: PricingSwitchProps = $props();
|
||||
|
||||
const ctx = getPricingContext();
|
||||
</script>
|
||||
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={ctx ? [ctx.period] : ['monthly']}
|
||||
onValueChange={(v) => ctx?.setPeriod((v[0] ?? 'monthly') as BillingPeriod)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<ToggleGroup.Item value="monthly">{monthlyLabel}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="annual">{annualLabel}</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Pricing — the plan-comparison section: a billing-period toggle over a row of
|
||||
* plans. The tier's first COORDINATING compound: the `Switch` writes the
|
||||
* period and each `PlanPrice` reads it, so the parts share a context (unlike
|
||||
* feature-grid / feature-split, whose parts only repeat).
|
||||
*
|
||||
* B contract: composes canon components only — no `.css`, no morfo, no
|
||||
* strings. The block never formats currency: the app composes `FormatNumber`
|
||||
* inside `PlanPrice`.
|
||||
*/
|
||||
import { Section } from '$uix/eidos/components/section';
|
||||
import { Container } from '$uix/eidos/components/container';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { setPricingContext, type BillingPeriod } from './context';
|
||||
import type { PricingProps } from './types';
|
||||
|
||||
let {
|
||||
period = $bindable('monthly'),
|
||||
container = 'lg',
|
||||
size = 'xl',
|
||||
children,
|
||||
...rest
|
||||
}: PricingProps = $props();
|
||||
|
||||
// A getter keeps `PlanPrice` reads reactive; `setPeriod` writes the bindable
|
||||
// so the app's `bind:period` sees the change too.
|
||||
setPricingContext({
|
||||
get period(): BillingPeriod {
|
||||
return period;
|
||||
},
|
||||
setPeriod: (next) => (period = next)
|
||||
});
|
||||
</script>
|
||||
|
||||
<section {...rest}>
|
||||
<Section {size}>
|
||||
<Container size={container}>
|
||||
<Stack gap={8} align="center">
|
||||
{@render children?.()}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
</section>
|
||||
@ -0,0 +1,85 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { ContainerSize } from '$uix/eidos/components/container';
|
||||
import type { SectionSize } from '$uix/eidos/components/section';
|
||||
import type { BoxProps } from '$uix/eidos/components/box';
|
||||
import type { HeadingProps } from '$uix/eidos/components/heading';
|
||||
import type { TextProps } from '$uix/eidos/components/text';
|
||||
import type { GroupProps } from '$uix/eidos/components/group';
|
||||
import type { StackProps } from '$uix/eidos/components/stack';
|
||||
import type { ComponentColorProp } from '$uix/eidos/lib/types';
|
||||
import type { BillingPeriod } from './context';
|
||||
|
||||
export type { BillingPeriod };
|
||||
|
||||
export type PricingProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
|
||||
/** Billing period, bindable. The `Switch` writes it, `PlanPrice` reads it. @default 'monthly' */
|
||||
period?: BillingPeriod;
|
||||
/** Content measure. Passed straight to `Container`. @default 'lg' */
|
||||
container?: ContainerSize;
|
||||
/** Block-axis padding. Passed straight to `Section`. @default 'xl' */
|
||||
size?: SectionSize;
|
||||
/** `Header`, `Switch`, `Plans` in composition order. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Box` (measure-cap, centered). `maxWidth` defaults to `48rem`. */
|
||||
export type PricingHeaderProps = BoxProps;
|
||||
|
||||
export type PricingSwitchProps = {
|
||||
/** Label for the monthly option. @default 'Mensual' */
|
||||
monthlyLabel?: string;
|
||||
/** Label for the annual option. @default 'Anual' */
|
||||
annualLabel?: string;
|
||||
/** Accessible name for the toggle. @default 'Periodo de facturación' */
|
||||
'aria-label'?: string;
|
||||
};
|
||||
|
||||
/** Wraps the responsive row. Fluid via `minChildWidth` (default `17rem`) or fixed `columns`. */
|
||||
export type PricingPlansProps = {
|
||||
/** Minimum plan inline-size before wrapping. @default '17rem' */
|
||||
minChildWidth?: string;
|
||||
/** Fixed column count instead of fluid. */
|
||||
columns?: number;
|
||||
/** Gap between plans (space scale). @default 6 */
|
||||
gap?: number;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
export type PricingPlanProps = {
|
||||
/** Emphasise this plan (accent border, elevation). @default false */
|
||||
featured?: boolean;
|
||||
/** A chip above the plan (e.g. "Más popular") — usually on the featured one. */
|
||||
badge?: Snippet;
|
||||
/** `PlanName` / `PlanPrice` / `PlanFeatures` / `PlanAction`, or free children. */
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Heading`. `level` defaults to `3`. */
|
||||
export type PricingPlanNameProps = HeadingProps;
|
||||
|
||||
/** Wraps a muted `Text` — the plan's one-line tagline. */
|
||||
export type PricingPlanDescriptionProps = TextProps;
|
||||
|
||||
export type PricingPlanPriceProps = {
|
||||
/** The monthly figure (the app composes `FormatNumber` — the block never formats currency). */
|
||||
monthly?: Snippet;
|
||||
/** The annual figure. Shown when the period is `annual`. */
|
||||
annual?: Snippet;
|
||||
/** A suffix beside the figure ("/mes", "por usuario"…) — the app supplies it. */
|
||||
suffix?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Stack` of `PlanFeature`s. */
|
||||
export type PricingPlanFeaturesProps = StackProps;
|
||||
|
||||
/** Wraps a `Group`: a check glyph + the feature text. `color` tints the check. */
|
||||
export type PricingPlanFeatureProps = Omit<GroupProps, 'children'> & {
|
||||
/** Accent of the check glyph. @default 'primary' */
|
||||
color?: ComponentColorProp;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
/** Wraps a `Box` for the plan's CTA (a `Button` set to `block`), pinned to the
|
||||
* card's bottom edge so a row of plans lines its buttons up. */
|
||||
export type PricingPlanActionProps = BoxProps;
|
||||
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
|
||||
* so the demo's iframe can drive them.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
|
||||
import '@/uix/eidos/index.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
|
||||
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
|
||||
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
</svelte:head>
|
||||
|
||||
<BootUix {mode} {dir} {language}>
|
||||
<div
|
||||
data-theme={mode}
|
||||
data-mode={mode}
|
||||
{dir}
|
||||
style="min-block-size: 100dvh; background: var(--color-surface-default); color: var(--color-content-primary);"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</BootUix>
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Standalone page for the pricing mini-site — same component the demo renders
|
||||
* inline, served as its OWN document for the device-width frame.
|
||||
*/
|
||||
import PricingSite from '../PricingSite.svelte';
|
||||
</script>
|
||||
|
||||
<PricingSite />
|
||||
Loading…
Reference in new issue