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:
parent
06effb5e6e
commit
4a891a5906
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue