diff --git a/e2e/suscripciones.e2e.ts b/e2e/suscripciones.e2e.ts new file mode 100644 index 0000000..9b3b321 --- /dev/null +++ b/e2e/suscripciones.e2e.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Suscripciones', () => { + test('la página pública explica los planes sin prometer un cobro inexistente', async ({ + page + }) => { + await page.goto('/suscripciones'); + + await expect(page.getByRole('heading', { level: 1, name: 'Suscripciones' })).toBeVisible(); + await expect( + page.getByRole('heading', { level: 2, name: 'Suscripción y compra no son lo mismo' }) + ).toBeVisible(); + await expect(page.getByText('Las canciones compradas')).toBeVisible(); + }); + + test('se puede alcanzar desde el pie y no desborda en móvil', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.locator('footer').getByRole('link', { name: 'Suscripciones' }).click(); + await expect(page).toHaveURL(/\/suscripciones$/); + + const ancho = await page.evaluate(() => document.documentElement.scrollWidth); + expect(ancho).toBeLessThanOrEqual(390); + }); +}); diff --git a/src/lib/components/PieDePagina.svelte b/src/lib/components/PieDePagina.svelte index f3d7730..05e6673 100644 --- a/src/lib/components/PieDePagina.svelte +++ b/src/lib/components/PieDePagina.svelte @@ -45,6 +45,16 @@ +
+

Acceso

