More
This commit is contained in:
parent
128b3191e7
commit
03445188ea
54 changed files with 596953 additions and 3577 deletions
131
frontend/src/components/account/AccountPage.tsx
Normal file
131
frontend/src/components/account/AccountPage.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useState } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
|
||||
const SUBSCRIPTION_OPTIONS = ['free', 'rental', 'buyer'] as const;
|
||||
|
||||
const SUBSCRIPTION_LABELS: Record<string, string> = {
|
||||
free: 'Free',
|
||||
rental: 'Rental',
|
||||
buyer: 'Buyer',
|
||||
};
|
||||
|
||||
export default function AccountPage({
|
||||
user,
|
||||
onRefreshAuth,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onRefreshAuth: () => Promise<void>;
|
||||
}) {
|
||||
const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
const res = await fetch(apiUrl('subscription'), {
|
||||
method: 'PATCH',
|
||||
...authHeaders({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: selectedSubscription }),
|
||||
}),
|
||||
});
|
||||
assertOk(res, 'Update subscription');
|
||||
await onRefreshAuth();
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update subscription';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const badgeColor =
|
||||
user.subscription === 'buyer'
|
||||
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
: user.subscription === 'rental'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
|
||||
<div className="max-w-lg mx-auto px-6 py-16">
|
||||
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-8">Account</h1>
|
||||
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
|
||||
{/* Email */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
|
||||
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
user.verified
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{user.verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
|
||||
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
|
||||
{SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin section */}
|
||||
{user.isAdmin && (
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
Admin: Change subscription
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedSubscription}
|
||||
onChange={(e) => setSelectedSubscription(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
>
|
||||
{SUBSCRIPTION_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{SUBSCRIPTION_LABELS[opt]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || selectedSubscription === user.subscription}
|
||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : null}
|
||||
{saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +1,63 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const HEX_COUNT = 70;
|
||||
const TAU = Math.PI * 2;
|
||||
const HEX_COUNT = 50;
|
||||
|
||||
interface Hex {
|
||||
x: number;
|
||||
y: number;
|
||||
baseY: number;
|
||||
interface HexConfig {
|
||||
size: number;
|
||||
opacity: number;
|
||||
speed: number;
|
||||
phase: number;
|
||||
top: number;
|
||||
driftDuration: number;
|
||||
bobDuration: number;
|
||||
bobAmount: number;
|
||||
delay: number;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
function initHexes(w: number, h: number): Hex[] {
|
||||
const hexes: Hex[] = [];
|
||||
function generateHexes(): HexConfig[] {
|
||||
const hexes: HexConfig[] = [];
|
||||
for (let i = 0; i < HEX_COUNT; i++) {
|
||||
const y = Math.random() * h;
|
||||
const side = Math.random() < 0.5 ? 'left' : 'right';
|
||||
const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3;
|
||||
const driftDuration = 18 + Math.random() * 35;
|
||||
hexes.push({
|
||||
x,
|
||||
y,
|
||||
baseY: y,
|
||||
size: 8 + Math.random() * 20,
|
||||
opacity: 0.08 + Math.random() * 0.15,
|
||||
speed: 6 + Math.random() * 14,
|
||||
phase: Math.random() * TAU,
|
||||
size: 10 + Math.random() * 32,
|
||||
opacity: 0.06 + Math.random() * 0.18,
|
||||
top: Math.random() * 100,
|
||||
driftDuration,
|
||||
bobDuration: 3 + Math.random() * 5,
|
||||
bobAmount: 8 + Math.random() * 30,
|
||||
delay: -Math.random() * driftDuration,
|
||||
reverse: Math.random() < 0.3,
|
||||
});
|
||||
}
|
||||
return hexes;
|
||||
}
|
||||
|
||||
function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) {
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (TAU / 6) * i - Math.PI / 6;
|
||||
const px = cx + r * Math.cos(angle);
|
||||
const py = cy + r * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const hexesRef = useRef<Hex[]>([]);
|
||||
const animRef = useRef(0);
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas!.parentElement!.getBoundingClientRect();
|
||||
w = rect.width;
|
||||
h = rect.height;
|
||||
canvas!.width = w * dpr;
|
||||
canvas!.height = h * dpr;
|
||||
canvas!.style.width = `${w}px`;
|
||||
canvas!.style.height = `${h}px`;
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
hexesRef.current = initHexes(w, h);
|
||||
}
|
||||
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas.parentElement!);
|
||||
|
||||
let prev = performance.now();
|
||||
|
||||
function frame(now: number) {
|
||||
const dt = (now - prev) / 1000;
|
||||
prev = now;
|
||||
ctx!.clearRect(0, 0, w, h);
|
||||
|
||||
for (const hex of hexesRef.current) {
|
||||
hex.x += hex.speed * dt * 0.3;
|
||||
if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
|
||||
hex.x = w * 0.7 + hex.size;
|
||||
}
|
||||
if (hex.x > w + hex.size * 2) {
|
||||
hex.x = -hex.size * 2;
|
||||
hex.y = Math.random() * h;
|
||||
hex.baseY = hex.y;
|
||||
}
|
||||
|
||||
const bob = Math.sin(now / 1000 + hex.phase) * 8;
|
||||
hex.y = hex.baseY + bob;
|
||||
|
||||
const dark = isDarkRef.current;
|
||||
ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1);
|
||||
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
|
||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||
ctx!.fill();
|
||||
|
||||
ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1);
|
||||
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
|
||||
ctx!.lineWidth = 1;
|
||||
drawHex(ctx!, hex.x, hex.y, hex.size);
|
||||
ctx!.stroke();
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
animRef.current = requestAnimationFrame(frame);
|
||||
return () => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
const hexes = useMemo(generateHexes, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
|
||||
{hexes.map((hex, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{
|
||||
top: `${hex.top}%`,
|
||||
animation: `hex-drift ${hex.driftDuration}s linear ${hex.delay}s infinite${hex.reverse ? ' reverse' : ''}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-teal-500"
|
||||
style={{
|
||||
width: hex.size,
|
||||
height: hex.size,
|
||||
opacity: hex.opacity * (isDark ? 0.6 : 1),
|
||||
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
|
||||
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
|
||||
'--bob': `${hex.bobAmount}px`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default function HomePage({
|
|||
<HexCanvas isDark={theme === 'dark'} />
|
||||
{/* Radial teal glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6">
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
|
||||
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
|
||||
Browsing listings is not a strategy. Knowing what you want is.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -119,7 +119,37 @@ export default memo(function Filters({
|
|||
onAiFilterSubmit,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter(
|
||||
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
|
||||
);
|
||||
|
||||
const listingToggles = useMemo(() => {
|
||||
const val = filters['Listing status'] as string[] | undefined;
|
||||
if (!val) return { historical: true, buy: true, rent: true };
|
||||
return {
|
||||
historical: val.includes('Historical sale'),
|
||||
buy: val.includes('For sale'),
|
||||
rent: val.includes('For rent'),
|
||||
};
|
||||
}, [filters]);
|
||||
|
||||
const handleListingToggle = useCallback(
|
||||
(key: 'historical' | 'buy' | 'rent') => {
|
||||
const next = { ...listingToggles, [key]: !listingToggles[key] };
|
||||
const allOn = next.historical && next.buy && next.rent;
|
||||
const allOff = !next.historical && !next.buy && !next.rent;
|
||||
if (allOn || allOff) {
|
||||
onRemoveFilter('Listing status');
|
||||
return;
|
||||
}
|
||||
const values: string[] = [];
|
||||
if (next.historical) values.push('Historical sale');
|
||||
if (next.buy) values.push('For sale');
|
||||
if (next.rent) values.push('For rent');
|
||||
onFilterChange('Listing status', values);
|
||||
},
|
||||
[listingToggles, onFilterChange, onRemoveFilter]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
|
|
@ -155,7 +185,8 @@ export default memo(function Filters({
|
|||
return scales;
|
||||
}, [features]);
|
||||
|
||||
const badgeCount = enabledFeatureList.length + activeModes.length;
|
||||
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
|
||||
const badgeCount = enabledFeatureList.length + activeModes.length + (hasListingFilter ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
|
|
@ -171,6 +202,17 @@ export default memo(function Filters({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
|
||||
<PillGroup>
|
||||
<PillToggle label="Historical" active={listingToggles.historical}
|
||||
onClick={() => handleListingToggle('historical')} size="xs" />
|
||||
<PillToggle label="Buy" active={listingToggles.buy}
|
||||
onClick={() => handleListingToggle('buy')} size="xs" />
|
||||
<PillToggle label="Rent" active={listingToggles.rent}
|
||||
onClick={() => handleListingToggle('rent')} size="xs" />
|
||||
</PillGroup>
|
||||
</div>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export default function LocationSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="absolute top-3 left-3 z-10 flex flex-col">
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
|
||||
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
||||
<PlaceSearchInput
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ interface MapProps {
|
|||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean) => void;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
theme?: 'light' | 'dark';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
import Map from './Map';
|
||||
|
|
@ -18,6 +18,9 @@ import { usePaneResize } from '../../hooks/usePaneResize';
|
|||
import { useAiFilters } from '../../hooks/useAiFilters';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTutorial } from '../../hooks/useTutorial';
|
||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||
import Joyride from 'react-joyride';
|
||||
import {
|
||||
useTravelTime,
|
||||
TRANSPORT_MODES,
|
||||
|
|
@ -191,8 +194,8 @@ export default function MapPage({
|
|||
// On mobile, open drawer and switch tab when hexagon is clicked
|
||||
const { handleHexagonClick } = selection;
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
(id: string, isPostcode?: boolean) => {
|
||||
handleHexagonClick(id, isPostcode);
|
||||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||||
handleHexagonClick(id, isPostcode, geometry);
|
||||
if (id) {
|
||||
setMobileDrawerOpen(true);
|
||||
}
|
||||
|
|
@ -225,6 +228,9 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
]);
|
||||
|
||||
// Tutorial
|
||||
const tutorial = useTutorial(initialLoading, isMobile);
|
||||
|
||||
// AI area summary
|
||||
const aiSummary = useAreaSummary({
|
||||
stats: selection.areaStats,
|
||||
|
|
@ -551,8 +557,20 @@ export default function MapPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Joyride
|
||||
steps={tutorial.steps}
|
||||
run={tutorial.run}
|
||||
continuous
|
||||
showProgress
|
||||
showSkipButton
|
||||
callback={tutorial.handleCallback}
|
||||
styles={getTutorialStyles(theme)}
|
||||
disableScrolling
|
||||
/>
|
||||
|
||||
{/* Left Pane */}
|
||||
<div
|
||||
data-tutorial="filters"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
style={{ width: leftPaneWidth }}
|
||||
>
|
||||
|
|
@ -566,7 +584,7 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
<div data-tutorial="map" className="flex-1 relative">
|
||||
<Map
|
||||
data={mapData.data}
|
||||
postcodeData={mapData.postcodeData}
|
||||
|
|
@ -599,6 +617,7 @@ export default function MapPage({
|
|||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
|
|
@ -614,6 +633,7 @@ export default function MapPage({
|
|||
|
||||
{/* Right Pane */}
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing';
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -200,7 +200,7 @@ export default function Header({
|
|||
{!isMobile && (
|
||||
<>
|
||||
{user ? (
|
||||
<UserMenu user={user} onLogout={onLogout} />
|
||||
<UserMenu user={user} onLogout={onLogout} onPageChange={onPageChange} />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export default function MobileMenu({
|
|||
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{mobileNavItem('pricing', 'Pricing')}
|
||||
{user && mobileNavItem('account', 'Account')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
{activePage === 'dashboard' && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { Page } from './Header';
|
||||
|
||||
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
|
||||
export default function UserMenu({
|
||||
user,
|
||||
onLogout,
|
||||
onPageChange,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
onPageChange: (page: Page) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -37,6 +46,15 @@ export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout:
|
|||
</p>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onPageChange('account');
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Account
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue