fix(auth): keep signup alive across page cards; sanitize trip links

This commit is contained in:
SkateSpots 2026-10-28 00:05:28 +07:00
parent e267b79281
commit 13ebea685e
7 changed files with 57 additions and 21 deletions

View file

@ -119,7 +119,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
} }
return ( return (
<DropdownMenu> <DropdownMenu onOpenChange={(open) => { if (!open) setQuery(''); }}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant={variant} variant={variant}

View file

@ -21,6 +21,7 @@ import { clearTranslationMemory } from '@/lib/translation';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AuthDialogContext } from '@/contexts/authDialog';
import { import {
MapPin, MapPin,
@ -585,6 +586,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
const mainRef = useRef<HTMLElement>(null); const mainRef = useRef<HTMLElement>(null);
return ( return (
<AuthDialogContext.Provider value={{ openLogin: requestLogin, openSignup: requestSignup }}>
<div className="min-h-screen bg-black text-white"> <div className="min-h-screen bg-black text-white">
{/* Skip-to-content — visually hidden until focused. A real <button> (not {/* Skip-to-content — visually hidden until focused. A real <button> (not
href="#main") because HashRouter treats the hash AS the route, so an href="#main") because HashRouter treats the hash AS the route, so an
@ -802,7 +804,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
</Button> </Button>
</Link> </Link>
<LoginArea className="max-w-60" onLoginRequest={requestLogin} onSignupRequest={requestSignup} /> <LoginArea className="max-w-60" />
</div> </div>
</div> </div>
</header> </header>
@ -843,7 +845,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
/> />
)} )}
<div className={cn(user && 'hidden')}> <div className={cn(user && 'hidden')}>
<LoginArea compact className="flex flex-row gap-1" onLoginRequest={requestLogin} onSignupRequest={requestSignup} /> <LoginArea compact className="flex flex-row gap-1" />
</div> </div>
<Sheet <Sheet
@ -1118,7 +1120,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
{t('chromeUi.loggedOutPrompt')} {t('chromeUi.loggedOutPrompt')}
</p> </p>
<LoginArea className="w-full justify-center" onLoginRequest={requestLogin} onSignupRequest={requestSignup} /> <LoginArea className="w-full justify-center" />
</div> </div>
</> </>
)} )}
@ -1296,5 +1298,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Mirrors in-app activity to OS notifications — inert until opted in. */} {/* Mirrors in-app activity to OS notifications — inert until opted in. */}
<OsNotificationBridge /> <OsNotificationBridge />
</div> </div>
</AuthDialogContext.Provider>
); );
} }

View file

@ -17,6 +17,7 @@ import { useCreateSpotCollection } from '@/hooks/useSpotCollections';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
const KIND_ICON = { const KIND_ICON = {
spot: MapPin, spot: MapPin,
@ -117,6 +118,7 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
<div className="space-y-1"> <div className="space-y-1">
{day.stops.map((stop, i) => { {day.stops.map((stop, i) => {
const Icon = KIND_ICON[stop.kind]; const Icon = KIND_ICON[stop.kind];
const stopUrl = sanitizeUrl(stop.url);
const inner = ( const inner = (
<div className="flex items-start gap-1.5"> <div className="flex items-start gap-1.5">
<Icon className="h-2.5 w-2.5 mt-0.5 flex-shrink-0 text-[#ccff00]/70" /> <Icon className="h-2.5 w-2.5 mt-0.5 flex-shrink-0 text-[#ccff00]/70" />
@ -131,10 +133,10 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
</div> </div>
</div> </div>
); );
return stop.url ? ( return stopUrl ? (
<a <a
key={i} key={i}
href={stop.url} href={stopUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block rounded px-1.5 py-1 hover:bg-[#ccff00]/5 transition-colors" className="block rounded px-1.5 py-1 hover:bg-[#ccff00]/5 transition-colors"
@ -158,17 +160,23 @@ export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
<Users className="h-2.5 w-2.5" /> {t('routeUi.localCrews')} <Users className="h-2.5 w-2.5" /> {t('routeUi.localCrews')}
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{itinerary.crews.map((c, i) => ( {itinerary.crews.map((c, i) => {
<a const crewUrl = sanitizeUrl(c.url);
key={i} const cls = 'text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-gray-200 hover:bg-[#ccff00]/10 hover:text-[#ccff00] transition-colors';
href={c.url} return crewUrl ? (
target="_blank" <a
rel="noopener noreferrer" key={i}
className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-gray-200 hover:bg-[#ccff00]/10 hover:text-[#ccff00] transition-colors" href={crewUrl}
> target="_blank"
👥 {c.name} rel="noopener noreferrer"
</a> className={cls}
))} >
👥 {c.name}
</a>
) : (
<span key={i} className={cls}>👥 {c.name}</span>
);
})}
</div> </div>
</div> </div>
)} )}

View file

@ -10,6 +10,7 @@ import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AccountSwitcher } from './AccountSwitcher'; import { AccountSwitcher } from './AccountSwitcher';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { useAuthDialog } from '@/contexts/authDialog';
export interface LoginAreaProps { export interface LoginAreaProps {
className?: string; className?: string;
@ -27,16 +28,19 @@ export interface LoginAreaProps {
export function LoginArea({ className, compact, onLoginRequest, onSignupRequest }: LoginAreaProps) { export function LoginArea({ className, compact, onLoginRequest, onSignupRequest }: LoginAreaProps) {
const { currentUser } = useLoggedInAccounts(); const { currentUser } = useLoggedInAccounts();
const { t } = useTranslation(); const { t } = useTranslation();
const authDialog = useAuthDialog();
const [loginDialogOpen, setLoginDialogOpen] = useState(false); const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupDialogOpen, setSignupDialogOpen] = useState(false); const [signupDialogOpen, setSignupDialogOpen] = useState(false);
const globalAuth = !!onLoginRequest && !!onSignupRequest; const globalAuth = !!authDialog || (!!onLoginRequest && !!onSignupRequest);
const openLogin = () => { const openLogin = () => {
if (onLoginRequest) onLoginRequest(); if (authDialog) authDialog.openLogin();
else if (onLoginRequest) onLoginRequest();
else setLoginDialogOpen(true); else setLoginDialogOpen(true);
}; };
const openSignup = () => { const openSignup = () => {
if (onSignupRequest) onSignupRequest(); if (authDialog) authDialog.openSignup();
else if (onSignupRequest) onSignupRequest();
else setSignupDialogOpen(true); else setSignupDialogOpen(true);
}; };
@ -57,6 +61,7 @@ export function LoginArea({ className, compact, onLoginRequest, onSignupRequest
size="icon" size="icon"
className='h-9 w-9 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90' className='h-9 w-9 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
title={t('settingsUi.loginArea.signUp')} title={t('settingsUi.loginArea.signUp')}
aria-label={t('settingsUi.loginArea.signUp')}
> >
<UserPlus className='w-4 h-4' /> <UserPlus className='w-4 h-4' />
</Button> </Button>
@ -66,6 +71,7 @@ export function LoginArea({ className, compact, onLoginRequest, onSignupRequest
size="icon" size="icon"
className="h-9 w-9 rounded-full border border-gray-600 hover:border-[#ff0040]" className="h-9 w-9 rounded-full border border-gray-600 hover:border-[#ff0040]"
title={t('settingsUi.loginArea.logIn')} title={t('settingsUi.loginArea.logIn')}
aria-label={t('settingsUi.loginArea.logIn')}
> >
<User className='w-4 h-4' /> <User className='w-4 h-4' />
</Button> </Button>

View file

@ -321,7 +321,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={cn("z-[2200] flex w-[calc(100%-1rem)] flex-col sm:max-w-md max-h-[calc(100dvh-2rem)] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}> <DialogContent onEscapeKeyDown={onClose} className={cn("z-[2200] flex w-[calc(100%-1rem)] flex-col sm:max-w-md max-h-[calc(100dvh-2rem)] p-0 overflow-hidden rounded-2xl bg-black border-2 border-[#ff0040]/50")}>
<DialogHeader className={cn('px-5 pt-5 pb-3')}> <DialogHeader className={cn('px-5 pt-5 pb-3')}>
<DialogTitle className="text-center font-black uppercase tracking-wide text-lg"> <DialogTitle className="text-center font-black uppercase tracking-wide text-lg">
{t('settingsUi.loginDialog.welcomeTitle')} {t('settingsUi.loginDialog.welcomeTitle')}

View file

@ -375,6 +375,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent <DialogContent
onEscapeKeyDown={onClose}
className={cn("z-[2200] w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")} className={cn("z-[2200] w-[calc(100%-1rem)] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")}
> >
<DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1 relative flex-shrink-0')}> <DialogHeader className={cn('px-4 sm:px-6 pt-4 sm:pt-6 pb-1 relative flex-shrink-0')}>

View file

@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
export interface AuthDialogActions {
openLogin: () => void;
openSignup: () => void;
}
/**
* App-wide auth dialog controls. `Layout` owns the dialogs so they survive the
* unmount of whichever `LoginArea` (or parent card/dialog) opened them — crucial
* because signup logs the user in immediately and many cards conditionally
* render their login prompt only while logged out.
*/
export const AuthDialogContext = createContext<AuthDialogActions | null>(null);
export function useAuthDialog(): AuthDialogActions | null {
return useContext(AuthDialogContext);
}