More
This commit is contained in:
parent
128b3191e7
commit
03445188ea
54 changed files with 596953 additions and 3577 deletions
|
|
@ -4,6 +4,7 @@ import PricingPage from './components/pricing/PricingPage';
|
|||
import HomePage from './components/home/HomePage';
|
||||
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
||||
import LearnPage from './components/learn/LearnPage';
|
||||
import AccountPage from './components/account/AccountPage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||
|
|
@ -32,6 +33,8 @@ case 'saved-searches':
|
|||
return '/learn';
|
||||
case 'pricing':
|
||||
return '/pricing';
|
||||
case 'account':
|
||||
return '/account';
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
|
|
@ -42,6 +45,7 @@ function pathToPage(pathname: string): Page | null {
|
|||
if (pathname === '/saved') return 'saved-searches';
|
||||
if (pathname === '/learn') return 'learn';
|
||||
if (pathname === '/pricing') return 'pricing';
|
||||
if (pathname === '/account') return 'account';
|
||||
if (pathname === '/') return 'home';
|
||||
return null;
|
||||
}
|
||||
|
|
@ -92,6 +96,7 @@ export default function App() {
|
|||
register,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
|
|
@ -233,6 +238,8 @@ export default function App() {
|
|||
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
||||
) : activePage === 'saved-searches' ? (
|
||||
<SavedSearchesPage
|
||||
searches={savedSearches.searches}
|
||||
|
|
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export interface AuthUser {
|
|||
id: string;
|
||||
email: string;
|
||||
verified: boolean;
|
||||
isAdmin: boolean;
|
||||
subscription: string;
|
||||
}
|
||||
|
||||
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
|
||||
|
|
@ -15,6 +17,8 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
|
|||
id: record.id,
|
||||
email: record.email,
|
||||
verified: typeof record.verified === 'boolean' ? record.verified : false,
|
||||
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
|
||||
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +114,11 @@ export function useAuth() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
const result = await pb.collection('users').authRefresh();
|
||||
setUser(recordToUser(result.record));
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
|
@ -123,6 +132,7 @@ export function useAuth() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
type TransportMode,
|
||||
type TravelTimeEntries,
|
||||
} from './useTravelTime';
|
||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
|
|
@ -40,7 +41,7 @@ interface UseDeckLayersProps {
|
|||
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;
|
||||
theme: 'light' | 'dark';
|
||||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
|
|
@ -89,9 +90,18 @@ export function useDeckLayers({
|
|||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
|
||||
// Marching ants animation
|
||||
const [marchTime, setMarchTime] = useState(0);
|
||||
const hasPostcodeGeometry = selectedPostcodeGeometry != null;
|
||||
useEffect(() => {
|
||||
if (!hasPostcodeGeometry) return;
|
||||
setMarchTime(0);
|
||||
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
|
||||
return () => clearInterval(id);
|
||||
}, [hasPostcodeGeometry]);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
|
||||
|
|
@ -110,8 +120,6 @@ export function useDeckLayers({
|
|||
selectedHexagonIdRef.current = selectedHexagonId;
|
||||
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
||||
hoveredHexagonIdRef.current = hoveredHexagonId;
|
||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
||||
selectedPostcodeRef.current = selectedPostcode;
|
||||
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
||||
hoveredPostcodeRef.current = hoveredPostcode;
|
||||
|
||||
|
|
@ -233,8 +241,7 @@ export function useDeckLayers({
|
|||
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
||||
const pc = info.object?.properties?.postcode;
|
||||
if (pc) {
|
||||
setSelectedPostcode((prev) => (prev === pc ? null : pc));
|
||||
onHexagonClickRef.current(pc, true);
|
||||
onHexagonClickRef.current(pc, true, info.object?.geometry);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -265,7 +272,7 @@ export function useDeckLayers({
|
|||
}, [travelTimeEntries, travelTimeColorRanges]);
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
||||
|
||||
// --- Layers ---
|
||||
const hexLayer = useMemo(
|
||||
|
|
@ -423,8 +430,6 @@ export function useDeckLayers({
|
|||
getLineColor: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
const dark = isDarkRef.current;
|
||||
if (pc === selectedPostcodeRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (pc === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
||||
|
|
@ -436,7 +441,6 @@ export function useDeckLayers({
|
|||
},
|
||||
getLineWidth: (f) => {
|
||||
const pc = f.properties.postcode;
|
||||
if (pc === selectedPostcodeRef.current) return 3;
|
||||
if (pc === hoveredPostcodeRef.current) return 2;
|
||||
return 1;
|
||||
},
|
||||
|
|
@ -500,37 +504,28 @@ export function useDeckLayers({
|
|||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
// Check if the selected postcode has data (passes current filters)
|
||||
const selectedPostcodeHasData = useMemo(() => {
|
||||
if (!selectedPostcodeGeometry || !selectedHexagonId) return false;
|
||||
return postcodeData.some((f) => f.properties.postcode === selectedHexagonId);
|
||||
}, [selectedPostcodeGeometry, selectedHexagonId, postcodeData]);
|
||||
|
||||
// Highlight layer for selected postcode (from search)
|
||||
const selectedPostcodeHighlightLayer = useMemo(() => {
|
||||
// Marching ants highlight layer for selected postcode (click or search)
|
||||
const marchingAntsLayer = useMemo(() => {
|
||||
if (!selectedPostcodeGeometry) return null;
|
||||
const hasData = selectedPostcodeHasData;
|
||||
const feature = {
|
||||
type: 'Feature' as const,
|
||||
geometry: selectedPostcodeGeometry,
|
||||
properties: {},
|
||||
};
|
||||
return new GeoJsonLayer({
|
||||
id: 'searched-postcode-highlight',
|
||||
data: [feature],
|
||||
getFillColor: hasData
|
||||
? [29, 228, 195, 40] // teal tint when has data
|
||||
: [255, 180, 0, 30], // orange tint when filtered out
|
||||
getLineColor: hasData
|
||||
? [29, 228, 195, 255] // solid teal when has data
|
||||
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
|
||||
getLineWidth: hasData ? 4 : 3,
|
||||
lineWidthUnits: 'pixels',
|
||||
id: 'marching-ants',
|
||||
data: [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
geometry: selectedPostcodeGeometry,
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
filled: false,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
getLineColor: [29, 228, 195, 255],
|
||||
getLineWidth: 3,
|
||||
lineWidthUnits: 'pixels' as const,
|
||||
pickable: false,
|
||||
marchTime,
|
||||
extensions: [new MarchingAntsExtension()],
|
||||
});
|
||||
}, [selectedPostcodeGeometry, selectedPostcodeHasData]);
|
||||
}, [selectedPostcodeGeometry, marchTime]);
|
||||
|
||||
// Destination markers: one red dot per mode with a destination
|
||||
const destinationMarkerData = useMemo(() => {
|
||||
|
|
@ -566,7 +561,7 @@ export function useDeckLayers({
|
|||
const baseLayers: any[] = usePostcodeView
|
||||
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
|
||||
: [hexLayer, poiLayer];
|
||||
if (selectedPostcodeHighlightLayer) baseLayers.push(selectedPostcodeHighlightLayer);
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
|
||||
return baseLayers;
|
||||
}, [
|
||||
|
|
@ -575,7 +570,7 @@ export function useDeckLayers({
|
|||
postcodeLayer,
|
||||
postcodeLabelsLayer,
|
||||
poiLayer,
|
||||
selectedPostcodeHighlightLayer,
|
||||
marchingAntsLayer,
|
||||
destinationMarkerLayer,
|
||||
]);
|
||||
|
||||
|
|
@ -594,7 +589,6 @@ export function useDeckLayers({
|
|||
postcodeCountRange,
|
||||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
selectedPostcode,
|
||||
hoveredPostcode,
|
||||
primaryTravelMode,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -99,15 +99,16 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
|
|||
);
|
||||
|
||||
const handleHexagonClick = useCallback(
|
||||
(id: string, isPostcode = false) => {
|
||||
setSelectedPostcodeGeometry(null);
|
||||
(id: string, isPostcode = false, geometry?: PostcodeGeometry) => {
|
||||
if (selectedHexagon?.id === id) {
|
||||
setSelectedHexagon(null);
|
||||
setProperties([]);
|
||||
setAreaStats(null);
|
||||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
const type = isPostcode ? 'postcode' : 'hexagon';
|
||||
setSelectedHexagon({ id, type, resolution });
|
||||
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
|
||||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
|
|
|
|||
86
frontend/src/hooks/useTutorial.ts
Normal file
86
frontend/src/hooks/useTutorial.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { Step, CallBackProps } from 'react-joyride';
|
||||
import { ACTIONS, EVENTS, STATUS } from 'react-joyride';
|
||||
|
||||
const STORAGE_KEY = 'tutorial_completed';
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
target: '[data-tutorial="filters"]',
|
||||
title: 'Filter Properties',
|
||||
content:
|
||||
'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="map"]',
|
||||
title: 'Explore the Map',
|
||||
content:
|
||||
'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="search"]',
|
||||
title: 'Search Locations',
|
||||
content:
|
||||
'Search for a place name or postcode to jump directly to that area on the map.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="right-pane"]',
|
||||
title: 'Area Stats & Properties',
|
||||
content:
|
||||
'After clicking a hexagon, view aggregated area statistics or browse individual properties in this pane.',
|
||||
placement: 'left',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="poi-button"]',
|
||||
title: 'Points of Interest',
|
||||
content:
|
||||
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
|
||||
placement: 'left',
|
||||
disableBeacon: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function useTutorial(initialLoading: boolean, isMobile: boolean) {
|
||||
const [run, setRun] = useState(() => {
|
||||
if (isMobile) return false;
|
||||
return !localStorage.getItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
const shouldRun = run && !initialLoading && !isMobile;
|
||||
|
||||
const handleCallback = useCallback((data: CallBackProps) => {
|
||||
const { status, action, type } = data;
|
||||
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
setRun(false);
|
||||
}
|
||||
// Also stop if user closes a tooltip via the X button
|
||||
if (action === ACTIONS.CLOSE && type === EVENTS.STEP_AFTER) {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
setRun(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetTutorial = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setRun(true);
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
steps: STEPS,
|
||||
run: shouldRun,
|
||||
handleCallback,
|
||||
resetTutorial,
|
||||
}),
|
||||
[shouldRun, handleCallback, resetTutorial]
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,17 @@ h3 {
|
|||
color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Hexagon background animations */
|
||||
@keyframes hex-drift {
|
||||
from { transform: translateX(-5vw); }
|
||||
to { transform: translateX(105vw); }
|
||||
}
|
||||
|
||||
@keyframes hex-bob {
|
||||
0%, 100% { transform: translateY(var(--bob)); }
|
||||
50% { transform: translateY(calc(var(--bob) * -1)); }
|
||||
}
|
||||
|
||||
/* Fade-in animation for homepage sections */
|
||||
.fade-in-section {
|
||||
opacity: 0;
|
||||
|
|
|
|||
53
frontend/src/lib/MarchingAntsExtension.ts
Normal file
53
frontend/src/lib/MarchingAntsExtension.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { LayerExtension } from '@deck.gl/core';
|
||||
|
||||
/** Animates a marching-ants border on PathLayer sublayers (alternating white/green dashes). */
|
||||
export class MarchingAntsExtension extends LayerExtension {
|
||||
static extensionName = 'MarchingAntsExtension';
|
||||
static defaultProps = {
|
||||
marchTime: { type: 'number', value: 0 },
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isEnabled(layer: any): boolean {
|
||||
return 'pathTesselator' in layer.state;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getShaders(extension: any): any {
|
||||
if (!extension.isEnabled(this)) return null;
|
||||
return {
|
||||
modules: [
|
||||
{
|
||||
name: 'marchingAnts',
|
||||
inject: {
|
||||
'fs:#decl': `\
|
||||
uniform marchingAntsUniforms {
|
||||
float marchTime;
|
||||
} marchingAnts;`,
|
||||
'fs:DECKGL_FILTER_COLOR': `\
|
||||
float marchSegLen = 4.0;
|
||||
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
|
||||
if (marchPos < marchSegLen) {
|
||||
color = vec4(1.0, 1.0, 1.0, color.a);
|
||||
} else {
|
||||
color = vec4(0.114, 0.894, 0.765, color.a);
|
||||
}`,
|
||||
},
|
||||
uniformTypes: {
|
||||
marchTime: 'f32',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateState(_params: any, extension: any): void {
|
||||
if (!extension.isEnabled(this)) return;
|
||||
// @ts-expect-error setShaderModuleProps exists on Layer
|
||||
this.setShaderModuleProps({
|
||||
// @ts-expect-error marchTime is a custom prop from this extension
|
||||
marchingAnts: { marchTime: this.props.marchTime || 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
52
frontend/src/lib/tutorial-styles.ts
Normal file
52
frontend/src/lib/tutorial-styles.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Styles } from 'react-joyride';
|
||||
|
||||
export function getTutorialStyles(theme: 'light' | 'dark'): Partial<Styles> {
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return {
|
||||
options: {
|
||||
arrowColor: isDark ? '#292524' : '#ffffff',
|
||||
backgroundColor: isDark ? '#292524' : '#ffffff',
|
||||
overlayColor: isDark ? 'rgba(10,14,26,0.75)' : 'rgba(0,0,0,0.5)',
|
||||
primaryColor: '#00a28c',
|
||||
textColor: isDark ? '#d6d3d1' : '#44403c',
|
||||
zIndex: 1000,
|
||||
},
|
||||
tooltip: {
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: isDark ? '#f5f5f4' : '#0a0e1a',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
},
|
||||
tooltipContent: {
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
padding: '8px 0 0',
|
||||
},
|
||||
buttonNext: {
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
padding: '6px 14px',
|
||||
},
|
||||
buttonBack: {
|
||||
color: isDark ? '#a8a29e' : '#78716c',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonSkip: {
|
||||
color: isDark ? '#78716c' : '#a8a29e',
|
||||
fontSize: 12,
|
||||
},
|
||||
buttonClose: {
|
||||
color: isDark ? '#a8a29e' : '#78716c',
|
||||
},
|
||||
spotlight: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue