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) => ( +
  1. + + {index + 1} + +
    + + {t(`whatsapp.steps.${step}.title`)} + + + {t(`whatsapp.steps.${step}.description`)} + +
    +
  2. + ))} +
+
+ +
+ +
+

+ {t('whatsapp.alias.title')} +

+

+ {t('whatsapp.alias.description')} +

+ {alias ? ( +

+ {alias} +

+ ) : null} +
+
+ +
+ +
+

+ {t('whatsapp.security.title')} +

+

+ {t('whatsapp.security.description')} +

+
+
+ + +
+ ); +}