feat(support): add support link with community-first help modal (#542)

## What

Adds a **Support** entry to the user dropdown menu (below Roadmap) for
reporting any kind of error.

Clicking it opens a modal that:
1. **Leads with the community** — a primary Discord button so users can
message us directly.
2. **Offers email as a fallback** — opens a
`mailto:support@whisper.money` pre-filled with a bug-report prompt plus
diagnostics: **user UUID, app version, locale, current page URL, and
browser user-agent**.

## Why

Give users a clear, low-friction path to report bugs and get help, while
steering them to the community first.

## Changes

- `resources/js/components/support-dialog.tsx` (new) — the modal +
mailto builder.
- `resources/js/components/user-menu-content.tsx` — Support menu item +
`onOpenSupport` prop.
- `resources/js/components/nav-user.tsx` — dialog state; rendered as a
sibling of the dropdown (Radix unmounts dropdown content on close).
- `resources/js/components/user-menu-content.test.tsx` — updated + new
click test.
- `lang/es.json`, `lang/fr.json` — 7 translation keys each.

## Notes

- Diagnostic labels (User ID, Browser, etc.) are intentionally left
untranslated so the support team reads them in English.
- No backend route needed — uses a `mailto:` link.

## Testing

- `bun run test resources/js/components/user-menu-content.test.tsx` 
- `vendor/bin/pest tests/Feature/LocalizationTest.php`  (es enforced)
- eslint + prettier 
This commit is contained in:
Víctor Falcón 2026-06-16 13:16:04 +02:00 committed by GitHub
parent 06effb5e6e
commit 4a891a5906
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 161 additions and 19 deletions

View File

@ -620,6 +620,7 @@
"Email address": "Dirección de correo electrónico",
"Email address, name, and password (encrypted)": "Dirección de correo electrónico, nombre y contraseña (encriptados)",
"Email password reset link": "Enviar enlace para restablecer contraseña",
"Email support": "Enviar correo a soporte",
"Email verification": "Verificación de correo electrónico",
"Email:": "Correo:",
"Email\\n address, name, and password (encrypted)": "Dirección de correo electrónico, nombre y contraseña (cifrada)",
@ -878,6 +879,8 @@
"Jessica P.": "Jessica P.",
"Join Waitlist": "Unirse a la lista",
"Join our Discord": "Únete a nuestro Discord",
"Join our community on Discord — the fastest way to get help. Prefer email? Write to us instead.": "Únete a nuestra comunidad en Discord, la forma más rápida de obtener ayuda. ¿Prefieres el correo? Escríbenos en su lugar.",
"Join the community": "Unirse a la comunidad",
"Join the waiting list. We'll let you know when you're in.": "Únete a la lista de espera. Te avisaremos cuando sea tu turno.",
"Join thousands of users who have taken control of their finances without compromising their privacy.": "Únete a miles de usuarios que han tomado el control de sus finanzas sin comprometer su privacidad.",
"Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.": "Simplemente responde a este correo y cuéntame qué está pasando. Leo personalmente cada respuesta y te ayudaré a empezar. Este es mi proyecto, así que me importa que funcione para ti.",
@ -1019,6 +1022,7 @@
"Navigate to...": "Navegar a...",
"Need Help Getting Started?": "¿Necesitas Ayuda para Empezar?",
"Need help importing your transactions, :name?": "¿Necesitas ayuda para importar tus transacciones, :name?",
"Need help?": "¿Necesitas ayuda?",
"Need help? Join our Discord": "¿Necesitas ayuda? Únete a nuestro Discord",
"Net": "Neto",
"Net Cashflow": "Flujo Neto",
@ -1147,6 +1151,7 @@
"Pick whichever plan fits you, paste the matching code at checkout, and you are set.": "Elige el plan que prefieras, pega el código correspondiente al pagar y listo.",
"Pink": "Rosa",
"Platform": "Plataforma",
"Please describe the problem and what you were doing when it happened. Keep the details below — they help us debug.": "Describe el problema y qué estabas haciendo cuando ocurrió. Conserva los detalles de abajo: nos ayudan a depurar.",
"Please enter a balance": "Por favor, ingresa un balance",
"Please enter a bank name.": "Por favor, ingresa un nombre de banco.",
"Please enter an account name.": "Por favor, ingresa un nombre de cuenta.",
@ -1433,6 +1438,8 @@
"Strong": "Fuerte",
"Subscription fees are billed in advance on a recurring basis until cancelled": "Las tarifas de suscripción se facturan por adelantado de forma recurrente hasta que se cancele",
"Subscription fees are billed in advance on a\\n recurring basis until cancelled": "Las tarifas de suscripción se facturan por adelantado de forma recurrente hasta que se cancelen",
"Support": "Soporte",
"Support request": "Solicitud de soporte",
"Supported formats": "Formatos soportados",
"Supports CSV, XLS, and XLSX files": "Soporta archivos CSV, XLS y XLSX",
"Swiss Franc": "Franco suizo",

View File

@ -613,6 +613,7 @@
"Email address": "Adresse email",
"Email address, name, and password (encrypted)": "Adresse e-mail, nom et mot de passe (cryptés)",
"Email password reset link": "Envoyer le lien de réinitialisation du mot de passe",
"Email support": "Envoyer un e-mail au support",
"Email verification": "Vérification des e-mails",
"Email:": "Mail:",
"Email\\n address, name, and password (encrypted)": "Adresse e-mail, nom et mot de passe (cryptés)",
@ -871,6 +872,8 @@
"Jessica P.": "Jessica P.",
"Join Waitlist": "Rejoignez la liste",
"Join our Discord": "Rejoignez notre Discord",
"Join our community on Discord — the fastest way to get help. Prefer email? Write to us instead.": "Rejoignez notre communauté sur Discord, le moyen le plus rapide d'obtenir de l'aide. Vous préférez l'e-mail ? Écrivez-nous plutôt.",
"Join the community": "Rejoindre la communauté",
"Join the waiting list. We'll let you know when you're in.": "Rejoignez la liste d'attente. Nous vous ferons savoir quand ce sera votre tour.",
"Join thousands of users who have taken control of their finances without compromising their privacy.": "Rejoignez des milliers d'utilisateurs qui ont pris le contrôle de leurs finances sans compromettre leur vie privée.",
"Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.": "Répondez simplement à cet e-mail et dites-moi ce qui se passe. J'ai personnellement lu chaque réponse et je vous aiderai à démarrer. C'est mon projet, donc je tiens à ce qu'il fonctionne pour vous.",
@ -1011,6 +1014,7 @@
"Navigate to...": "Accédez à...",
"Need Help Getting Started?": "Avez-vous besoin d'aide pour démarrer ?",
"Need help importing your transactions, :name?": "Avez-vous besoin d'aide pour importer vos transactions, :name ?",
"Need help?": "Besoin d'aide ?",
"Need help? Join our Discord": "Avez-vous besoin d'aide ? Rejoignez notre Discord",
"Net": "Filet",
"Net Cashflow": "Flux net",
@ -1139,6 +1143,7 @@
"Pick whichever plan fits you, paste the matching code at checkout, and you are set.": "Choisissez le forfait que vous préférez, collez le code correspondant lors du paiement et le tour est joué.",
"Pink": "Rose",
"Platform": "Plate-forme",
"Please describe the problem and what you were doing when it happened. Keep the details below — they help us debug.": "Décrivez le problème et ce que vous faisiez lorsqu'il s'est produit. Conservez les détails ci-dessous : ils nous aident à déboguer.",
"Please enter a balance": "Veuillez saisir un solde",
"Please enter a bank name.": "Veuillez saisir un nom de banque.",
"Please enter an account name.": "Veuillez saisir un nom de compte.",
@ -1424,6 +1429,8 @@
"Strong": "Fort",
"Subscription fees are billed in advance on a recurring basis until cancelled": "Les frais d'abonnement sont facturés à l'avance de manière récurrente jusqu'à annulation.",
"Subscription fees are billed in advance on a\\n recurring basis until cancelled": "Les frais d'abonnement sont facturés à l'avance de manière récurrente jusqu'à annulation.",
"Support": "Support",
"Support request": "Demande de support",
"Supported formats": "Formats pris en charge",
"Supports CSV, XLS, and XLSX files": "Prend en charge les fichiers CSV, XLS et XLSX",
"Swiss Franc": "franc suisse",

View File

@ -1,3 +1,4 @@
import { SupportDialog } from '@/components/support-dialog';
import {
DropdownMenu,
DropdownMenuContent,
@ -15,11 +16,13 @@ import { useIsMobile } from '@/hooks/use-mobile';
import { type SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
import { ChevronsUpDown } from 'lucide-react';
import { useState } from 'react';
export function NavUser({ className }: { className?: string }) {
const { auth } = usePage<SharedData>().props;
const { state } = useSidebar();
const isMobile = useIsMobile();
const [supportOpen, setSupportOpen] = useState(false);
return (
<SidebarMenu className={className}>
@ -46,9 +49,17 @@ export function NavUser({ className }: { className?: string }) {
: 'bottom'
}
>
<UserMenuContent user={auth.user} />
<UserMenuContent
user={auth.user}
onOpenSupport={() => setSupportOpen(true)}
/>
</DropdownMenuContent>
</DropdownMenu>
<SupportDialog
open={supportOpen}
onOpenChange={setSupportOpen}
user={auth.user}
/>
</SidebarMenuItem>
</SidebarMenu>
);

View File

@ -0,0 +1,92 @@
import DiscordIcon from '@/components/icons/DiscordIcon';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { type SharedData, type User } from '@/types';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { Mail } from 'lucide-react';
const DISCORD_URL = 'https://discord.gg/m8hUhx6D9D';
const SUPPORT_EMAIL = 'support@whisper.money';
function buildSupportMailto(user: User, version: string): string {
const userAgent =
typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
const page =
typeof window !== 'undefined' ? window.location.href : 'unknown';
const body = [
__(
'Please describe the problem and what you were doing when it happened. Keep the details below — they help us debug.',
),
'',
'',
'--- Diagnostics (please keep) ---',
`User ID: ${user.id}`,
`App version: ${version}`,
`Locale: ${user.locale ?? 'en'}`,
`Page: ${page}`,
`Browser: ${userAgent}`,
].join('\n');
const query = `subject=${encodeURIComponent(__('Support request'))}&body=${encodeURIComponent(body)}`;
return `mailto:${SUPPORT_EMAIL}?${query}`;
}
interface SupportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User;
}
export function SupportDialog({
open,
onOpenChange,
user,
}: SupportDialogProps) {
const { version } = usePage<SharedData>().props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{__('Need help?')}</DialogTitle>
<DialogDescription>
{__(
'Join our community on Discord — the fastest way to get help. Prefer email? Write to us instead.',
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Button asChild>
<a
href={DISCORD_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => onOpenChange(false)}
>
<DiscordIcon className="size-4" />
{__('Join the community')}
</a>
</Button>
<Button variant="outline" asChild>
<a
href={buildSupportMailto(user, version)}
onClick={() => onOpenChange(false)}
>
<Mail className="size-4" />
{__('Email support')}
</a>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,5 +1,5 @@
import { type User } from '@/types';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { UserMenuContent } from './user-menu-content';
@ -8,9 +8,13 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuGroup: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: ReactNode;
onClick?: () => void;
}) => <div onClick={onClick}>{children}</div>,
DropdownMenuLabel: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
@ -66,21 +70,21 @@ vi.mock('@inertiajs/react', () => ({
}),
}));
const user: User = {
id: '0194d20b-2b25-7000-8000-000000000001',
name: 'Test User',
email: 'test@example.com',
currency_code: 'USD',
locale: 'en',
timezone: 'UTC',
email_verified_at: null,
created_at: '2026-01-01T00:00:00.000000Z',
updated_at: '2026-01-01T00:00:00.000000Z',
};
describe('UserMenuContent', () => {
it('shows community above feedback in the user dropdown', () => {
const user: User = {
id: '0194d20b-2b25-7000-8000-000000000001',
name: 'Test User',
email: 'test@example.com',
currency_code: 'USD',
locale: 'en',
timezone: 'UTC',
email_verified_at: null,
created_at: '2026-01-01T00:00:00.000000Z',
updated_at: '2026-01-01T00:00:00.000000Z',
};
render(<UserMenuContent user={user} />);
render(<UserMenuContent user={user} onOpenSupport={vi.fn()} />);
const community = screen.getByRole('link', { name: /community/i });
const feedback = screen.getByRole('link', { name: /feedback/i });
@ -92,4 +96,14 @@ describe('UserMenuContent', () => {
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it('triggers the support callback when the support item is clicked', () => {
const onOpenSupport = vi.fn();
render(<UserMenuContent user={user} onOpenSupport={onOpenSupport} />);
fireEvent.click(screen.getByText('Support'));
expect(onOpenSupport).toHaveBeenCalledOnce();
});
});

View File

@ -18,6 +18,7 @@ import { Link, router, usePage } from '@inertiajs/react';
import {
Eye,
EyeOff,
LifeBuoy,
LogOut,
Map,
MessageSquare,
@ -27,9 +28,10 @@ import {
interface UserMenuContentProps {
user: User;
onOpenSupport: () => void;
}
export function UserMenuContent({ user }: UserMenuContentProps) {
export function UserMenuContent({ user, onOpenSupport }: UserMenuContentProps) {
const cleanup = useMobileNavigation();
const { isPrivacyModeEnabled, togglePrivacyMode } = usePrivacyMode();
const { version } = usePage<SharedData>().props;
@ -129,6 +131,15 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
{__('Roadmap')}
</a>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onOpenSupport();
cleanup();
}}
>
<LifeBuoy className="mr-2" />
{__('Support')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>