From e7677d6d8724103d2b56d638ecb9020c382b96e0 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 17 Aug 2026 22:05:36 +0200 Subject: [PATCH] =?UTF-8?q?blocks(pricing):=20la=20tabla=20de=20comparaci?= =?UTF-8?q?=C3=B3n,=20y=20las=20palabras=20que=20una=20marca=20no=20dice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La brecha recurrente del dossier en pricing, y la última fila del primer tramo de la ola. Es una PARTE y no un hermano porque tiene que obedecer al periodo de la sección, y sólo una parte puede leer ese contexto: B-10 le prohíbe a un hermano importar el block que lo posee. Vivir dentro de la sección es además lo que permite que la app meta el precio en una celda y cambie con el conmutador, sin que esta página cablee nada. La app construye la tabla y el block la compone. B-5 admite datos justo donde el canon compuesto ya es data-driven, y la tabla del canon lo es: exige la instancia que devuelve createTable. Así que la parte no inventa un formato de matriz propio ni traduce nada de ida y vuelta, que es el acoplamiento que esa regla existe para evitar. Las tarjetas y la tabla conviven, como en las referencias, alimentadas por el mismo array de planes. Dos cosas que el plan había esbozado y que construirlas demostró equivocadas, y quedan escritas en vez de calladas. No hay un Record de ejes visuales por plan: acentuar una columna es componer canon, que es justo lo que ya permite el snippet de cabecera, y ese Record habría sido una segunda fuente de lo que la app declara en su plan. Y el block no fija la columna: el motor es del app, y meter mano ahí sería el block decidiendo cómo se comporta la tabla de otro. Lo que sí posee es lo que la app no puede: el nombre accesible de la tabla y las palabras que una marca de visto o de guion no dice en voz alta. Ese glifo lleva todo el significado de la celda y es silencio para un lector de pantalla, así que va oculto y la frase viaja en su etiqueta, desde un mapa exhaustivo con respaldo en inglés y su test en las dos direcciones. Y absorbe el cast del canon siendo genérico: la tabla tipa su ranura sin parámetro mientras el motor sí lo devuelve, así que todo consumidor castea, empezando por la demo del propio componente. Aquí se castea una vez a la entrada y se deshace a la salida, para que el snippet de celda devuelva la fila con la forma del app. Medido a 1280 y 375: ocho filas por cuatro columnas, tabla nombrada, marcas que se anuncian, y en móvil la tabla desplaza con la columna de características quieta y la última columna alcanzable. Co-Authored-By: Claude Opus 5 --- docs/process/CONTINUE-blocks.md | 4 +- docs/process/PLAN-blocks.md | 36 ++++++ src/uix/blocks/pricing/README.md | 54 +++++++++ src/uix/blocks/pricing/index.ts | 8 +- src/uix/blocks/pricing/plan-state.test.ts | 26 ++++- src/uix/blocks/pricing/plan-state.ts | 15 +++ src/uix/blocks/pricing/pricing-compare.svelte | 107 ++++++++++++++++++ src/uix/blocks/pricing/types.ts | 53 +++++++++ web/routes/blocks/_lib/blocks-langs.ts | 4 + web/routes/blocks/pricing/+page.svelte | 39 ++++++- web/routes/blocks/pricing/PricingSite.svelte | 76 ++++++++++++- .../blocks/pricing/preview/+page.svelte | 3 +- 12 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 src/uix/blocks/pricing/pricing-compare.svelte diff --git a/docs/process/CONTINUE-blocks.md b/docs/process/CONTINUE-blocks.md index 949c27a9a..07f3100fe 100644 --- a/docs/process/CONTINUE-blocks.md +++ b/docs/process/CONTINUE-blocks.md @@ -256,8 +256,8 @@ del ancho; él lo ve centrado y tiene razón), y **A-65/A-75 son el mismo patró **La fuente es `PLAN-blocks.md` §F2b** — no dupliques aquí sus tablas. Cuatro tramos, en orden: **A** variantes de suelo (~~V4 hero `form`~~ ✅ · ~~V8 single-price~~ ✅ · ~~V5 cta split~~ ✅ · ~~V2 testimonials spotlight~~ ✅ · -~~V3 faq-lista~~ ✅ **las cinco HECHAS 2026-08-17** → **queda V1 -`Pricing.Compare`, en tanda propia**) · **B** la matriz de +~~V3 faq-lista~~ ✅ · ~~V1 `Pricing.Compare`~~ ✅ — **TRAMO A CERRADO 6/6 el +2026-08-17; entra por el tramo B**) · **B** la matriz de equivalencia variante-a-variante en el README de cada block, con recetas demostradas en demo (el entregable central: responde a la brecha de cardinalidad sin competir en dumps) · **C** tres blocks nuevos — `logo-cloud` diff --git a/docs/process/PLAN-blocks.md b/docs/process/PLAN-blocks.md index 0f0187673..8d90c957e 100644 --- a/docs/process/PLAN-blocks.md +++ b/docs/process/PLAN-blocks.md @@ -2310,3 +2310,39 @@ type="button">` es una pista de MIME falsa) — el morfo lo declara ocultas; list → 0 disparadores, **5 `h3`**, tres pistas de 309px, medida 1024 y **todas las respuestas visibles sin pulsar**; a 375 cae a una columna conservando ambas cosas. El acordeón queda idéntico. +- 2026-08-17 — **F2b · V1 `Pricing.Compare` HECHA — tramo A CERRADO (6/6)**. La + brecha recurrente del dossier en pricing. **PARTE y no hermano**: tiene que + obedecer al periodo de la sección y sólo una parte lee ese contexto (B-10 le + prohíbe a un hermano importar el block que lo posee) — vivir dentro de + `` es también lo que permite que la app meta un `PlanPrice` en una + celda y cambie con el conmutador, sin cablear nada. **La app construye la tabla + y el block la COMPONE**: B-5 admite datos justo donde el canon compuesto ya es + data-driven, y `Table` lo es (exige la instancia de `createTable`), así que + `.Compare` no inventa formato de matriz ni traduce. `.Plans` y `.Compare` + CONVIVEN, alimentados por el mismo array. + ⚠️ **Dos cosas que el plan sketchó y construirlas demostró equivocadas**, + registradas y no calladas: (1) **sin `Record` por plan** — acentuar es componer + canon, que es lo que ya permite el snippet `columnHeader`; el `Record` habría + sido una segunda fuente de lo que la app declara en su `.Plan`; (2) **el block + NO pinnea**: el motor es del app y mutarlo desde el block sería decidir cómo se + comporta su tabla. La app declara `enablePinning` + `initialColumnPinning`. + **Posee** el nombre accesible de la tabla y las palabras que una marca ✓/— no + dice (`PRICING_COMPARE_VALUE`, `Record` exhaustivo con test en las dos + direcciones; el glifo va `aria-hidden`). **Y absorbe el cast del canon** siendo + genérico: `Table` tipa `TableInstance` mientras `createTable` + devuelve `TableInstance` — todo consumidor castea, su propia demo incluida —, + así que `.Compare` castea UNA vez a la entrada y lo deshace a la salida para que + el snippet `cell` devuelva la fila en la forma del app. + **Dos defectos de CANON salieron de aquí** (`theming/changelog.md` §51): la + tabla **no tenía escape horizontal** —a 375 medía 420 en un contenedor de 327 y + 93px eran inalcanzables, con `overflow: hidden` en los dos ejes— **ARREGLADO** + (`overflow-x: auto` + `overflow-y: hidden`, guard probado por mutación); y **la + columna fijada no aguanta en RTL** —se ancla con `left` FÍSICO y al desplazar + (`scrollLeft` −94) su borde pasó de 350 a 444 y salió de pantalla— **FILADO**, + porque es el eje de dirección y tiene doctrina propia. Además, `Table` **no + exportaba los tipos que su API exige**: el guard B-4 rechazó `$libs/datagrid` + por nombre y tenía razón — se corrige exponiéndolos desde el canon, no + ensanchando la frontera. + ⚠️ **Nota de proceso, segunda reincidencia**: pasé prettier por + `docs/theming/changelog.md` otra vez y reformateó 400 líneas ajenas. Restaurado + y reaplicado a mano. **Ese fichero no se formatea.** diff --git a/src/uix/blocks/pricing/README.md b/src/uix/blocks/pricing/README.md index 48182f8c6..483933bcf 100644 --- a/src/uix/blocks/pricing/README.md +++ b/src/uix/blocks/pricing/README.md @@ -21,6 +21,7 @@ monthly ↔ annual with the prices updating in step. | `.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 | | `.PlanReason` | `Text` (sm, secondary) | the sentence for a plan that cannot be chosen; renders NOTHING when it can. Owns the id `.PlanAction` points `aria-describedby` at | +| `.Compare` | the canon `Table` (engine from the app's `createTable`) | the features×plans matrix. Owns the table's accessible NAME and the words a ✓ / — cell does not say | **Landmark + headings**: a bare `
` with NO accessible name of its own, so it is not an exposed landmark — the app names it by passing `aria-label` / @@ -162,6 +163,59 @@ Same token, no new one, `pointer-events` untouched. Guarded by `components/card/disabled-attenuation.test.ts` and proved by mutation. Detail: `theming/changelog.md` §50. +### The comparison table (`.Compare`) — 2026-08-17, plan F2b · V1 + +The dossier's recurring gap for `pricing`, and the last row of the wave's first +stretch. The two decisions the plan deliberately left open: + +- **A PART, not a sibling block.** It has to obey the section's billing period, + and only a part can read that context — B-10 forbids a sibling from importing + the block that owns it. Living inside `` is also what lets the app drop + a `Pricing.PlanPrice` into a cell and have it switch with the toggle: the + coordination comes from where it sits, not from wiring. +- **The app builds the table; the block composes it.** B-5 allows data-shaped + input «only where the composed canon component is already data-driven», and the + canon `Table` is exactly that — it requires the `createTable({ data, columns })` + instance. So `.Compare` invents no matrix format and translates nothing. `.Plans` + and `.Compare` coexist (the references show both), fed by the SAME array of + plans, so a plan's state is declared once and reaches both views. + +⚠️ **Two things the plan sketched and building them proved wrong**, both recorded +rather than quietly dropped: + +- **No per-plan `Record` of visual axes.** The plan had `.Compare` take + `{ featured, state }` keyed by column id so it could accent the recommended + column. But a block paints no CSS: accenting means composing canon components, + which is what the `columnHeader` snippet already lets the app do with its own + `Badge` and `Text`. The `Record` would have been a second source for something + the app already declares on its `.Plan`. +- **The block does not pin the feature column itself.** Pinning is engine state, + and the engine belongs to the app; reaching in to mutate it would be the block + deciding how the app's table behaves. The app declares `enablePinning` + + `initialColumnPinning` — the demo shows it — and gets the sticky column the + references have on narrow screens. + +**What it owns**: the table's accessible name (`label`), and the words a ✓ / — +cell does not say out loud. That mark carries the whole meaning of the cell and +is silence to a screen reader — the row header names the feature, the column +header names the plan, and the value between them would be nothing. So the marks +are `aria-hidden` decoration and `PRICING_COMPARE_VALUE` (exhaustive `Record`, +idlangref with English fallback) supplies «Included» / «Not included». + +**It also absorbs the canon's cast.** `Table` types its slot as +`TableInstance` while `createTable` returns `TableInstance`, and +they are invariant in `columns`, so every typed consumer casts — the canon's own +demo included. `.Compare` is generic and casts once on the way in, and undoes it +on the way out so the `cell` snippet hands the row back in the app's own shape. + +**Measured** (1280 and 375): 8 rows × 4 columns, table named, marks reading +«Incluido» / «No incluido» · at 375 the table is 420px in a 327px container and +**scrolls**, with the pinned feature column staying put (x=25 before and after a +95px scroll) and the last column reachable. Two canon defects came out of this and +are fixed or filed in `theming/changelog.md` §51: the table had no horizontal +escape at all (fixed), and the pinned column does not hold in RTL (filed — the +offset is physical, and that is the direction axis). + ## Found while composing - **A «deferred» disposition can be a wrong measurement, not a postponed one** diff --git a/src/uix/blocks/pricing/index.ts b/src/uix/blocks/pricing/index.ts index 6c26891b7..85dde0cbd 100644 --- a/src/uix/blocks/pricing/index.ts +++ b/src/uix/blocks/pricing/index.ts @@ -46,6 +46,7 @@ import PlanFeatures from './pricing-plan-features.svelte'; import PlanFeature from './pricing-plan-feature.svelte'; import PlanAction from './pricing-plan-action.svelte'; import PlanReason from './pricing-plan-reason.svelte'; +import Compare from './pricing-compare.svelte'; type PricingNamespace = typeof PricingComponent & { Header: typeof Header; @@ -59,6 +60,7 @@ type PricingNamespace = typeof PricingComponent & { PlanFeature: typeof PlanFeature; PlanAction: typeof PlanAction; PlanReason: typeof PlanReason; + Compare: typeof Compare; }; const Pricing = PricingComponent as PricingNamespace; @@ -73,12 +75,13 @@ Pricing.PlanFeatures = PlanFeatures; Pricing.PlanFeature = PlanFeature; Pricing.PlanAction = PlanAction; Pricing.PlanReason = PlanReason; +Pricing.Compare = Compare; export { Pricing }; export default Pricing; export type { PricingPlanState } from './plan-state'; -export { canChoosePlan, PRICING_PLAN_REASON } from './plan-state'; +export { canChoosePlan, PRICING_PLAN_REASON, PRICING_COMPARE_VALUE } from './plan-state'; export type { PricingProps, @@ -93,5 +96,6 @@ export type { PricingPlanFeaturesProps as PlanFeaturesProps, PricingPlanFeatureProps as PlanFeatureProps, PricingPlanActionProps as PlanActionProps, - PricingPlanReasonProps as PlanReasonProps + PricingPlanReasonProps as PlanReasonProps, + PricingCompareProps as CompareProps } from './types'; diff --git a/src/uix/blocks/pricing/plan-state.test.ts b/src/uix/blocks/pricing/plan-state.test.ts index 8283f983b..a4ad4cd2a 100644 --- a/src/uix/blocks/pricing/plan-state.test.ts +++ b/src/uix/blocks/pricing/plan-state.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { canChoosePlan, PRICING_PLAN_REASON, type PricingPlanState } from './plan-state'; +import { + canChoosePlan, + PRICING_COMPARE_VALUE, + PRICING_PLAN_REASON, + type PricingPlanState +} from './plan-state'; const ALL: PricingPlanState[] = ['available', 'current', 'selected']; @@ -40,3 +45,22 @@ describe('pricing plan state', () => { expect(Object.keys(PRICING_PLAN_REASON).sort()).toEqual([...ALL].sort()); }); }); + +describe('pricing comparison cell words', () => { + it('covers BOTH booleans — a glyph says nothing to a screen reader', () => { + expect(Object.keys(PRICING_COMPARE_VALUE).sort()).toEqual(['false', 'true']); + }); + + it('every word is an idlangref under the block namespace with a fallback', () => { + for (const ref of Object.values(PRICING_COMPARE_VALUE)) { + expect(ref.startsWith('#?'), ref).toBe(true); + const [key, fallback] = ref.slice(2).split('|'); + expect(key.startsWith('blocks.pricing.'), key).toBe(true); + expect(fallback?.length, `${key} needs a fallback`).toBeGreaterThan(0); + } + }); + + it('the two cases do not share a sentence', () => { + expect(PRICING_COMPARE_VALUE.true).not.toBe(PRICING_COMPARE_VALUE.false); + }); +}); diff --git a/src/uix/blocks/pricing/plan-state.ts b/src/uix/blocks/pricing/plan-state.ts index 19ab176e0..3b1a79ab5 100644 --- a/src/uix/blocks/pricing/plan-state.ts +++ b/src/uix/blocks/pricing/plan-state.ts @@ -50,3 +50,18 @@ export const PRICING_PLAN_REASON: Record = { current: '#?blocks.pricing.reason.current|This is your current plan', selected: null }; + +/** + * The words a comparison cell needs and its glyph cannot give. + * + * A ✓ or a — in a features×plans matrix carries the whole meaning of the cell and + * says NOTHING to a screen reader: the row header names the feature and the + * column header names the plan, but the value between them is a decoration. So + * the block owns these two, by the same rule and in the same shape as + * `PRICING_PLAN_REASON` — idlangref with an English fallback, and a `Record` + * keyed by the boolean so neither case can be forgotten. + */ +export const PRICING_COMPARE_VALUE: Record<'true' | 'false', string> = { + true: '#?blocks.pricing.compare.included|Included', + false: '#?blocks.pricing.compare.notIncluded|Not included' +}; diff --git a/src/uix/blocks/pricing/pricing-compare.svelte b/src/uix/blocks/pricing/pricing-compare.svelte new file mode 100644 index 000000000..44d9e588a --- /dev/null +++ b/src/uix/blocks/pricing/pricing-compare.svelte @@ -0,0 +1,107 @@ + + + + + {#each instance.headers as header (header.id)} + + {#if columnHeader} + {@render columnHeader({ header })} + {:else} + {header.label} + {/if} + + {/each} + + + + {#each instance.rows as row (row.id)} + + {#each row.cells as c, index (c.id)} + + {#if cell} + {@render cell({ + value: c.value, + columnId: c.column.id, + row: row as unknown as TableRow + })} + {:else if typeof c.value === 'boolean'} + + + {#if c.value} + + {:else} + {c.value} + {/if} + + {/each} + + {/each} + +
diff --git a/src/uix/blocks/pricing/types.ts b/src/uix/blocks/pricing/types.ts index b51fa8233..307b6b143 100644 --- a/src/uix/blocks/pricing/types.ts +++ b/src/uix/blocks/pricing/types.ts @@ -10,6 +10,7 @@ import type { StackProps } from '$uix/eidos/components/stack'; import type { ComponentColorProp } from '$uix/eidos/lib/types'; import type { BillingPeriod } from './context'; import type { PricingPlanState } from './plan-state'; +import type { TableProps, TableHeader, TableInstance, TableRow } from '$uix/eidos/components/table'; export type { BillingPeriod, PricingPlanState }; @@ -147,3 +148,55 @@ export type PricingPlanActionProps = Omit & { /** Wraps a small `Text` — the sentence for a plan that cannot be chosen. Renders * nothing when the plan is choosable. */ export type PricingPlanReasonProps = Omit; + +/** + * The features×plans matrix — `Pricing.Compare`. + * + * It takes a `TableInstance` and not a matrix of its own, and that is the whole + * design decision. B-5 allows data-shaped input «only where the composed canon + * component is already data-driven», and the canon `Table` is exactly that: it + * requires the instance `createTable({ data, columns })` returns. So the app + * builds its table like any other in the ecosystem and the block composes it — + * inventing a second matrix format here would mean translating the app's data + * into it and back out, which is the coupling B-5 exists to prevent. + * + * ⚠️ Two things this part deliberately does NOT do, decided while building it: + * + * - **It does not take a per-plan `Record` of visual axes.** The plan sketched + * one (`{ featured, state }` keyed by column id) so the block could accent the + * recommended column. But a block paints no CSS, so accenting means composing + * canon components — which is what the `columnHeader` snippet already lets the + * app do, with its own `Badge` and `Text`. The `Record` would have been a + * second source for something the app already declares on its `.Plan`. + * - **It does not pin the feature column itself.** Pinning is engine state + * (`enablePinning` + `initialColumnPinning`), and the engine belongs to the + * app; reaching in to mutate it from the block would be the block deciding how + * the app's table behaves. The app declares it — the demo shows how — and the + * README says so. + */ +export type PricingCompareProps = Omit & { + /** + * The instance from `createTable({ data, columns })`. Required. + * + * Generic on purpose: the canon `Table` types this slot as + * `TableInstance`, so every typed consumer casts —its own demo does + * `table as unknown as TableInstance`. This part carries the type parameter and + * absorbs that cast ONCE, on the way in, so an app never writes it. + */ + table: TableInstance; + /** + * Accessible name for the table. A comparison matrix with no name is a grid + * of marks a screen reader cannot place — the canon `Table` falls back to a + * generic «Data table», which here would say nothing about what is compared. + */ + label?: string; + /** Header cell content. Defaults to the column's own label. */ + columnHeader?: Snippet<[{ header: TableHeader }]>; + /** + * Body cell content. The block paints booleans (a mark plus the words it owes) + * and plain values by default; pass this to compose anything else — a + * `Pricing.PlanPrice` in the price row reads the section's period from + * context and switches with the toggle, because `Compare` lives inside it. + */ + cell?: Snippet<[{ value: unknown; columnId: string; row: TableRow }]>; +}; diff --git a/web/routes/blocks/_lib/blocks-langs.ts b/web/routes/blocks/_lib/blocks-langs.ts index 147f1d45f..472030d4d 100644 --- a/web/routes/blocks/_lib/blocks-langs.ts +++ b/web/routes/blocks/_lib/blocks-langs.ts @@ -18,6 +18,10 @@ export const blocksLangs = { es: 'Es tu plan actual.', en: 'This is your current plan.' } + }, + compare: { + included: { es: 'Incluido', en: 'Included' }, + notIncluded: { es: 'No incluido', en: 'Not included' } } }, newsletter: { diff --git a/web/routes/blocks/pricing/+page.svelte b/web/routes/blocks/pricing/+page.svelte index b63bd8a48..fd5d83173 100644 --- a/web/routes/blocks/pricing/+page.svelte +++ b/web/routes/blocks/pricing/+page.svelte @@ -18,9 +18,10 @@ let planCount = $state<1 | 2 | 3>(3); let lift = $state(false); let scenario = $state<'new' | 'upgrade'>('new'); + let compare = $state(false); const previewSrc = $derived( - `/blocks/pricing/preview?plans=${planCount}&lift=${lift}&scenario=${scenario}` + `/blocks/pricing/preview?plans=${planCount}&lift=${lift}&scenario=${scenario}&compare=${compare}` ); @@ -37,7 +38,7 @@ ]} > {#snippet preview()} - + {/snippet} {#snippet controls()} @@ -58,6 +59,21 @@ + + tabla comparativa + (compare = (v[0] ?? 'no') === 'sí')} + aria-label="mostrar la tabla de comparación" + > + sí + no + + + escenario monthly · annual · suffix (snippets). Sin annual, el monthly vale para ambos periodos. + + table (la instancia de createTable, requerida) · + label · columnHeader y cell (snippets). La app construye + su tabla como cualquier otra del ecosistema y el block la COMPONE: B-5 admite datos justo donde + el canon compuesto ya es data-driven, y Table lo es. El block no inventa un formato + de matriz ni traduce nada; lo que sí posee es el nombre accesible de la tabla y las palabras que + una marca ✓/— no dice — + aria-hidden en el glifo y la frase en el aria-label. Fijar la + columna de características la declara la app en su createTable + (enablePinning + initialColumnPinning): el motor es suyo. + El snippet de .PlanAction RECIBE el estado derivado (canChoose, describedBy), así que la app compone su propio Button sin volver a deducir @@ -198,9 +225,11 @@ {#snippet gaps()} - Scope-approval pendiente — la brecha recurrente del dossier en pricing: una - matriz features × planes. Es una tabla, no una fila de tarjetas, así que probablemente un - hermano (pricing-table) — decisión tuya, no un añadido aquí. + Hecho (V1, 2026-08-17) — .Compare, y como PARTE, no como + hermano: tiene que obedecer al periodo de la sección y sólo una parte puede leer ese + contexto (B-10 le prohíbe a un hermano importar el block que lo posee). Enciende el control + tabla comparativa: el conmutador mensual/anual mueve las tarjetas y la tabla a la + vez. Hecho (V8, 2026-08-17) — y no como estaba anotado. «El layout ya lo cubre» diff --git a/web/routes/blocks/pricing/PricingSite.svelte b/web/routes/blocks/pricing/PricingSite.svelte index 3a8d3e6d2..d622302fc 100644 --- a/web/routes/blocks/pricing/PricingSite.svelte +++ b/web/routes/blocks/pricing/PricingSite.svelte @@ -11,18 +11,26 @@ import { Heading } from '$uix/eidos/components/heading'; import { Text } from '$uix/eidos/components/text'; import { Badge } from '$uix/eidos/components/badge'; + import { Group } from '$uix/eidos/components/group'; import { Button } from '$uix/eidos/components/button'; import { Link } from '$uix/eidos/components/link'; import { tick } from 'svelte'; import { getActiveUix } from '$active-uix'; + import { createTable, type ColumnDef } from '$libs/datagrid'; const uix = getActiveUix(); let { planCount = 3, lift = false, - scenario = 'new' - }: { planCount?: 1 | 2 | 3; lift?: boolean; scenario?: 'new' | 'upgrade' } = $props(); + scenario = 'new', + compare = false + }: { + planCount?: 1 | 2 | 3; + lift?: boolean; + scenario?: 'new' | 'upgrade'; + compare?: boolean; + } = $props(); // The full semantic arc of choosing a plan. Pressing already emits // `contact-activate` (the Button's own morfo event, for free by composition); @@ -103,6 +111,49 @@ } ]; + /** + * La matriz de comparación. La construye la APP con `createTable`, como + * cualquier otra tabla del ecosistema: el block compone la tabla del canon, no + * inventa un formato propio (B-5 admite datos justo donde el canon compuesto ya + * es data-driven, y `Table` lo es). + * + * Las columnas son los planes, y salen del MISMO array que alimenta las + * tarjetas: el estado de un plan se declara una vez y viaja a las dos vistas. + */ + type CompareRow = { + feature: string; + Starter: boolean | string; + Pro: boolean | string; + Enterprise: boolean | string; + }; + + const compareRows: CompareRow[] = [ + { feature: 'Eventos al mes', Starter: '10k', Pro: 'Ilimitados', Enterprise: 'Ilimitados' }, + { feature: 'Proyectos', Starter: '1', Pro: 'Ilimitados', Enterprise: 'Ilimitados' }, + { feature: 'Retención de datos', Starter: '7 días', Pro: '12 meses', Enterprise: 'A medida' }, + { feature: 'Cohortes y embudos', Starter: false, Pro: true, Enterprise: true }, + { feature: 'Alertas y automatizaciones', Starter: false, Pro: true, Enterprise: true }, + { feature: 'SSO/SAML + SCIM', Starter: false, Pro: false, Enterprise: true }, + { feature: 'Residencia de datos', Starter: false, Pro: false, Enterprise: true }, + { feature: 'SLA 99,9 %', Starter: false, Pro: false, Enterprise: true } + ]; + + /** + * `enablePinning` + `initialColumnPinning` los declara la APP, no el block: + * el motor es suyo y un block que lo mutara estaría decidiendo cómo se comporta + * la tabla de otro. Es lo que mantiene la columna de características a la vista + * cuando la tabla scrollea en horizontal. + */ + const compareColumns: ColumnDef[] = [ + { id: 'feature', accessorKey: 'feature', header: 'Características', enablePinning: true }, + ...plans.map((p) => ({ id: p.name, accessorKey: p.name, header: p.name })) + ]; + + const compareTable = createTable({ + data: () => compareRows, + columns: compareColumns, + initialColumnPinning: { left: ['feature'] } + }); /** * The single-price and two-plan arrangements the reference catalogs ship. With * ONE plan the recommended tier is the one that stands alone; with two, the @@ -207,6 +258,27 @@ {/each} + + {#if compare} + + + {#snippet columnHeader({ header })} + {#if header.id === 'Pro'} + + {header.label} + Más popular + + {:else} + {header.label} + {/if} + {/snippet} + + {/if}
diff --git a/web/routes/blocks/pricing/preview/+page.svelte b/web/routes/blocks/pricing/preview/+page.svelte index 0c3b546ed..f87d61b9c 100644 --- a/web/routes/blocks/pricing/preview/+page.svelte +++ b/web/routes/blocks/pricing/preview/+page.svelte @@ -12,6 +12,7 @@ const planCount = $derived((Number(page.url.searchParams.get('plans') ?? 3) || 3) as 1 | 2 | 3); const lift = $derived(page.url.searchParams.get('lift') === 'true'); const scenario = $derived((page.url.searchParams.get('scenario') ?? 'new') as 'new' | 'upgrade'); + const compare = $derived(page.url.searchParams.get('compare') === 'true'); - +