Pack de diez por un café, fotos en las láminas y oro con reflejo

El pack pasa de cinco a diez canciones por el mismo 1,50 €: diez canciones por
lo que cuesta un café. Es la propuesta, así que el número y el importe se leen
juntos en todas partes.

Las pruebas de la tienda estaban escritas con el cinco metido a mano y hubo que
reescribirlas enteras. Ahora dicen la regla en términos del propio tamaño del
pack, y el helper de las pruebas de extremo a extremo añade lo que haya a la
venta hasta llenarlo en vez de nombrar los temas uno a uno: así cambiar el
tamaño otra vez no obliga a tocar nada.

Las dos láminas de llamada estrenan foto propia con su velo, más cubriente que
el de la textura: sobre una imagen clara el texto blanco no se lee.

Y el dorado deja de ser plano en los botones. Un metal refleja, y lo que lo hace
parecer metal es la banda de luz que lo cruza; el fondo se pinta más alto que el
botón para que al acercar el puntero la luz recorra la pieza en vez de
limitarse a aclararla. El acento sube un punto de croma y baja uno de claridad,
que es lo que lo separa de un amarillo de rotulador.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 1 month ago
parent ba72469710
commit da127119c6

@ -3,7 +3,7 @@
Web de un letrista y compositor, con tienda. La obra se agrupa en **estilos Web de un letrista y compositor, con tienda. La obra se agrupa en **estilos
por estilo** —las baladas románticas como estilos principal, y junto a ellas por estilo** —las baladas románticas como estilos principal, y junto a ellas
clásica, rock y pop melódico—, se escucha desde un **reproductor persistente** clásica, rock y pop melódico—, se escucha desde un **reproductor persistente**
que no se corta al navegar, y se puede **comprar en packs de cinco canciones** que no se corta al navegar, y se puede **comprar en packs de diez canciones**
que se entregan como descarga en MP3. que se entregan como descarga en MP3.
El catálogo vive en Markdown dentro del repositorio: publicar una canción es El catálogo vive en Markdown dentro del repositorio: publicar una canción es
@ -23,6 +23,15 @@ azulado, que es lo que le da el aspecto de terciopelo en vez del de pizarra;
todos los fondos comparten tono y solo cambian de claridad. El dorado es el todos los fondos comparten tono y solo cambian de claridad. El dorado es el
único acento y lo llevan las cosas accionables, nada más. único acento y lo llevan las cosas accionables, nada más.
**En los botones el dorado no es plano.** Un metal refleja, y lo que lo hace
parecer metal es la banda de luz que lo cruza: el degradado `--oro` va de la
sombra del canto al brillo y vuelve, con un filo de luz arriba que lo bisela. El
fondo se pinta más alto que el botón a propósito, así que al acercar el puntero
la luz **recorre** la pieza en vez de limitarse a aclararla; es la diferencia
entre algo que se ilumina y algo que gira. El color plano se queda debajo como
reserva: si el degradado no llegara a pintarse, el botón sigue siendo dorado y
legible.
Hay **dos modos, y el claro es una inversión de verdad**: el texto pasa a ser Hay **dos modos, y el claro es una inversión de verdad**: el texto pasa a ser
el propio marino y los fondos, blancos apenas teñidos de él. El dorado baja de el propio marino y los fondos, blancos apenas teñidos de él. El dorado baja de
claridad en el modo claro —el que se lee bien sobre marino desaparece sobre claridad en el modo claro —el que se lee bien sobre marino desaparece sobre
@ -543,6 +552,17 @@ Dónde se usa cada cosa: **tarjetas** donde se descubre música (portada, temas
destacados de cada estilos, colecciones de singles) y **lista numerada** en la destacados de cada estilos, colecciones de singles) y **lista numerada** en la
página de un disco, donde el orden de las pistas es la información. página de un disco, donde el orden de las pistas es la información.
## Las láminas de llamada
Las dos que no son la cabecera llevan **foto propia**, no la textura de marca:
un café con un cuaderno para la tienda, y alguien escribiendo en la calle para
el taller. En las dos, lo que se ve está a la derecha y el texto va a la
izquierda, que es como está calibrado el velo de tipo `foto`: sobre una imagen
clara el texto blanco no se lee, así que ahí el velo no atenúa, **oscurece**.
Llegan en JPEG de más de medio mega y se sirven en tres anchos WebP, como el
fondo de marca.
## El fondo de la portada ## El fondo de la portada
Encima de todo, en la cabecera, van **ondas animadas** Encima de todo, en la cabecera, van **ondas animadas**
@ -739,8 +759,10 @@ poder comparar arreglos seguidos.
### Precio ### Precio
Las canciones se venden en **packs cerrados de cinco, a 1,50 € el pack**. El Las canciones se venden en **packs cerrados de diez, a 1,50 € el pack**: diez
carrito no deja pagar hasta completar un múltiplo de cinco, dice cuántas faltan canciones por lo que cuesta un café, que es la propuesta y por eso el número y
el importe se leen siempre juntos. El carrito no deja pagar hasta completar un
múltiplo de diez, dice cuántas faltan
y propone temas para cerrar el pack. Toda esa aritmética está en y propone temas para cerrar el pack. Toda esa aritmética está en
[`src/lib/tienda.ts`](src/lib/tienda.ts), en funciones puras que usan por igual [`src/lib/tienda.ts`](src/lib/tienda.ts), en funciones puras que usan por igual
la página y el servidor: no hay dos versiones del cálculo. la página y el servidor: no hay dos versiones del cálculo.

