feat: add brand assets, design tokens and light/dark theme

- Use real logo/isotype SVGs across app and emails
- Token-based color system aligned with landing (brand scale + semantic tokens)
- ThemeProvider (light/dark/system) with header toggle and settings card
- Embed logo in transactional emails; centralize email colors
This commit is contained in:
Jose Selesan
2026-09-16 17:35:24 -03:00
parent 35ab3f0c06
commit 6100c998be
34 changed files with 435 additions and 115 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,9 +1,31 @@
import { readFileSync } from 'node:fs'
import nodemailer, { type Transporter } from 'nodemailer' import nodemailer, { type Transporter } from 'nodemailer'
type VerificationEmailData = { email: string; url: string; name?: string } type VerificationEmailData = { email: string; url: string; name?: string }
type PasswordResetEmailData = { email: string; url: string; name?: string } type PasswordResetEmailData = { email: string; url: string; name?: string }
type InvitationEmailData = { email: string; url: string; organizationName: string } type InvitationEmailData = { email: string; url: string; organizationName: string }
// Colores de marca (mismo sistema que la web: index.css de apps/web).
const BRAND_600 = '#2544ea'
const TEXT_STRONG = '#0f172a'
const TEXT_MUTED = '#475569'
const ON_ACCENT = '#ffffff'
const LINK_BG = BRAND_600
// Logotipo embebido como data URI para que los mails no dependan de hosting.
function loadLogoTypoDataUri(): string {
try {
const base64 = readFileSync(
new URL('../assets/logotipo-y-organica.png', import.meta.url),
).toString('base64')
return `data:image/png;base64,${base64}`
} catch {
return ''
}
}
const LOGO_DATA_URI = loadLogoTypoDataUri()
interface EmailProvider { interface EmailProvider {
sendVerificationEmail(data: VerificationEmailData): Promise<void> sendVerificationEmail(data: VerificationEmailData): Promise<void>
sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void> sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void>
@@ -30,11 +52,15 @@ class SMTPEmailProvider implements EmailProvider {
} }
private layout(title: string, body: string) { private layout(title: string, body: string) {
const header = LOGO_DATA_URI
? `<img src="${LOGO_DATA_URI}" alt="Gruperly" width="252" height="83" style="height:36px;width:auto;display:block;margin:0 auto;" />`
: '<h2 style="margin:0 0 16px;">Gruperly</h2>'
return ` return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #0a2540;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: ${TEXT_MUTED};">
<h2 style="margin: 0 0 16px;">Gruperly</h2> <div style="text-align:center;margin-bottom:20px;">${header}</div>
<h3 style="margin: 0 0 8px;">${title}</h3> <h3 style="margin: 0 0 8px; color: ${TEXT_STRONG};">${title}</h3>
<p style="line-height: 1.6; color: #334155;">${body}</p> <p style="line-height: 1.6; color: ${TEXT_MUTED};">${body}</p>
</div> </div>
` `
} }
@@ -49,7 +75,7 @@ class SMTPEmailProvider implements EmailProvider {
} }
async sendVerificationEmail({ email, url, name }: VerificationEmailData) { async sendVerificationEmail({ email, url, name }: VerificationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>` const link = `<a href="${url}" style="display:inline-block;background:${LINK_BG};color:${ON_ACCENT};text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>`
await this.send( await this.send(
email, email,
'Verifica tu email en Gruperly', 'Verifica tu email en Gruperly',
@@ -61,7 +87,7 @@ class SMTPEmailProvider implements EmailProvider {
} }
async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) { async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>` const link = `<a href="${url}" style="display:inline-block;background:${LINK_BG};color:${ON_ACCENT};text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>`
await this.send( await this.send(
email, email,
'Restablece tu contraseña en Gruperly', 'Restablece tu contraseña en Gruperly',
@@ -73,7 +99,7 @@ class SMTPEmailProvider implements EmailProvider {
} }
async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) { async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>` const link = `<a href="${url}" style="display:inline-block;background:${LINK_BG};color:${ON_ACCENT};text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>`
await this.send( await this.send(
email, email,
`Te invitaron a ${organizationName} en Gruperly`, `Te invitaron a ${organizationName} en Gruperly`,

View File

@@ -4,6 +4,25 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gruperly</title> <title>Gruperly</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var isDark =
stored === 'dark' ||
(stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="718.5 93 144 144">
<title>Gruperly</title>
<style>
.branch { fill: #171D54; }
.diag { fill: #93B4FD; }
@media (prefers-color-scheme: dark) {
.branch { fill: #EEF4FF; }
}
</style>
<path class="branch" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
<path class="diag" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 885 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="718.5 93 144 144">
<title>Gruperly — isotipo y orgánica</title>
<desc>Y orgánica con rama corta blanco azulado #EEF4FF y diagonal larga celeste #93B4FD, separadas, sobre fondo transparente.</desc>
<path fill="#EEF4FF" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 885 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="718.5 93 144 144">
<title>Gruperly — isotipo y orgánica</title>
<desc>Y orgánica con rama corta azul noche #171D54 y diagonal larga celeste #93B4FD, separadas, sobre fondo transparente.</desc>
<path fill="#171D54" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 881 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="624" height="206" viewBox="250 42 624 206">
<title>Gruperly — y orgánica bicolor</title>
<desc>Rama corta azul noche y diagonal larga celeste, separadas, con curvas suaves y terminales redondeados.</desc>
<path fill="#EEF4FF" d="m 358,111 v 65.55 q 0,9.75 -2.4,17.55 -2.4,7.95 -7.5,13.35 -4.95,5.55 -12.6,8.55 -7.65,3 -18.15,3 -4.35,0 -9.3,-0.75 -4.8,-0.75 -9.6,-2.25 -4.65,-1.5 -9,-3.75 -4.35,-2.25 -7.8,-5.1 L 292,193.2 q 5.25,4.95 11.85,7.5 6.6,2.7 13.35,2.7 6.45,0 10.8,-1.95 4.5,-1.8 7.05,-5.1 2.7,-3.3 3.75,-7.8 1.2,-4.5 1.2,-9.9 v -5.25 h -0.3 q -3.9,5.25 -10.05,8.1 -6,2.7 -13.5,2.7 -8.1,0 -14.7,-3 -6.6,-3 -11.25,-8.1 -4.5,-5.1 -7.05,-11.85 -2.55,-6.9 -2.55,-14.55 0,-7.8 2.4,-14.7 2.55,-6.9 7.05,-12 4.65,-5.1 11.1,-8.1 6.6,-3 14.7,-3 7.65,0 14.25,3.15 6.6,3.15 10.5,9.75 h 0.3 V 111 Z m -38.4,12.6 q -4.95,0 -8.85,1.8 -3.75,1.8 -6.45,4.95 -2.55,3 -4.05,7.2 -1.35,4.2 -1.35,9 0,4.35 1.35,8.4 1.5,4.05 4.05,7.35 2.7,3.15 6.45,5.1 3.9,1.95 8.7,1.95 4.95,0 8.85,-1.8 4.05,-1.95 6.75,-5.1 2.85,-3.15 4.35,-7.2 1.5,-4.2 1.5,-8.7 0,-4.65 -1.5,-8.85 -1.5,-4.2 -4.35,-7.35 -2.7,-3.15 -6.6,-4.95 -3.9,-1.8 -8.85,-1.8 z m 55,-12.6 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 115.30004,72 h -17.1 v -11.55 h -0.3 q -2.4,5.4 -8.4,9.6 -5.85,4.05 -13.95,4.05 -7.05,0 -12.15,-2.4 -4.95,-2.55 -8.25,-6.6 -3.15,-4.05 -4.65,-9.3 -1.5,-5.25 -1.5,-10.8 v -45 h 18 v 39.9 q 0,3.15 0.45,6.6 0.45,3.45 1.95,6.3 1.5,2.7 4.05,4.5 2.7,1.8 7.05,1.8 4.2,0 7.35,-1.65 3.3,-1.8 5.25,-4.5 2.1,-2.7 3.15,-6.15 1.05,-3.6 1.05,-7.2 V 111 h 18 z m 33.85011,-72 v 10.05 h 0.45 q 1.35,-2.1 3.45,-4.2 2.25,-2.25 5.4,-3.9 3.15,-1.8 7.05,-2.85 4.05,-1.2 9,-1.2 7.65,0 14.1,3 6.45,2.85 11.1,7.95 4.65,5.1 7.2,12 2.55,6.9 2.55,14.85 0,7.95 -2.55,15 -2.4,6.9 -7.05,12.15 -4.5,5.1 -11.1,8.25 -6.45,3 -14.7,3 -7.65,0 -14.1,-3.15 -6.3,-3.15 -9.75,-8.55 h -0.3 V 219 h -18 V 111 Z m 42,35.7 q 0,-4.2 -1.35,-8.25 -1.2,-4.2 -3.75,-7.5 -2.55,-3.3 -6.45,-5.25 -3.9,-2.1 -9.15,-2.1 -4.95,0 -8.85,2.1 -3.9,2.1 -6.75,5.4 -2.7,3.3 -4.2,7.5 -1.35,4.2 -1.35,8.4 0,4.2 1.35,8.4 1.5,4.05 4.2,7.35 2.85,3.3 6.75,5.4 3.9,1.95 8.85,1.95 5.25,0 9.15,-2.1 3.9,-2.1 6.45,-5.4 2.55,-3.3 3.75,-7.5 1.35,-4.2 1.35,-8.4 z m 81.3999,-6.9 q 0,-3.45 -1.05,-6.6 -0.9,-3.15 -3,-5.55 -2.1,-2.4 -5.4,-3.75 -3.15,-1.5 -7.5,-1.5 -8.1,0 -13.8,4.95 -5.55,4.8 -6.15,12.45 z m 18,8.1 q 0,1.2 0,2.4 0,1.2 -0.15,2.4 h -54.75 q 0.3,3.9 1.95,7.2 1.8,3.15 4.65,5.55 2.85,2.25 6.45,3.6 3.6,1.35 7.5,1.35 6.75,0 11.4,-2.4 4.65,-2.55 7.65,-6.9 l 12,9.6 q -10.65,14.4 -30.9,14.4 -8.4,0 -15.45,-2.55 -7.05,-2.7 -12.3,-7.5 -5.1,-4.8 -8.1,-11.7 -2.85,-7.05 -2.85,-15.9 0,-8.7 2.85,-15.75 3,-7.2 8.1,-12.15 5.1,-5.1 12,-7.8 7.05,-2.85 15.15,-2.85 7.5,0 13.8,2.55 6.45,2.4 11.1,7.35 4.65,4.8 7.2,12.15 2.7,7.2 2.7,16.95 z M 677.54997,111 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 49.90007,-41.4 h 18 V 183 h -18 z"/>
<path fill="#EEF4FF" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="624" height="206" viewBox="250 42 624 206">
<title>Gruperly — y orgánica bicolor</title>
<desc>Rama corta azul noche y diagonal larga celeste, separadas, con curvas suaves y terminales redondeados.</desc>
<path fill="#171D54" d="m 358,111 v 65.55 q 0,9.75 -2.4,17.55 -2.4,7.95 -7.5,13.35 -4.95,5.55 -12.6,8.55 -7.65,3 -18.15,3 -4.35,0 -9.3,-0.75 -4.8,-0.75 -9.6,-2.25 -4.65,-1.5 -9,-3.75 -4.35,-2.25 -7.8,-5.1 L 292,193.2 q 5.25,4.95 11.85,7.5 6.6,2.7 13.35,2.7 6.45,0 10.8,-1.95 4.5,-1.8 7.05,-5.1 2.7,-3.3 3.75,-7.8 1.2,-4.5 1.2,-9.9 v -5.25 h -0.3 q -3.9,5.25 -10.05,8.1 -6,2.7 -13.5,2.7 -8.1,0 -14.7,-3 -6.6,-3 -11.25,-8.1 -4.5,-5.1 -7.05,-11.85 -2.55,-6.9 -2.55,-14.55 0,-7.8 2.4,-14.7 2.55,-6.9 7.05,-12 4.65,-5.1 11.1,-8.1 6.6,-3 14.7,-3 7.65,0 14.25,3.15 6.6,3.15 10.5,9.75 h 0.3 V 111 Z m -38.4,12.6 q -4.95,0 -8.85,1.8 -3.75,1.8 -6.45,4.95 -2.55,3 -4.05,7.2 -1.35,4.2 -1.35,9 0,4.35 1.35,8.4 1.5,4.05 4.05,7.35 2.7,3.15 6.45,5.1 3.9,1.95 8.7,1.95 4.95,0 8.85,-1.8 4.05,-1.95 6.75,-5.1 2.85,-3.15 4.35,-7.2 1.5,-4.2 1.5,-8.7 0,-4.65 -1.5,-8.85 -1.5,-4.2 -4.35,-7.35 -2.7,-3.15 -6.6,-4.95 -3.9,-1.8 -8.85,-1.8 z m 55,-12.6 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 115.30004,72 h -17.1 v -11.55 h -0.3 q -2.4,5.4 -8.4,9.6 -5.85,4.05 -13.95,4.05 -7.05,0 -12.15,-2.4 -4.95,-2.55 -8.25,-6.6 -3.15,-4.05 -4.65,-9.3 -1.5,-5.25 -1.5,-10.8 v -45 h 18 v 39.9 q 0,3.15 0.45,6.6 0.45,3.45 1.95,6.3 1.5,2.7 4.05,4.5 2.7,1.8 7.05,1.8 4.2,0 7.35,-1.65 3.3,-1.8 5.25,-4.5 2.1,-2.7 3.15,-6.15 1.05,-3.6 1.05,-7.2 V 111 h 18 z m 33.85011,-72 v 10.05 h 0.45 q 1.35,-2.1 3.45,-4.2 2.25,-2.25 5.4,-3.9 3.15,-1.8 7.05,-2.85 4.05,-1.2 9,-1.2 7.65,0 14.1,3 6.45,2.85 11.1,7.95 4.65,5.1 7.2,12 2.55,6.9 2.55,14.85 0,7.95 -2.55,15 -2.4,6.9 -7.05,12.15 -4.5,5.1 -11.1,8.25 -6.45,3 -14.7,3 -7.65,0 -14.1,-3.15 -6.3,-3.15 -9.75,-8.55 h -0.3 V 219 h -18 V 111 Z m 42,35.7 q 0,-4.2 -1.35,-8.25 -1.2,-4.2 -3.75,-7.5 -2.55,-3.3 -6.45,-5.25 -3.9,-2.1 -9.15,-2.1 -4.95,0 -8.85,2.1 -3.9,2.1 -6.75,5.4 -2.7,3.3 -4.2,7.5 -1.35,4.2 -1.35,8.4 0,4.2 1.35,8.4 1.5,4.05 4.2,7.35 2.85,3.3 6.75,5.4 3.9,1.95 8.85,1.95 5.25,0 9.15,-2.1 3.9,-2.1 6.45,-5.4 2.55,-3.3 3.75,-7.5 1.35,-4.2 1.35,-8.4 z m 81.3999,-6.9 q 0,-3.45 -1.05,-6.6 -0.9,-3.15 -3,-5.55 -2.1,-2.4 -5.4,-3.75 -3.15,-1.5 -7.5,-1.5 -8.1,0 -13.8,4.95 -5.55,4.8 -6.15,12.45 z m 18,8.1 q 0,1.2 0,2.4 0,1.2 -0.15,2.4 h -54.75 q 0.3,3.9 1.95,7.2 1.8,3.15 4.65,5.55 2.85,2.25 6.45,3.6 3.6,1.35 7.5,1.35 6.75,0 11.4,-2.4 4.65,-2.55 7.65,-6.9 l 12,9.6 q -10.65,14.4 -30.9,14.4 -8.4,0 -15.45,-2.55 -7.05,-2.7 -12.3,-7.5 -5.1,-4.8 -8.1,-11.7 -2.85,-7.05 -2.85,-15.9 0,-8.7 2.85,-15.75 3,-7.2 8.1,-12.15 5.1,-5.1 12,-7.8 7.05,-2.85 15.15,-2.85 7.5,0 13.8,2.55 6.45,2.4 11.1,7.35 4.65,4.8 7.2,12.15 2.7,7.2 2.7,16.95 z M 677.54997,111 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 49.90007,-41.4 h 18 V 183 h -18 z"/>
<path fill="#171D54" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"/>
<path fill="#93B4FD" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Logo, LogoIcon } from '../brand' import { Logo } from '../brand'
export type AuthShellProps = { export type AuthShellProps = {
title: string title: string
@@ -11,11 +11,10 @@ export function AuthShell({ title, subtitle, children }: AuthShellProps) {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8"> <div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="mb-6 flex items-center justify-center gap-2"> <div className="mb-6 flex items-center justify-center">
<LogoIcon className="size-9" /> <Logo className="h-10" />
<Logo className="text-xl" />
</div> </div>
<div className="rounded-xl border border-border bg-white p-6 shadow-sm"> <div className="rounded-xl border border-border bg-surface p-6 shadow-sm">
<h1 className="text-xl font-bold text-primary">{title}</h1> <h1 className="text-xl font-bold text-primary">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null} {subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
<div className="mt-5">{children}</div> <div className="mt-5">{children}</div>

View File

@@ -1,21 +1,33 @@
import { forwardRef, type HTMLAttributes } from 'react' import { forwardRef, type SVGProps } from 'react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
export type LogoProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & { export type LogoProps = SVGProps<SVGSVGElement>
className?: string
}
export const Logo = forwardRef<HTMLSpanElement, LogoProps>( export const Logo = forwardRef<SVGSVGElement, LogoProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
return ( return (
<span <svg
ref={ref} ref={ref}
className={cn('font-extrabold text-primary select-none', className)} viewBox="250 42 624 206"
role="img"
aria-label="Gruperly"
className={cn('h-6 w-auto', className)}
{...props} {...props}
> >
gruperl <title>Gruperly</title>
<span className="text-accent">y</span> <path
</span> className="fill-brand-night dark:fill-brand-cloud"
d="m 358,111 v 65.55 q 0,9.75 -2.4,17.55 -2.4,7.95 -7.5,13.35 -4.95,5.55 -12.6,8.55 -7.65,3 -18.15,3 -4.35,0 -9.3,-0.75 -4.8,-0.75 -9.6,-2.25 -4.65,-1.5 -9,-3.75 -4.35,-2.25 -7.8,-5.1 L 292,193.2 q 5.25,4.95 11.85,7.5 6.6,2.7 13.35,2.7 6.45,0 10.8,-1.95 4.5,-1.8 7.05,-5.1 2.7,-3.3 3.75,-7.8 1.2,-4.5 1.2,-9.9 v -5.25 h -0.3 q -3.9,5.25 -10.05,8.1 -6,2.7 -13.5,2.7 -8.1,0 -14.7,-3 -6.6,-3 -11.25,-8.1 -4.5,-5.1 -7.05,-11.85 -2.55,-6.9 -2.55,-14.55 0,-7.8 2.4,-14.7 2.55,-6.9 7.05,-12 4.65,-5.1 11.1,-8.1 6.6,-3 14.7,-3 7.65,0 14.25,3.15 6.6,3.15 10.5,9.75 h 0.3 V 111 Z m -38.4,12.6 q -4.95,0 -8.85,1.8 -3.75,1.8 -6.45,4.95 -2.55,3 -4.05,7.2 -1.35,4.2 -1.35,9 0,4.35 1.35,8.4 1.5,4.05 4.05,7.35 2.7,3.15 6.45,5.1 3.9,1.95 8.7,1.95 4.95,0 8.85,-1.8 4.05,-1.95 6.75,-5.1 2.85,-3.15 4.35,-7.2 1.5,-4.2 1.5,-8.7 0,-4.65 -1.5,-8.85 -1.5,-4.2 -4.35,-7.35 -2.7,-3.15 -6.6,-4.95 -3.9,-1.8 -8.85,-1.8 z m 55,-12.6 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 115.30004,72 h -17.1 v -11.55 h -0.3 q -2.4,5.4 -8.4,9.6 -5.85,4.05 -13.95,4.05 -7.05,0 -12.15,-2.4 -4.95,-2.55 -8.25,-6.6 -3.15,-4.05 -4.65,-9.3 -1.5,-5.25 -1.5,-10.8 v -45 h 18 v 39.9 q 0,3.15 0.45,6.6 0.45,3.45 1.95,6.3 1.5,2.7 4.05,4.5 2.7,1.8 7.05,1.8 4.2,0 7.35,-1.65 3.3,-1.8 5.25,-4.5 2.1,-2.7 3.15,-6.15 1.05,-3.6 1.05,-7.2 V 111 h 18 z m 33.85011,-72 v 10.05 h 0.45 q 1.35,-2.1 3.45,-4.2 2.25,-2.25 5.4,-3.9 3.15,-1.8 7.05,-2.85 4.05,-1.2 9,-1.2 7.65,0 14.1,3 6.45,2.85 11.1,7.95 4.65,5.1 7.2,12 2.55,6.9 2.55,14.85 0,7.95 -2.55,15 -2.4,6.9 -7.05,12.15 -4.5,5.1 -11.1,8.25 -6.45,3 -14.7,3 -7.65,0 -14.1,-3.15 -6.3,-3.15 -9.75,-8.55 h -0.3 V 219 h -18 V 111 Z m 42,35.7 q 0,-4.2 -1.35,-8.25 -1.2,-4.2 -3.75,-7.5 -2.55,-3.3 -6.45,-5.25 -3.9,-2.1 -9.15,-2.1 -4.95,0 -8.85,2.1 -3.9,2.1 -6.75,5.4 -2.7,3.3 -4.2,7.5 -1.35,4.2 -1.35,8.4 0,4.2 1.35,8.4 1.5,4.05 4.2,7.35 2.85,3.3 6.75,5.4 3.9,1.95 8.85,1.95 5.25,0 9.15,-2.1 3.9,-2.1 6.45,-5.4 2.55,-3.3 3.75,-7.5 1.35,-4.2 1.35,-8.4 z m 81.3999,-6.9 q 0,-3.45 -1.05,-6.6 -0.9,-3.15 -3,-5.55 -2.1,-2.4 -5.4,-3.75 -3.15,-1.5 -7.5,-1.5 -8.1,0 -13.8,4.95 -5.55,4.8 -6.15,12.45 z m 18,8.1 q 0,1.2 0,2.4 0,1.2 -0.15,2.4 h -54.75 q 0.3,3.9 1.95,7.2 1.8,3.15 4.65,5.55 2.85,2.25 6.45,3.6 3.6,1.35 7.5,1.35 6.75,0 11.4,-2.4 4.65,-2.55 7.65,-6.9 l 12,9.6 q -10.65,14.4 -30.9,14.4 -8.4,0 -15.45,-2.55 -7.05,-2.7 -12.3,-7.5 -5.1,-4.8 -8.1,-11.7 -2.85,-7.05 -2.85,-15.9 0,-8.7 2.85,-15.75 3,-7.2 8.1,-12.15 5.1,-5.1 12,-7.8 7.05,-2.85 15.15,-2.85 7.5,0 13.8,2.55 6.45,2.4 11.1,7.35 4.65,4.8 7.2,12.15 2.7,7.2 2.7,16.95 z M 677.54997,111 h 17.25 v 12 h 0.3 q 3,-6.3 8.4,-10.2 5.4,-3.9 12.6,-3.9 1.05,0 2.25,0.15 1.2,0 2.1,0.3 v 16.5 q -1.8,-0.45 -3.15,-0.6 -1.2,-0.15 -2.4,-0.15 -6.15,0 -9.9,2.25 -3.75,2.25 -5.85,5.4 -2.1,3.15 -2.85,6.45 -0.75,3.3 -0.75,5.25 V 183 h -18 z m 49.90007,-41.4 h 18 V 183 h -18 z"
/>
<path
className="fill-brand-night dark:fill-brand-cloud"
d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"
/>
<path
className="fill-brand-celeste"
d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"
/>
</svg>
) )
}, },
) )

View File

@@ -10,26 +10,20 @@ export const LogoIcon = forwardRef<SVGSVGElement, LogoIconProps>(
return ( return (
<svg <svg
ref={ref} ref={ref}
viewBox="0 0 40 40" viewBox="718.5 93 144 144"
fill="none"
role="img" role="img"
aria-label="Gruperly" aria-label="Gruperly"
className={cn('size-8', className)} className={cn('size-8', className)}
{...props} {...props}
> >
<rect <title>Gruperly</title>
width="40" <path
height="40" className="fill-brand-night dark:fill-brand-cloud"
rx="10" d="M755 111 H767.4 Q771.3 111 772.6 114.8 C776.5 126.2 781.3 137.6 787.6 148.9 Q789.2 151.9 785.8 152.5 C781.9 153.2 777.8 153.4 774 152.9 Q770.2 152.4 768.5 149 C762.9 138 757.9 126.7 753.1 115.3 Q751.3 111 755 111 Z"
className="fill-accent-soft"
/> />
<path <path
d="M17 14.5c0-1.4 3-1.4 3 0V27.5c0 3-2.5 6-6.5 6-3.6 0-6-2.1-6-5 0-1.4 3-1.4 3 0 0 1 .6 2 3 2 2 0 3-1.2 3-3V17z" className="fill-brand-celeste"
className="fill-accent" d="M812.5 111 H824.4 Q828.8 111 827.2 115.1 C817.2 140.2 807.3 167 796.4 194.6 C790.1 210.7 783.2 219 767.2 219 C764.2 219 761.5 218.7 759 218.2 Q756.6 217.7 757 214.7 L758 205.1 Q758.3 202.4 761.1 203 C763.1 203.5 765.2 203.9 767 203.9 C774.9 203.9 778 198.7 781.6 189.4 C791.9 162.5 800.1 138.1 808.4 114 Q809.5 111 812.5 111 Z"
/>
<path
d="M20 15.5c0-1.4 3-1.4 3 0v6c0 3 2 5 5.5 5 1.4 0 3-1.2 3-3 0-1.4 3-1.4 3 0 0 3-2.6 4.6-6.5 4.6-4 0-5-3-5-6.6v-6z"
className="fill-accent"
/> />
</svg> </svg>
) )

View File

@@ -6,7 +6,7 @@ export function BottomNav() {
const location = useLocation() const location = useLocation()
return ( return (
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-white/95 backdrop-blur lg:hidden"> <nav className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface/95 backdrop-blur lg:hidden">
<div className="mx-auto grid max-w-md grid-cols-4"> <div className="mx-auto grid max-w-md grid-cols-4">
{NAV_ITEMS.map(({ label, to, icon: Icon }) => { {NAV_ITEMS.map(({ label, to, icon: Icon }) => {
const isActive = location.pathname === to const isActive = location.pathname === to
@@ -16,7 +16,7 @@ export function BottomNav() {
to={to} to={to}
className={cn( className={cn(
'flex flex-col items-center gap-0.5 py-2.5 text-[11px] font-medium transition-colors', 'flex flex-col items-center gap-0.5 py-2.5 text-[11px] font-medium transition-colors',
isActive ? 'text-accent' : 'text-foreground/50 hover:text-primary', isActive ? 'text-accent-fg' : 'text-foreground/50 hover:text-primary',
)} )}
> >
<Icon <Icon

View File

@@ -1,19 +1,38 @@
import { Logo, LogoIcon } from '../brand' import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../context/ThemeProvider'
import { Logo } from '../brand'
import { Breadcrumb } from './Breadcrumb' import { Breadcrumb } from './Breadcrumb'
import { UserMenu } from './UserMenu' import { UserMenu } from './UserMenu'
function ThemeToggle() {
const { isDark, setTheme } = useTheme()
return (
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
className="flex size-9 items-center justify-center rounded-full border border-border text-foreground/70 transition-colors hover:bg-primary-soft hover:text-primary"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
)
}
export function Header() { export function Header() {
return ( return (
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur"> <header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between px-4 lg:px-6"> <div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between px-4 lg:px-6">
<div className="flex min-w-0 items-center"> <div className="flex min-w-0 items-center">
<a href="/" className="flex items-center gap-2 lg:hidden" aria-label="Gruperly inicio"> <a href="/" className="flex items-center lg:hidden" aria-label="Gruperly inicio">
<LogoIcon className="size-7" /> <Logo className="h-8" />
<Logo className="text-lg tracking-tight" />
</a> </a>
<Breadcrumb /> <Breadcrumb />
</div> </div>
<UserMenu /> <div className="flex items-center gap-2">
<ThemeToggle />
<UserMenu />
</div>
</div> </div>
</header> </header>
) )

View File

@@ -1,16 +1,15 @@
import { Link, useLocation } from '@tanstack/react-router' import { Link, useLocation } from '@tanstack/react-router'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { Logo, LogoIcon } from '../brand' import { Logo } from '../brand'
import { NAV_ITEMS } from './nav-items' import { NAV_ITEMS } from './nav-items'
export function Sidebar() { export function Sidebar() {
const location = useLocation() const location = useLocation()
return ( return (
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex"> <aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-surface px-4 py-6 lg:flex">
<a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio"> <a href="/" className="mb-8 flex items-center px-1" aria-label="Gruperly inicio">
<LogoIcon className="size-8" /> <Logo className="h-10" />
<Logo className="text-lg tracking-tight" />
</a> </a>
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
@@ -23,7 +22,7 @@ export function Sidebar() {
className={cn( className={cn(
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors', 'flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors',
isActive isActive
? 'bg-accent-soft text-accent' ? 'bg-accent-soft text-accent-fg'
: 'text-foreground/60 hover:bg-primary-soft hover:text-primary', : 'text-foreground/60 hover:bg-primary-soft hover:text-primary',
)} )}
> >

View File

@@ -78,15 +78,15 @@ export function UserMenu() {
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden"> <div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden">
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 animate-fade-in bg-black/20 backdrop-blur-sm" className="absolute inset-0 animate-fade-in bg-overlay backdrop-blur-sm"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
/> />
<div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-white/90 backdrop-blur"> <div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-surface/90 backdrop-blur">
<button <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
aria-label="Cerrar menú" aria-label="Cerrar menú"
className="absolute right-4 top-4 z-20 rounded-full bg-white/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-white" className="absolute right-4 top-4 z-20 rounded-full bg-surface/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-surface"
> >
<X className="size-5" /> <X className="size-5" />
</button> </button>
@@ -105,7 +105,7 @@ export function UserMenu() {
</div> </div>
</div> </div>
<div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-white/90 p-2 shadow-xl backdrop-blur"> <div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-surface/90 p-2 shadow-xl backdrop-blur">
<Link <Link
role="menuitem" role="menuitem"
to="/profile" to="/profile"
@@ -132,7 +132,7 @@ export function UserMenu() {
onClick={handleSignOut} onClick={handleSignOut}
className={cn( className={cn(
itemClass, itemClass,
'w-full justify-center border border-border bg-white/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft', 'w-full justify-center border border-border bg-surface/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft',
)} )}
> >
<LogOut className="size-5 text-foreground/60" /> <LogOut className="size-5 text-foreground/60" />
@@ -147,7 +147,7 @@ export function UserMenu() {
: open ? ( : open ? (
<div <div
role="menu" role="menu"
className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-white p-1.5 shadow-lg" className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-surface p-1.5 shadow-lg"
> >
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p> <p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p>

View File

@@ -35,7 +35,7 @@ export function ConfirmationStep({ group, providerName }: ConfirmationStepProps)
</p> </p>
</header> </header>
<div className="rounded-xl border border-border bg-white p-5"> <div className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3> <h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>

View File

@@ -89,8 +89,8 @@ export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
className={cn( className={cn(
'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors', 'h-9 rounded-full border px-3.5 text-sm font-medium transition-colors',
isActive isActive
? 'border-accent bg-accent text-white' ? 'border-accent bg-accent text-on-accent'
: 'border-border bg-white text-primary hover:bg-primary-soft', : 'border-border bg-surface text-primary hover:bg-primary-soft',
)} )}
> >
{label} {label}
@@ -168,7 +168,7 @@ export function FirstGroupStep({ onBack, onCompleted }: FirstGroupStepProps) {
'rounded-xl border px-3 py-2.5 text-left transition-colors', 'rounded-xl border px-3 py-2.5 text-left transition-colors',
isActive isActive
? 'border-accent bg-accent-soft' ? 'border-accent bg-accent-soft'
: 'border-border bg-white hover:bg-primary-soft', : 'border-border bg-surface hover:bg-primary-soft',
)} )}
> >
<span <span

View File

@@ -46,7 +46,7 @@ export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepP
</header> </header>
<div className="rounded-xl border border-success/30 bg-success-soft p-5 text-center"> <div className="rounded-xl border border-success/30 bg-success-soft p-5 text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-white"> <span className="mx-auto flex size-12 items-center justify-center rounded-full bg-surface">
<CheckCircle2 className="size-6 text-success" /> <CheckCircle2 className="size-6 text-success" />
</span> </span>
<p className="mt-3 text-sm font-semibold text-success">Cuenta conectada</p> <p className="mt-3 text-sm font-semibold text-success">Cuenta conectada</p>
@@ -96,7 +96,7 @@ export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepP
aria-checked={isSelected} aria-checked={isSelected}
onClick={() => setSelected(provider.value)} onClick={() => setSelected(provider.value)}
className={cn( className={cn(
'flex w-full items-center gap-3 rounded-xl border bg-white p-4 text-left transition-colors', 'flex w-full items-center gap-3 rounded-xl border bg-surface p-4 text-left transition-colors',
isSelected isSelected
? 'border-accent ring-2 ring-accent/20' ? 'border-accent ring-2 ring-accent/20'
: 'border-border hover:bg-primary-soft', : 'border-border hover:bg-primary-soft',
@@ -123,7 +123,7 @@ export function PaymentStep({ initialResult, onConnected, onBack }: PaymentStepP
<span <span
className={cn( className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors', 'flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors',
isSelected ? 'border-accent bg-accent text-white' : 'border-border', isSelected ? 'border-accent bg-accent text-on-accent' : 'border-border',
)} )}
> >
{isSelected ? <Check className="size-3" strokeWidth={3} /> : null} {isSelected ? <Check className="size-3" strokeWidth={3} /> : null}

View File

@@ -22,7 +22,7 @@ export function Stepper({ steps, current }: StepperProps) {
<span <span
className={cn( className={cn(
'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors', 'flex size-8 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
isDone && 'border-accent bg-accent text-white', isDone && 'border-accent bg-accent text-on-accent',
isActive && 'border-accent text-accent ring-4 ring-accent/15', isActive && 'border-accent text-accent ring-4 ring-accent/15',
!isDone && !isActive && 'border-border text-foreground/40', !isDone && !isActive && 'border-border text-foreground/40',
)} )}

View File

@@ -41,7 +41,7 @@ export function WelcomeStep({ name, onNext }: WelcomeStepProps) {
{STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => ( {STEPS_TO_SETUP.map(({ icon: Icon, title, description }) => (
<li <li
key={title} key={title}
className="flex items-center gap-3 rounded-xl border border-border bg-white p-4" className="flex items-center gap-3 rounded-xl border border-border bg-surface p-4"
> >
<span className="rounded-lg bg-accent-soft p-2"> <span className="rounded-lg bg-accent-soft p-2">
<Icon className="size-5 text-accent" /> <Icon className="size-5 text-accent" />

View File

@@ -22,7 +22,7 @@ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-accent text-sm font-semibold text-white', 'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-accent text-sm font-semibold text-on-accent',
className, className,
)} )}
{...props} {...props}

View File

@@ -10,9 +10,9 @@ export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
} }
const variants: Record<ButtonVariant, string> = { const variants: Record<ButtonVariant, string> = {
primary: 'bg-accent text-white hover:bg-accent-strong', primary: 'bg-accent text-on-accent hover:bg-accent-strong',
ghost: 'bg-transparent text-primary hover:bg-primary-soft', ghost: 'bg-transparent text-primary hover:bg-primary-soft',
outline: 'border border-border bg-white text-primary hover:bg-primary-soft', outline: 'border border-border bg-surface text-primary hover:bg-primary-soft',
} }
const sizes: Record<ButtonSize, string> = { const sizes: Record<ButtonSize, string> = {

View File

@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
<input <input
ref={ref} ref={ref}
className={cn( className={cn(
'h-10 w-full rounded-xl border border-border bg-white px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50', 'h-10 w-full rounded-xl border border-border bg-surface px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50',
invalid && 'border-danger focus:border-danger focus:ring-danger/30', invalid && 'border-danger focus:border-danger focus:ring-danger/30',
className, className,
)} )}

View File

@@ -0,0 +1,68 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
export type Theme = 'light' | 'dark' | 'system'
type ThemeContextValue = {
theme: Theme
isDark: boolean
setTheme: (theme: Theme) => void
}
const STORAGE_KEY = 'theme'
function readStoredTheme(): Theme {
const value = localStorage.getItem(STORAGE_KEY)
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
}
function systemPrefersDark() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
function resolveDark(theme: Theme) {
return theme === 'dark' || (theme === 'system' && systemPrefersDark())
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(readStoredTheme)
const [isDark, setIsDark] = useState(() =>
document.documentElement.classList.contains('dark'),
)
useEffect(() => {
const update = () => {
document.documentElement.classList.toggle('dark', resolveDark(theme))
setIsDark(document.documentElement.classList.contains('dark'))
localStorage.setItem(STORAGE_KEY, theme)
}
update()
if (theme !== 'system') return
const media = window.matchMedia('(prefers-color-scheme: dark)')
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [theme])
const setTheme = useCallback((next: Theme) => setThemeState(next), [])
const value = useMemo(() => ({ theme, isDark, setTheme }), [theme, isDark, setTheme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme debe usarse dentro de <ThemeProvider>')
}
return context
}

View File

@@ -1,28 +1,53 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @custom-variant dark (&:where(.dark, .dark *));
/* Colores de marca */
--color-background: #f8fafc;
--color-primary: #0a2540;
--color-primary-soft: #f0f4f9;
--color-accent: #1e90ff;
--color-accent-strong: #0088ff;
--color-accent-soft: #e6f4ff;
--color-foreground: #0f172a;
/* Badges de estado */ @theme inline {
--color-success: #10b981; /* Escala de marca (identidad fija, igual a la landing) */
--color-success-soft: #dcfce7; --color-brand-50: var(--brand-50);
--color-warning: #f59e0b; --color-brand-100: var(--brand-100);
--color-warning-soft: #fef3c7; --color-brand-200: var(--brand-200);
--color-danger: #ef4444; --color-brand-300: var(--brand-300);
--color-danger-soft: #fee2e2; --color-brand-400: var(--brand-400);
--color-border: #e2e8f0; --color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--color-brand-950: var(--brand-950);
/* Colores de la gráfica del isotipo/logotipo */
--color-brand-night: var(--brand-night);
--color-brand-celeste: var(--brand-celeste);
--color-brand-cloud: var(--brand-cloud);
/* Tokens semánticos (conmutan con tema claro/oscuro) */
--color-background: var(--background);
--color-surface: var(--surface);
--color-primary: var(--primary);
--color-primary-soft: var(--primary-soft);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-accent-strong: var(--accent-strong);
--color-accent-soft: var(--accent-soft);
--color-accent-fg: var(--accent-fg);
--color-border: var(--border);
--color-on-accent: var(--on-accent);
--color-overlay: var(--overlay);
--color-success: var(--success);
--color-success-soft: var(--success-soft);
--color-warning: var(--warning);
--color-warning-soft: var(--warning-soft);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
/* Border radius por defecto */ /* Border radius por defecto */
--radius-xl: 0.75rem; --radius-xl: 0.75rem;
/* Animaciones */ /* Tipografía (igual que la landing) */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Animaciones */
--animate-fade-in: fade-in 0.2s ease-out; --animate-fade-in: fade-in 0.2s ease-out;
--animate-step-enter: step-enter 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; --animate-step-enter: step-enter 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
@@ -43,17 +68,77 @@
} }
} }
@layer base { :root {
:root { color-scheme: light;
color-scheme: light;
}
/* Escala de marca */
--brand-50: #eef4ff;
--brand-100: #dbe6fe;
--brand-200: #bfd3fe;
--brand-300: #93b4fd;
--brand-400: #608afa;
--brand-500: #3b63f5;
--brand-600: #2544ea;
--brand-700: #1d33d8;
--brand-800: #1e2cb0;
--brand-900: #1e2b8b;
--brand-950: #171d54;
--brand-night: #171d54;
--brand-celeste: #93b4fd;
--brand-cloud: #eef4ff;
/* Tema claro */
--background: #ffffff;
--surface: #ffffff;
--primary: #0f172a;
--primary-soft: #f1f5f9;
--foreground: #475569;
--accent: #2544ea;
--accent-strong: #1d33d8;
--accent-soft: #eef4ff;
--accent-fg: #2544ea;
--border: #e2e8f0;
--on-accent: #ffffff;
--overlay: rgb(0 0 0 / 0.2);
--success: #059669;
--success-soft: #ecfdf5;
--warning: #b45309;
--warning-soft: #fffbeb;
--danger: #f43f5e;
--danger-soft: #fff1f2;
}
.dark {
color-scheme: dark;
/* Tema oscuro (paleta slate/brand de la landing) */
--background: #020617;
--surface: #0f172a;
--primary: #f1f5f9;
--primary-soft: #1e293b;
--foreground: #94a3b8;
--accent: #2544ea;
--accent-strong: #1d33d8;
--accent-soft: color-mix(in srgb, var(--brand-500) 15%, transparent);
--accent-fg: #93b4fd;
--border: #1e293b;
--on-accent: #ffffff;
--overlay: rgb(0 0 0 / 0.4);
--success: #34d399;
--success-soft: color-mix(in srgb, var(--success) 15%, transparent);
--warning: #fcd34d;
--warning-soft: color-mix(in srgb, var(--warning) 15%, transparent);
--danger: #fb7185;
--danger-soft: color-mix(in srgb, var(--danger) 15%, transparent);
}
@layer base {
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-primary antialiased; @apply bg-background text-primary antialiased transition-colors;
min-height: 100dvh; min-height: 100dvh;
} }
} }

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router' import { RouterProvider } from '@tanstack/react-router'
import { router } from './router' import { router } from './router'
import { AuthProvider } from './context/AuthProvider' import { AuthProvider } from './context/AuthProvider'
import { ThemeProvider } from './context/ThemeProvider'
import './index.css' import './index.css'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -15,9 +16,11 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <ThemeProvider>
<RouterProvider router={router} /> <AuthProvider>
</AuthProvider> <RouterProvider router={router} />
</AuthProvider>
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -74,7 +74,7 @@ export function VerifyEmailPage() {
{status === 'success' ? ( {status === 'success' ? (
<Link <Link
to="/" to="/"
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-white hover:bg-accent-strong" className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-on-accent hover:bg-accent-strong"
> >
Ir a Gruperly Ir a Gruperly
</Link> </Link>

View File

@@ -10,7 +10,7 @@ function GroupCard({ group }: { group: GroupDto }) {
const hasSchedule = (group.days?.length ?? 0) > 0 const hasSchedule = (group.days?.length ?? 0) > 0
return ( return (
<article className="rounded-xl border border-border bg-white p-5"> <article className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate text-base font-semibold text-primary">{group.name}</h3> <h3 className="truncate text-base font-semibold text-primary">{group.name}</h3>
@@ -81,7 +81,7 @@ export function GroupsView() {
) : null} ) : null}
{groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? ( {groupsQuery.isSuccess && groupsQuery.data.data.length === 0 ? (
<div className="mt-8 rounded-xl border border-dashed border-border bg-white px-4 py-12 text-center"> <div className="mt-8 rounded-xl border border-dashed border-border bg-surface px-4 py-12 text-center">
<p className="font-medium text-primary">Todavía no tenés grupos</p> <p className="font-medium text-primary">Todavía no tenés grupos</p>
<p className="mt-1 text-sm text-foreground/60"> <p className="mt-1 text-sm text-foreground/60">
Crea tu primer grupo para empezar a cobrar. Crea tu primer grupo para empezar a cobrar.

View File

@@ -63,7 +63,7 @@ export function OnboardingView() {
<div className="flex min-h-dvh items-center justify-center px-4"> <div className="flex min-h-dvh items-center justify-center px-4">
<div className="w-full max-w-sm space-y-4 text-center"> <div className="w-full max-w-sm space-y-4 text-center">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft"> <span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-accent-soft">
<LogoIcon className="size-8 text-accent" /> <LogoIcon className="size-8" />
</span> </span>
<h1 className="text-2xl font-bold text-primary">Tu cuenta, lista</h1> <h1 className="text-2xl font-bold text-primary">Tu cuenta, lista</h1>
<p className="text-sm text-foreground/60"> <p className="text-sm text-foreground/60">
@@ -92,9 +92,8 @@ export function OnboardingView() {
return ( return (
<main className="mx-auto flex min-h-dvh w-full max-w-md flex-col px-4 pb-12 pt-8 sm:pt-12"> <main className="mx-auto flex min-h-dvh w-full max-w-md flex-col px-4 pb-12 pt-8 sm:pt-12">
<header className="mb-6 flex items-center justify-center gap-2"> <header className="mb-6 flex items-center justify-center">
<LogoIcon className="size-7" /> <Logo className="h-6" />
<Logo className="text-xl" />
</header> </header>
<Stepper steps={STEPS} current={step} /> <Stepper steps={STEPS} current={step} />

View File

@@ -69,7 +69,7 @@ function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
} }
return ( return (
<ul className="divide-y divide-border rounded-xl border border-border bg-white"> <ul className="divide-y divide-border rounded-xl border border-border bg-surface">
{organizations.map((org) => ( {organizations.map((org) => (
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3"> <li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2"> <span className="rounded-lg bg-accent-soft p-2">
@@ -148,7 +148,7 @@ export function OrganizationsPage() {
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p> <p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
) : null} ) : null}
<div className="rounded-xl border border-border bg-white p-5"> <div className="rounded-xl border border-border bg-surface p-5">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<Plus className="size-4 text-accent" /> <Plus className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Crear grupo</h2> <h2 className="text-base font-semibold text-primary">Crear grupo</h2>
@@ -182,7 +182,7 @@ export function OrganizationsPage() {
</form> </form>
</div> </div>
<div className="rounded-xl border border-border bg-white p-5"> <div className="rounded-xl border border-border bg-surface p-5">
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2> <h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} /> <OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
</div> </div>

View File

@@ -11,7 +11,7 @@ export function ProfileView() {
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p> <p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
</div> </div>
<div className="rounded-xl border border-border bg-white p-5"> <div className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" /> <Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -42,7 +42,7 @@ function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
} }
return ( return (
<ul className="divide-y divide-border rounded-xl border border-border bg-white"> <ul className="divide-y divide-border rounded-xl border border-border bg-surface">
{passkeys.map((passkey) => ( {passkeys.map((passkey) => (
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3"> <li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
<span className="rounded-lg bg-accent-soft p-2"> <span className="rounded-lg bg-accent-soft p-2">
@@ -126,7 +126,7 @@ export function SecurityPage() {
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p> <p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
) : null} ) : null}
<div className="rounded-xl border border-border bg-white p-5"> <div className="rounded-xl border border-border bg-surface p-5">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<KeyRound className="size-4 text-accent" /> <KeyRound className="size-4 text-accent" />
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2> <h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
@@ -165,7 +165,7 @@ export function SecurityPage() {
</form> </form>
</div> </div>
<div className="rounded-xl border border-border bg-white p-5"> <div className="rounded-xl border border-border bg-surface p-5">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Fingerprint className="size-4 text-accent" /> <Fingerprint className="size-4 text-accent" />

View File

@@ -1,7 +1,64 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { Building2, Fingerprint } from 'lucide-react' import { Building2, Check, Fingerprint, Monitor, Moon, Sun } from 'lucide-react'
import { signOut } from '../lib/auth-client' import { signOut } from '../lib/auth-client'
import { Button } from '../components/ui' import { Button } from '../components/ui'
import { useTheme, type Theme } from '../context/ThemeProvider'
import { cn } from '../lib/utils'
const THEME_OPTIONS: { value: Theme; label: string; description: string }[] = [
{ value: 'light', label: 'Claro', description: 'Interfaz en tonos claros' },
{ value: 'dark', label: 'Oscuro', description: 'Interfaz en tonos oscuros' },
{ value: 'system', label: 'Sistema', description: 'Sigue la preferencia de tu dispositivo' },
]
const THEME_ICONS: Record<Theme, typeof Sun> = {
light: Sun,
dark: Moon,
system: Monitor,
}
function AppearanceCard() {
const { theme, setTheme } = useTheme()
return (
<div className="divide-y divide-border rounded-xl border border-border bg-surface">
<div className="px-4 py-3">
<p className="text-sm font-medium text-primary">Apariencia</p>
<p className="text-xs text-foreground/50">Tema claro u oscuro</p>
</div>
{THEME_OPTIONS.map((option) => {
const Icon = THEME_ICONS[option.value]
const isSelected = theme === option.value
return (
<button
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-primary-soft"
>
<span
className={cn(
'rounded-lg p-2',
isSelected ? 'bg-accent-soft' : 'bg-primary-soft',
)}
>
<Icon
className={cn('size-4', isSelected ? 'text-accent-fg' : 'text-foreground/70')}
/>
</span>
<div className="min-w-0 flex-1">
<p className={cn('text-sm font-medium', isSelected ? 'text-accent-fg' : 'text-primary')}>
{option.label}
</p>
<p className="text-xs text-foreground/50">{option.description}</p>
</div>
{isSelected ? <Check className="size-4 text-accent-fg" /> : null}
</button>
)
})}
</div>
)
}
export function SettingsView() { export function SettingsView() {
const handleSignOut = async () => { const handleSignOut = async () => {
@@ -18,13 +75,13 @@ export function SettingsView() {
<p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p> <p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
</div> </div>
<div className="divide-y divide-border rounded-xl border border-border bg-white"> <div className="divide-y divide-border rounded-xl border border-border bg-surface">
<Link <Link
to="/settings/organizations" to="/settings/organizations"
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
> >
<span className="rounded-lg bg-accent-soft p-2"> <span className="rounded-lg bg-accent-soft p-2">
<Building2 className="size-4 text-accent" /> <Building2 className="size-4 text-accent-fg" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Grupos</p> <p className="text-sm font-medium text-primary">Grupos</p>
@@ -34,7 +91,7 @@ export function SettingsView() {
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"> <Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
<span className="rounded-lg bg-accent-soft p-2"> <span className="rounded-lg bg-accent-soft p-2">
<Fingerprint className="size-4 text-accent" /> <Fingerprint className="size-4 text-accent-fg" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">Seguridad</p> <p className="text-sm font-medium text-primary">Seguridad</p>
@@ -43,6 +100,8 @@ export function SettingsView() {
</Link> </Link>
</div> </div>
<AppearanceCard />
<Button variant="outline" onClick={handleSignOut}> <Button variant="outline" onClick={handleSignOut}>
Cerrar sesión Cerrar sesión
</Button> </Button>