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 @@
+
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 @@
+
+
+
+
+
+
+
+ {#if data.planes.length > 0}
+
+
+
+
+ {#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}
+
+ {/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),