@ -115,7 +115,7 @@ test.describe('Carrusel de la portada', () => {
).toBeVisible(); ).toBeVisible();
await page.getByRole('button', { name: 'Ir a: Comprar canciones' }).click(); await page.getByRole('button', { name: 'Ir a: Comprar canciones' }).click();
await expect(page.getByRole('heading', { name: 'Llévate las canciones' })).toBeVisible(); await expect(page.getByRole('heading', { name: /canciones por un café/ })).toBeVisible();
}); });
test('el teclado no entra en las láminas que no se ven', async ({ page }) => { test('el teclado no entra en las láminas que no se ven', async ({ page }) => {

@ -1,5 +1,8 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
/** El tamaño del pack, tal como lo dice `$lib/tienda`. */
const CANCIONES_POR_PACK = 10;
/** Añade un tema desde el listado de un álbum, por su título. */ /** Añade un tema desde el listado de un álbum, por su título. */
async function anadir(page: Page, titulo: string) { async function anadir(page: Page, titulo: string) {
// Dentro de la lista de temas, no en cualquier lista: las migas de pan // Dentro de la lista de temas, no en cualquier lista: las migas de pan
@ -13,14 +16,33 @@ async function anadir(page: Page, titulo: string) {
await expect(fila.getByRole('button', { name: 'Quitar' })).toBeVisible(); await expect(fila.getByRole('button', { name: 'Quitar' })).toBeVisible();
} }
/** Deja el carrito con las cinco canciones de un pack completo. */ /**
async function llenarUnPack(page: Page) { * Deja el carrito con un pack completo.
await page.goto('/albumes/como-si-nada'); *
for (const titulo of ['Como si nada', 'Media verdad', 'El último tren', 'Domingo largo']) { * Recorre discos añadiendo lo que haya a la venta hasta llegar al número, en
await anadir(page, titulo); * vez de nombrar los temas uno a uno: así el tamaño del pack puede cambiar
* —pasó de cinco a diez— sin que haya que reescribir la lista, y añadir o
* quitar una canción del catálogo no rompe la prueba.
*/
async function llenarUnPack(page: Page, cuantas = CANCIONES_POR_PACK) {
let puestas = 0;
for (const album of ['como-si-nada', 'dos-orillas', 'ruido-blanco', 'preludios-para-piano']) {
if (puestas >= cuantas) break;
await page.goto(`/albumes/${album}`);
const botones = page
.getByRole('list', { name: 'Temas' })
.getByRole('button', { name: 'Añadir' });
while (puestas < cuantas && (await botones.count()) > 0) {
await botones.first().click();
// El botón cambia a «Quitar», así que la cuenta de «Añadir» baja sola.
await expect(botones).toHaveCount((await botones.count()) - 1 + 1 - 1);
puestas++;
}
} }
await page.goto('/albumes/dos-orillas');
await anadir(page, 'Dos orillas'); expect(puestas, 'no hay bastantes temas a la venta para llenar un pack').toBe(cuantas);
} }
test.describe('Carrito', () => { test.describe('Carrito', () => {
@ -39,7 +61,7 @@ test.describe('Carrito', () => {
await page.goto('/carrito'); await page.goto('/carrito');
await expect( await expect(
page.getByText('Faltan 4 canciones para completar el pack.').first() page.getByText(`Faltan ${CANCIONES_POR_PACK - 1} canciones para completar el pack.`).first()
).toBeVisible(); ).toBeVisible();
// Sin sesión el botón de pagar ni siquiera aparece: primero hay que entrar. // Sin sesión el botón de pagar ni siquiera aparece: primero hay que entrar.
await expect(page.getByRole('link', { name: 'Entrar para comprar' })).toBeVisible(); await expect(page.getByRole('link', { name: 'Entrar para comprar' })).toBeVisible();
@ -54,11 +76,11 @@ test.describe('Carrito', () => {
await expect(sugerencias).toBeVisible(); await expect(sugerencias).toBeVisible();
}); });
test('con cinco canciones cuenta un pack y su precio', async ({ page }) => { test('un pack completo cuenta y se puede pagar', async ({ page }) => {
await llenarUnPack(page); await llenarUnPack(page);
await page.goto('/carrito'); await page.goto('/carrito');
await expect(page.getByText('1 pack de 5 canciones.')).toBeVisible(); await expect(page.getByText(`1 pack de ${CANCIONES_POR_PACK} canciones.`)).toBeVisible();
await expect(page.getByText('1,50 €').first()).toBeVisible(); await expect(page.getByText('1,50 €').first()).toBeVisible();
// Al estar completo, ya no hay nada que sugerir. // Al estar completo, ya no hay nada que sugerir.
await expect(page.getByRole('heading', { level: 2, name: 'Completa el pack' })).toHaveCount(0); await expect(page.getByRole('heading', { level: 2, name: 'Completa el pack' })).toHaveCount(0);

@ -33,8 +33,15 @@
--c-text: oklch(0.96 0.012 255); --c-text: oklch(0.96 0.012 255);
--c-text-muted: oklch(0.74 0.022 258); --c-text-muted: oklch(0.74 0.022 258);
--c-accent: oklch(0.82 0.13 88); /*
* El oro. El plano de antes tiraba a amarillo; este baja de claridad y sube
* de croma, que es lo que separa un metal de un color de rotulador.
*/
--c-accent: oklch(0.8 0.145 84);
--c-accent-text: oklch(0.16 0.042 264); --c-accent-text: oklch(0.16 0.042 264);
/* Los dos extremos del reflejo: la luz que lo cruza y la sombra del canto. */
--c-accent-brillo: oklch(0.94 0.095 96);
--c-accent-sombra: oklch(0.63 0.13 72);
--c-danger: oklch(0.72 0.16 22); --c-danger: oklch(0.72 0.16 22);
--c-focus: oklch(0.84 0.12 88); --c-focus: oklch(0.84 0.12 88);
@ -68,6 +75,25 @@
--sp-7: 3rem; --sp-7: 3rem;
--sp-8: 5rem; --sp-8: 5rem;
/*
* El oro de una superficie no es un color plano: un metal refleja, y lo que
* lo hace parecer metal es la banda de luz que lo cruza. Cinco paradas —de
* la sombra del canto al brillo y otra vez a la sombra— bastan para
* sugerirlo sin que parezca un tubo de cromo.
*
* El fondo se pinta más alto que el elemento a propósito: así, al acercar el
* puntero, se desplaza y la luz recorre la pieza en vez de limitarse a
* aclararla. Es la diferencia entre algo que se ilumina y algo que gira.
*/
--oro: linear-gradient(
166deg,
var(--c-accent-sombra) 0%,
var(--c-accent) 24%,
var(--c-accent-brillo) 46%,
var(--c-accent) 64%,
var(--c-accent-sombra) 100%
);
--radius: 4px; --radius: 4px;
/* Radio propio: 4px en una tarjeta grande se lee como un recorte, no como una pieza. */ /* Radio propio: 4px en una tarjeta grande se lee como un recorte, no como una pieza. */
--radius-tarjeta: 14px; --radius-tarjeta: 14px;
@ -119,8 +145,10 @@
--c-text: oklch(0.24 0.055 264); --c-text: oklch(0.24 0.055 264);
--c-text-muted: oklch(0.48 0.038 264); --c-text-muted: oklch(0.48 0.038 264);
--c-accent: oklch(0.58 0.115 75); --c-accent: oklch(0.57 0.125 74);
--c-accent-text: oklch(0.99 0.004 264); --c-accent-text: oklch(0.99 0.004 264);
--c-accent-brillo: oklch(0.72 0.14 82);
--c-accent-sombra: oklch(0.44 0.1 66);
--c-danger: oklch(0.5 0.17 25); --c-danger: oklch(0.5 0.17 25);
--c-focus: oklch(0.5 0.13 264); --c-focus: oklch(0.5 0.13 264);
@ -137,8 +165,10 @@
--c-text: oklch(0.24 0.055 264); --c-text: oklch(0.24 0.055 264);
--c-text-muted: oklch(0.48 0.038 264); --c-text-muted: oklch(0.48 0.038 264);
--c-accent: oklch(0.58 0.115 75); --c-accent: oklch(0.57 0.125 74);
--c-accent-text: oklch(0.99 0.004 264); --c-accent-text: oklch(0.99 0.004 264);
--c-accent-brillo: oklch(0.72 0.14 82);
--c-accent-sombra: oklch(0.44 0.1 66);
--c-danger: oklch(0.5 0.17 25); --c-danger: oklch(0.5 0.17 25);
--c-focus: oklch(0.5 0.13 264); --c-focus: oklch(0.5 0.13 264);
@ -286,14 +316,27 @@ button {
} }
.btn--primary { .btn--primary {
background: var(--c-accent); /* El color plano queda de reserva: si el degradado no se pintara, el botón
sigue siendo dorado y legible, no transparente. */
background-color: var(--c-accent);
background-image: var(--oro);
background-size: 100% 240%;
background-position: 0 22%;
border-color: var(--c-accent-sombra);
color: var(--c-accent-text); color: var(--c-accent-text);
border-color: var(--c-accent); /* El filo de luz de arriba. Sin él, el degradado se ve pintado, no biselado. */
box-shadow: inset 0 1px 0 color-mix(in oklab, white 45%, transparent);
transition: background-position 0.5s var(--sal);
} }
.btn--primary:hover { .btn--primary:hover {
opacity: 0.85; background-position: 0 78%;
background: var(--c-accent); }
@media (prefers-reduced-motion: reduce) {
.btn--primary {
transition: none;
}
} }
/* --- etiquetas ----------------------------------------------------------- /* --- etiquetas -----------------------------------------------------------

@ -109,14 +109,24 @@
.solido { .solido {
padding: var(--sp-2) var(--sp-4); padding: var(--sp-2) var(--sp-4);
background: var(--c-accent); background-color: var(--c-accent);
background-image: var(--oro);
background-size: 100% 240%;
background-position: 0 22%;
border-color: var(--c-accent-sombra);
color: var(--c-accent-text); color: var(--c-accent-text);
border-color: var(--c-accent); box-shadow: inset 0 1px 0 color-mix(in oklab, white 45%, transparent);
transition: background-position 0.5s var(--sal);
} }
.solido:hover:not(:disabled) { .solido:hover:not(:disabled) {
background: var(--c-accent); background-position: 0 78%;
opacity: 0.85; }
@media (prefers-reduced-motion: reduce) {
.solido {
transition: none;
}
} }
.solido.activo { .solido.activo {

@ -26,8 +26,17 @@
* la derecha, que es donde se ve el tocadiscos. * la derecha, que es donde se ve el tocadiscos.
* - `cerrado` — parejo y más cubriente. En una lámina sin nada que enseñar * - `cerrado` — parejo y más cubriente. En una lámina sin nada que enseñar
* a la derecha, lo único que importa es que se lea lo que se propone. * a la derecha, lo único que importa es que se lea lo que se propone.
* - `foto` — para una imagen real y clara. El texto del tema oscuro es
* blanco, y sobre un mármol iluminado no se lee: aquí el velo no atenúa,
* oscurece.
*/ */
velo?: 'lateral' | 'cerrado'; velo?: 'lateral' | 'cerrado' | 'foto';
/**
* Imagen propia de la lámina. Sin esto se usa el fondo de marca, que
* cambia con el tema; una foto no cambia, así que quien la pasa se
* responsabiliza también de que el velo la deje legible.
*/
imagen?: { src: string; srcset: string; ancho: number; alto: number };
/** /**
* Solo la primera lámina lo pide con prioridad alta: es lo que se ve al * Solo la primera lámina lo pide con prioridad alta: es lo que se ve al
* llegar. Pedir tres fondos con prioridad alta es no priorizar nada. * llegar. Pedir tres fondos con prioridad alta es no priorizar nada.
@ -58,9 +67,15 @@
ondas?: boolean; ondas?: boolean;
} }
let { velo = 'lateral', prioritario = false, clip = false, ondas = false }: Props = $props(); let {
velo = 'lateral',
prioritario = false,
clip = false,
ondas = false,
imagen
}: Props = $props();
const fondo = $derived(heroe.fondo[tema.actual]); const fondo = $derived(imagen ?? heroe.fondo[tema.actual]);
/* /*
* Donde hay imagen no se monta el clip, así que nunca conviven los dos en la * Donde hay imagen no se monta el clip, así que nunca conviven los dos en la
* página. Lo que sí ocurre es que el servidor pinta el tema oscuro —el de * página. Lo que sí ocurre es que el servidor pinta el tema oscuro —el de
@ -126,7 +141,7 @@
<OndasWebgl /> <OndasWebgl />
{/if} {/if}
<span class="velo" class:cerrado={velo === 'cerrado'}></span> <span class="velo" class:cerrado={velo === 'cerrado'} class:foto={velo === 'foto'}></span>
</div> </div>
<style> <style>
@ -181,6 +196,22 @@
} }
} }
/*
* Sobre una foto clara hace falta oscurecer de verdad, no atenuar: el texto
* es blanco. Se carga la izquierda, donde va, y se deja respirar la derecha
* para que la imagen siga contándose.
*/
.velo.foto {
background:
linear-gradient(
to right,
color-mix(in oklab, var(--c-bg) 94%, transparent) 0%,
color-mix(in oklab, var(--c-bg) 80%, transparent) 45%,
color-mix(in oklab, var(--c-bg) 35%, transparent) 100%
),
linear-gradient(to top, var(--c-bg) 0%, transparent 45%);
}
.velo.cerrado { .velo.cerrado {
background: background:
linear-gradient( linear-gradient(

@ -13,6 +13,8 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
/** Fondo propio de la lámina. Sin esto, el de marca. */
imagen?: { src: string; srcset: string; ancho: number; alto: number };
/** Rótulo corto sobre el título. */ /** Rótulo corto sobre el título. */
eyebrow: string; eyebrow: string;
titulo: string; titulo: string;
@ -21,11 +23,11 @@
acciones: Snippet; acciones: Snippet;
} }
let { eyebrow, titulo, texto, acciones }: Props = $props(); let { imagen, eyebrow, titulo, texto, acciones }: Props = $props();
</script> </script>
<section class="lamina-cta"> <section class="lamina-cta">
<FondoDePortada velo="cerrado" /> <FondoDePortada {imagen} velo={imagen ? 'foto' : 'cerrado'} />
<div class="container"> <div class="container">
<p class="eyebrow">{eyebrow}</p> <p class="eyebrow">{eyebrow}</p>

@ -54,6 +54,28 @@ export const heroe = {
}, },
claro: null claro: null
}, },
/**
* Fondos de las dos laminas de llamada. Son fotos y no texturas, asi que
* llevan su propio velo: sobre una imagen clara, el texto blanco del tema
* oscuro no se leeria.
*
* En las dos, lo que se ve esta a la derecha y el texto va a la izquierda,
* que es como esta calibrado el velo de tipo `foto`.
*/
taller: {
src: '/background/taller-1408.webp',
ancho: 1408,
alto: 768,
srcset:
'/background/taller-700.webp 700w, /background/taller-1000.webp 1000w, /background/taller-1408.webp 1408w'
},
cafe: {
src: '/background/cafe-1376.webp',
ancho: 1376,
alto: 768,
srcset:
'/background/cafe-700.webp 700w, /background/cafe-1000.webp 1000w, /background/cafe-1376.webp 1376w'
},
/** Un clip por tema: el oscuro sobre marino, el claro sobre blanco. */ /** Un clip por tema: el oscuro sobre marino, el claro sobre blanco. */
video: { oscuro: '/video/heroe.mp4', claro: '/video/heroe-claro.mp4' }, video: { oscuro: '/video/heroe.mp4', claro: '/video/heroe-claro.mp4' },
/** Primer fotograma; es lo que se ve sin video o con movimiento reducido. */ /** Primer fotograma; es lo que se ve sin video o con movimiento reducido. */

@ -18,8 +18,8 @@ describe('resumirCarrito', () => {
}); });
}); });
it('cinco canciones son un pack completo', () => { it('un pack justo está completo', () => {
expect(resumirCarrito(5)).toMatchObject({ expect(resumirCarrito(CANCIONES_POR_PACK)).toMatchObject({
packs: 1, packs: 1,
faltan: 0, faltan: 0,
completo: true, completo: true,
@ -27,8 +27,8 @@ describe('resumirCarrito', () => {
}); });
}); });
it('diez canciones son dos packs', () => { it('el doble son dos packs', () => {
expect(resumirCarrito(10)).toMatchObject({ expect(resumirCarrito(2 * CANCIONES_POR_PACK)).toMatchObject({
packs: 2, packs: 2,
completo: true, completo: true,
totalCentimos: 2 * PRECIO_PACK_CENTIMOS totalCentimos: 2 * PRECIO_PACK_CENTIMOS
@ -37,22 +37,23 @@ describe('resumirCarrito', () => {
it('un pack a medias no se puede pagar', () => { it('un pack a medias no se puede pagar', () => {
// Es la regla del negocio: solo packs cerrados. // Es la regla del negocio: solo packs cerrados.
for (const n of [1, 2, 3, 4]) { for (let n = 1; n < CANCIONES_POR_PACK; n++) {
expect(resumirCarrito(n).completo).toBe(false); expect(resumirCarrito(n).completo).toBe(false);
} }
}); });
it('dice cuántas faltan para cerrar el pack', () => { it('dice cuántas faltan para cerrar el pack', () => {
expect(resumirCarrito(1).faltan).toBe(4); expect(resumirCarrito(1).faltan).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(4).faltan).toBe(1); expect(resumirCarrito(CANCIONES_POR_PACK - 1).faltan).toBe(1);
expect(resumirCarrito(6).faltan).toBe(4); // Y vuelve a contar desde cero en el pack siguiente.
expect(resumirCarrito(9).faltan).toBe(1); expect(resumirCarrito(CANCIONES_POR_PACK + 1).faltan).toBe(CANCIONES_POR_PACK - 1);
expect(resumirCarrito(2 * CANCIONES_POR_PACK - 1).faltan).toBe(1);
}); });
it('solo cobra los packs cerrados', () => { it('solo cobra los packs cerrados', () => {
// Con siete canciones hay un pack completo y dos sueltas: no se cobran // Un pack completo y dos sueltas: las sueltas no se cobran, y el pago
// las sueltas, y el pago sigue bloqueado hasta completarlas. // sigue bloqueado hasta completarlas.
const resumen = resumirCarrito(7); const resumen = resumirCarrito(CANCIONES_POR_PACK + 2);
expect(resumen.packs).toBe(1); expect(resumen.packs).toBe(1);
expect(resumen.totalCentimos).toBe(PRECIO_PACK_CENTIMOS); expect(resumen.totalCentimos).toBe(PRECIO_PACK_CENTIMOS);
expect(resumen.completo).toBe(false); expect(resumen.completo).toBe(false);
@ -84,14 +85,20 @@ describe('mensajeDeCarrito', () => {
}); });
it('cuenta los packs cuando está completo', () => { it('cuenta los packs cuando está completo', () => {
expect(mensajeDeCarrito(resumirCarrito(5))).toBe(`1 pack de ${CANCIONES_POR_PACK} canciones.`); expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK))).toBe(
expect(mensajeDeCarrito(resumirCarrito(10))).toBe( `1 pack de ${CANCIONES_POR_PACK} canciones.`
);
expect(mensajeDeCarrito(resumirCarrito(2 * CANCIONES_POR_PACK))).toBe(
`2 packs de ${CANCIONES_POR_PACK} canciones.` `2 packs de ${CANCIONES_POR_PACK} canciones.`
); );
}); });
it('concuerda el singular al faltar una sola', () => { it('concuerda el singular al faltar una sola', () => {
expect(mensajeDeCarrito(resumirCarrito(4))).toBe('Falta 1 canción para completar el pack.'); expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 1))).toBe(
expect(mensajeDeCarrito(resumirCarrito(3))).toBe('Faltan 2 canciones para completar el pack.'); 'Falta 1 canción para completar el pack.'
);
expect(mensajeDeCarrito(resumirCarrito(CANCIONES_POR_PACK - 2))).toBe(
'Faltan 2 canciones para completar el pack.'
);
}); });
}); });

@ -1,15 +1,18 @@
/** /**
* Reglas de la tienda. * Reglas de la tienda.
* *
* Las canciones se venden en packs cerrados de cinco: no hay precio por tema * Las canciones se venden en packs cerrados de diez: no hay precio por tema
* suelto, y el pago se bloquea hasta que el carrito tiene un multiplo de * suelto, y el pago se bloquea hasta que el carrito tiene un multiplo de diez.
* cinco. Toda esa aritmetica vive aqui, en funciones puras, porque la usan por * Toda esa aritmetica vive aqui, en funciones puras, porque la usan por igual
* igual la pagina del carrito y la accion de pago del servidor, y no puede * la pagina del carrito y la accion de pago del servidor, y no puede haber dos
* haber dos versiones del calculo. * versiones del calculo.
*
* Diez canciones por el precio de un cafe. Es la propuesta, y por eso el numero
* y el importe se leen juntos en todas partes: sueltos no dicen nada.
*/ */
/** Canciones que componen un pack. */ /** Canciones que componen un pack. */
export const CANCIONES_POR_PACK = 5; export const CANCIONES_POR_PACK = 10;
/** Precio de un pack, en centimos. */ /** Precio de un pack, en centimos. */
export const PRECIO_PACK_CENTIMOS = 150; export const PRECIO_PACK_CENTIMOS = 150;

@ -10,7 +10,7 @@
import { revelar } from '$lib/acciones/revelar'; import { revelar } from '$lib/acciones/revelar';
import { aPistas } from '$lib/player/pista'; import { aPistas } from '$lib/player/pista';
import { slugDeEtiqueta } from '$lib/content'; import { slugDeEtiqueta } from '$lib/content';
import { site } from '$lib/site'; import { heroe, site } from '$lib/site';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda'; import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
let { data } = $props(); let { data } = $props();
@ -36,6 +36,7 @@
<Heroe album={data.heroe} /> <Heroe album={data.heroe} />
{:else if i === 1} {:else if i === 1}
<LaminaCta <LaminaCta
imagen={heroe.taller}
eyebrow="Taller de letras" eyebrow="Taller de letras"
titulo="¿Quieres escribir tus propias letras?" titulo="¿Quieres escribir tus propias letras?"
texto="Lo que he aprendido escribiendo, puesto en orden: los principios que hacen que una letra funcione y una ficha de creación por cada estilo hispanoamericano, con lo que hay que tener delante para escribir en él." texto="Lo que he aprendido escribiendo, puesto en orden: los principios que hacen que una letra funcione y una ficha de creación por cada estilo hispanoamericano, con lo que hay que tener delante para escribir en él."
@ -49,11 +50,12 @@
</LaminaCta> </LaminaCta>
{:else} {:else}
<LaminaCta <LaminaCta
imagen={heroe.cafe}
eyebrow="La tienda" eyebrow="La tienda"
titulo="Llévate las canciones" titulo="{CANCIONES_POR_PACK} canciones por un café"
texto="{CANCIONES_POR_PACK} canciones por {formatearPrecio( texto="{formatearPrecio(
PRECIO_PACK_CENTIMOS PRECIO_PACK_CENTIMOS
)}, en MP3 y con todas sus versiones. En la web suena un fragmento; la descarga es el archivo completo." )} el pack, en MP3 y con todas sus versiones. En la web suena un fragmento; la descarga es el archivo completo, para llevártelo donde quieras."
> >
{#snippet acciones()} {#snippet acciones()}
<a class="btn btn--primary" href={resolve('/musica')}>Elegir canciones</a> <a class="btn btn--primary" href={resolve('/musica')}>Elegir canciones</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Loading…
Cancel
Save

Powered by TurnKey Linux.