Add public subscription plans page

codex/redisenio-dominio-musical
dev 4 weeks ago
parent f1527efd25
commit b6b35c8ed7

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

@ -45,6 +45,16 @@
</ul>
</div>
<div>
<p class="eyebrow">Acceso</p>
<ul class="lista">
<li><a href={resolve('/suscripciones')}>Suscripciones</a></li>
{#if page.data.usuario}
<li><a href={resolve('/cuenta')}>Mi cuenta</a></li>
{/if}
</ul>
</div>
{#if redes.length > 0}
<div>
<p class="eyebrow">Redes</p>

@ -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({

@ -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() !== ''
)
}));
}

@ -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');
});
});

@ -0,0 +1,14 @@
import type { Periodicidad } from './comercio';
const PERIODOS: Record<Periodicidad, { singular: string; plural: string }> = {
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}`;
}

@ -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', () => {

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

@ -66,10 +66,11 @@
El acceso se concede manualmente y no se renueva ni se cobra automáticamente.
</p>{/if}
<p class="muted">
{data.suscripcionActiva
? 'Tu suscripción está activa: puedes escuchar el catálogo exclusivo.'
: 'No tienes una suscripción activa.'}
<a href={resolve('/contacto')}>Solicitar o gestionar el acceso</a>.
{#if data.suscripcionActiva}
Tu suscripción está activa: puedes escuchar el catálogo exclusivo.
{:else}
No tienes una suscripción activa. <a href={resolve('/suscripciones')}>Ver los planes</a>.
{/if}
</p>
</section>

@ -0,0 +1,5 @@
import { planesDeSuscripcionPublicos } from '$lib/server/suscripciones';
export async function load() {
return { planes: await planesDeSuscripcionPublicos() };
}

@ -0,0 +1,282 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { pedirContacto } from '$lib/contacto-modal.svelte';
import Meta from '$lib/components/Meta.svelte';
import { periodoDeSuscripcion } from '$lib/suscripciones';
import { formatearPrecio } from '$lib/tienda';
let { data } = $props();
</script>
<Meta
titulo="Suscripciones"
descripcion="Planes de suscripción para acceder al catálogo exclusivo de Senza Paura."
/>
<div class="container pagina">
<header class="portada">
<p class="eyebrow">Escucha y apoyo</p>
<h1>Suscripciones</h1>
<p class="entradilla">
Elige el nivel que encaje contigo. Una suscripción activa permite escuchar el contenido
exclusivo; cada plan detalla además sus propias prestaciones.
</p>
</header>
{#if data.planes.length > 0}
<section class="planes" aria-labelledby="titulo-planes">
<header class="cabecera-seccion">
<h2 id="titulo-planes">Tipos de suscripción</h2>
<p class="muted">Ordenados de menor a mayor nivel.</p>
</header>
<ul class="rejilla">
{#each data.planes as plan (plan.id)}
<li>
<article class="plan">
<div class="cabecera-plan">
<p class="nivel">Nivel {plan.nivel}</p>
<h3>{plan.nombre}</h3>
{#if plan.descripcion}
<p class="descripcion">{plan.descripcion}</p>
{/if}
</div>
<p class="precio">
<strong>{formatearPrecio(plan.precioCentimos, 'es-ES', plan.moneda)}</strong>
<span>{periodoDeSuscripcion(plan.periodicidad, plan.intervalos)}</span>
</p>
{#if plan.prestaciones.length > 0}
<ul class="prestaciones" aria-label="Incluye">
{#each plan.prestaciones as prestacion (prestacion)}
<li>{prestacion}</li>
{/each}
</ul>
{:else}
<p class="sin-prestaciones muted">
Las prestaciones se anunciarán antes de abrir el plan.
</p>
{/if}
{#if data.suscripcionActiva}
<a class="btn" href={resolve('/cuenta')}>Gestionar mi suscripción</a>
{:else}
<a
class="btn"
href={resolve('/contacto')}
onclick={(evento) => pedirContacto(evento)}
>
Consultar este plan
</a>
{/if}
</article>
</li>
{/each}
</ul>
</section>
{:else}
<section class="vacio" aria-labelledby="titulo-preparacion">
<p class="eyebrow">Próximamente</p>
<h2 id="titulo-preparacion">Estamos definiendo los primeros planes</h2>
<p class="muted">
Publicaremos aquí cada nivel con su precio, periodicidad y prestaciones antes de abrir las
suscripciones.
</p>
<a class="btn" href={resolve('/contacto')} onclick={(evento) => pedirContacto(evento)}>
Pedir información
</a>
</section>
{/if}
<section class="aclaraciones" aria-labelledby="titulo-aclaraciones">
<h2 id="titulo-aclaraciones">Suscripción y compra no son lo mismo</h2>
<div class="columnas">
<p>
<strong>La suscripción</strong>
<span class="muted">mantiene el acceso al catálogo exclusivo mientras esté activa.</span>
</p>
<p>
<strong>Las canciones compradas</strong>
<span class="muted">quedan en tu cuenta y sus descargas no dependen de la suscripción.</span
>
</p>
</div>
</section>
</div>
<style>
.pagina {
min-width: 0;
}
.portada {
max-width: 50rem;
padding-block: var(--sp-7) var(--sp-6);
}
.portada h1,
.portada p {
margin-top: 0;
}
.entradilla {
max-width: 58ch;
margin-bottom: 0;
color: var(--c-text-muted);
font-size: var(--fs-lg);
font-weight: 300;
}
.planes,
.aclaraciones {
padding-block: var(--sp-5) var(--sp-7);
border-top: 1px solid var(--c-border);
}
.cabecera-seccion {
display: flex;
align-items: end;
justify-content: space-between;
gap: var(--sp-4);
margin-bottom: var(--sp-5);
}
.cabecera-seccion h2,
.cabecera-seccion p {
margin: 0;
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
gap: var(--sp-4);
margin: 0;
padding: 0;
list-style: none;
}
.plan {
display: flex;
min-height: 100%;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
.nivel {
margin: 0 0 var(--sp-2);
color: var(--c-accent);
font-size: var(--fs-xs);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.cabecera-plan h3 {
margin: 0;
font-size: var(--fs-xl);
}
.descripcion {
margin: var(--sp-2) 0 0;
color: var(--c-text-muted);
}
.precio {
display: flex;
align-items: baseline;
gap: var(--sp-2);
margin: 0;
}
.precio strong {
font-family: var(--font-body);
font-size: var(--fs-2xl);
font-variant-numeric: tabular-nums;
}
.precio span {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
.prestaciones {
display: grid;
gap: var(--sp-2);
margin: 0;
padding: 0;
list-style: none;
}
.prestaciones li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--sp-2);
}
.prestaciones li::before {
content: '✓';
color: var(--c-accent);
font-weight: 700;
}
.sin-prestaciones {
font-size: var(--fs-sm);
}
.plan > .btn {
align-self: start;
margin-top: auto;
}
.vacio {
max-width: 42rem;
padding: var(--sp-6);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
.vacio h2 {
margin-top: 0;
}
.aclaraciones {
margin-top: var(--sp-7);
}
.aclaraciones h2 {
max-width: 22ch;
}
.columnas {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-5);
}
.columnas p {
display: grid;
gap: var(--sp-1);
margin: 0;
}
@media (max-width: 36rem) {
.cabecera-seccion {
align-items: start;
flex-direction: column;
}
.columnas {
grid-template-columns: minmax(0, 1fr);
}
.vacio {
padding: var(--sp-4);
}
}
</style>

@ -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),

Loading…
Cancel
Save

Powered by TurnKey Linux.