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);
|
||||
});
|
||||
});
|
||||
@ -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}`;
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in new issue