From 3dfb1689adbbfc9ce43729f5e262c49e4e9cead6 Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 27 Apr 2026 03:21:15 +0000 Subject: [PATCH] Booking flow: required terms and privacy checkbox with i18n Also includes admin, dashboard, and API updates; PWA icon assets; and assorted layout and utility changes on dev. --- backend/src/db/migrate.ts | 23 ++- backend/src/db/schema.ts | 6 +- backend/src/lib/utils.ts | 43 ++++ backend/src/routes/admin.ts | 8 +- backend/src/routes/events.ts | 19 +- backend/src/routes/tickets.ts | 136 ++++++++++++ frontend/public/images/icon-192.png | Bin 0 -> 9752 bytes frontend/public/images/icon-512.png | Bin 0 -> 27717 bytes .../src/app/(public)/book/[eventId]/page.tsx | 81 +++++++- .../(public)/components/NextEventSection.tsx | 4 +- .../dashboard/components/PaymentsTab.tsx | 3 +- .../dashboard/components/ProfileTab.tsx | 3 +- .../dashboard/components/SecurityTab.tsx | 3 +- .../dashboard/components/TicketsTab.tsx | 3 +- frontend/src/app/(public)/page.tsx | 3 +- frontend/src/app/admin/bookings/page.tsx | 3 +- frontend/src/app/admin/contacts/page.tsx | 3 +- frontend/src/app/admin/emails/page.tsx | 7 +- frontend/src/app/admin/events/[id]/page.tsx | 194 ++++++++++++++---- frontend/src/app/admin/events/page.tsx | 27 ++- frontend/src/app/admin/gallery/page.tsx | 3 +- frontend/src/app/admin/legal-pages/page.tsx | 3 +- frontend/src/app/admin/page.tsx | 3 +- frontend/src/app/admin/payments/page.tsx | 3 +- frontend/src/app/admin/scanner/page.tsx | 7 +- frontend/src/app/admin/settings/page.tsx | 3 +- frontend/src/app/admin/tickets/page.tsx | 3 +- frontend/src/app/admin/users/page.tsx | 3 +- frontend/src/app/llms.txt/route.ts | 9 +- frontend/src/components/layout/Footer.tsx | 1 + frontend/src/components/layout/Header.tsx | 2 + frontend/src/i18n/locales/en.json | 9 +- frontend/src/i18n/locales/es.json | 9 +- frontend/src/lib/api.ts | 15 ++ frontend/src/lib/utils.ts | 39 +++- 35 files changed, 575 insertions(+), 106 deletions(-) create mode 100644 frontend/public/images/icon-192.png create mode 100644 frontend/public/images/icon-512.png diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 675c5f5..c085ac0 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -173,6 +173,11 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`); } catch (e) { /* column may already exist */ } + + // Migration: Add is_guest column to tickets + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // SQLite doesn't support altering column constraints, so we'll just ensure new entries work @@ -533,8 +538,8 @@ async function migrate() { description_es TEXT, short_description VARCHAR(300), short_description_es VARCHAR(300), - start_datetime TIMESTAMP NOT NULL, - end_datetime TIMESTAMP, + start_datetime TIMESTAMPTZ NOT NULL, + end_datetime TIMESTAMPTZ, location VARCHAR(500) NOT NULL, location_url VARCHAR(500), price DECIMAL(10, 2) NOT NULL DEFAULT 0, @@ -565,6 +570,15 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`); } catch (e) { /* column may already exist */ } + // Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for + // unambiguous UTC storage (eliminates pg driver timezone interpretation). + try { + await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`); + } catch (e) { /* already timestamptz or other issue */ } + try { + await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`); + } catch (e) { /* already timestamptz or other issue */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS tickets ( id UUID PRIMARY KEY, @@ -599,6 +613,11 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`); } catch (e) { /* column may already exist */ } + // Migration: Add is_guest column to tickets + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 739fd92..d2b672a 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -99,6 +99,7 @@ export const sqliteTickets = sqliteTable('tickets', { checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in qrCode: text('qr_code'), adminNote: text('admin_note'), + isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull(), }); @@ -392,8 +393,8 @@ export const pgEvents = pgTable('events', { descriptionEs: pgText('description_es'), shortDescription: varchar('short_description', { length: 300 }), shortDescriptionEs: varchar('short_description_es', { length: 300 }), - startDatetime: timestamp('start_datetime').notNull(), - endDatetime: timestamp('end_datetime'), + startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(), + endDatetime: timestamp('end_datetime', { withTimezone: true }), location: varchar('location', { length: 500 }).notNull(), locationUrl: varchar('location_url', { length: 500 }), price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'), @@ -423,6 +424,7 @@ export const pgTickets = pgTable('tickets', { checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in qrCode: varchar('qr_code', { length: 255 }), adminNote: pgText('admin_note'), + isGuest: pgInteger('is_guest').notNull().default(0), createdAt: timestamp('created_at').notNull(), }); diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index a116b91..ac4e7da 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -41,6 +41,49 @@ export function toDbDate(date: Date | string): string | Date { return getDbType() === 'postgres' ? d : d.toISOString(); } +const NAIVE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/; + +/** + * Parse a datetime string that represents wall-clock time in a given timezone + * and return the corresponding UTC Date. + * + * Naive strings (no "Z" or offset) are interpreted as wall-clock time in + * `timezone`. Strings that already carry a timezone indicator are parsed + * directly so existing UTC ISO values still work. + */ +export function parseEventDatetime( + datetime: string, + timezone: string = 'America/Asuncion', +): Date { + if (!NAIVE_DATETIME_RE.test(datetime)) { + return new Date(datetime); + } + + // Treat the digits as UTC so we have a stable reference instant. + const fakeUTC = new Date(datetime + 'Z'); + + // Ask Intl what that UTC instant looks like in both UTC and the target tz. + const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' }); + const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone }); + + // The gap between the two tells us the tz offset at this point in time. + const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime(); + + return new Date(fakeUTC.getTime() + offsetMs); +} + +/** + * Convert a datetime string to the appropriate DB format, interpreting naive + * strings as wall-clock time in `timezone` (defaults to America/Asuncion). + */ +export function toDbDateTz( + datetime: string, + timezone: string = 'America/Asuncion', +): string | Date { + const d = parseEventDatetime(datetime, timezone); + return getDbType() === 'postgres' ? d : d.toISOString(); +} + /** * Convert a boolean value to the appropriate format for the database type. * - SQLite: returns boolean (true/false) for mode: 'boolean' diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 911a026..70feaeb 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; -import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm'; +import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; @@ -129,7 +129,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { .where( and( eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + eq((tickets as any).status, 'confirmed'), + ne((tickets as any).isGuest, 1) ) ) ); @@ -141,7 +142,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { .where( and( eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'checked_in') + eq((tickets as any).status, 'checked_in'), + ne((tickets as any).isGuest, 1) ) ) ); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index ebc383f..bc7d228 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; +import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; interface UserContext { @@ -201,6 +201,13 @@ eventsRouter.get('/:id', async (c) => { }); }); +async function getSiteTimezone(): Promise { + const settings = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + return settings?.timezone || 'America/Asuncion'; +} + // Helper function to get ticket count for an event async function getEventTicketCount(eventId: string): Promise { const ticketCount = await dbGet( @@ -316,6 +323,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const user = c.get('user'); const now = getNow(); const id = generateId(); + const tz = await getSiteTimezone(); // Convert data for database compatibility const dbData = convertBooleansForDb(data); @@ -323,8 +331,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const newEvent = { id, ...dbData, - startDatetime: toDbDate(data.startDatetime), - endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null, + startDatetime: toDbDateTz(data.startDatetime, tz), + endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null, createdAt: now, updatedAt: now, }; @@ -351,14 +359,15 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', } const now = getNow(); + const tz = await getSiteTimezone(); // Convert data for database compatibility const updateData: Record = { ...convertBooleansForDb(data), updatedAt: now }; // Convert datetime fields if present if (data.startDatetime) { - updateData.startDatetime = toDbDate(data.startDatetime); + updateData.startDatetime = toDbDateTz(data.startDatetime, tz); } if (data.endDatetime !== undefined) { - updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null; + updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null; } await (db as any) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 59e3537..43705fe 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1394,6 +1394,142 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); +// Admin invite guest ticket (free, confirmed, not counted in revenue) +ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ + eventId: z.string(), + firstName: z.string().min(1), + lastName: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + phone: z.string().optional().or(z.literal('')), + preferredLanguage: z.enum(['en', 'es']).optional(), + adminNote: z.string().max(1000).optional(), +})), async (c) => { + const data = c.req.valid('json'); + + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.eventId)) + ); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + const now = getNow(); + const adminUser = (c as any).get('user'); + + // Find or create user (use placeholder email if none provided) + const attendeeEmail = data.email && data.email.trim() + ? data.email.trim() + : `guest-${generateId()}@guestinvite.local`; + + const fullName = data.lastName && data.lastName.trim() + ? `${data.firstName} ${data.lastName}`.trim() + : data.firstName; + + let user = await dbGet( + (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + ); + + if (!user) { + const userId = generateId(); + user = { + id: userId, + email: attendeeEmail, + password: '', + name: fullName, + phone: data.phone || null, + role: 'user', + languagePreference: null, + createdAt: now, + updatedAt: now, + }; + await (db as any).insert(users).values(user); + } + + // Check for existing active ticket (only for real emails, not placeholder) + if (data.email && data.email.trim()) { + const existingTicket = await dbGet( + (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).userId, user.id), + eq((tickets as any).eventId, data.eventId) + ) + ) + ); + if (existingTicket && existingTicket.status !== 'cancelled') { + return c.json({ error: 'This person already has a ticket for this event' }, 400); + } + } + + const ticketId = generateId(); + const qrCode = generateTicketCode(); + + const newTicket = { + id: ticketId, + userId: user.id, + eventId: data.eventId, + attendeeFirstName: data.firstName, + attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, + attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null, + attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, + preferredLanguage: data.preferredLanguage || null, + status: 'confirmed', + isGuest: true, + qrCode, + checkinAt: null, + adminNote: data.adminNote || null, + createdAt: now, + }; + + await (db as any).insert(tickets).values(newTicket); + + // Create a $0 payment record to track the invite + const paymentId = generateId(); + const newPayment = { + id: paymentId, + ticketId, + provider: 'cash', + amount: 0, + currency: event.currency, + status: 'paid', + reference: 'Guest invite', + paidAt: now, + paidByAdminId: adminUser?.id || null, + createdAt: now, + updatedAt: now, + }; + + await (db as any).insert(payments).values(newPayment); + + // Send booking confirmation email if a real email was provided + if (data.email && data.email.trim()) { + emailService.sendBookingConfirmation(ticketId).then(result => { + if (result.success) { + console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`); + } else { + console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending booking confirmation for guest ticket:', err); + }); + } + + return c.json({ + ticket: { + ...newTicket, + event: { + title: event.title, + startDatetime: event.startDatetime, + location: event.location, + }, + }, + payment: newPayment, + message: 'Guest ticket created successfully', + }, 201); +}); + // Get all tickets (admin) - includes payment for each ticket ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); diff --git a/frontend/public/images/icon-192.png b/frontend/public/images/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..7ed7695d51cabbc710d81ba108c3ece66edd1789 GIT binary patch literal 9752 zcmds-cQ;($`}fD_En4(8f~ZlV1;JpnNEjsu(S<>j5Op$)PDBim_(X5fTl6-%5Tf@s zh^RwMl+n%Ydq0fdALpF4_FCuK=UjWQtGwPj4r-u7OT|G2003z9bRovK&(8l{6u{g2 za{DC|01#Z!gQ%MZX6$DMWN`J>AA}Z_wz{}Pm`zmrSXMA5(Mxls725#$`@Td(iObXokyK zL^y?bbqy~DZavwvD8ZWJDyLy{oevgK3tb^pA!K_oz;GlAMOpJnc*o!y8Y+!ynT1a4 z?ka`{-zhq5KQ|9Ivwoqc4JId+vDm;y2UKm*9_*YP9sf8D>feG@#?Wngi-!Y*&l16R z{fxrw3%ZbZ2-I}nkrRKXd?U9N^k|gA$hZwv+LT}M<9g(OW)doMaO z+^?`Mr8)tn>H3NIq1cySZbXX=#Py6sebjy$DHqR9=897&SUP(ocy$f zAn}~9OjXTiz1FJ=C3mG`tR~t4OhuE@Dk1gEK$0J)qBBltqYqc5@1p1(I;9=<1ReIY zOvfeNm|Ma>ESP}Rz39BxwQXd~y(|Zm&ASY{o)~Y~?II%C9)2#>hIq|@7_Q1Nt=12M z!=JRqr@7`ibDXf#l?lKna149M`6p6pjEn%U%cl?83?+k-13be4T$J`3&vJxk#j2z6 zx$L65$Z(82p%*|er^dD;pl;DD6B$gk`G#b_#{?HPD5|)rp}51bt03Lhv;7BCoJn#Q za}RTs6ui~Ado&MQA|3IyB0DXT6^YIe5VC%tZ3_U15`TprU^cH$Voa%EV4^$4TT;?6 zs_7B>h{C&bB;}o4nBRGcZ!DuG<_}APFBNuci%E%{5b_eJ{M)d23lb|zvN&AwHGQ5r-D>Mo(RB@687%4v=Y0{!e z1Mas5B>dsV+AZc1^-{Id(E*^Aq8A^E(mwT#?c@;zQr?bQn_f0=$$4J-jpcjy1fR@E zPV0n%l_D64>|>HiH5|=etpaIMET$aUPE?GK({&Lab4rs;F_KlYOT3~z&#VIbx7s#L z(C7S^W4?nkE)iCeIphX1N$3UTK7YQQeo*f#pIyU}EnM|{<~TF66Yf9fwF7#S&Xb@P&x*3|SppGn{C_C=MLs!(-%`bqj+ z_hVqb;?Bb-*;qfX9m$)**|xCd6A;L#cr{1QmG{+hd5K!mrw+R0`5yrSTC6CbUg*p# zw;RXpFOi2-e_N}%d2|^IxD!Z`D5&bYg!gU*{+sQmU?oy{qNvb+Xzu~xBvTb_nk-Q_ zm$A-ustjRrmtdK?Xw=J&U!iNQ!RKYV0JD2VA$++H2LK=GH4EQ=XN^yCF=`B}kYtrV z%+a16h~3=Qm}gCs$9|T?_8D%=d4V>fpAG&T2=_^HHd20(bq*i*VzltX71S34C#W<^ z4SDg|O+aQrC=Y;M-Mv6AndPKdOD~`)q0OB(&7>O8|5IfN0=4;zXIK>pM3T-ic8k_g z=BeIy52c5WYaXWyL}%S|D>Pc6B)1RN*)ruNjEC;PVXoks>C+T-j_nyuo;M{K-pnV; z50#)Xjylr&Qg?VCqZw-vGu+uyR$((?=WZvRS=PK&2a6I2=M z_vN1*gvhR182KhY-c=fac*iT&FT?l5ZB%$Sf_fxKEMIbXH~C%c=lN@&5h)`C`L@p^ zNx@a2{h{aDHb<&YDsUn|kcVth$qMYUCbif{mmF3c2y~!j#Pm!a4=N;i1ER4;gf}B{ z*?*U79AUiOn}tdT|BjkG`NY{JjGbR{D9tzT?w6b7e0~N%22_k=S2mwl?VNb|UT=95 zF*HZVEy$x^{FCs=^vU%5k(v8R3cZMMx?gn-conq!4{?U-CzQz@nO6^W$K+BsAv9|q zPGk`_N2gc3$cX%>qNu4$XihjhZO@hbIF~SZ=s#yf7iig-F3kIHJ{Wz6JY4eoFnKEI zwQ%a-rIn1=3C|&D63 z|DVj3oH9lllCEWE?>V7h_2Z{mIop+@w~S1NXolhJON zb4yc4$&r>iZ}ZRUMz;c!@PA20b+8;eL0IRw&)_rsD@o;9$?uYIgRC>_T{R*U>?dfW z`;<@oEKT&%(q)Biv(w=yi}1zf#yKk(xbG(Y_a0THgj3zrx18&xvpHrE``a~#Gj@FA zN**RZ$wyNSb!e2fN*mu+Bq_o+vl!-Z0=|oviSx{5XQEMg=0|$d(?G`z%AG|s`&)yA z>6o8So$3N|i{2cNld;DCc%=Rc0h^k4n3@L!F1iHoTLquExBXMW)Dy8=u&!vnLae&j z2~J(D?4I&!2Hh*-0hHTQpZwk5jUW`Z>%32Mb=VfUO+*5szE33bW|bvK?A0%)dY)B{viX&wT0HfIqE|2T7F+`Q@q&pzGT+7ZsN9^knb_rcxv zT)_zLru^V5^LU#BAdg@)4*OC48q@r&Dn#XYy&geNyt+TslzV4_%KAH%EG`g4W%)=r z7IfJ5ae-Xy5p$k@G*{>=Se)2sIzldmPJKKj0esJrPDm<3fbq^CqxWmixnfq8_0bj# zIv^Z=03m!k*xeX?bMz%#^R52}EKOvjSxR}0Wacv!vaS;!L@kIAg@mu=h&FOd3D(f4`MlTot> zyHR`6(iphNSb!!z42C1`v|;#rS4Hyt$!OCwmfhdatZITU>AzTd7ZLV4a%I^DC};*E zeXkvMRRw5}B0PQ8)JJ^c^*9^CS9hZ?6w5;Gd7jt%?y7kcyA32=fFL@9nX;APpu~#= zzsmE1GoEU2xo>Ep&G~3?5M!mzb)t9W4M@xr(_mV4-D-2q;T4z~z>S=>D(mzj0W#_Z z9Z%mTK;leRiDCrB5Zo!+=3j7=07zWcqAm7zQi6fszz)$U$ss#@%sG8cZ zhZTBJpK_MG2ZoYe$Nc^m+1s2yj+_QBvcM#5yegXX=2KNg`>hv5x|qRi^j%i0&8JVp zV_%k(ucas}l;<3(_W4mWpNdd&Sl%~mCbgNGhebJQNWzydu^S+wKbHb*fCI~@&3*ew zVL_Aq+%g1)B9S>!)S$_vnx(tc)!A-|YV&>G z1ho)YPzMVDpsw}TCtFZUQprO-5{}^ORJX2B(wj+~UWqNwlPJKqk~T>{PQYDrFbBZv z9bE!#^`3XhNDLQ(PnQdt%JWuVW3xF`_VX!I{>W!X$wXO0l^Evc>llDLvhTn6 zobWGFkqIefv%~Q=5AN*Cn1F~Xif7rjYUZm4b81e_`F74~JId6uxjznv(F#N)3gm!4 zN7xH$COVAU0fKw3#*`WBdigk%h#$>)DUEIvKZe;>UBmwh?&k~WExY&FF21JfncZ`r z@pxu?x3=mZbQcZuai65PTV2vfttFs*58P_L8U3V>S zeu1J>Fea&o4TAJ64_n9U6fHZ2V4$W;ExBW09IipDnfhGS4=WU$K;-bNF)Fh9HWCcL zv`hYLp)GiSGauh@FECYbfKeb8pAs%mL!#$~sCH(7jEBv2M8^W4iI%%BsBL4~#cs1M ziEv?jt_p&=I~)($FPR~wySE;r-1nj*@F){lnKJ3a8ySQpTBkFq#eHj+6yzD^YYnM?9Nuxk6;5M?XA)M5qo z-(^4iomSCtVYe}v?PVOcD5C_WM>ORB!_%2BU+<(PJabjQr1PQBz!z@~v>DMXRWl4K z=Lp9U=Vx*=cSqy@G!2b4jfbxF=Uy6!2`~n)8xuZrZb+|0VY?}}a1a=|p#(9BTGZ4> zTo7eTOXmdD?}R%W4!?oEU^d$U$a@ipk%VR!2N*-t=f>JIuJ{$tIb>ZlY~%4IQ^wQ> zC$`sod*$T1nn+PHfN&wv-N&vW(bsS7>d8Y3l6n3a3;_arS}Wes-envq`ql87X$_CXvb=d&NYu?oI1CuY?FC0!KCBL5ZZEm&CJtYCk!FO2? zs_e9@obtS0IQaTr%+Q(fY}1NX{N6)=yuEpSP6Z{ne|xermY2)?W#l*15KWtlP}h(Q zfVe0IJz|u1CXt;+;KBK) zd>tgrO5fSnx6sS=cfU^J+!3S4WS!uavm@%chW|sj`R~cFGK7Z4rNj19#gqGTTV^TD zo+Q+HclHEG7xm(m!=uJ4(Jv%v*ZW8UXBXJk?$Lm&0(q#$L+EI!9p+pC3$GDUOEmjt z7r%r0Ra@PBBd~_tP`Q!-LE>cL*t^Z^1>cKkcj}wk-vqvzpRY%VtJCPx%Zya+n^*-c zxnmnsb>Ga4sS*$BKjL6HeNsJaKU=y48Uoi)8T9cX{xs~&ZT#+JQ`*@I?(=O6V&^LB z6dXi82kzV@w1gppX=1WCJ?k4{11f$OJ~NeGU47L{&vI3Fa~1Sx{A>emDhD}MQ0nmdxmFqR>9j5*&NA@wp@k7Y zbMfWaYo5Z0^8xs`2uCvt8VgEno$`Xz1{*3)m?ZBE3z!Pf@T6dpPfKt1RBw`f1BH=R z+Z>U3SrHF?qyfb)nAi*UuvMb#!>AsQ!{6I^Wx>=*v$n5Gge;3OEwKU9UGE*D9I0__N;A5`$dBc=6Xa{_Ix5ufUym7WF3HMiM zIUnf9yL~|7NO0ciJHK1gfb(0^Jv89Z8(ceJ~> z@?%pT0F3T8I*~dHu^aE9rAyC8m@db6;?@0 zx`=p~aM5$Q1~HOb&~erH*a6&bd$esv-&wx73>*v*r*#PGRY2ZTOV2zGiz3mT8Y$RS z$1UM5`8(aid`?5p_`rMiSEk4DY`5FH`K?RWv9x!A41UP8}4tb`S?<@Ab#S{h0 zQf*MGg7mAc_IiQ4UA*TBjIGd&~0aIFfzXcxh z&~iQKx5n@!>_Lbaz^3DBAASIVhO86HNIX1^9i(`qptueeOL-LB_mXguo(Eo(Pn-mG zC8S?95_q3DNf>jZgsx;SeTA6zhTx|P7x_hb)=^zkkrnKXQS6oR9_s;F$Z%BiUpa55 zCgwB>sy07qB;J@_4<<-v@TK26kwsxaHV?X6tl(wBi{T`Zs(%A=M?rl zj}@~!Y#&REU&H9SXR=jxI^HrRtM3|vEnxnANw%!~BYPJ=neQZnZ_A_Gnk?ncIxod7 z8<;m=+-d=qSL`$z6MB5owJq{{@!4pz2f?xwcA}3UVifvS!CDXAvFRy$<$pj;dJ>JD zZyM%gjk>@Mt?w)XcLq9EjYivNTsx?()%U?gckIjUQ;nwWp#Tz#9x|^CIyG+3A-B{a zM!=&Dyg7lSOn~alg}$fZsz}0|_r_3ul{dEgzh&)Eyq_W=y?X5TPd)S48y(F6sOnV1 zn$)u#n~RjwGjk$Z2CbZkoO-_-P16OjDHG+fl1YOZ0Br1!6f9LvJRUo{(?U8Hl2Xvd z4IPN0BidE#6)E8CXR<48tVsmBew4`MPa&uT!Zn<~! zMvd!>_wm&^h@i6$3zr+R2NIwjDLPon{p)$PQ(J zV*Y&kAh;%;Sa3T~x7*{;#d? z<6^qn0~Qqtjpr#ExZooCO0mMViI=dsZU$@i696{xgvRXLDI!^2P$na>+n>m? zPO67p${jdoYg%Ov<0yzIzxF7Ksp@aRLq)FpOCbHK%T_{n$6>UVuy2lErLl&IWtMlQ z-1@A&+c>_UR^Nt9#N39Ely-#m9wh_J=*neen(${VWEG|_kHjAdj9dwhG5x=eG?=AYt(1=f9!6Jc@ex{laPo1}~zvm5pdJ4K$15LzwJS zofAj(xkHNsM90SZ=`L#9qaabf8(g~zlp!2uacWqf`qlA_8a>ed3)UN>j~#hTHH{>N z-?NQ*uhoZJKQ~tCObmLwWo2xqCdJlSZTv}BD&KwLLQSz|veAL#mOD^2{i3n)YI68h zdcYbHRkgZ`8*J>Z^y@;lcfb?2FUp3MH%ncoR0}cHw>+(y(P1nFs^Uw~L?0ey1V=&! zKkL}KW*&i1+{VhHM2haHv3Q=7EzGa<)Go5U?qIPJn$PA|TWTQfEaHmpCxQEhtn^US zdCsfW(^XSU6PG`m5mAbH1=tW#O7vD4q@=W#`W-0fAgZK^v^b#j@n&@^CqcEY*&g9II@wc5SPo$ar|2e^;oAAM}!cic6KNBmX)kW{cxd zA^$?M|2_VRNE28i_Ewaz--@>{GCC;^S8%H3jWS=d+F2GB84rVfl+(Rk?ak?*9b>er8Oy%CU%;uD{ z3X0mN)_?K>OAAhDC|nWO&iv%@?#gSGe)|b< zaKfx1$g^ZC;Q;%*YPa6&aHbFL^xLD%%Gl<=xJHO^m9)cvUY`Gb+Q;%sD%?5D!W7u= ze#^jvq{$>qlKHuIUf<{bmwhGs7qwftND>e=wStRe5jtnddXB*Y`AMJ%XrP$kV<9Nu zfwJ3BZyu2Cba!ryD*a*Ma%D#I$g}E;SI*uyLwIP5RH|RSprh@-Hnulr7VF=Sfb%T{ zNS{CN?f$Ir|BoSMmYJgmIWgow(KMA4g|akF;qTw8+1HCngs;A9qv~NrUno7Bcm-*fQ-H%8#cOPLz+gD+s{0 zkOoh?|9_3Zq+kLJ6}<#7MhbzS$Hb`%#Twa$dAGSpXu7$C1*c6obVuQ5Z-DzQ`k(*( z^$XsyJ((!F^!;Pp75(`+6hCbx zrm`O=7?YQ;U7F6XG~1KzJ-jRSY$*3|g8{0VQL&$C9x5UH8NK`5hH&S=lH5y@vOB^p z@~)qL*P;Lz`QgcZvR!s~A>wEGOHb^cn>F62%w}B7cI_E;lRZ=0Dh(21Dv}G8AdOQG zC*(8U3u{WZE>&D;Uu7uen6}g`UxxP|74~_*Mxx&IRWt32-C!!CI`5z)i;y)WL)lBE z@`j%N0EJ{Ul8>OkbX;lxzF@;H0mt?ZyMsOx4u_hqB0YSPdrc!zDt< z9)7Q)^!F4s$B}tr-bW|2!5J=3C9GTl%7D;UHqA1RcrLp4krE5=}NLQOE+rsV6o)prJ zFVQQC=DVQ*kzfIea4gQ>>jvBmNc%+Gn4F z1}~7GFSdzp=_-`tGw&Of%TmQWXUmvrJcgWr0uJ);Ia0M-dd_uXS7vP6N@vl1%4Q&@ zDB@S3MD&vCL88&SdA?y!Ab(4}2s-8)W1G4~n`0E=7jy6lTzJSf=T>>k-Q~buk@ulK zU8mkW8*Y^`A0CkKtF+Z#CS#HQ`GfB$Q6h)Ss4>N>T{yMzU7mSIuKB2Xq0t94lqbOP zk>W1fx-46S>5p5Da~BOyqDMVsr8{s2Pbgs}(8nU^E4h?MfS#k`Q~Qzn_#O|vO!TLV zY#nfrlZt{z;v??M3J5y(0;fvoJLuA9?mdN(+Q#J8QSbwM*S6+Z)fSn5NWS&gbr(_x zYBZEu(UZjixv!t<_~rdcGFM)C_o-ATSMZbA7$3wy1pqFu?VD=t{X{Sn$>9p}qmEVb z($=(hAl&rNnRoDiYyu2_&Dg5-&eA0rzr-21xo}>6qAnKW6R*xo&cbUMS{1F(%tmzR z044T&_)2+bb0&wkkP_n*n!10sQiN_=5$F2C*m%=A1PjHWyYY)E;jf{rx)3LlsSOHk ziT(JsB4zUqq3F!W!l&Cv7WowLhF!|r1FCp7B5klW z><+B9*>~S<*{lBVh|$~6xl=g7K8gW)YJQNW_y;7-cPZe*-tPa412<|SpJw*VxN2f< QPkaINvem?EnA( literal 0 HcmV?d00001 diff --git a/frontend/public/images/icon-512.png b/frontend/public/images/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..f80f7dbe74b859cfd12cd0b77681668c1174d97a GIT binary patch literal 27717 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 Mt7)lLDVqoWAA^JC{Qv*} literal 0 HcmV?d00001 diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index d8c8e89..959054c 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -110,6 +110,10 @@ export default function BookingPage() { const [errors, setErrors] = useState>>({}); + // Terms & Privacy agreement (not persisted across page loads) + const [agreedToTerms, setAgreedToTerms] = useState(false); + const [termsError, setTermsError] = useState(null); + const rucPattern = /^\d{6,10}$/; // Format RUC input: digits only, max 10 @@ -217,6 +221,13 @@ export default function BookingPage() { } }, [user]); + // Clear the terms error as soon as the user agrees + useEffect(() => { + if (agreedToTerms && termsError) { + setTermsError(null); + } + }, [agreedToTerms, termsError]); + const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); @@ -261,7 +272,20 @@ export default function BookingPage() { setErrors(newErrors); setAttendeeErrors(newAttendeeErrors); - return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0; + + let termsOk = true; + if (!agreedToTerms) { + setTermsError(t('booking.form.errors.termsRequired')); + termsOk = false; + } else { + setTermsError(null); + } + + return ( + Object.keys(newErrors).length === 0 && + Object.keys(newAttendeeErrors).length === 0 && + termsOk + ); }; // Connect to SSE for real-time payment updates @@ -376,6 +400,10 @@ export default function BookingPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!agreedToTerms) { + setTermsError(t('booking.form.errors.termsRequired')); + return; + } if (!event || !validateForm()) return; setSubmitting(true); @@ -1323,13 +1351,58 @@ export default function BookingPage() { + {/* Terms & Privacy agreement */} + +
+ setAgreedToTerms(e.target.checked)} + aria-required="true" + aria-invalid={termsError ? true : undefined} + aria-describedby={termsError ? 'booking-terms-error' : undefined} + className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer" + /> + +
+ {termsError && ( +

+ {termsError} +

+ )} +
+ {/* Submit Button */} - -

- {t('booking.form.termsNote')} -

)} diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index 7404344..5a50712 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -102,11 +102,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
- {formatDate(nextEvent.startDatetime)} + {formatDate(nextEvent.startDatetime)}
- {fmtTime(nextEvent.startDatetime)} + {fmtTime(nextEvent.startDatetime)}
diff --git a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx index ea9fd21..4a2e5ee 100644 --- a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { UserPayment } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; interface PaymentsTabProps { payments: UserPayment[]; @@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) { }); const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx b/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx index bff2f5f..3894eb8 100644 --- a/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx @@ -7,6 +7,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { dashboardApi, UserProfile } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; import toast from 'react-hot-toast'; interface ProfileTabProps { @@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) { {profile?.memberSince - ? new Date(profile.memberSince).toLocaleDateString( + ? parseDate(profile.memberSince).toLocaleDateString( language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' } ) diff --git a/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx b/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx index fd43bbe..380ed31 100644 --- a/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx @@ -7,6 +7,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; import toast from 'react-hot-toast'; export default function SecurityTab() { @@ -147,7 +148,7 @@ export default function SecurityTab() { }; const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx index 69335f7..8e7dee2 100644 --- a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { UserTicket } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; interface TicketsTabProps { tickets: UserTicket[]; @@ -26,7 +27,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) { }); const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index ed4e23e..c9dd594 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -3,6 +3,7 @@ import HeroSection from './components/HeroSection'; import NextEventSectionWrapper from './components/NextEventSectionWrapper'; import AboutSection from './components/AboutSection'; import MediaCarouselSection from './components/MediaCarouselSection'; +import { parseDate } from '@/lib/utils'; import NewsletterSection from './components/NewsletterSection'; import HomepageFaqSection from './components/HomepageFaqSection'; import { getCarouselImages } from '@/lib/carouselImages'; @@ -63,7 +64,7 @@ export async function generateMetadata(): Promise { }; } - const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', { + const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index 7ed6141..b6f1c8a 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; @@ -116,7 +117,7 @@ export default function AdminBookingsPage() { }; const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', diff --git a/frontend/src/app/admin/contacts/page.tsx b/frontend/src/app/admin/contacts/page.tsx index 8953ed9..30bf0c7 100644 --- a/frontend/src/app/admin/contacts/page.tsx +++ b/frontend/src/app/admin/contacts/page.tsx @@ -7,6 +7,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; +import { parseDate } from '@/lib/utils'; export default function AdminContactsPage() { const { t, locale } = useLanguage(); @@ -44,7 +45,7 @@ export default function AdminContactsPage() { }; const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 426d928..3aec4ac 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; @@ -391,7 +392,7 @@ export default function AdminEmailsPage() { }; const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', @@ -572,7 +573,7 @@ export default function AdminEmailsPage() {
{hasDraft && ( - Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''} + Draft saved {composeForm.savedAt ? parseDate(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''} )} +
@@ -2046,6 +2080,86 @@ export default function AdminEventDetailPage() {
)} + {/* Invite Guest Modal */} + {showInviteGuestModal && ( +
setShowInviteGuestModal(false)} + role="presentation" + > + e.stopPropagation()} + > +
+
+

Invite Guest

+

Free ticket — not counted in revenue

+
+ +
+
+
+
+ + setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="First name" /> +
+
+ + setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="Last name" /> +
+
+
+ + setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="email@example.com (optional)" /> +

If provided, a confirmation email will be sent

+
+
+ + setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="+595 981 123456" /> +
+
+ +