feat(blocks): F2.4 `pricing` — el primer compound CON CONTEXTO del tier

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
dev 3 months ago
parent cd399f7c7c
commit 594e5a5db4

@ -8,11 +8,17 @@ commit `ae7b4f8c3`.
## Lo siguiente
**F2.4 `pricing`** (ficha en `PLAN-blocks.md` §F2.4). COMPOUND (como
feature-grid): `.Plan` se repite. Compone `CardGroup`/`Card` + `Badge` (plan
destacado) + `ToggleGroup` (mensual/anual, estado de vista local permitido §1) +
filas de features. Después: testimonials → faq → stats-band → cta → newsletter →
site-footer → banner → team → contact → content-section.
**F2.5 `testimonials`** (ficha en `PLAN-blocks.md` §F2.5). COMPOUND (`.Item`
repite): `Section` + `AutoGrid` + `Card` + `Avatar` + `Text` + `Group`. v1 =
grid; la cita única en spotlight (logo+quote+avatar) es la brecha del dossier
(scope-approval). Después: faq → stats-band → cta → newsletter → site-footer →
banner → team → contact → content-section.
**F2.4 `pricing` HECHO** — primer compound CON CONTEXTO del tier (el `Switch`
escribe el periodo, los `PlanPrice` lo leen). Precedente para cualquier block
cuyas partes COORDINEN: contexto reactivo vía `context.ts` (getter sobre un
`$bindable`), como `CardGroup`. El block NUNCA formatea moneda — la app compone
`FormatNumber` en los snippets `monthly`/`annual`.
**F2.1 site-header · F2.2 hero · F2.3 feature-grid · F2.3b feature-split HECHOS**,
**+ primitivo canon `Mockup`** (`$uix/eidos/components/mockup`: `chrome` browser/

@ -1154,3 +1154,26 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
Gates: `blocks:check` verde (4 blocks) · `svelte-check` sin errores propios
(73 = deuda ajena) · eidos lint.test + morfo 131/131. **Siguiente**: F2.4
`pricing`.
- 2026-07-24 — **Fix de `Grid` + F2.4 `pricing` HECHOS**.
- **Fix canon `Grid`** (theming/changelog §47): `align`/`justify`/`alignContent`
NO aplicaban — los atajos `place-items`/`place-content` con fallback
`revert-layer` (cuando su prop no está puesto) revertían los longhands a su
inicial, pisándolos. Ahora el fallback COMPONE los vars de los longhands. 353
tests eidos verdes, grids por defecto sin cambio. Encontrado alineando
`feature-split`.
- **`pricing`** (compound **CON CONTEXTO** — el primero del tier cuyas partes se
COORDINAN, no solo se repiten): `<Pricing bind:period>` + `.Header` +
`.Switch` + `.Plans` + `.Plan`(featured, badge) + `.PlanName`/`.PlanDescription`/
`.PlanPrice`/`.PlanFeatures`/`.PlanFeature`/`.PlanAction`. El `Switch`
(`ToggleGroup`) escribe el periodo en un contexto reactivo (`context.ts`,
getter sobre el `$bindable`) y cada `PlanPrice` lo lee → muestra el snippet
`monthly`/`annual`. **El block NO formatea moneda**: la app compone
`FormatNumber` en los snippets (B-7). `.PlanAction` fija el CTA al borde
inferior (`margin-block-start: auto`) para alinear los botones; `.Plan` con
`align="start"` deja los checks en columna. Verificado: el toggle cambia los 3
precios a la vez (0/29/99 → 0/23/79), featured con acento+elevación, claro/
oscuro/RTL. **Hueco a decisión del usuario**: tabla de comparación
(features × planes) — otra disposición, candidato a hermano `pricing-table`.
Gates: `blocks:check` verde (5 blocks) · `svelte-check` sin errores propios.
**Siguiente**: F2.5 `testimonials`.

@ -0,0 +1,65 @@
# Pricing
## Function
The plan-comparison section: a billing-period toggle over a row of plans, one
usually featured. It answers "what does it cost" and lets the visitor flip
monthly ↔ annual with the prices updating in step.
## Composition map
| Part | Composes | Notes |
| --- | --- | --- |
| root | bare `<section>` + `Section` + `Container` + `Stack` | `bind:period`; provides the period context |
| `.Header` | `Box` + `Stack` | the app's section `Heading` + `Text` |
| `.Switch` | `ToggleGroup` | writes the period; reads it back to stay in sync |
| `.Plans` | `AutoGrid` | fluid row of equal-height plan cards |
| `.Plan` | `Card` | `featured` → accent colour + elevation; `badge` chip |
| `.PlanName` | `Heading` (level 3) | |
| `.PlanDescription` | `Text` (muted, sm) | one-line tagline |
| `.PlanPrice` | `Group` + `Text` | shows the `monthly` / `annual` snippet per the period; `suffix` beside it |
| `.PlanFeatures` / `.PlanFeature` | `Stack` / `Group` + `Icon.Check` + `Text` | the checklist; the check is decorative (aria-hidden) |
| `.PlanAction` | `Box` (`margin-block-start: auto`) | the CTA, pinned to the card's bottom so a row lines up |
## Why compound WITH a context
`feature-grid` and `feature-split` are compound because their parts **repeat**
but do not coordinate. `Pricing` is the tier's first compound whose parts
**talk to each other**: the `Switch` writes the billing period and every
`PlanPrice` reads it. That coordination is exactly what earns a shared context
(`context.ts`) — the admission rule for the strongest form of compound.
**The block never formats currency.** `PlanPrice` takes `monthly` / `annual`
snippets; the app composes `FormatNumber` (locale, currency, rounding) inside
them. The block owns the switching, not the money.
## Decisions
**2026-07-24 — reference floor** (dossier §P1: Tailwind Plus «Pricing 12» ·
Untitled UI «Pricing 22» · Flowbite):
- **Adopted**: the monthly/annual toggle, the featured plan, and the
per-plan feature checklist + CTA — the dossier's "toggle + featured = paridad
OK". The prices switch through a shared context so the toggle is honest.
- **The app owns the money**: `FormatNumber` in the price snippets, per B-7.
`period` is bindable so the app can observe (analytics, a persisted choice).
- **Discarded**: static dumps; the plans are the app's markup, not a variant
matrix.
## Demo
`web/routes/blocks/pricing/` — the block full-bleed, three plans (featured in
the middle), the live monthly/annual toggle switching every price. Mini-page in
`PricingSite.svelte`, shared by both surfaces.
## Gaps
| Gap | Disposition |
| --- | --- |
| **Feature-comparison table** (features × plans matrix) | **scope-approval pending** — the dossier's recurring pricing gap. It is a table, not a row of cards, so likely a sibling (`pricing-table`) or a `.Compare` sub-block — a user decision, not a silent add |
| **Single-price variant** (one plan, centered) | **deferred** — one `.Plan` in `.Plans`; the layout already handles it |
| **Per-period savings note** ("2 meses gratis") | **app-land** — the app puts it in the `annualLabel` or a `.PlanDescription` |
## Found while composing
- _(none yet — recorded here as they surface.)_

@ -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,85 @@
// 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 through a shared context.
//
// import { Pricing } from '$blocks/pricing';
// import { FormatNumber } from '$uix/eidos/components/format-number';
//
// <Pricing bind:period>
// <Pricing.Header>
// <Heading level={2} align="center">Precios que crecen contigo</Heading>
// </Pricing.Header>
// <Pricing.Switch annualLabel="Anual (−20%)" />
// <Pricing.Plans>
// <Pricing.Plan featured>
// {#snippet badge()}<Badge color="primary">Más popular</Badge>{/snippet}
// <Pricing.PlanName>Pro</Pricing.PlanName>
// <Pricing.PlanPrice>
// {#snippet monthly()}29 €{/snippet}
// {#snippet annual()}23 €{/snippet}
// {#snippet suffix()}/mes{/snippet}
// </Pricing.PlanPrice>
// <Pricing.PlanFeatures>
// <Pricing.PlanFeature>Eventos ilimitados</Pricing.PlanFeature>
// </Pricing.PlanFeatures>
// <Pricing.PlanAction><Button block>Empezar</Button></Pricing.PlanAction>
// </Pricing.Plan>
// </Pricing.Plans>
// </Pricing>
//
// The block never formats currency — the app composes `FormatNumber` inside the
// `monthly` / `annual` snippets (B-7).
import PricingComponent from './pricing.svelte';
import Header from './pricing-header.svelte';
import Switch from './pricing-switch.svelte';
import Plans from './pricing-plans.svelte';
import Plan from './pricing-plan.svelte';
import PlanName from './pricing-plan-name.svelte';
import PlanDescription from './pricing-plan-description.svelte';
import PlanPrice from './pricing-plan-price.svelte';
import PlanFeatures from './pricing-plan-features.svelte';
import PlanFeature from './pricing-plan-feature.svelte';
import PlanAction from './pricing-plan-action.svelte';
type PricingNamespace = typeof PricingComponent & {
Header: typeof Header;
Switch: typeof Switch;
Plans: typeof Plans;
Plan: typeof Plan;
PlanName: typeof PlanName;
PlanDescription: typeof PlanDescription;
PlanPrice: typeof PlanPrice;
PlanFeatures: typeof PlanFeatures;
PlanFeature: typeof PlanFeature;
PlanAction: typeof PlanAction;
};
const Pricing = PricingComponent as PricingNamespace;
Pricing.Header = Header;
Pricing.Switch = Switch;
Pricing.Plans = Plans;
Pricing.Plan = Plan;
Pricing.PlanName = PlanName;
Pricing.PlanDescription = PlanDescription;
Pricing.PlanPrice = PlanPrice;
Pricing.PlanFeatures = PlanFeatures;
Pricing.PlanFeature = PlanFeature;
Pricing.PlanAction = PlanAction;
export { Pricing };
export default Pricing;
export type {
PricingProps,
BillingPeriod,
PricingHeaderProps as HeaderProps,
PricingSwitchProps as SwitchProps,
PricingPlansProps as PlansProps,
PricingPlanProps as PlanProps,
PricingPlanNameProps as PlanNameProps,
PricingPlanDescriptionProps as PlanDescriptionProps,
PricingPlanPriceProps as PlanPriceProps,
PricingPlanFeaturesProps as PlanFeaturesProps,
PricingPlanFeatureProps as PlanFeatureProps,
PricingPlanActionProps as PlanActionProps
} from './types';

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

@ -23,7 +23,7 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{ slug: 'hero', label: 'Hero', shipped: true },
{ slug: 'feature-grid', label: 'Feature grid', shipped: true },
{ slug: 'feature-split', label: 'Feature split', shipped: true },
{ slug: 'pricing', label: 'Pricing', shipped: false },
{ slug: 'pricing', label: 'Pricing', shipped: true },
{ slug: 'testimonials', label: 'Testimonials', shipped: false },
{ slug: 'faq', label: 'FAQ', shipped: false },
{ slug: 'stats-band', label: 'Stats band', shipped: false },

@ -0,0 +1,135 @@
<script lang="ts">
/**
* Pricing demo. Full-bleed on the page; device widths from `./preview`.
*/
import { Stack } from '$uix/eidos/components/stack';
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import BlockDemo from '../_lib/BlockDemo.svelte';
import PricingSite from './PricingSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
// The billing toggle is the block's own live control — no demo chrome needed
// beyond the section's axes. Device widths drive the preview URL.
const previewSrc = '/blocks/pricing/preview';
</script>
<BlockDemo
name="Pricing"
slug="$blocks/pricing"
{previewSrc}
previewHeight="820px"
meta={[
{ key: 'compone', value: 'Section · Container · AutoGrid · Card · ToggleGroup · Text' },
{ key: 'tipo', value: 'compound + contexto (Switch ↔ PlanPrice)' },
{ key: 'partes', value: 'Header · Switch · Plans · Plan · Plan*' },
{ key: 'strings propias', value: '0' }
]}
>
{#snippet preview()}
<PricingSite />
{/snippet}
{#snippet lede()}
La sección de planes: un <Code>Switch</Code> de periodo sobre una fila de planes, uno destacado.
Es el primer block <strong>compound con contexto</strong> del tier — el <Code>Switch</Code>
escribe el periodo y cada <Code>PlanPrice</Code> lo lee, así que las partes se COORDINAN. Prueba
el toggle mensual/anual: todos los precios cambian a la vez.
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El block coloca y coordina; el contenido —y el dinero— son de la app.
</Text>
<Stack gap={3}>
<DocRow term="contexto">
La raíz provee el periodo (bindable). <Code>Switch</Code> lo escribe vía
<Code>ToggleGroup</Code>; <Code>PlanPrice</Code> lo lee y muestra el snippet
<Code>monthly</Code> o <Code>annual</Code>. Esa coordinación es lo que gana el contexto.
</DocRow>
<DocRow term=".Plans · .Plan">
<Code>AutoGrid</Code> de tarjetas de igual alto; <Code>.Plan</Code> es una
<Code>Card</Code> — <Code>featured</Code> le da acento y elevación, y un
<Code>badge</Code> encima.
</DocRow>
<DocRow term=".PlanPrice">
Muestra la cifra activa según el periodo. <strong>El block no formatea moneda</strong>:
la app compone <Code>FormatNumber</Code> dentro de los snippets (B-7).
</DocRow>
<DocRow term=".PlanAction">
El CTA, fijado al borde inferior de la tarjeta (<Code>margin-block-start: auto</Code>),
para que una fila de planes alinee sus botones aunque tengan distinto nº de features.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="Pricing">
<Code>period</Code> (<Code>'monthly' | 'annual'</Code>, bindable, def.
<Code>monthly</Code>) · <Code>container</Code> (def. <Code>lg</Code>) · <Code>size</Code>
(def. <Code>xl</Code>).
</DocRow>
<DocRow term=".Switch">
<Code>monthlyLabel</Code> · <Code>annualLabel</Code> · <Code>aria-label</Code>.
</DocRow>
<DocRow term=".Plan">
<Code>featured</Code> (bool) · <Code>badge</Code> (snippet).
</DocRow>
<DocRow term=".PlanPrice">
<Code>monthly</Code> · <Code>annual</Code> · <Code>suffix</Code> (snippets). Sin
<Code>annual</Code>, el <Code>monthly</Code> vale para ambos periodos.
</DocRow>
<DocRow term=".PlanName · .PlanDescription · .PlanFeatures · .PlanFeature · .PlanAction">
Partes de conveniencia sobre <Code>Heading</Code>/<Code>Text</Code>/<Code>Stack</Code>/<Code>Group</Code>.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Toggle">
<Code>Switch</Code> compone <Code>ToggleGroup</Code> (radiogroup): teclado y ARIA de
selección salen del componente del canon.
</DocRow>
<DocRow term="Encabezados">
La sección un <Code>h2</Code>; cada plan un <Code>h3</Code>. El check de cada feature es
decorativo (<Code>aria-hidden</Code>).
</DocRow>
<DocRow term="Precio">
La cifra es <Code>Text</Code>, no un heading — no ensucia el esquema del documento.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Tabla de comparación">
<strong>Scope-approval pendiente</strong> — la brecha recurrente del dossier en pricing: una
matriz features × planes. Es una tabla, no una fila de tarjetas, así que probablemente un
hermano (<Code>pricing-table</Code>) — decisión tuya, no un añadido aquí.
</DocRow>
<DocRow term="Plan único">
<strong>Diferido</strong> — un solo <Code>.Plan</Code> en <Code>.Plans</Code>; el layout ya
lo cubre.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Por qué contexto, no solo repetición">
<Code>feature-grid</Code> y <Code>feature-split</Code> son compound porque sus partes se
REPITEN. <Code>Pricing</Code> es el primero cuyas partes se HABLAN: el <Code>Switch</Code>
escribe el periodo y los <Code>PlanPrice</Code> lo leen. Esa coordinación es la que gana el
contexto — la forma más fuerte de compound.
</DocRow>
<DocRow term="El dinero es de la app">
<Code>period</Code> es bindable por si la app quiere observarlo (analítica, recordar la
elección), y el importe lo formatea la app con <Code>FormatNumber</Code>.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,118 @@
<script lang="ts">
/**
* The pricing mini-page: three plans (featured in the middle) with a live
* monthly/annual toggle, plus a page floor. Prices are plain strings here; a
* real app composes `FormatNumber` in the `monthly` / `annual` snippets.
*/
import { Pricing } from '$blocks/pricing';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Badge } from '$uix/eidos/components/badge';
import { Button } from '$uix/eidos/components/button';
import { Link } from '$uix/eidos/components/link';
type Plan = {
name: string;
desc: string;
mo: string;
yr: string;
cta: string;
featured: boolean;
features: string[];
};
const plans: Plan[] = [
{
name: 'Starter',
desc: 'Para empezar a medir',
mo: '0 €',
yr: '0 €',
cta: 'Empezar gratis',
featured: false,
features: ['Hasta 10k eventos/mes', '1 proyecto', 'Retención 7 días']
},
{
name: 'Pro',
desc: 'Para equipos de producto',
mo: '29 €',
yr: '23 €',
cta: 'Probar 14 días',
featured: true,
features: [
'Eventos ilimitados',
'Proyectos ilimitados',
'Cohortes, embudos y retención',
'Alertas y automatizaciones',
'Retención de 12 meses'
]
},
{
name: 'Enterprise',
desc: 'SSO, auditoría y SLA',
mo: '99 €',
yr: '79 €',
cta: 'Hablar con ventas',
featured: false,
features: ['Todo lo de Pro', 'SSO/SAML + SCIM', 'Residencia de datos', 'SLA 99,9 %', 'Soporte dedicado']
}
];
</script>
<Pricing>
<Pricing.Header>
<Heading level={2} align="center">Precios que crecen contigo</Heading>
<Text size="lg" color="muted" align="center">
Sin coste por asiento y sin sorpresas: eliges el plan por volumen de eventos, no por número de
personas.
</Text>
</Pricing.Header>
<Pricing.Switch annualLabel="Anual (−20 %)" />
<Pricing.Plans>
{#each plans as plan (plan.name)}
<Pricing.Plan featured={plan.featured}>
{#snippet badge()}
{#if plan.featured}
<Badge variant="solid" color="primary" size="sm">Más popular</Badge>
{/if}
{/snippet}
<Pricing.PlanName>{plan.name}</Pricing.PlanName>
<Pricing.PlanDescription>{plan.desc}</Pricing.PlanDescription>
<Pricing.PlanPrice>
{#snippet monthly()}{plan.mo}{/snippet}
{#snippet annual()}{plan.yr}{/snippet}
{#snippet suffix()}/mes{/snippet}
</Pricing.PlanPrice>
<Pricing.PlanFeatures>
{#each plan.features as feature (feature)}
<Pricing.PlanFeature>{feature}</Pricing.PlanFeature>
{/each}
</Pricing.PlanFeatures>
<Pricing.PlanAction>
<Button block variant={plan.featured ? 'solid' : 'surface'}>{plan.cta}</Button>
</Pricing.PlanAction>
</Pricing.Plan>
{/each}
</Pricing.Plans>
</Pricing>
<!-- A floor so the page reads as a page. -->
<Section size="lg">
<Container size="lg">
<Stack gap={4} align="center">
<Heading level={2} align="center">¿Dudas con el plan?</Heading>
<Text color="muted" align="center">
Todos los planes incluyen la API completa y sin límite de asientos.
<Link href="#faq">Mira las preguntas frecuentes</Link>.
</Text>
</Stack>
</Container>
</Section>

@ -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…
Cancel
Save

Powered by TurnKey Linux.