fix(auth): keep signup alive across page cards; sanitize trip links
This commit is contained in:
parent
e267b79281
commit
13ebea685e
7 changed files with 57 additions and 21 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
const crewUrl = sanitizeUrl(c.url);
|
||||||
|
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';
|
||||||
|
return crewUrl ? (
|
||||||
<a
|
<a
|
||||||
key={i}
|
key={i}
|
||||||
href={c.url}
|
href={crewUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
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"
|
className={cls}
|
||||||
>
|
>
|
||||||
👥 {c.name}
|
👥 {c.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
) : (
|
||||||
|
<span key={i} className={cls}>👥 {c.name}</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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')}>
|
||||||
|
|
|
||||||
18
src/contexts/authDialog.ts
Normal file
18
src/contexts/authDialog.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue