Add workshop subscription and redesign plan cards

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 3518da8b00
commit 41141632f5

@ -71,13 +71,17 @@ entregar varios archivos mediante relaciones específicas cuando se publiquen.
`plan_suscripcion` define código, nombre, nivel, precio y periodicidad. Sus
capacidades operativas —publicidad, calidad de escucha, cupo y calidad de
descargas, y acceso a versiones— son columnas estructuradas; `prestaciones`
descargas, acceso a versiones, contenido del taller y descarga de sus
materiales— son columnas estructuradas y dependen del ámbito del plan;
`prestaciones`
queda para ventajas editoriales adicionales y no para tomar decisiones de
acceso. También conserva la referencia de precio externa. `suscripcion` registra el periodo
contratado por cada cuenta y apunta opcionalmente al plan. La relación es
opcional para conservar las concesiones manuales históricas.
Los niveles públicos iniciales son Follower, Friend y VIP. La contratación y la
Los planes musicales públicos iniciales son Seguidor, Amigo y Buen amigo. El Taller
de letras dispone de una suscripción anual independiente, con acceso completo y
descarga de materiales. La contratación y la
aplicación efectiva de publicidad, calidades y cupos se conectarán sobre estas
capacidades, sin comparar nombres ni textos comerciales.

@ -0,0 +1,77 @@
ALTER TABLE "plan_suscripcion" DROP CONSTRAINT "plan_suscripcion_calidad_escucha_conocida";--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ALTER COLUMN "calidad_escucha" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "ambito" text DEFAULT 'musica' NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "acceso_contenido_taller" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD COLUMN "descarga_material_taller" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_ambito_conocido" CHECK ("plan_suscripcion"."ambito" in ('musica', 'taller_letras'));--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_capacidades_segun_ambito" CHECK ((
"plan_suscripcion"."ambito" = 'musica'
and "plan_suscripcion"."calidad_escucha" is not null
and not "plan_suscripcion"."acceso_contenido_taller"
and not "plan_suscripcion"."descarga_material_taller"
) or (
"plan_suscripcion"."ambito" = 'taller_letras'
and "plan_suscripcion"."calidad_escucha" is null
and not "plan_suscripcion"."incluye_publicidad"
and "plan_suscripcion"."descargas_incluidas" = 0
and "plan_suscripcion"."calidad_descarga" is null
and not "plan_suscripcion"."acceso_versiones"
));--> statement-breakpoint
ALTER TABLE "plan_suscripcion" ADD CONSTRAINT "plan_suscripcion_calidad_escucha_conocida" CHECK ("plan_suscripcion"."calidad_escucha" is null or "plan_suscripcion"."calidad_escucha" in ('media', 'alta'));
--> statement-breakpoint
UPDATE "plan_suscripcion"
SET
"descripcion" = CASE "codigo"
WHEN 'follower' THEN 'Para descubrir el catálogo a tu ritmo, crear tus listas y apoyar el proyecto desde el primer día.'
WHEN 'friend' THEN 'Una escucha sin interrupciones para quienes quieren llevarse sus canciones favoritas.'
WHEN 'vip' THEN 'La experiencia musical más completa: más descargas y acceso a las distintas versiones de cada canción.'
END,
"prestaciones" = '["Favoritos y listas de reproducción"]'::jsonb,
"actualizado_en" = now()
WHERE "codigo" IN ('follower', 'friend', 'vip');
--> statement-breakpoint
INSERT INTO "plan_suscripcion" (
"id", "codigo", "nombre", "descripcion", "nivel", "precio_centimos", "moneda",
"periodicidad", "intervalos", "ambito", "incluye_publicidad", "calidad_escucha",
"descargas_incluidas", "calidad_descarga", "acceso_versiones",
"acceso_contenido_taller", "descarga_material_taller", "prestaciones", "estado"
) VALUES (
'08af3913-6788-47b9-ad50-ce2371d0f05d',
'taller-letras',
'Taller de letras',
'Un año para profundizar en la escritura de canciones, avanzar a tu ritmo y conservar todos los materiales.',
1,
990,
'eur',
'ano',
1,
'taller_letras',
false,
null,
0,
null,
false,
true,
true,
'["Aprendizaje a tu ritmo"]'::jsonb,
'disponible'
)
ON CONFLICT ("codigo") DO UPDATE SET
"nombre" = EXCLUDED."nombre",
"descripcion" = EXCLUDED."descripcion",
"nivel" = EXCLUDED."nivel",
"precio_centimos" = EXCLUDED."precio_centimos",
"moneda" = EXCLUDED."moneda",
"periodicidad" = EXCLUDED."periodicidad",
"intervalos" = EXCLUDED."intervalos",
"ambito" = EXCLUDED."ambito",
"incluye_publicidad" = EXCLUDED."incluye_publicidad",
"calidad_escucha" = EXCLUDED."calidad_escucha",
"descargas_incluidas" = EXCLUDED."descargas_incluidas",
"calidad_descarga" = EXCLUDED."calidad_descarga",
"acceso_versiones" = EXCLUDED."acceso_versiones",
"acceso_contenido_taller" = EXCLUDED."acceso_contenido_taller",
"descarga_material_taller" = EXCLUDED."descarga_material_taller",
"prestaciones" = EXCLUDED."prestaciones",
"estado" = EXCLUDED."estado",
"actualizado_en" = now();

