diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index df3cdd3..747094b 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1,58 +1,62 @@
{
- "$schema": "https://inlang.com/schema/inlang-message-format",
- "accommodation_info": "Accommodation info",
- "about": "About",
- "agenda": "Agenda",
- "an_error_has_occurred": "An error has occurred",
- "cancel": "Cancel",
- "check_your_email": "Check your email",
- "close": "Close",
- "code_of_conduct": "Code of Conduct",
- "contact_info": "Contact info",
- "cookie_policy": "Cookie policy",
- "day": "Day",
- "did_you_forget_password": "Did you forget your password?",
- "email": "Email",
- "email_or_password_incorrect": "Email or password incorrect",
- "edit": "Edit",
- "edit_pseudonym": "Tell us your pseudonym in STE",
- "edit_menu_options": "Tell us if you have any requirements on the menu (allergies, intolerances..).",
- "enter_your_email": "Enter your email",
- "enter_your_email_and_password": "You need to enter your email and password to see your registration data",
- "enter_your_email_to_send_you_link": "Enter your email and we'll send you a link to set a new password.",
- "enter_your_password": "Enter your password",
- "enter_your_password_again": "Enter it again, for security",
- "enter_your_new_password": "Enter your new password to access the web:",
- "error_404_not_found": "404 Not found",
- "error_404_message": "Sorry, we can't find the requested page. Please go back and try a different thing.",
- "error_500_internal_error": "500 Internal error",
- "error_500_message": "Sorry, an error has ocurred. Please try again.",
- "error_generic": "Error {status}",
- "forgot_password": "Forgotten password",
- "forgot_password_press_here": "Forgotten password? Press here.",
- "get_magic_link": "Get a magic link for another email→",
- "it_has_a_magic_link": "It has a magic link to log in without a password.",
- "invalid_reset_link": "The reset password link is invalid or timed out.",
- "legal": "Legal",
- "login": "Login",
- "logout": "Logout",
- "new_password": "New password",
- "new_password_set": "Your new password has been set. You can now log in.",
- "org_team": "Org team",
- "password": "Password",
- "password_length": "Password length must be at least 6 characters",
- "passwords_must_match": "Passwords must match",
- "press_kit": "Press kit",
- "price": "Price",
- "prices": "Prices",
- "privacy_policy": "Privacy policy",
- "register": "Register",
- "registration": "Registration",
- "reset_password_sent": "If your email is registered, a message has been sent with a link to set a new password.",
- "send": "Send",
- "speakers": "Speakers",
- "streaming": "Streaming",
- "venue_info": "Venue info",
- "we_sent_an_email": "We sent an email to you at {email}",
- "you_need_to_register": "You need to register to be able to enter the tickets queue and get your tickets."
-}
\ No newline at end of file
+ "$schema": "https://inlang.com/schema/inlang-message-format",
+ "accommodation_info": "Accommodation info",
+ "about": "About",
+ "agenda": "Agenda",
+ "an_error_has_occurred": "An error has occurred",
+ "cancel": "Cancel",
+ "check_your_email": "Check your email",
+ "close": "Close",
+ "code_of_conduct": "Code of Conduct",
+ "contact_info": "Contact info",
+ "cookie_policy": "Cookie policy",
+ "day": "Day",
+ "did_you_forget_password": "Did you forget your password?",
+ "email": "Email",
+ "email_or_password_incorrect": "Email or password incorrect",
+ "edit": "Edit",
+ "edit_pseudonym": "Tell us your pseudonym in STE",
+ "edit_menu_options": "Tell us if you have any requirements on the menu (allergies, intolerances..).",
+ "enter_your_email": "Enter your email",
+ "enter_your_email_and_password": "You need to enter your email and password to see your registration data",
+ "enter_your_email_to_send_you_link": "Enter your email and we'll send you a link to set a new password.",
+ "enter_your_password": "Enter your password",
+ "enter_your_password_again": "Enter it again, for security",
+ "enter_your_new_password": "Enter your new password to access the web:",
+ "error_403_forbidden": "Access not allowed",
+ "error_403_message": "You do not have permission to access this page or resource.",
+ "error_404_not_found": "404 Not found",
+ "error_404_message": "Sorry, we can't find the requested page. Please go back and try a different thing.",
+ "error_500_internal_error": "500 Internal error",
+ "error_500_message": "Sorry, an error has ocurred. Please try again.",
+ "error_generic": "Error {status}",
+ "forgot_password": "Forgotten password",
+ "forgot_password_forbidden": "We couldn’t send the recovery link. Please check that the email is registered or contact the organization.",
+ "forgot_password_press_here": "Forgotten password? Press here.",
+ "get_magic_link": "Get a magic link for another email→",
+ "it_has_a_magic_link": "It has a magic link to log in without a password.",
+ "invalid_reset_link": "The reset password link is invalid or timed out.",
+ "legal": "Legal",
+ "login": "Login",
+ "login_forbidden": "We couldn’t sign you in. Please check your password and make sure your account is active.",
+ "logout": "Logout",
+ "new_password": "New password",
+ "new_password_set": "Your new password has been set. You can now log in.",
+ "org_team": "Org team",
+ "password": "Password",
+ "password_length": "Password length must be at least 6 characters",
+ "passwords_must_match": "Passwords must match",
+ "press_kit": "Press kit",
+ "price": "Price",
+ "prices": "Prices",
+ "privacy_policy": "Privacy policy",
+ "register": "Register",
+ "registration": "Registration",
+ "reset_password_sent": "If your email is registered, a message has been sent with a link to set a new password.",
+ "send": "Send",
+ "speakers": "Speakers",
+ "streaming": "Streaming",
+ "venue_info": "Venue info",
+ "we_sent_an_email": "We sent an email to you at {email}",
+ "you_need_to_register": "You need to register to be able to enter the tickets queue and get your tickets."
+}
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index c0bab22..4737596 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -24,18 +24,22 @@
"enter_your_password": "Introduce tu contraseña",
"enter_your_password_again": "Introducela otra vez, por seguridad",
"enter_your_new_password": "Introduce tu nueva contraseña para acceder a la web:",
- "error_404_not_found": "404 Página no encontrada",
+ "error_403_forbidden": "Acceso no permitido",
+ "error_403_message": "No tienes permiso para acceder a esta página o recurso.",
+ "error_404_not_found": "Página no encontrada",
"error_404_message": "Lo sentimos, no podemos encontrar la página solicitada. Por favor vuelve atrás e intenta algo diferente.",
- "error_500_internal_error": "500 Error interno",
+ "error_500_internal_error": "Error interno",
"error_500_message": "Lo sentimos, ha ocurrido un error. Por favor intenta de nuevo.",
"error_generic": "Error {status}",
"forgot_password": "Contraseña olvidada",
+ "forgot_password_forbidden": "No se ha podido enviar el enlace de recuperación. Revisa que el email esté registrado o contacta con la organización.",
"forgot_password_press_here": "¿Olvidaste la contraseña? Pulsa aquí.",
"get_magic_link": "Obtener un enlace para otro correo→",
"it_has_a_magic_link": "Contiene un enlace para entrar sin contraseña.",
"invalid_reset_link": "El enlace de establecer contraseña es inválido o está caducado.",
"legal": "Aviso legal",
"login": "Entrar",
+ "login_forbidden": "No se ha podido iniciar sesión. Revisa tu contraseña o que tu cuenta esté activa.",
"logout": "Salir",
"new_password": "Nueva contraseña",
"new_password_set": "Se ha establecido tu nueva contraseña. Ahora puedes entrar.",
diff --git a/frontend/src/lib/ErrorView.svelte b/frontend/src/lib/ErrorView.svelte
new file mode 100644
index 0000000..f579681
--- /dev/null
+++ b/frontend/src/lib/ErrorView.svelte
@@ -0,0 +1,198 @@
+
+
+
+
+
+ {#if errorImageUrl}
+
+ {/if}
+
+
{status}
+
+
+
{title}
+
{message}
+
+
Volver al inicio
+
+
+
+
+
+
diff --git a/frontend/src/lib/Header.svelte b/frontend/src/lib/Header.svelte
index 4106056..5b2f517 100644
--- a/frontend/src/lib/Header.svelte
+++ b/frontend/src/lib/Header.svelte
@@ -67,6 +67,15 @@
: null
].filter(Boolean) as MenuItem[];
+ const featuredMenuItems: MenuItem[] = [
+ data.show_speakers
+ ? {
+ label: m.speakers(),
+ href: i18n.resolveRoute('/speakers')
+ }
+ : null
+ ].filter(Boolean) as MenuItem[];
+
const secondaryMenuItems: MenuItem[] = [
data.show_agenda
? {
@@ -74,12 +83,6 @@
href: i18n.resolveRoute('/agenda')
}
: null,
- data.show_speakers
- ? {
- label: m.speakers(),
- href: i18n.resolveRoute('/speakers')
- }
- : null,
data.show_streaming
? {
label: m.streaming(),
@@ -95,13 +98,15 @@
]
.filter(Boolean)
.sort((a, b) => a.label.localeCompare(b.label, 'es', { sensitivity: 'base' })) as MenuItem[];
-
const menuItems: MenuItem[] = hasCustomNavMenu
? customMenuItems
- : [...priorityMenuItems, ...secondaryMenuItems];
+ : [...priorityMenuItems, ...featuredMenuItems, ...secondaryMenuItems];
+
+ const hasHeaderCta = isRegistrationOpen && Boolean(data.register_cta);
+ const shouldUseDesktopOverflow = hasHeaderCta ? menuItems.length >= 5 : menuItems.length >= 6;
- const desktopPrimaryMenuItems = menuItems.length > 5 ? menuItems.slice(0, 4) : menuItems;
- const desktopOverflowMenuItems = menuItems.length > 5 ? menuItems.slice(4) : [];
+ const desktopPrimaryMenuItems = shouldUseDesktopOverflow ? menuItems.slice(0, 4) : menuItems;
+ const desktopOverflowMenuItems = shouldUseDesktopOverflow ? menuItems.slice(4) : [];
const showMenu = menuItems.length > 0;
diff --git a/frontend/src/lib/PriceImageModal.svelte b/frontend/src/lib/PriceImageModal.svelte
new file mode 100644
index 0000000..f204b83
--- /dev/null
+++ b/frontend/src/lib/PriceImageModal.svelte
@@ -0,0 +1,400 @@
+
+
+{#if isOpen}
+
+
+
+
+
+
+
+ {#if images.length > 0}
+
+ {#each images as image}
+ {#if imageZoom}
+
openZoom(image.url)}
+ aria-label="Ampliar imagen"
+ >
+
+
+ {:else}
+
+ {/if}
+ {/each}
+
+ {:else}
+ {unavailableText}
+ {/if}
+
+
+ {#if tag1}
+
+ {/if}
+
+
+
+ {#if zoomImage}
+
+
+
+
+
+
+
+
+
+
+ {/if}
+{/if}
+
+
diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte
index 883b5ca..2284b6f 100644
--- a/frontend/src/routes/+error.svelte
+++ b/frontend/src/routes/+error.svelte
@@ -1,27 +1,25 @@
-
-
-
- {#if page.status === 404}
-
{m.error_404_not_found()}
-
-
{m.error_404_message()}
-
- {:else if page.status === 500}
-
{m.error_500_internal_error()}
-
-
{m.error_500_message()}
-
- {:else}
-
{m.error_generic({status:page.status})}
- {#if page.error.message}
-
- {/if}
- {/if}
-
-
+
diff --git a/frontend/src/routes/+layout.server.js b/frontend/src/routes/+layout.server.js
index ad4c812..900f857 100644
--- a/frontend/src/routes/+layout.server.js
+++ b/frontend/src/routes/+layout.server.js
@@ -1,15 +1,63 @@
+import { redirect } from '@sveltejs/kit';
import { fetchSingle } from '../services/api';
import { getUserCard } from '../services/users';
-export async function load({cookies}) {
- let data = {}
+const ROUTE_VISIBILITY_FLAGS = {
+ // Añade aquí las páginas que quieras bloquear cuando su flag esté en false.
+ // Formato:
+ // '/ruta-en-espanol': 'nombre_del_flag_en_strapi',
+ // '/internal-route': 'nombre_del_flag_en_strapi',
+
+ '/registro': 'loginEnabled',
+ '/register': 'loginEnabled',
+
+ '/noticias': 'show_news_items',
+ '/news-items': 'show_news_items',
+
+ '/faqs': 'show_faqs',
+
+ '/prensa': 'show_press_kit',
+ '/press-kit': 'show_press_kit',
+
+ '/patrocinadores': 'show_sponsors_info',
+ '/sponsors': 'show_sponsors_info',
+
+ '/streaming': 'show_streaming',
+
+ '/seo': 'show_seo',
+
+ '/tracks': 'show_tracks'
+};
+
+function getVisibilityFlag(pathname) {
+ if (ROUTE_VISIBILITY_FLAGS[pathname]) {
+ return ROUTE_VISIBILITY_FLAGS[pathname];
+ }
+
+ const firstSegment = `/${pathname.split('/').filter(Boolean)[0] ?? ''}`;
+
+ return ROUTE_VISIBILITY_FLAGS[firstSegment];
+}
+
+export async function load({ cookies, url }) {
+ let data = {};
+
const [settingsEntry, siteEntry] = await Promise.all([
- fetchSingle("/setting?populate=*", cookies),
- fetchSingle("/site", cookies)
- ]);
- const userCard = getUserCard(cookies);
- if (settingsEntry || siteEntry) {
- data = {...settingsEntry, ...siteEntry, ...userCard}
- }
- return data;
+ fetchSingle('/setting?populate=*', cookies),
+ fetchSingle('/site', cookies)
+ ]);
+
+ if (settingsEntry || siteEntry) {
+ data = { ...settingsEntry, ...siteEntry };
+ }
+
+ const visibilityFlag = getVisibilityFlag(url.pathname);
+
+ if (visibilityFlag && data[visibilityFlag] !== true) {
+ redirect(302, '/no-disponible');
+ }
+
+ const userCard = getUserCard(cookies);
+
+ return { ...data, ...userCard };
}
diff --git a/frontend/src/routes/+page.js b/frontend/src/routes/+page.js
index 6c98c14..f508720 100644
--- a/frontend/src/routes/+page.js
+++ b/frontend/src/routes/+page.js
@@ -1,4 +1,4 @@
-import { encodeQuery, fetchSingle } from '../services/api';
+import { encodeQuery, fetchBasic, fetchSingle } from '../services/api';
const homepagePath = `/homepage?${encodeQuery({
populate: '*'
@@ -20,22 +20,49 @@ const registrationInfoPath = `/registration-info?${encodeQuery({
populate: '*'
})}`;
+const wheelGameImagePath = `/upload/files?${encodeQuery({
+ filters: {
+ name: {
+ $eq: 'gira-el-timon.jpg'
+ }
+ }
+})}`;
+
+const wheelGamePagePath = `/simple-pages?${encodeQuery({
+ filters: {
+ path: {
+ $eq: 'gira-el-timon'
+ }
+ }
+})}`;
+
/** @type {import('./$types').PageLoad} */
export async function load({ params }) {
let data = {};
- const [homepageData, speakersData, siteData, registrationInfoData] = await Promise.all([
+ const [
+ homepageData,
+ speakersData,
+ siteData,
+ registrationInfoData,
+ wheelGameImages,
+ wheelGamePages
+ ] = await Promise.all([
fetchSingle(homepagePath),
fetchSingle(speakersPath),
fetchSingle(sitePath),
- fetchSingle(registrationInfoPath)
+ fetchSingle(registrationInfoPath),
+ fetchBasic(wheelGameImagePath),
+ fetchSingle(wheelGamePagePath)
]);
data = {
homepage: homepageData,
speakers: speakersData,
site: siteData,
- registrationInfo: registrationInfoData
+ registrationInfo: registrationInfoData,
+ wheelGameImage: wheelGameImages?.[0] ?? null,
+ wheelGamePage: wheelGamePages?.[0] ?? null
};
return data;
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 1efd26f..1598fd9 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -131,9 +131,35 @@
FINISHED: 'finished'
};
- const eventStartDate = new Date('2026-09-24T00:00:00');
- const today = new Date();
- const daysUntilEvent = Math.max(0, Math.ceil((eventStartDate - today) / (1000 * 60 * 60 * 24)));
+ const MS_PER_DAY = 1000 * 60 * 60 * 24;
+
+ function localDate(year, month, day) {
+ return new Date(year, month - 1, day);
+ }
+
+ function startOfLocalDay(date) {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ }
+
+ const eventStartDate = localDate(2026, 9, 24);
+ const eventEndDate = localDate(2026, 9, 27);
+ const postEventDate = localDate(2026, 9, 28);
+ const today = startOfLocalDay(new Date());
+ // const today = localDate(2026, 9, 24);
+ // const today = localDate(2026, 9, 25);
+ // const today = localDate(2026, 9, 30);
+
+ const daysUntilEvent = Math.max(0, Math.round((eventStartDate - today) / MS_PER_DAY));
+
+ let heroStatusMessage = '';
+
+ if (today >= postEventDate) {
+ heroStatusMessage = 'Nos vemos en la próxima EstelCon';
+ } else if (today.getTime() === eventStartDate.getTime()) {
+ heroStatusMessage = '¡Hoy comienza esta travesía!';
+ } else if (today > eventStartDate && today <= eventEndDate) {
+ heroStatusMessage = 'La Mereth Gaerlen ya está en marcha';
+ }
const eventStatus = site?.eventStatus ?? site?.event_status ?? EVENT_STATUS.HYPE;
function plainStatusText(content, fallback) {
@@ -160,6 +186,19 @@
'Inscripciones finalizadas'
);
const heroImage = heroLosPuertosGrises;
+
+ function getStrapiMediaUrl(image) {
+ if (!image?.url) return '';
+
+ if (image.url.startsWith('http')) {
+ return image.url;
+ }
+
+ return `${storage_url}${image.url}`;
+ }
+
+ const wheelGameImageUrl = getStrapiMediaUrl(data.wheelGameImage);
+ const showWheelGame = Boolean(wheelGameImageUrl && data.wheelGamePage);
@@ -219,11 +258,17 @@
-
- Faltan
- {daysUntilEvent}
- días
-
+ {#if heroStatusMessage}
+
+ {:else}
+
+ Faltan
+ {daysUntilEvent}
+ días
+
+ {/if}
@@ -255,36 +300,57 @@
-
+
📜
Conferencias
Encuentros, conversaciones y reflexiones para compartir la obra de Tolkien desde distintas
miradas.
-
+
-
+
🪶
Talleres
Actividades participativas para crear, aprender y disfrutar en comunidad durante el
evento.
-
+
-
+
🎶
Conciertos y lecturas
Música, relatos y momentos compartidos en un ambiente cuidado junto al mar.
-
+
-
+
⚓
Más actividades
Artesanía, juegos y propuestas para vivir la Estelcon durante todo el día.
-
+
+
+ {#if showWheelGame}
+
+ {/if}
@@ -519,23 +585,21 @@
{/if}
diff --git a/frontend/src/routes/error/+page.svelte b/frontend/src/routes/error/+page.svelte
new file mode 100644
index 0000000..8d97e9b
--- /dev/null
+++ b/frontend/src/routes/error/+page.svelte
@@ -0,0 +1,30 @@
+
+
+
diff --git a/frontend/src/routes/forgot-password/+page.server.js b/frontend/src/routes/forgot-password/+page.server.js
index 412916d..c9ffec9 100644
--- a/frontend/src/routes/forgot-password/+page.server.js
+++ b/frontend/src/routes/forgot-password/+page.server.js
@@ -1,51 +1,56 @@
import { fail, redirect } from '@sveltejs/kit';
import { message, superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
-import * as m from '$lib/paraglide/messages.js'
+import * as m from '$lib/paraglide/messages.js';
import { zod } from 'sveltekit-superforms/adapters';
import { fetchCMSData } from '../../services/api';
import { isAuthorizedUser, setUser } from '../../services/users';
-let data = {}
+let data = {};
const schema = z.object({
- email: z.string().email(),
+ email: z.string().email()
});
export async function load({ params, cookies }) {
- if (isAuthorizedUser(cookies)) {
- redirect(302, "/me");
- }
- const form = await superValidate(zod(schema));
- return { form };
+ if (isAuthorizedUser(cookies)) {
+ redirect(302, '/me');
+ }
+ const form = await superValidate(zod(schema));
+ return { form };
}
export const actions = {
- default: async ({ request, cookies }) => {
- const form = await superValidate(request, zod(schema));
-
- if (!form.valid) {
- return fail(400, { form });
- }
-
- const { response, error } = await fetchCMSData(
- "POST", "/auth/forgot-password", {
- email: form.data.email,
- }, cookies
- )
-
- if (response) {
- return message(form, {success: m.reset_password_sent()})
- }
-
- let error_msg;
- if (error?.status === 400) {
- error_msg = m.email_or_password_incorrect();
- } else if (error?.message) {
- error_msg = error.message;
- } else {
- error_msg = m.an_error_has_occurred();
- }
- return message(form, {error: error_msg});
- }
- };
\ No newline at end of file
+ default: async ({ request, cookies }) => {
+ const form = await superValidate(request, zod(schema));
+
+ if (!form.valid) {
+ return fail(400, { form });
+ }
+
+ const { response, error } = await fetchCMSData(
+ 'POST',
+ '/auth/forgot-password',
+ {
+ email: form.data.email
+ },
+ cookies
+ );
+
+ if (response) {
+ return message(form, { success: m.reset_password_sent() });
+ }
+
+ let error_msg;
+ if (error?.status === 400) {
+ error_msg = m.email_or_password_incorrect();
+ } else if (error?.status === 403) {
+ error_msg = m.forgot_password_forbidden();
+ } else if (error?.message) {
+ error_msg = error.message;
+ } else {
+ error_msg = m.an_error_has_occurred();
+ }
+ return message(form, { error: error_msg });
+ }
+};
diff --git a/frontend/src/routes/gira-el-timon/+page.svelte b/frontend/src/routes/gira-el-timon/+page.svelte
index 9132510..4318e6b 100644
--- a/frontend/src/routes/gira-el-timon/+page.svelte
+++ b/frontend/src/routes/gira-el-timon/+page.svelte
@@ -100,14 +100,16 @@
{/if}
-
- {isSpinning ? 'El timón está girando...' : 'Pulsa para girar el timón'}
-
+
+
+ {isSpinning ? 'El timón está girando...' : 'Pulsa para girar el timón'}
+
+
@@ -161,16 +163,27 @@
.wheel-game {
display: grid;
justify-items: center;
- gap: 2rem;
- padding: 1rem 0 2rem;
+ gap: 3.25rem;
+ padding: 1rem 0 3.5rem;
+ }
+
+ .wheel-actions {
+ position: relative;
+ z-index: 20;
+ display: flex;
+ justify-content: center;
+ width: 100%;
}
.wheel-spin-button {
+ position: relative;
+ z-index: 21;
padding: 1rem 2rem;
background: #d7b56d;
color: #0d3b44;
font-family: var(--bulma-family-primary, inherit);
font-weight: 800;
+ line-height: 1.15;
box-shadow: 0 0.8rem 1.8rem rgba(13, 59, 68, 0.18);
}
@@ -184,10 +197,10 @@
--number-radius: 11.5rem;
position: relative;
- width: min(100%, 34rem);
- aspect-ratio: 1;
display: grid;
place-items: center;
+ width: min(100%, 34rem);
+ aspect-ratio: 1;
}
.wheel-pointer {
@@ -257,12 +270,10 @@
position: absolute;
top: 50%;
left: 50%;
- transform: translate(-50%, -50%) rotate(var(--angle))
- translateY(calc(-1 * var(--number-radius))) rotate(calc(-1 * var(--angle)));
- width: 3rem;
- height: 3rem;
display: grid;
place-items: center;
+ width: 3rem;
+ height: 3rem;
border: 2px solid #0d3b44;
border-radius: 50%;
background: rgba(255, 244, 207, 0.96);
@@ -270,6 +281,8 @@
font-family: var(--bulma-family-primary, inherit);
font-weight: 800;
box-shadow: 0 0.45rem 0.9rem rgba(13, 59, 68, 0.16);
+ transform: translate(-50%, -50%) rotate(var(--angle))
+ translateY(calc(-1 * var(--number-radius))) rotate(calc(-1 * var(--angle)));
}
.wheel__number span {
@@ -289,8 +302,8 @@
.prize-modal__backdrop {
position: absolute;
inset: 0;
- background: rgba(0, 0, 0, 0.55);
border: 0;
+ background: rgba(0, 0, 0, 0.55);
}
.prize-card {
@@ -298,20 +311,20 @@
z-index: 1;
width: min(100%, 34rem);
padding: 2rem;
- background: #fff4cf;
border: 3px solid #4d2b13;
+ background: #fff4cf;
+ text-align: center;
box-shadow:
inset 0 0 0 4px rgba(255, 255, 255, 0.38),
0 1.4rem 3rem rgba(0, 0, 0, 0.32);
- text-align: center;
}
.prize-card__eyebrow {
margin-bottom: 0.4rem;
+ color: #b06b2d;
font-family: var(--bulma-family-primary, inherit);
font-weight: 800;
text-transform: uppercase;
- color: #b06b2d;
}
.prize-card__title {
@@ -330,15 +343,56 @@
}
@media screen and (max-width: 768px) {
+ .wheel-game {
+ gap: 3rem;
+ padding-bottom: 4rem;
+ }
+
.wheel-stage {
--number-radius: 7.4rem;
+
width: min(100%, 22rem);
}
+ .wheel-spin-button {
+ width: min(100%, 18rem);
+ white-space: normal;
+ }
+
.wheel__number {
width: 2.25rem;
height: 2.25rem;
font-size: 0.9rem;
}
}
+
+ @media screen and (max-width: 420px) {
+ .wheel-stage {
+ --number-radius: 6.55rem;
+ }
+
+ .wheel-pointer {
+ top: -2.15rem;
+ width: 3rem;
+ height: 3.9rem;
+ }
+
+ .wheel-pointer::before {
+ width: 1.15rem;
+ height: 1.15rem;
+ border-width: 2px;
+ }
+
+ .wheel-pointer::after {
+ top: 0.85rem;
+ width: 1.7rem;
+ height: 2.8rem;
+ }
+
+ .wheel__number {
+ width: 2rem;
+ height: 2rem;
+ font-size: 0.82rem;
+ }
+ }
diff --git a/frontend/src/routes/login/+page.server.js b/frontend/src/routes/login/+page.server.js
index a506c3b..5d3d7a1 100644
--- a/frontend/src/routes/login/+page.server.js
+++ b/frontend/src/routes/login/+page.server.js
@@ -1,54 +1,59 @@
import { fail, redirect } from '@sveltejs/kit';
import { message, superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
-import * as m from '$lib/paraglide/messages.js'
+import * as m from '$lib/paraglide/messages.js';
import { zod } from 'sveltekit-superforms/adapters';
import { fetchCMSData } from '../../services/api';
import { isAuthorizedUser, setUser } from '../../services/users';
-let data = {}
+let data = {};
const schema = z.object({
- email: z.string().email(),
- password: z.string(),
+ email: z.string().email(),
+ password: z.string()
});
export async function load({ params, cookies }) {
- if (isAuthorizedUser(cookies)) {
- redirect(302, "/me");
- }
- const form = await superValidate(zod(schema));
- return { form };
+ if (isAuthorizedUser(cookies)) {
+ redirect(302, '/me');
+ }
+ const form = await superValidate(zod(schema));
+ return { form };
}
export const actions = {
- default: async ({ request, cookies }) => {
- const form = await superValidate(request, zod(schema));
-
- if (!form.valid) {
- return fail(400, { form });
- }
-
- const { response, error } = await fetchCMSData(
- "POST", "/auth/local", {
- identifier: form.data.email,
- password: form.data.password
- }, cookies
- )
-
- if (response) {
- setUser(cookies, response.jwt, response.user);
- redirect(302, "/me")
- }
-
- let error_msg;
- if (error?.status === 400) {
- error_msg = m.email_or_password_incorrect();
- } else if (error?.message) {
- error_msg = error.message;
- } else {
- error_msg = m.an_error_has_occurred();
- }
- return message(form, error_msg);
- }
- };
\ No newline at end of file
+ default: async ({ request, cookies }) => {
+ const form = await superValidate(request, zod(schema));
+
+ if (!form.valid) {
+ return fail(400, { form });
+ }
+
+ const { response, error } = await fetchCMSData(
+ 'POST',
+ '/auth/local',
+ {
+ identifier: form.data.email,
+ password: form.data.password
+ },
+ cookies
+ );
+
+ if (response) {
+ setUser(cookies, response.jwt, response.user);
+ redirect(302, '/me');
+ }
+
+ let error_msg;
+ if (error?.status === 400) {
+ error_msg = m.email_or_password_incorrect();
+ } else if (error?.status === 403) {
+ error_msg = m.login_forbidden();
+ } else if (error?.message) {
+ error_msg = error.message;
+ } else {
+ error_msg = m.an_error_has_occurred();
+ }
+ return message(form, error_msg);
+ }
+};
diff --git a/frontend/src/routes/price/+page.js b/frontend/src/routes/price/+page.js
index f6c0294..cf72455 100644
--- a/frontend/src/routes/price/+page.js
+++ b/frontend/src/routes/price/+page.js
@@ -2,30 +2,32 @@ import { encodeQuery, fetchBasic, fetchCollection } from '../../services/api';
/** @type {import('./$types').PageLoad} */
export async function load({}) {
- const tshirtFrontImagePath = `/upload/files?${encodeQuery({
+ const tshirtImagesPath = `/upload/files?${encodeQuery({
filters: {
name: {
- $eq: 'camiseta.jpg'
+ $startsWith: 'camiseta'
}
- }
+ },
+ sort: ['name:asc']
})}`;
- const tshirtBackImagePath = `/upload/files?${encodeQuery({
+ const allIncludedPriceCardPath = `/upload/files?${encodeQuery({
filters: {
name: {
- $eq: 'camiseta_trasera.jpg'
+ $eq: 'carta_ti.jpeg'
}
}
})}`;
- const [priceEntries, tshirtFrontImages, tshirtBackImages] = await Promise.all([
+ const [priceEntries, tshirtImages, allIncludedPriceCards] = await Promise.all([
fetchCollection('/prices?populate=*'),
- fetchBasic(tshirtFrontImagePath),
- fetchBasic(tshirtBackImagePath)
+ fetchBasic(tshirtImagesPath),
+ fetchBasic(allIncludedPriceCardPath)
]);
return {
prices: priceEntries ?? [],
- tshirtImages: [tshirtFrontImages?.[0], tshirtBackImages?.[0]].filter(Boolean)
+ tshirtImages: tshirtImages ?? [],
+ allIncludedPriceCard: allIncludedPriceCards?.[0] ?? null
};
}
diff --git a/frontend/src/routes/price/+page.svelte b/frontend/src/routes/price/+page.svelte
index d079334..c3b7868 100644
--- a/frontend/src/routes/price/+page.svelte
+++ b/frontend/src/routes/price/+page.svelte
@@ -2,7 +2,7 @@
import SvelteMarkdown from '@humanspeak/svelte-markdown';
import { modals } from 'svelte-modals';
import * as m from '$lib/paraglide/messages.js';
- import ActivityModal from '$lib/ActivityModal.svelte';
+ import PriceImageModal from '$lib/PriceImageModal.svelte';
import roomImage from '../../assets/images/camarote.jpeg';
import treasureImage from '../../assets/images/tesoro.jpeg';
@@ -26,6 +26,7 @@
prices: Price[];
content?: string;
tshirtImages?: StrapiImage[];
+ allIncludedPriceCard?: StrapiImage | null;
}
export let data: Data;
@@ -60,6 +61,15 @@
return price.name.toLowerCase().includes('camiseta');
}
+ function isAllIncludedPrice(price: Price): boolean {
+ const normalizedName = price.name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '');
+
+ return normalizedName.includes('todo incluido');
+ }
+
function getStrapiMediaUrl(image?: StrapiImage | null): string {
if (!image?.url) return '';
@@ -75,24 +85,36 @@
.map((image) => getStrapiMediaUrl(image))
.filter(Boolean);
- modals.open(ActivityModal, {
- activity: {
- title: price.name,
- short_description: price.description,
- long_description:
- tshirtImageUrls.length > 0
- ? tshirtImageUrls
- .map((url, index) => {
- const label =
- index === 0
- ? 'Diseño delantero de la camiseta'
- : 'Diseño trasero de la camiseta';
- return ``;
- })
- .join(' ')
- : 'El diseño de la camiseta todavía no está disponible.',
- tag1: 'Diseño'
- }
+ modals.open(PriceImageModal, {
+ title: price.name,
+ short_description: price.description,
+ images: tshirtImageUrls.map((url, index) => ({
+ url,
+ alt: index === 0 ? 'Diseño delantero de la camiseta' : 'Diseño trasero de la camiseta'
+ })),
+ tag1: 'Diseño',
+ imageZoom: true,
+ unavailableText: 'El diseño de la camiseta todavía no está disponible.'
+ });
+ }
+
+ function openAllIncludedModal(price: Price) {
+ const priceCardUrl = getStrapiMediaUrl(data.allIncludedPriceCard);
+
+ modals.open(PriceImageModal, {
+ title: price.name,
+ short_description: price.description,
+ images: priceCardUrl
+ ? [
+ {
+ url: priceCardUrl,
+ alt: 'Carta de precios todo incluido'
+ }
+ ]
+ : [],
+ tag1: 'Carta de precios',
+ imageZoom: true,
+ unavailableText: 'La carta de precios todavía no está disponible.'
});
}
@@ -219,6 +241,32 @@
{/if}
+
+ {#if isPricePending(priceObj.value)}
+ PRECIO POR DETERMINAR
+ {:else}
+ {priceObj.value}
+ {priceObj.currency}
+ {/if}
+
+
+ {:else if isAllIncludedPrice(priceObj)}
+
openAllIncludedModal(priceObj)}
+ >
+
+
{priceObj.name}
+
+ {#if priceObj.description}
+
{priceObj.description}
+ {/if}
+
+
string}
*/
export const encodeQuery = (query) =>
@@ -25,7 +25,7 @@ export const encodeQuery = (query) =>
/**
* Generic request to backend API.
- *
+ *
* @param {string} method ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
* @param {string} path
* @param {Record} [payload]
@@ -33,123 +33,123 @@ export const encodeQuery = (query) =>
* @param {boolean} [forceApiToken]
*/
export const fetchCMSData = async (method, path, payload, cookies, forceApiToken) => {
- if (!base) {
- return {
- error: {
- message: "You need to configure base URL in .env file."
- }
- };
- }
+ if (!base) {
+ return {
+ error: {
+ message: 'You need to configure base URL in .env file.'
+ }
+ };
+ }
- const authToken = getAuthToken(cookies, forceApiToken);
- const authHeaders = authToken ? {"Authorization": `Bearer ${authToken}`} : {};
- const headers = {...baseHeaders, ...authHeaders};
+ const authToken = getAuthToken(cookies, forceApiToken);
+ const authHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : {};
+ const headers = { ...baseHeaders, ...authHeaders };
- const config = {
+ const config = {
method,
- headers: {...baseHeaders, ...authHeaders},
- data: payload && JSON.stringify(payload),
- }
-
- try {
- const response = await axios(`${base}${path}`, config);
- return { response: response.data };
- } catch(error) {
- console.error(`ERROR fetching data ${method} ${base}${path}`, error.toJSON());
- return {
- error: {
- status: error.status,
- message: error.message
- }
- };
- }
-}
+ headers: { ...baseHeaders, ...authHeaders },
+ data: payload && JSON.stringify(payload)
+ };
+
+ try {
+ const response = await axios(`${base}${path}`, config);
+ return { response: response.data };
+ } catch (error) {
+ console.error(`ERROR fetching data ${method} ${base}${path}`, error.toJSON());
+ return {
+ error: {
+ status: error.response?.status ?? error.status,
+ message: error.response?.data?.error?.message ?? error.message
+ }
+ };
+ }
+};
/**
* Call backend API to retrieve a single type.
- *
+ *
* @param {string} path
* @param {object} cookies
*/
export const fetchSingle = async (path, cookies) => {
- const { response, error } = await fetchCMSData("GET", path, {}, cookies);
- if (response) {
- /* For a single CMS type, the response is like
- * {
- * "data": {
- * "id": 12345,
- * "attr1": val1,
- * "attr2": val2,
- * ...
- * },
- * "meta": {}
- * }
- */
- return response.data;
- } else {
- console.error("->", error.message)
- return null;
- }
-}
+ const { response, error } = await fetchCMSData('GET', path, {}, cookies);
+ if (response) {
+ /* For a single CMS type, the response is like
+ * {
+ * "data": {
+ * "id": 12345,
+ * "attr1": val1,
+ * "attr2": val2,
+ * ...
+ * },
+ * "meta": {}
+ * }
+ */
+ return response.data;
+ } else {
+ console.error('->', error.message);
+ return null;
+ }
+};
/**
* call backend api to retrieve a collection type.
- *
+ *
* @param {string} path
* @param {object} cookies
*/
export const fetchCollection = async (path, cookies) => {
- const { response, error } = await fetchCMSData("GET", path, {}, cookies);
- if (response) {
- /* for a collection CMS type, the response is like
- * {
- * "data": [
- * {
- * "id": 12345,
- * "attr1": val1,
- * "attr2": val2,
- * ...
- * },
- * {
- * "id": 67890,
- * "attr1": val3,
- * "attr2": val4,
- * ...
- * },
- * ],
- * "meta": {}
- * }
- */
- return response.data;
- } else {
- console.error("->", error.message)
- return [];
- }
-}
+ const { response, error } = await fetchCMSData('GET', path, {}, cookies);
+ if (response) {
+ /* for a collection CMS type, the response is like
+ * {
+ * "data": [
+ * {
+ * "id": 12345,
+ * "attr1": val1,
+ * "attr2": val2,
+ * ...
+ * },
+ * {
+ * "id": 67890,
+ * "attr1": val3,
+ * "attr2": val4,
+ * ...
+ * },
+ * ],
+ * "meta": {}
+ * }
+ */
+ return response.data;
+ } else {
+ console.error('->', error.message);
+ return [];
+ }
+};
/**
* Call backend API to retrieve a basic type.
- *
+ *
* @param {string} path
* @param {object} cookies
*/
export const fetchBasic = async (path, cookies) => {
- const { response, error } = await fetchCMSData("GET", path, {}, cookies);
- if (response) {
- /* For a basic Strapi API type, the response is directly the requested object
- * {
- * "id": 12345,
- * "attr1": val1,
- * "attr2": val2,
- * ...
- * }
- */
- return response;
- } else {
- console.error("->", error.message)
- return null;
- }
-}
+ const { response, error } = await fetchCMSData('GET', path, {}, cookies);
+ if (response) {
+ /* For a basic Strapi API type, the response is directly the requested object
+ * {
+ * "id": 12345,
+ * "attr1": val1,
+ * "attr2": val2,
+ * ...
+ * }
+ */
+ return response;
+ } else {
+ console.error('->', error.message);
+ return null;
+ }
+};
/**
* @param {string} method ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
@@ -158,17 +158,17 @@ export const fetchBasic = async (path, cookies) => {
* @param {string} [authToken]
*/
export const apiClient = (method, path, data, authToken) => {
- const authHeaders = authToken ? {"Authorization": `Bearer ${authToken}`} : {}
+ const authHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : {};
- const config = {
+ const config = {
method,
- headers: {...baseHeaders, ...authHeaders},
- }
- if (data) {
- config.data = data && JSON.stringify(data)
- }
+ headers: { ...baseHeaders, ...authHeaders }
+ };
+ if (data) {
+ config.data = data && JSON.stringify(data);
+ }
return axios(`${base}/${path}`, config)
- .then(response => response.data)
- .catch((err) => console.error("###### error:", err.response.data.error))
-}
+ .then((response) => response.data)
+ .catch((err) => console.error('###### error:', err.response.data.error));
+};