+ +
+ {#if redes.length > 0}

Redes

diff --git a/src/lib/server/integracion-postgres.spec.ts b/src/lib/server/integracion-postgres.spec.ts index 45843fb..82e6fd7 100644 --- a/src/lib/server/integracion-postgres.spec.ts +++ b/src/lib/server/integracion-postgres.spec.ts @@ -33,6 +33,7 @@ import { guardarCancion, cancionParaEditar, type CambiosDeCancion } from './pane import { renombrarLista, moverEnLista, borrarLista, listasDeUsuario } from './listas-reproduccion'; import { concederSuscripcion, revocarSuscripcion } from './operaciones'; import { resumirCarrito } from '$lib/tienda'; +import { planesDeSuscripcionPublicos } from './suscripciones'; const conexion = process.env.TEST_DATABASE_URL; describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => { @@ -235,6 +236,23 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => { 'cancelada' ); }); + it('publica únicamente los planes disponibles y no expone la referencia de Stripe', async () => { + await cliente.query( + `INSERT INTO plan_suscripcion + (id,codigo,nombre,nivel,precio_centimos,periodicidad,prestaciones,estado,referencia_precio_externa) + VALUES + ('sp1','apoyo','Apoyo',1,500,'mes','["Catálogo exclusivo"]','disponible','price_privado'), + ('sp2','borrador','Borrador',2,900,'mes','[]','borrador',null)` + ); + const planes = await planesDeSuscripcionPublicos(); + expect(planes).toHaveLength(1); + expect(planes[0]).toMatchObject({ + codigo: 'apoyo', + nombre: 'Apoyo', + prestaciones: ['Catálogo exclusivo'] + }); + expect(planes[0]).not.toHaveProperty('referenciaPrecioExterna'); + }); it('una sesión existente no admite otro propietario', async () => { await expect( crearPedido({ diff --git a/src/lib/server/suscripciones.ts b/src/lib/server/suscripciones.ts new file mode 100644 index 0000000..f9214ef --- /dev/null +++ b/src/lib/server/suscripciones.ts @@ -0,0 +1,36 @@ +import { asc, eq } from 'drizzle-orm'; +import { db } from './db'; +import { planSuscripcion } from './db/schema'; + +/** + * Planes que se pueden enseñar públicamente. + * + * No se expone la referencia del precio en Stripe: pertenece al cobro, no a la + * ficha comercial que viaja al navegador. + */ +export async function planesDeSuscripcionPublicos() { + const filas = await db + .select({ + id: planSuscripcion.id, + codigo: planSuscripcion.codigo, + nombre: planSuscripcion.nombre, + descripcion: planSuscripcion.descripcion, + nivel: planSuscripcion.nivel, + precioCentimos: planSuscripcion.precioCentimos, + moneda: planSuscripcion.moneda, + periodicidad: planSuscripcion.periodicidad, + intervalos: planSuscripcion.intervalos, + prestaciones: planSuscripcion.prestaciones + }) + .from(planSuscripcion) + .where(eq(planSuscripcion.estado, 'disponible')) + .orderBy(asc(planSuscripcion.nivel), asc(planSuscripcion.precioCentimos)); + + return filas.map((plan) => ({ + ...plan, + prestaciones: plan.prestaciones.filter( + (prestacion): prestacion is string => + typeof prestacion === 'string' && prestacion.trim() !== '' + ) + })); +} diff --git a/src/lib/suscripciones.spec.ts b/src/lib/suscripciones.spec.ts new file mode 100644 index 0000000..627aff4 --- /dev/null +++ b/src/lib/suscripciones.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { periodoDeSuscripcion } from './suscripciones'; + +describe('periodoDeSuscripcion', () => { + it('describe una renovación simple', () => { + expect(periodoDeSuscripcion('mes', 1)).toBe('al mes'); + expect(periodoDeSuscripcion('ano', 1)).toBe('al año'); + }); + + it('describe planes con varios intervalos', () => { + expect(periodoDeSuscripcion('mes', 3)).toBe('cada 3 meses'); + expect(periodoDeSuscripcion('semana', 2)).toBe('cada 2 semanas'); + }); +}); diff --git a/src/lib/suscripciones.ts b/src/lib/suscripciones.ts new file mode 100644 index 0000000..7530a10 --- /dev/null +++ b/src/lib/suscripciones.ts @@ -0,0 +1,14 @@ +import type { Periodicidad } from './comercio'; + +const PERIODOS: Record = { + dia: { singular: 'día', plural: 'días' }, + semana: { singular: 'semana', plural: 'semanas' }, + mes: { singular: 'mes', plural: 'meses' }, + ano: { singular: 'año', plural: 'años' } +}; + +/** Texto corto que acompaña al precio de un plan. */ +export function periodoDeSuscripcion(periodicidad: Periodicidad, intervalos: number): string { + const periodo = PERIODOS[periodicidad]; + return intervalos === 1 ? `al ${periodo.singular}` : `cada ${intervalos} ${periodo.plural}`; +} diff --git a/src/lib/tienda.spec.ts b/src/lib/tienda.spec.ts index 15e6cd5..ab429b6 100644 --- a/src/lib/tienda.spec.ts +++ b/src/lib/tienda.spec.ts @@ -79,6 +79,10 @@ describe('formatearPrecio', () => { expect(normalizar(formatearPrecio(0))).toBe('0,00 €'); expect(normalizar(formatearPrecio(1050))).toBe('10,50 €'); }); + + it('respeta la moneda indicada por un plan', () => { + expect(formatearPrecio(500, 'en-US', 'usd')).toBe('$5.00'); + }); }); describe('mensajeDeCarrito', () => { diff --git a/src/lib/tienda.ts b/src/lib/tienda.ts index 9d8b35c..f21dea7 100644 --- a/src/lib/tienda.ts +++ b/src/lib/tienda.ts @@ -91,10 +91,10 @@ export function resumirCarrito(canciones: number): ResumenCarrito { } /** "1,50 €" */ -export function formatearPrecio(centimos: number, locale = 'es-ES'): string { +export function formatearPrecio(centimos: number, locale = 'es-ES', moneda = MONEDA): string { return new Intl.NumberFormat(locale, { style: 'currency', - currency: MONEDA.toUpperCase() + currency: moneda.toUpperCase() }).format(centimos / 100); } diff --git a/src/routes/(sitio)/cuenta/+page.svelte b/src/routes/(sitio)/cuenta/+page.svelte index 1004f25..147adce 100644 --- a/src/routes/(sitio)/cuenta/+page.svelte +++ b/src/routes/(sitio)/cuenta/+page.svelte @@ -66,10 +66,11 @@ El acceso se concede manualmente y no se renueva ni se cobra automáticamente.

{/if}

- {data.suscripcionActiva - ? 'Tu suscripción está activa: puedes escuchar el catálogo exclusivo.' - : 'No tienes una suscripción activa.'} - Solicitar o gestionar el acceso. + {#if data.suscripcionActiva} + Tu suscripción está activa: puedes escuchar el catálogo exclusivo. + {:else} + No tienes una suscripción activa. Ver los planes. + {/if}

diff --git a/src/routes/(sitio)/suscripciones/+page.server.ts b/src/routes/(sitio)/suscripciones/+page.server.ts new file mode 100644 index 0000000..b69f3e8 --- /dev/null +++ b/src/routes/(sitio)/suscripciones/+page.server.ts @@ -0,0 +1,5 @@ +import { planesDeSuscripcionPublicos } from '$lib/server/suscripciones'; + +export async function load() { + return { planes: await planesDeSuscripcionPublicos() }; +} diff --git a/src/routes/(sitio)/suscripciones/+page.svelte b/src/routes/(sitio)/suscripciones/+page.svelte new file mode 100644 index 0000000..4b3190b --- /dev/null +++ b/src/routes/(sitio)/suscripciones/+page.svelte @@ -0,0 +1,282 @@ + + + + +
+
+

Escucha y apoyo

+

Suscripciones

+

+ Elige el nivel que encaje contigo. Una suscripción activa permite escuchar el contenido + exclusivo; cada plan detalla además sus propias prestaciones. +

+
+ + {#if data.planes.length > 0} +
+
+

Tipos de suscripción

+

Ordenados de menor a mayor nivel.

+
+ +
    + {#each data.planes as plan (plan.id)} +
  • +
    +
    +

    Nivel {plan.nivel}

    +

    {plan.nombre}

    + {#if plan.descripcion} +

    {plan.descripcion}

    + {/if} +
    + +

    + {formatearPrecio(plan.precioCentimos, 'es-ES', plan.moneda)} + {periodoDeSuscripcion(plan.periodicidad, plan.intervalos)} +

    + + {#if plan.prestaciones.length > 0} +
      + {#each plan.prestaciones as prestacion (prestacion)} +
    • {prestacion}
    • + {/each} +
    + {:else} +

    + Las prestaciones se anunciarán antes de abrir el plan. +

    + {/if} + + {#if data.suscripcionActiva} + Gestionar mi suscripción + {:else} + pedirContacto(evento)} + > + Consultar este plan + + {/if} +
    +
  • + {/each} +
+
+ {:else} +
+

Próximamente

+

Estamos definiendo los primeros planes

+

+ Publicaremos aquí cada nivel con su precio, periodicidad y prestaciones antes de abrir las + suscripciones. +

+ pedirContacto(evento)}> + Pedir información + +
+ {/if} + +
+

Suscripción y compra no son lo mismo

+
+

+ La suscripción + mantiene el acceso al catálogo exclusivo mientras esté activa. +

+

+ Las canciones compradas + quedan en tu cuenta y sus descargas no dependen de la suscripción. +

+
+
+
+ + diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 0cdddf3..528c314 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -60,6 +60,7 @@ export const GET: RequestHandler = async () => { url('/', PRIORIDAD.portada), url('/musica', PRIORIDAD.seccion), url('/interpretes', PRIORIDAD.seccion), + url('/suscripciones', PRIORIDAD.seccion), url('/taller', PRIORIDAD.seccion), url('/blog', PRIORIDAD.seccion), url('/contacto', PRIORIDAD.resto),