@ -0,0 +1,9 @@
UPDATE "plan_suscripcion"
SET
"nombre" = CASE "codigo"
WHEN 'follower' THEN 'Seguidor'
WHEN 'friend' THEN 'Amigo'
WHEN 'vip' THEN 'Buen amigo'
END,
"actualizado_en" = now()
WHERE "codigo" IN ('follower', 'friend', 'vip');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -232,6 +232,20 @@
"when": 1789255031254,
"tag": "0032_bent_hydra",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1789256088294,
"tag": "0033_superb_silverclaw",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1789256852614,
"tag": "0034_planes_en_espanol",
"breakpoints": true
}
]
}

@ -7,13 +7,25 @@ test.describe('Suscripciones', () => {
await page.goto('/suscripciones');
await expect(page.getByRole('heading', { level: 1, name: 'Suscripciones' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Follower' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Friend' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'VIP' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Seguidor' })).toBeVisible();
await expect(
page.getByRole('heading', { level: 3, name: 'Amigo', exact: true })
).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Buen amigo' })).toBeVisible();
await expect(page.getByRole('heading', { level: 3, name: 'Taller de letras' })).toBeVisible();
await expect(page.getByText('Escucha con publicidad')).toBeVisible();
await expect(page.getByText('30 descargas en calidad media')).toBeVisible();
await expect(page.getByText('100 descargas', { exact: true })).toBeVisible();
await expect(page.getByText('Acceso a versiones')).toBeVisible();
await expect(page.getByText('Acceso a todo el contenido')).toBeVisible();
await expect(page.getByText('Descarga de todo el material')).toBeVisible();
await expect(page.getByRole('link', { name: 'Empezar gratis' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Elegir Amigo' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Elegir Buen amigo' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Acceder al taller' })).toBeVisible();
await expect(page.getByText(/Ordenados de menor a mayor nivel/i)).toHaveCount(0);
await expect(page.getByText(/Nivel [123]/i)).toHaveCount(0);
await expect(page.getByText(/Consultar (?:este )?plan/i)).toHaveCount(0);
await expect(
page.getByRole('heading', { level: 2, name: 'Suscripción y compra no son lo mismo' })
).toBeVisible();

@ -266,6 +266,20 @@ await rechaza(
(id, codigo, nombre, nivel, precio_centimos, periodicidad, calidad_descarga)
values ('ps4', 'zz-calidad', 'Roto', 2, 500, 'mes', 'media')`
);
await rechaza(
'un plan del taller no mezcla capacidades de música',
`insert into plan_suscripcion
(id, codigo, nombre, nivel, precio_centimos, periodicidad, ambito, calidad_escucha,
acceso_contenido_taller, descarga_material_taller)
values ('ps5', 'zz-taller-mal', 'Roto', 1, 990, 'ano', 'taller_letras', 'alta', true, true)`
);
await permite(
'un plan del taller conserva el acceso y la descarga de materiales',
`insert into plan_suscripcion
(id, codigo, nombre, nivel, precio_centimos, periodicidad, ambito, calidad_escucha,
acceso_contenido_taller, descarga_material_taller)
values ('ps6', 'zz-taller', 'Taller', 1, 990, 'ano', 'taller_letras', null, true, true)`
);
await permite(
'un libro disponible se puede añadir al carrito con cantidad',
`insert into producto

@ -1,28 +1,31 @@
import { and, eq, gt, isNull, lte, or } from 'drizzle-orm';
import { and, eq, gt, isNull, lte, or, sql } from 'drizzle-orm';
import type { ContextoDeAccesoCanciones } from '$lib/acceso-canciones';
import type { AccesoCancion } from '$lib/canciones';
import { db } from './db';
import { accesoCancionUsuario, cancion, suscripcion } from './db/schema';
import { accesoCancionUsuario, cancion, planSuscripcion, suscripcion } from './db/schema';
const SIN_ACCESO: ContextoDeAccesoCanciones = {
suscripcionActiva: false,
accesosPrivados: []
};
async function tieneSuscripcionActiva(usuarioId: string): Promise<boolean> {
const ahora = new Date();
const [periodo] = await db
async function tieneSuscripcionMusicalActiva(usuarioId: string): Promise<boolean> {
const consulta = db
.select({ id: suscripcion.id })
.from(suscripcion)
.leftJoin(planSuscripcion, eq(planSuscripcion.id, suscripcion.planId))
.where(
and(
eq(suscripcion.usuarioId, usuarioId),
eq(suscripcion.estado, 'activa'),
lte(suscripcion.iniciaEn, ahora),
or(isNull(suscripcion.terminaEn), gt(suscripcion.terminaEn, ahora))
lte(suscripcion.iniciaEn, sql`CURRENT_TIMESTAMP`),
or(isNull(suscripcion.terminaEn), gt(suscripcion.terminaEn, sql`CURRENT_TIMESTAMP`)),
// Las concesiones manuales antiguas no tenían plan y siguen siendo musicales.
or(isNull(suscripcion.planId), eq(planSuscripcion.ambito, 'musica'))
)
)
.limit(1);
const [periodo] = await consulta;
return Boolean(periodo);
}
@ -33,7 +36,7 @@ export async function contextoDeAccesoCanciones(
if (!usuarioId) return SIN_ACCESO;
const [suscripcionActiva, concesiones] = await Promise.all([
tieneSuscripcionActiva(usuarioId),
tieneSuscripcionMusicalActiva(usuarioId),
db
.select({ slug: cancion.slug })
.from(accesoCancionUsuario)
@ -57,7 +60,7 @@ export async function usuarioPuedeEscuchar(
if (acceso === 'ninguna' || !usuarioId) return false;
if (acceso === 'exclusiva') {
return tieneSuscripcionActiva(usuarioId);
return tieneSuscripcionMusicalActiva(usuarioId);
}
const [concesion] = await db

@ -19,7 +19,7 @@ import {
import { sql } from 'drizzle-orm';
import { ahora, id, instante } from './comun';
import { ESTADOS_PLAN_SUSCRIPCION, PERIODICIDADES } from '../../../comercio';
import { CALIDADES_AUDIO_SUSCRIPCION } from '../../../suscripciones';
import { AMBITOS_PLAN_SUSCRIPCION, CALIDADES_AUDIO_SUSCRIPCION } from '../../../suscripciones';
/* -------------------------------------------------------------------------
* Cuentas
@ -78,13 +78,14 @@ export const planSuscripcion = pgTable(
moneda: text('moneda').notNull().default('eur'),
periodicidad: text('periodicidad', { enum: PERIODICIDADES }).notNull(),
intervalos: integer('intervalos').notNull().default(1),
ambito: text('ambito', { enum: AMBITOS_PLAN_SUSCRIPCION }).notNull().default('musica'),
incluyePublicidad: boolean('incluye_publicidad').notNull().default(false),
calidadEscucha: text('calidad_escucha', { enum: CALIDADES_AUDIO_SUSCRIPCION })
.notNull()
.default('media'),
calidadEscucha: text('calidad_escucha', { enum: CALIDADES_AUDIO_SUSCRIPCION }).default('media'),
descargasIncluidas: integer('descargas_incluidas').notNull().default(0),
calidadDescarga: text('calidad_descarga', { enum: CALIDADES_AUDIO_SUSCRIPCION }),
accesoVersiones: boolean('acceso_versiones').notNull().default(false),
accesoContenidoTaller: boolean('acceso_contenido_taller').notNull().default(false),
descargaMaterialTaller: boolean('descarga_material_taller').notNull().default(false),
referenciaPrecioExterna: text('referencia_precio_externa').unique(),
prestaciones: jsonb('prestaciones').notNull().default([]).$type<string[]>(),
estado: text('estado', { enum: ESTADOS_PLAN_SUSCRIPCION }).notNull().default('borrador'),
@ -104,9 +105,10 @@ export const planSuscripcion = pgTable(
check('plan_suscripcion_nivel_positivo', sql`${t.nivel} > 0`),
check('plan_suscripcion_precio_no_negativo', sql`${t.precioCentimos} >= 0`),
check('plan_suscripcion_intervalos_positivos', sql`${t.intervalos} > 0`),
check('plan_suscripcion_ambito_conocido', sql`${t.ambito} in ('musica', 'taller_letras')`),
check(
'plan_suscripcion_calidad_escucha_conocida',
sql`${t.calidadEscucha} in ('media', 'alta')`
sql`${t.calidadEscucha} is null or ${t.calidadEscucha} in ('media', 'alta')`
),
check(
'plan_suscripcion_calidad_descarga_conocida',
@ -117,6 +119,22 @@ export const planSuscripcion = pgTable(
'plan_suscripcion_calidad_solo_con_descargas',
sql`${t.descargasIncluidas} > 0 or ${t.calidadDescarga} is null`
),
check(
'plan_suscripcion_capacidades_segun_ambito',
sql`(
${t.ambito} = 'musica'
and ${t.calidadEscucha} is not null
and not ${t.accesoContenidoTaller}
and not ${t.descargaMaterialTaller}
) or (
${t.ambito} = 'taller_letras'
and ${t.calidadEscucha} is null
and not ${t.incluyePublicidad}
and ${t.descargasIncluidas} = 0
and ${t.calidadDescarga} is null
and not ${t.accesoVersiones}
)`
),
check('plan_suscripcion_moneda_iso', sql`${t.moneda} ~ '^[a-z]{3}$'`),
check('plan_suscripcion_codigo_no_vacio', sql`btrim(${t.codigo}) <> ''`),
check('plan_suscripcion_nombre_no_vacio', sql`btrim(${t.nombre}) <> ''`),

@ -34,6 +34,7 @@ import { renombrarLista, moverEnLista, borrarLista, listasDeUsuario } from './li
import { concederSuscripcion, revocarSuscripcion } from './operaciones';
import { resumirCarrito } from '$lib/tienda';
import { planesDeSuscripcionPublicos } from './suscripciones';
import { usuarioPuedeEscuchar } from './acceso-canciones';
const conexion = process.env.TEST_DATABASE_URL;
describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
@ -252,6 +253,7 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
expect(planes[0]).toMatchObject({
codigo: 'apoyo',
nombre: 'Apoyo',
ambito: 'musica',
incluyePublicidad: false,
calidadEscucha: 'alta',
descargasIncluidas: 30,
@ -261,6 +263,24 @@ describe.runIf(!!conexion)('PostgreSQL con tablas temporales aisladas', () => {
});
expect(planes[0]).not.toHaveProperty('referenciaPrecioExterna');
});
it('una suscripción del taller no desbloquea el catálogo musical exclusivo', async () => {
await cliente.query(
`INSERT INTO plan_suscripcion
(id,codigo,nombre,nivel,precio_centimos,periodicidad,ambito,calidad_escucha,
acceso_contenido_taller,descarga_material_taller,estado)
VALUES
('spt','taller-prueba','Taller',1,990,'ano','taller_letras',null,true,true,'disponible'),
('spm','musica-prueba','Música',1,990,'ano','musica','alta',false,false,'disponible');
INSERT INTO suscripcion (id,usuario_id,plan_id,estado)
VALUES ('st','u','spt','activa')`
);
expect(await usuarioPuedeEscuchar('u', 'exclusiva', 'tema')).toBe(false);
await cliente.query(
"INSERT INTO suscripcion (id,usuario_id,plan_id,estado) VALUES ('sm','u','spm','activa')"
);
expect(await usuarioPuedeEscuchar('u', 'exclusiva', 'tema')).toBe(true);
});
it('una sesión existente no admite otro propietario', async () => {
await expect(
crearPedido({

@ -1,6 +1,6 @@
import { asc, eq } from 'drizzle-orm';
import { and, asc, eq, gt, isNotNull, isNull, lte, or, sql } from 'drizzle-orm';
import { db } from './db';
import { planSuscripcion } from './db/schema';
import { planSuscripcion, suscripcion } from './db/schema';
/**
* Planes que se pueden enseñar públicamente.
@ -20,16 +20,23 @@ export async function planesDeSuscripcionPublicos() {
moneda: planSuscripcion.moneda,
periodicidad: planSuscripcion.periodicidad,
intervalos: planSuscripcion.intervalos,
ambito: planSuscripcion.ambito,
incluyePublicidad: planSuscripcion.incluyePublicidad,
calidadEscucha: planSuscripcion.calidadEscucha,
descargasIncluidas: planSuscripcion.descargasIncluidas,
calidadDescarga: planSuscripcion.calidadDescarga,
accesoVersiones: planSuscripcion.accesoVersiones,
accesoContenidoTaller: planSuscripcion.accesoContenidoTaller,
descargaMaterialTaller: planSuscripcion.descargaMaterialTaller,
prestaciones: planSuscripcion.prestaciones
})
.from(planSuscripcion)
.where(eq(planSuscripcion.estado, 'disponible'))
.orderBy(asc(planSuscripcion.nivel), asc(planSuscripcion.precioCentimos));
.orderBy(
asc(planSuscripcion.ambito),
asc(planSuscripcion.nivel),
asc(planSuscripcion.precioCentimos)
);
return filas.map((plan) => ({
...plan,
@ -39,3 +46,23 @@ export async function planesDeSuscripcionPublicos() {
)
}));
}
/** Planes concretos que la cuenta tiene vigentes; admite varios ámbitos a la vez. */
export async function planesDeSuscripcionActivos(usuarioId?: string): Promise<string[]> {
if (!usuarioId) return [];
const filas = await db
.select({ planId: suscripcion.planId })
.from(suscripcion)
.where(
and(
eq(suscripcion.usuarioId, usuarioId),
eq(suscripcion.estado, 'activa'),
isNotNull(suscripcion.planId),
lte(suscripcion.iniciaEn, sql`CURRENT_TIMESTAMP`),
or(isNull(suscripcion.terminaEn), gt(suscripcion.terminaEn, sql`CURRENT_TIMESTAMP`))
)
);
return filas.flatMap(({ planId }) => (planId ? [planId] : []));
}

@ -17,11 +17,14 @@ describe('prestacionesDePlan', () => {
it('describe las capacidades estructuradas sin depender del código del plan', () => {
expect(
prestacionesDePlan({
ambito: 'musica',
incluyePublicidad: false,
calidadEscucha: 'alta',
descargasIncluidas: 30,
calidadDescarga: 'media',
accesoVersiones: false,
accesoContenidoTaller: false,
descargaMaterialTaller: false,
prestaciones: []
})
).toEqual(['Calidad de escucha alta', 'Sin publicidad', '30 descargas en calidad media']);
@ -30,11 +33,14 @@ describe('prestacionesDePlan', () => {
it('añade acceso a versiones y conserva prestaciones editoriales no duplicadas', () => {
expect(
prestacionesDePlan({
ambito: 'musica',
incluyePublicidad: false,
calidadEscucha: 'alta',
descargasIncluidas: 100,
calidadDescarga: null,
accesoVersiones: true,
accesoContenidoTaller: false,
descargaMaterialTaller: false,
prestaciones: ['Acceso anticipado', 'Acceso a versiones', ' ']
})
).toEqual([
@ -45,4 +51,20 @@ describe('prestacionesDePlan', () => {
'Acceso anticipado'
]);
});
it('describe el taller sin mezclar prestaciones de escucha', () => {
expect(
prestacionesDePlan({
ambito: 'taller_letras',
incluyePublicidad: false,
calidadEscucha: null,
descargasIncluidas: 0,
calidadDescarga: null,
accesoVersiones: false,
accesoContenidoTaller: true,
descargaMaterialTaller: true,
prestaciones: []
})
).toEqual(['Acceso a todo el contenido', 'Descarga de todo el material']);
});
});

@ -3,12 +3,18 @@ import type { Periodicidad } from './comercio';
export const CALIDADES_AUDIO_SUSCRIPCION = ['media', 'alta'] as const;
export type CalidadAudioSuscripcion = (typeof CALIDADES_AUDIO_SUSCRIPCION)[number];
export const AMBITOS_PLAN_SUSCRIPCION = ['musica', 'taller_letras'] as const;
export type AmbitoPlanSuscripcion = (typeof AMBITOS_PLAN_SUSCRIPCION)[number];
export interface CapacidadesPlanSuscripcion {
ambito: AmbitoPlanSuscripcion;
incluyePublicidad: boolean;
calidadEscucha: CalidadAudioSuscripcion;
calidadEscucha: CalidadAudioSuscripcion | null;
descargasIncluidas: number;
calidadDescarga: CalidadAudioSuscripcion | null;
accesoVersiones: boolean;
accesoContenidoTaller: boolean;
descargaMaterialTaller: boolean;
prestaciones: readonly string[];
}
@ -27,20 +33,27 @@ export function periodoDeSuscripcion(periodicidad: Periodicidad, intervalos: num
/** Convierte las capacidades aplicables por el sistema en textos para la ficha pública. */
export function prestacionesDePlan(plan: CapacidadesPlanSuscripcion): string[] {
const prestaciones = [
`Calidad de escucha ${plan.calidadEscucha}`,
plan.incluyePublicidad ? 'Escucha con publicidad' : 'Sin publicidad'
];
if (plan.descargasIncluidas > 0) {
prestaciones.push(
`${plan.descargasIncluidas} descargas${
plan.calidadDescarga ? ` en calidad ${plan.calidadDescarga}` : ''
}`
);
const prestaciones: string[] = [];
if (plan.ambito === 'musica') {
if (plan.calidadEscucha) prestaciones.push(`Calidad de escucha ${plan.calidadEscucha}`);
prestaciones.push(plan.incluyePublicidad ? 'Escucha con publicidad' : 'Sin publicidad');
if (plan.descargasIncluidas > 0) {
prestaciones.push(
`${plan.descargasIncluidas} descargas${
plan.calidadDescarga ? ` en calidad ${plan.calidadDescarga}` : ''
}`
);
}
if (plan.accesoVersiones) prestaciones.push('Acceso a versiones');
}
if (plan.accesoVersiones) prestaciones.push('Acceso a versiones');
if (plan.ambito === 'taller_letras') {
if (plan.accesoContenidoTaller) prestaciones.push('Acceso a todo el contenido');
if (plan.descargaMaterialTaller) prestaciones.push('Descarga de todo el material');
}
for (const prestacion of plan.prestaciones) {
const texto = prestacion.trim();
@ -49,3 +62,8 @@ export function prestacionesDePlan(plan: CapacidadesPlanSuscripcion): string[] {
return prestaciones;
}
/** Etiqueta editorial del ámbito, independiente del nombre comercial del plan. */
export function etiquetaDeAmbito(plan: Pick<CapacidadesPlanSuscripcion, 'ambito'>): string {
return plan.ambito === 'taller_letras' ? 'Formación' : 'Música';
}

@ -1,5 +1,10 @@
import { planesDeSuscripcionPublicos } from '$lib/server/suscripciones';
import { planesDeSuscripcionActivos, planesDeSuscripcionPublicos } from '$lib/server/suscripciones';
export async function load() {
return { planes: await planesDeSuscripcionPublicos() };
export async function load({ locals }) {
const [planes, planesActivos] = await Promise.all([
planesDeSuscripcionPublicos(),
planesDeSuscripcionActivos(locals.usuario?.id)
]);
return { planes, planesActivos };
}

@ -1,11 +1,18 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { pedirContacto } from '$lib/contacto-modal.svelte';
import Meta from '$lib/components/Meta.svelte';
import { periodoDeSuscripcion, prestacionesDePlan } from '$lib/suscripciones';
import { etiquetaDeAmbito, periodoDeSuscripcion, prestacionesDePlan } from '$lib/suscripciones';
import { formatearPrecio } from '$lib/tienda';
let { data } = $props();
function textoDeAccion(plan: (typeof data.planes)[number]): string {
if (plan.ambito === 'taller_letras') return 'Acceder al taller';
if (plan.precioCentimos === 0) return 'Empezar gratis';
return `Elegir ${plan.nombre}`;
}
const acceso = `${resolve('/entrar')}?volverA=${encodeURIComponent(resolve('/suscripciones'))}`;
</script>
<Meta
@ -18,76 +25,54 @@
<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.
Elige el acceso que encaje contigo. Hay planes para escuchar música y un acceso específico al
Taller de letras; cada uno detalla 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>
<p class="eyebrow">Escucha</p>
<h2 id="titulo-planes">Suscripciones de música</h2>
<p class="introduccion-grupo muted">
Tres formas de disfrutar el catálogo, desde el acceso gratuito hasta la experiencia
completa.
</p>
</header>
<ul class="rejilla">
{#each data.planes as plan (plan.id)}
{@const prestaciones = prestacionesDePlan(plan)}
<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>
{#if plan.precioCentimos === 0}<span class="gratis">Gratis</span>{/if}
</p>
{#if prestaciones.length > 0}
<ul class="prestaciones" aria-label="Incluye">
{#each 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>
<ul class="rejilla rejilla--musica">
{#each data.planes.filter((plan) => plan.ambito === 'musica') as plan (plan.id)}
{@render tarjeta(plan)}
{/each}
</ul>
{#if data.planes.some((plan) => plan.ambito !== 'musica')}
<section class="otros" aria-labelledby="titulo-otros">
<header class="cabecera-seccion">
<h2 id="titulo-otros">Otras suscripciones</h2>
<p class="introduccion-grupo muted">
Accesos independientes de la suscripción musical, pensados para aprender y trabajar
con los contenidos del proyecto.
</p>
</header>
<ul class="rejilla rejilla--otras">
{#each data.planes.filter((plan) => plan.ambito !== 'musica') as plan (plan.id)}
{@render tarjeta(plan)}
{/each}
</ul>
</section>
{/if}
</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
Publicaremos aquí cada opción 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>
<a class="btn" href={acceso}>Crear mi cuenta</a>
</section>
{/if}
@ -107,6 +92,72 @@
</section>
</div>
{#snippet tarjeta(plan: (typeof data.planes)[number])}
{@const prestaciones = prestacionesDePlan(plan)}
<li>
<article class="plan" data-codigo={plan.codigo} data-ambito={plan.ambito}>
<div class="cabecera-plan">
<div class="identidad-plan">
<p class="ambito">{etiquetaDeAmbito(plan)}</p>
<span class="icono-plan" aria-hidden="true">
{#if plan.ambito === 'taller_letras'}
<svg viewBox="0 0 24 24">
<path d="M4 20c4-1 7-4 9-9m-6 6 3 3M5 19C3 11 8 4 20 3c0 9-5 14-12 15" />
</svg>
{:else if plan.codigo === 'vip'}
<svg viewBox="0 0 24 24">
<path
d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2L12 17.3l-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"
/>
</svg>
{:else if plan.codigo === 'friend'}
<svg viewBox="0 0 24 24">
<path
d="M20.8 5.8a5.5 5.5 0 0 0-7.8 0L12 6.9l-1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 22l8.8-8.4a5.5 5.5 0 0 0 0-7.8Z"
/>
</svg>
{:else}
<svg viewBox="0 0 24 24">
<path d="M5 9v6m4-9v12m4-9v6m4-9v12m4-9v6" />
</svg>
{/if}
</span>
</div>
<h3>{plan.nombre}</h3>
{#if plan.descripcion}<p class="descripcion">{plan.descripcion}</p>{/if}
</div>
<div class="bloque-precio">
<p class="precio">
<strong>
{plan.precioCentimos === 0
? '0 €'
: formatearPrecio(plan.precioCentimos, 'es-ES', plan.moneda)}
</strong>
<span>{periodoDeSuscripcion(plan.periodicidad, plan.intervalos)}</span>
</p>
<p class="condicion">
{plan.precioCentimos === 0 ? 'Acceso gratuito' : 'Facturación anual'}
</p>
</div>
{#if prestaciones.length > 0}
<ul class="prestaciones" aria-label="Incluye">
{#each prestaciones as prestacion (prestacion)}<li>{prestacion}</li>{/each}
</ul>
{/if}
{#if data.planesActivos.includes(plan.id)}
<a class="btn" href={resolve('/cuenta')}>Gestionar mi suscripción</a>
{:else}
<a class="btn btn--primary" href={data.usuario ? resolve('/cuenta') : acceso}>
{textoDeAccion(plan)}
</a>
{/if}
</article>
</li>
{/snippet}
<style>
.pagina {
min-width: 0;
@ -133,72 +184,204 @@
.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);
margin-bottom: var(--sp-6);
}
.cabecera-seccion h2,
.cabecera-seccion p {
.cabecera-seccion h2 {
margin: 0;
}
.introduccion-grupo {
max-width: 62ch;
margin: var(--sp-2) 0 0;
line-height: 1.55;
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
gap: var(--sp-4);
gap: var(--sp-5);
margin: 0;
padding: 0;
list-style: none;
}
.rejilla--musica {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.rejilla--otras {
grid-template-columns: minmax(0, 1fr);
}
.otros {
margin-top: var(--sp-8);
}
.rejilla--otras .plan {
display: grid;
grid-template-areas:
'cabecera precio'
'prestaciones accion';
grid-template-columns: minmax(0, 1.2fr) minmax(17rem, 0.8fr);
align-items: start;
column-gap: var(--sp-7);
}
.rejilla--otras .cabecera-plan {
grid-area: cabecera;
}
.rejilla--otras .bloque-precio {
grid-area: precio;
align-self: end;
}
.rejilla--otras .prestaciones {
grid-area: prestaciones;
}
.rejilla--otras .plan > .btn {
grid-area: accion;
align-self: end;
}
.plan {
--tono-plan: var(--c-dato);
position: relative;
display: flex;
min-height: 100%;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5);
border: 1px solid var(--c-border);
gap: var(--sp-5);
overflow: hidden;
padding: clamp(1.35rem, 2.5vw, 2rem);
border: 1px solid color-mix(in oklab, var(--tono-plan) 28%, var(--c-border));
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
background:
radial-gradient(
circle at 100% 0%,
color-mix(in oklab, var(--tono-plan) 16%, transparent),
transparent 42%
),
linear-gradient(
155deg,
color-mix(in oklab, var(--c-bg-raised) 97%, var(--tono-plan)),
var(--c-bg-raised)
);
box-shadow:
0 1.5rem 3rem -2.25rem color-mix(in oklab, var(--c-text) 45%, transparent),
inset 0 1px 0 color-mix(in oklab, white 10%, transparent);
transition:
transform 0.35s var(--sal),
border-color 0.25s var(--suave),
box-shadow 0.35s var(--sal);
}
.plan::before {
position: absolute;
inset: 0 0 auto;
height: 3px;
background: linear-gradient(90deg, var(--tono-plan), transparent 82%);
content: '';
}
.plan[data-codigo='follower'] {
--tono-plan: var(--c-text-muted);
}
.plan[data-codigo='friend'] {
--tono-plan: var(--c-dato);
}
.plan[data-codigo='vip'] {
--tono-plan: var(--c-accent);
}
.plan[data-ambito='taller_letras'] {
--tono-plan: var(--c-dato-tenue);
}
.plan:hover {
transform: translateY(-4px);
border-color: color-mix(in oklab, var(--tono-plan) 58%, var(--c-border));
box-shadow:
0 1.75rem 3.5rem -2rem color-mix(in oklab, var(--tono-plan) 34%, transparent),
inset 0 1px 0 color-mix(in oklab, white 13%, transparent);
}
.nivel {
margin: 0 0 var(--sp-2);
color: var(--c-accent);
.identidad-plan {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.ambito {
margin: 0;
color: var(--tono-plan);
font-size: var(--fs-xs);
font-weight: 700;
letter-spacing: 0.1em;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.icono-plan {
display: grid;
width: 2.75rem;
aspect-ratio: 1;
place-items: center;
border: 1px solid color-mix(in oklab, var(--tono-plan) 42%, transparent);
border-radius: 50%;
background: color-mix(in oklab, var(--tono-plan) 12%, transparent);
color: var(--tono-plan);
}
.icono-plan svg {
width: 1.25rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.cabecera-plan h3 {
margin: 0;
font-size: var(--fs-xl);
font-size: clamp(1.75rem, 3vw, 2.4rem);
line-height: 1.05;
}
.descripcion {
margin: var(--sp-2) 0 0;
min-height: 4.7em;
margin: var(--sp-3) 0 0;
color: var(--c-text-muted);
font-size: var(--fs-sm);
line-height: 1.55;
}
.precio {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--sp-2);
margin: 0;
}
.bloque-precio {
display: grid;
gap: var(--sp-2);
}
.precio strong {
font-family: var(--font-body);
font-size: var(--fs-2xl);
font-size: clamp(2.25rem, 4vw, 3rem);
font-weight: 650;
font-variant-numeric: tabular-nums;
letter-spacing: -0.045em;
line-height: 1;
}
.precio span {
@ -206,42 +389,38 @@
font-size: var(--fs-sm);
}
.precio .gratis {
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--c-accent) 14%, transparent);
color: var(--c-accent);
.condicion {
margin: 0;
color: var(--c-text-muted);
font-size: var(--fs-xs);
font-weight: 700;
text-transform: uppercase;
}
.prestaciones {
display: grid;
gap: var(--sp-2);
gap: var(--sp-3);
margin: 0;
padding: 0;
padding: var(--sp-4) 0 0;
border-top: 1px solid color-mix(in oklab, var(--tono-plan) 24%, var(--c-border));
list-style: none;
}
.prestaciones li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--sp-2);
gap: var(--sp-3);
font-size: var(--fs-sm);
line-height: 1.45;
}
.prestaciones li::before {
color: var(--tono-plan);
content: '✓';
color: var(--c-accent);
font-weight: 700;
}
.sin-prestaciones {
font-size: var(--fs-sm);
}
.plan > .btn {
align-self: start;
justify-content: center;
width: 100%;
margin-top: auto;
}
@ -258,11 +437,16 @@
}
.aclaraciones {
display: grid;
grid-template-columns: minmax(18rem, 0.9fr) minmax(0, 2fr);
align-items: start;
gap: var(--sp-7);
margin-top: var(--sp-7);
}
.aclaraciones h2 {
max-width: 22ch;
margin: 0;
}
.columnas {
@ -277,18 +461,40 @@
margin: 0;
}
@media (max-width: 36rem) {
.cabecera-seccion {
align-items: start;
flex-direction: column;
@media (max-width: 48rem) {
.rejilla {
grid-template-columns: minmax(0, 1fr);
}
.columnas {
grid-template-columns: minmax(0, 1fr);
}
.aclaraciones {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-4);
}
.vacio {
padding: var(--sp-4);
}
.descripcion {
min-height: 0;
}
.rejilla--otras .plan {
display: flex;
}
}
@media (prefers-reduced-motion: reduce) {
.plan {
transition: none;
}
.plan:hover {
transform: none;
}
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.