diff --git a/e2e/whatsapp.spec.ts b/e2e/whatsapp.spec.ts
new file mode 100644
index 0000000..bee3f9d
--- /dev/null
+++ b/e2e/whatsapp.spec.ts
@@ -0,0 +1,36 @@
+import { expect, test } from '@playwright/test';
+import { installMockApi } from './fixtures/mock-api';
+
+test.beforeEach(async ({ page }) => {
+ await installMockApi(page);
+});
+
+test('bottom nav opens the WhatsApp view', async ({ page }) => {
+ await page.goto('/');
+
+ await page
+ .getByRole('navigation', { name: 'Navegación principal' })
+ .getByRole('link', { name: 'WhatsApp' })
+ .click();
+
+ await expect(page).toHaveURL(/\/whatsapp$/);
+ await expect(page.getByText('Tu wallet en WhatsApp')).toBeVisible();
+ await expect(page.getByText('Enviar por BRE-B')).toBeVisible();
+ await expect(page.getByText('Recargar por PSE')).toBeVisible();
+});
+
+test('home card links to the WhatsApp view', async ({ page }) => {
+ await page.goto('/');
+
+ await page.getByRole('link', { name: /Usa tu wallet por WhatsApp/ }).click();
+
+ await expect(page).toHaveURL(/\/whatsapp$/);
+});
+
+test('CTA stays disabled until the number is configured', async ({ page }) => {
+ await page.goto('/whatsapp');
+
+ const ctas = page.getByRole('button', { name: /Abrir WhatsApp/ });
+ await expect(ctas.first()).toBeDisabled();
+ await expect(ctas.first()).toContainText('Próximamente');
+});
diff --git a/src/components/bottom-nav.tsx b/src/components/bottom-nav.tsx
index ca22b3b..2467773 100644
--- a/src/components/bottom-nav.tsx
+++ b/src/components/bottom-nav.tsx
@@ -3,6 +3,7 @@
import { Link, useLocation } from '@tanstack/react-router';
import { ArrowLeftRight, CreditCard, Home, UserCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
+import { WhatsAppIcon } from '~/components/whatsapp-icon';
import { cn } from '~/lib/utils';
export function BottomNav() {
@@ -12,6 +13,12 @@ export function BottomNav() {
const navItems = [
{ href: '/', label: t('nav.home'), icon: Home },
{ href: '/movements', label: t('nav.movements'), icon: ArrowLeftRight },
+ {
+ href: '/whatsapp',
+ label: t('nav.whatsapp'),
+ icon: WhatsAppIcon,
+ highlight: true,
+ },
{ href: '/card', label: t('nav.card'), icon: CreditCard },
{ href: '/profile', label: t('nav.profile'), icon: UserCircle },
];
@@ -25,6 +32,35 @@ export function BottomNav() {
{navItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
+ if (item.highlight) {
+ return (
+
+
+
+
+
+ {item.label}
+
+
+ );
+ }
return (
) {
+ return (
+
+ );
+}
diff --git a/src/config/whatsapp.ts b/src/config/whatsapp.ts
new file mode 100644
index 0000000..20000d9
--- /dev/null
+++ b/src/config/whatsapp.ts
@@ -0,0 +1,13 @@
+import { PUBLIC_APP_ENV } from './env';
+
+/**
+ * Official Console WhatsApp number per environment, in international format
+ * without `+` (as `wa.me` expects). `null` keeps the CTA disabled until the
+ * number is assigned.
+ */
+const WHATSAPP_NUMBERS: Record<'dev' | 'prod', string | null> = {
+ dev: null,
+ prod: null,
+};
+
+export const WHATSAPP_NUMBER = WHATSAPP_NUMBERS[PUBLIC_APP_ENV];
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 5556c9d..8b54845 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -309,6 +309,10 @@
"topup": "Top up",
"send": "Send",
"needsAccount": "Create a pocket first to use this."
+ },
+ "whatsappCard": {
+ "title": "Use your wallet on WhatsApp",
+ "description": "Send and top up money by chatting."
}
},
"send": {
@@ -674,6 +678,7 @@
"home": "Home",
"accounts": "Pockets",
"movements": "Movements",
+ "whatsapp": "WhatsApp",
"card": "Card",
"profile": "Profile"
},
@@ -780,5 +785,64 @@
"title": "You need a pocket",
"description": "Create a pocket to top up, send money, use BRE-B and create cards.",
"loadError": "We couldn't load your pockets."
+ },
+ "whatsapp": {
+ "title": "WhatsApp",
+ "cta": "Open WhatsApp",
+ "prefilled": "Hi Bloque, I want to use my wallet on WhatsApp.",
+ "prefilledWithAlias": "Hi Bloque, I want to use my wallet on WhatsApp. My alias is {{alias}}.",
+ "hero": {
+ "title": "Your wallet on WhatsApp",
+ "subtitle": "Send and top up money by chatting, without opening the app."
+ },
+ "chat": {
+ "ariaLabel": "Sample conversation with Bloque on WhatsApp",
+ "user1": "Send 50,000 to the key @juan",
+ "bloque1": "You're sending $50,000 COP to Juan Pérez via BRE-B. To confirm, type the code we sent you.",
+ "user2": "482913",
+ "bloque2": "Done! Your transfer was successful ✅"
+ },
+ "operations": {
+ "title": "What you can do",
+ "brebSend": {
+ "title": "Send via BRE-B",
+ "description": "To any BRE-B key, instantly."
+ },
+ "banks": {
+ "title": "Send to banks",
+ "description": "To Colombian bank accounts."
+ },
+ "brebTopup": {
+ "title": "Top up via BRE-B",
+ "description": "Receive money in your wallet with your key."
+ },
+ "pseTopup": {
+ "title": "Top up via PSE",
+ "description": "Top up from your online banking."
+ }
+ },
+ "steps": {
+ "title": "How it works",
+ "open": {
+ "title": "Open the chat",
+ "description": "Tap the button and message us on WhatsApp."
+ },
+ "ask": {
+ "title": "Ask for what you need",
+ "description": "Write the way you talk: “top up 100,000 via PSE”."
+ },
+ "confirm": {
+ "title": "Confirm the transaction",
+ "description": "We'll ask for an OTP code and some details before moving your money."
+ }
+ },
+ "alias": {
+ "title": "Identify yourself with your alias",
+ "description": "Use the same email or phone you sign in to the wallet with."
+ },
+ "security": {
+ "title": "Your money, protected",
+ "description": "To move money we'll ask for an OTP code and confirmation details. Share them only in Bloque's official chat and never with anyone else."
+ }
}
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index abd64f7..683e840 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -309,6 +309,10 @@
"topup": "Recargar",
"send": "Enviar",
"needsAccount": "Crea un bolsillo primero para poder usar esta opción."
+ },
+ "whatsappCard": {
+ "title": "Usa tu wallet por WhatsApp",
+ "description": "Envía y recarga dinero chateando."
}
},
"send": {
@@ -674,6 +678,7 @@
"home": "Inicio",
"accounts": "Bolsillos",
"movements": "Movimientos",
+ "whatsapp": "WhatsApp",
"card": "Tarjeta",
"profile": "Perfil"
},
@@ -780,5 +785,64 @@
"title": "Necesitas un bolsillo",
"description": "Crea un bolsillo para poder recargar, enviar dinero, usar BRE-B y crear tarjetas.",
"loadError": "No pudimos cargar tus bolsillos."
+ },
+ "whatsapp": {
+ "title": "WhatsApp",
+ "cta": "Abrir WhatsApp",
+ "prefilled": "Hola Bloque, quiero usar mi wallet por WhatsApp.",
+ "prefilledWithAlias": "Hola Bloque, quiero usar mi wallet por WhatsApp. Mi alias es {{alias}}.",
+ "hero": {
+ "title": "Tu wallet en WhatsApp",
+ "subtitle": "Envía y recarga dinero chateando, sin abrir la app."
+ },
+ "chat": {
+ "ariaLabel": "Ejemplo de conversación con Bloque en WhatsApp",
+ "user1": "Envía 50.000 a la llave @juan",
+ "bloque1": "Vas a enviar $50.000 COP a Juan Pérez por BRE-B. Para confirmar, escribe el código que te enviamos.",
+ "user2": "482913",
+ "bloque2": "¡Listo! Tu envío fue exitoso ✅"
+ },
+ "operations": {
+ "title": "Qué puedes hacer",
+ "brebSend": {
+ "title": "Enviar por BRE-B",
+ "description": "A cualquier llave BRE-B, al instante."
+ },
+ "banks": {
+ "title": "Enviar a bancos",
+ "description": "A cuentas de bancos colombianos."
+ },
+ "brebTopup": {
+ "title": "Recargar por BRE-B",
+ "description": "Recibe dinero en tu wallet con tu llave."
+ },
+ "pseTopup": {
+ "title": "Recargar por PSE",
+ "description": "Recarga desde tu banco en línea."
+ }
+ },
+ "steps": {
+ "title": "Cómo funciona",
+ "open": {
+ "title": "Abre el chat",
+ "description": "Toca el botón y escríbenos desde WhatsApp."
+ },
+ "ask": {
+ "title": "Pide lo que necesitas",
+ "description": "Escribe como hablas: «recarga 100.000 por PSE»."
+ },
+ "confirm": {
+ "title": "Confirma el movimiento",
+ "description": "Te pediremos un código OTP y algunos datos antes de mover tu dinero."
+ }
+ },
+ "alias": {
+ "title": "Identifícate con tu alias",
+ "description": "Usa el mismo correo o teléfono con el que ingresas a la wallet."
+ },
+ "security": {
+ "title": "Tu dinero, protegido",
+ "description": "Para mover dinero te pediremos un código OTP y datos de confirmación. Compártelos solo en el chat oficial de Bloque y nunca con otras personas."
+ }
}
}
diff --git a/src/lib/whatsapp.test.ts b/src/lib/whatsapp.test.ts
new file mode 100644
index 0000000..90467a7
--- /dev/null
+++ b/src/lib/whatsapp.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from 'bun:test';
+import { buildWhatsAppUrl } from './whatsapp';
+
+describe('buildWhatsAppUrl', () => {
+ test('strips non-digits from the number', () => {
+ expect(buildWhatsAppUrl('+57 300-123 4567', 'Hola')).toBe(
+ 'https://wa.me/573001234567?text=Hola',
+ );
+ });
+
+ test('encodes the prefilled text', () => {
+ expect(buildWhatsAppUrl('573001234567', 'Hola, soy a+b@x.co')).toBe(
+ 'https://wa.me/573001234567?text=Hola%2C%20soy%20a%2Bb%40x.co',
+ );
+ });
+});
diff --git a/src/lib/whatsapp.ts b/src/lib/whatsapp.ts
new file mode 100644
index 0000000..52afff8
--- /dev/null
+++ b/src/lib/whatsapp.ts
@@ -0,0 +1,5 @@
+/** `wa.me` deep link that opens a chat with `number` and a prefilled `text`. */
+export function buildWhatsAppUrl(number: string, text: string) {
+ const digits = number.replace(/\D/g, '');
+ return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`;
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 5516485..1eff859 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -16,6 +16,7 @@ import { Route as AuthedSendRouteRouteImport } from './routes/_authed/send/route
import { Route as AuthedCardRouteRouteImport } from './routes/_authed/card/route'
import { Route as AuthedBrebKeysRouteRouteImport } from './routes/_authed/breb-keys/route'
import { Route as PublicLoginIndexRouteImport } from './routes/_public/login/index'
+import { Route as AuthedWhatsappIndexRouteImport } from './routes/_authed/whatsapp/index'
import { Route as AuthedTopupIndexRouteImport } from './routes/_authed/topup/index'
import { Route as AuthedSendIndexRouteImport } from './routes/_authed/send/index'
import { Route as AuthedProfileIndexRouteImport } from './routes/_authed/profile/index'
@@ -73,6 +74,11 @@ const PublicLoginIndexRoute = PublicLoginIndexRouteImport.update({
path: '/login/',
getParentRoute: () => PublicRoute,
} as any)
+const AuthedWhatsappIndexRoute = AuthedWhatsappIndexRouteImport.update({
+ id: '/whatsapp/',
+ path: '/whatsapp/',
+ getParentRoute: () => AuthedRoute,
+} as any)
const AuthedTopupIndexRoute = AuthedTopupIndexRouteImport.update({
id: '/',
path: '/',
@@ -210,6 +216,7 @@ export interface FileRoutesByFullPath {
'/profile/': typeof AuthedProfileIndexRoute
'/send/': typeof AuthedSendIndexRoute
'/topup/': typeof AuthedTopupIndexRoute
+ '/whatsapp/': typeof AuthedWhatsappIndexRoute
'/login/': typeof PublicLoginIndexRoute
'/card/details/$urn': typeof AuthedCardDetailsUrnRoute
'/breb-keys/deposit/': typeof AuthedBrebKeysDepositIndexRoute
@@ -236,6 +243,7 @@ export interface FileRoutesByTo {
'/profile': typeof AuthedProfileIndexRoute
'/send': typeof AuthedSendIndexRoute
'/topup': typeof AuthedTopupIndexRoute
+ '/whatsapp': typeof AuthedWhatsappIndexRoute
'/login': typeof PublicLoginIndexRoute
'/card/details/$urn': typeof AuthedCardDetailsUrnRoute
'/breb-keys/deposit': typeof AuthedBrebKeysDepositIndexRoute
@@ -269,6 +277,7 @@ export interface FileRoutesById {
'/_authed/profile/': typeof AuthedProfileIndexRoute
'/_authed/send/': typeof AuthedSendIndexRoute
'/_authed/topup/': typeof AuthedTopupIndexRoute
+ '/_authed/whatsapp/': typeof AuthedWhatsappIndexRoute
'/_public/login/': typeof PublicLoginIndexRoute
'/_authed/card/details/$urn': typeof AuthedCardDetailsUrnRoute
'/_authed/breb-keys/deposit/': typeof AuthedBrebKeysDepositIndexRoute
@@ -301,6 +310,7 @@ export interface FileRouteTypes {
| '/profile/'
| '/send/'
| '/topup/'
+ | '/whatsapp/'
| '/login/'
| '/card/details/$urn'
| '/breb-keys/deposit/'
@@ -327,6 +337,7 @@ export interface FileRouteTypes {
| '/profile'
| '/send'
| '/topup'
+ | '/whatsapp'
| '/login'
| '/card/details/$urn'
| '/breb-keys/deposit'
@@ -359,6 +370,7 @@ export interface FileRouteTypes {
| '/_authed/profile/'
| '/_authed/send/'
| '/_authed/topup/'
+ | '/_authed/whatsapp/'
| '/_public/login/'
| '/_authed/card/details/$urn'
| '/_authed/breb-keys/deposit/'
@@ -430,6 +442,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PublicLoginIndexRouteImport
parentRoute: typeof PublicRoute
}
+ '/_authed/whatsapp/': {
+ id: '/_authed/whatsapp/'
+ path: '/whatsapp'
+ fullPath: '/whatsapp/'
+ preLoaderRoute: typeof AuthedWhatsappIndexRouteImport
+ parentRoute: typeof AuthedRoute
+ }
'/_authed/topup/': {
id: '/_authed/topup/'
path: '/'
@@ -668,6 +687,7 @@ interface AuthedRouteChildren {
AuthedKycIndexRoute: typeof AuthedKycIndexRoute
AuthedMovementsIndexRoute: typeof AuthedMovementsIndexRoute
AuthedProfileIndexRoute: typeof AuthedProfileIndexRoute
+ AuthedWhatsappIndexRoute: typeof AuthedWhatsappIndexRoute
AuthedLegalFeesIndexRoute: typeof AuthedLegalFeesIndexRoute
AuthedLegalPrivacyIndexRoute: typeof AuthedLegalPrivacyIndexRoute
AuthedLegalTermsIndexRoute: typeof AuthedLegalTermsIndexRoute
@@ -686,6 +706,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedKycIndexRoute: AuthedKycIndexRoute,
AuthedMovementsIndexRoute: AuthedMovementsIndexRoute,
AuthedProfileIndexRoute: AuthedProfileIndexRoute,
+ AuthedWhatsappIndexRoute: AuthedWhatsappIndexRoute,
AuthedLegalFeesIndexRoute: AuthedLegalFeesIndexRoute,
AuthedLegalPrivacyIndexRoute: AuthedLegalPrivacyIndexRoute,
AuthedLegalTermsIndexRoute: AuthedLegalTermsIndexRoute,
diff --git a/src/routes/_authed/(home)/-components/whatsapp-card.tsx b/src/routes/_authed/(home)/-components/whatsapp-card.tsx
new file mode 100644
index 0000000..99aa394
--- /dev/null
+++ b/src/routes/_authed/(home)/-components/whatsapp-card.tsx
@@ -0,0 +1,27 @@
+import { Link } from '@tanstack/react-router';
+import { ChevronRight } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { WhatsAppIcon } from '~/components/whatsapp-icon';
+
+export function WhatsAppCard() {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t('home.whatsappCard.title')}
+
+
+ {t('home.whatsappCard.description')}
+
+
+
+
+ );
+}
diff --git a/src/routes/_authed/(home)/index.tsx b/src/routes/_authed/(home)/index.tsx
index dd464ee..9b5fea3 100644
--- a/src/routes/_authed/(home)/index.tsx
+++ b/src/routes/_authed/(home)/index.tsx
@@ -13,6 +13,7 @@ import { useShowBalances } from '~/lib/show-balances';
import { MovementDetailDrawer } from '../../../components/movement-detail-drawer';
import { BalanceToggle } from './-components/currency-balance-card';
import { QuickActions } from './-components/quick-actions';
+import { WhatsAppCard } from './-components/whatsapp-card';
import { useBalance } from './-hooks/use-balance';
export const Route = createFileRoute('/_authed/(home)/')({
@@ -148,6 +149,8 @@ function RouteComponent() {
+
+
diff --git a/src/routes/_authed/whatsapp/index.tsx b/src/routes/_authed/whatsapp/index.tsx
new file mode 100644
index 0000000..42790ff
--- /dev/null
+++ b/src/routes/_authed/whatsapp/index.tsx
@@ -0,0 +1,206 @@
+import { createFileRoute } from '@tanstack/react-router';
+import {
+ ArrowDownToLine,
+ AtSign,
+ Building2,
+ KeyRound,
+ ShieldCheck,
+ Smartphone,
+} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { WhatsAppIcon } from '~/components/whatsapp-icon';
+import { WHATSAPP_NUMBER } from '~/config/whatsapp';
+import { useAuth } from '~/contexts/auth/auth-context';
+import { buildWhatsAppUrl } from '~/lib/whatsapp';
+
+export const Route = createFileRoute('/_authed/whatsapp/')({
+ component: RouteComponent,
+});
+
+function WhatsAppCta({ href }: { href: string | null }) {
+ const { t } = useTranslation();
+ const className =
+ 'inline-flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-[#25D366] text-sm font-semibold text-[#052e16] transition-all active:scale-[0.985]';
+
+ if (!href) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {t('whatsapp.cta')}
+
+ );
+}
+
+function ChatBubble({
+ from,
+ children,
+}: {
+ from: 'user' | 'bloque';
+ children: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function RouteComponent() {
+ const { t } = useTranslation();
+ const { user } = useAuth();
+ const alias = user?.email || user?.phone || '';
+
+ const message = alias
+ ? t('whatsapp.prefilledWithAlias', { alias })
+ : t('whatsapp.prefilled');
+ const href = WHATSAPP_NUMBER
+ ? buildWhatsAppUrl(WHATSAPP_NUMBER, message)
+ : null;
+
+ const operations = [
+ { icon: KeyRound, key: 'brebSend' },
+ { icon: Building2, key: 'banks' },
+ { icon: ArrowDownToLine, key: 'brebTopup' },
+ { icon: Smartphone, key: 'pseTopup' },
+ ] as const;
+
+ const steps = ['open', 'ask', 'confirm'] as const;
+
+ return (
+
+
+ {t('whatsapp.title')}
+
+
+
+
+
+
+
+
+
+ {t('whatsapp.hero.title')}
+
+
+ {t('whatsapp.hero.subtitle')}
+
+
+
+
+
+ {t('whatsapp.chat.user1')}
+ {t('whatsapp.chat.bloque1')}
+ {t('whatsapp.chat.user2')}
+ {t('whatsapp.chat.bloque2')}
+
+
+
+
+
+
+
+ {t('whatsapp.operations.title')}
+
+
+ {operations.map(({ icon: Icon, key }) => (
+
+
+
+
+
+ {t(`whatsapp.operations.${key}.title`)}
+
+
+ {t(`whatsapp.operations.${key}.description`)}
+
+
+ ))}
+
+
+
+
+
+ {t('whatsapp.steps.title')}
+
+
+ {steps.map((step, index) => (
+ -
+
+ {index + 1}
+
+
+
+ {t(`whatsapp.steps.${step}.title`)}
+
+
+ {t(`whatsapp.steps.${step}.description`)}
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {t('whatsapp.alias.title')}
+
+
+ {t('whatsapp.alias.description')}
+
+ {alias ? (
+
+ {alias}
+
+ ) : null}
+
+
+
+
+
+
+
+ {t('whatsapp.security.title')}
+
+
+ {t('whatsapp.security.description')}
+
+
+
+
+
+
+ );
+}