All changes
This commit is contained in:
parent
593f380581
commit
49f7ec2f5a
60 changed files with 1783 additions and 679 deletions
|
|
@ -119,7 +119,8 @@ Serves `frontend/dist/` as static fallback in production **only** when `--dist`
|
|||
React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. No state management library — pure React hooks.
|
||||
|
||||
**Architecture:**
|
||||
- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation (home/dashboard/data-sources/faq)
|
||||
- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation. Page type is `'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite'`. Auth-required pages (`account`, `saved`, `invites`) redirect to home with login modal when unauthenticated. `pageToPath()` / `pathToPage()` map between Page values and URL paths.
|
||||
- `AccountPage.tsx` — Exports three separate page components: `SavedPage` (`/saved` — saved searches + saved properties with sub-tabs), `InvitesPage` (`/invites` — invite link generation + history), and `AccountPage` (default export, `/account` — email, subscription, newsletter, support). Note: `'invite'` (singular, `/invite/:code`) is the invite *redemption* flow — distinct from `'invites'` (plural, `/invites`) which is the invite *management* page.
|
||||
- `MapPage.tsx` — Dashboard layout: composes map + left/right panes, uses custom hooks for all logic
|
||||
- Custom hooks in `hooks/` encapsulate stateful logic:
|
||||
- `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation
|
||||
|
|
@ -138,6 +139,8 @@ React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. N
|
|||
- Viewport bounds computed via `getBoundsFromViewState()` in `map-utils.ts` — uses Web Mercator math with **TILE_SIZE=512** (MapLibre/deck.gl convention, NOT 256)
|
||||
- Properties pane uses feature names from API response (human-readable), not hardcoded field names
|
||||
- Proxy: dev server on :3001 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns
|
||||
- **Nav links must be `<a>` tags, not `<button>`**: All page navigation items in `Header.tsx` and `MobileMenu.tsx` use `<a href={PAGE_PATHS[page]}>` with an `onClick` that calls `e.preventDefault()` + client-side navigation for normal clicks, but lets CMD/Ctrl+click fall through to open in a new tab. `PAGE_PATHS` is exported from `Header.tsx`. Pattern: `if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;` before `preventDefault()`.
|
||||
- **Portal outside-click handlers must check both refs**: When a dropdown uses `createPortal(content, document.body)`, the portal DOM is outside the trigger's container. An outside-click handler using `container.contains(e.target)` will treat clicks on portal content as "outside" and close the dropdown. On mobile this breaks selection entirely because the native `mousedown` listener on `document` preempts React's synthetic event on the portal content. Fix: add a separate ref to the portal content and check both in the handler (`!containerRef.current.contains(target) && !dropdownRef.current?.contains(target)`). See `DestinationDropdown.tsx` for the pattern.
|
||||
|
||||
**Shared UI Components (`frontend/src/components/ui/`):**
|
||||
- `icons/` — One file per icon (CloseIcon, InfoIcon, EyeIcon, PlusIcon, ChevronIcon, FilterIcon, LightbulbIcon, DownloadIcon, MapPinIcon, CheckIcon, ClipboardIcon, SunIcon, MoonIcon, SpinnerIcon). All accept `className` prop. **Never inline SVGs** — always extract to this folder.
|
||||
|
|
@ -332,6 +335,7 @@ Follow these conventions in all Rust code:
|
|||
- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras.
|
||||
- **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check
|
||||
- **OG tag injection**: Uses `<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__"/>` placeholder in HTML, replaced at runtime by middleware
|
||||
- **Dev invite code**: The code `devdevdevdev` is recognized as a valid admin invite in dev mode only (`state.index_html.is_none()`, i.e., `--dist` not passed). Both `get_invite` and `post_redeem_invite` short-circuit for this code, returning a fake valid admin invite / no-op "licensed" response without hitting PocketBase. Preview at `http://localhost:3001/invite/devdevdevdev`.
|
||||
|
||||
## Rust Performance Patterns (server-rs)
|
||||
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -21,12 +21,11 @@ WORKDIR /app
|
|||
COPY --from=server /app/server-rs/target/release/property-map-server ./
|
||||
COPY --from=frontend /app/frontend/dist ./frontend/dist/
|
||||
|
||||
# COPY property-data/wide.parquet ./data/
|
||||
# COPY property-data/filtered_uk_pois.parquet ./data/
|
||||
# COPY property-data/places.parquet ./data/
|
||||
# COPY property-data/uk.pmtiles ./data/
|
||||
# COPY manual-data/postcode_boundaries ./data/postcode_boundaries/
|
||||
# COPY property-data/travel-times ./data/travel-times/
|
||||
# Data is provided via volume mounts at runtime — not baked into the image.
|
||||
# Mount points:
|
||||
# /app/data - properties.parquet, postcode.parquet, filtered_uk_pois.parquet, places.parquet, uk.pmtiles, postcode_boundaries/, travel-times/
|
||||
# /app/data-scraped - online_listings_buy.parquet, online_listings_rent.parquet
|
||||
VOLUME ["/app/data", "/app/data-scraped"]
|
||||
|
||||
RUN chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ Odd vertical spacing on mobile
|
|||
|
||||
Show even number of cards on mobile
|
||||
|
||||
Construction age is spaced oit
|
||||
Construction year is spaced oit
|
||||
|
||||
Make prop density smaller
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import MapPage, { type ExportState } from './components/map/MapPage';
|
|||
import PricingPage from './components/pricing/PricingPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import LearnPage from './components/learn/LearnPage';
|
||||
import AccountPage from './components/account/AccountPage';
|
||||
import AccountPage, { SavedPage, InvitesPage } from './components/account/AccountPage';
|
||||
import InvitePage from './components/invite/InvitePage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
|
|
@ -19,6 +19,7 @@ import { useTheme } from './hooks/useTheme';
|
|||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useSavedSearches } from './hooks/useSavedSearches';
|
||||
import { useSavedProperties } from './hooks/useSavedProperties';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -34,6 +35,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
return '/learn';
|
||||
case 'pricing':
|
||||
return '/pricing';
|
||||
case 'saved':
|
||||
return '/saved';
|
||||
case 'invites':
|
||||
return '/invites';
|
||||
case 'account':
|
||||
return '/account';
|
||||
case 'invite':
|
||||
|
|
@ -45,7 +50,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
|
||||
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
|
||||
if (pathname === '/dashboard') return { page: 'dashboard' };
|
||||
if (pathname === '/saved') return { page: 'account' };
|
||||
if (pathname === '/saved') return { page: 'saved' };
|
||||
if (pathname === '/invites') return { page: 'invites' };
|
||||
if (pathname === '/learn') return { page: 'learn' };
|
||||
if (pathname === '/pricing') return { page: 'pricing' };
|
||||
if (pathname === '/account') return { page: 'account' };
|
||||
|
|
@ -134,6 +140,7 @@ export default function App() {
|
|||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
const savedProperties = useSavedProperties(user?.id ?? null);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -207,15 +214,21 @@ export default function App() {
|
|||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { fetchSearches } = savedSearches;
|
||||
const { fetchProperties: fetchSavedProperties } = savedProperties;
|
||||
useEffect(() => {
|
||||
if (activePage === 'account') {
|
||||
if (activePage === 'saved') {
|
||||
fetchSearches();
|
||||
fetchSavedProperties();
|
||||
}
|
||||
}, [activePage, fetchSearches]);
|
||||
if (activePage === 'dashboard' && user) {
|
||||
fetchSavedProperties();
|
||||
}
|
||||
}, [activePage, fetchSearches, fetchSavedProperties, user]);
|
||||
|
||||
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved' || activePage === 'invites';
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (activePage === 'account' && !user) {
|
||||
if (isAuthRequiredPage && !user) {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
navigateTo('home');
|
||||
|
|
@ -223,10 +236,24 @@ export default function App() {
|
|||
if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) {
|
||||
navigateTo('dashboard');
|
||||
}
|
||||
}, [activePage, user, authLoading, navigateTo]);
|
||||
}, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]);
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
if ((isScreenshotMode || isOgMode) && inviteCode) {
|
||||
return (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={null}
|
||||
theme={theme}
|
||||
screenshotMode
|
||||
onLoginClick={() => {}}
|
||||
onRegisterClick={() => {}}
|
||||
onLicenseGranted={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isScreenshotMode) {
|
||||
return (
|
||||
<MapPage
|
||||
|
|
@ -271,7 +298,7 @@ export default function App() {
|
|||
onLogout={logout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{user && !user.verified && !verificationDismissed && activePage === 'account' && (
|
||||
{user && !user.verified && !verificationDismissed && isAuthRequiredPage && (
|
||||
<VerificationBanner
|
||||
email={user.email}
|
||||
onRequestVerification={requestVerification}
|
||||
|
|
@ -295,22 +322,34 @@ export default function App() {
|
|||
/>
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage
|
||||
user={user}
|
||||
onRefreshAuth={refreshAuth}
|
||||
onRequestVerification={requestVerification}
|
||||
) : activePage === 'saved' && user ? (
|
||||
<SavedPage
|
||||
searches={savedSearches.searches}
|
||||
searchesLoading={savedSearches.loading}
|
||||
onDeleteSearch={savedSearches.deleteSearch}
|
||||
onOpenSearch={(params) => {
|
||||
window.location.href = `/dashboard?${params}`;
|
||||
}}
|
||||
savedProperties={savedProperties.properties}
|
||||
propertiesLoading={savedProperties.loading}
|
||||
onDeleteProperty={savedProperties.deleteProperty}
|
||||
onOpenProperty={(postcode) => {
|
||||
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'invites' && user ? (
|
||||
<InvitesPage user={user} />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage
|
||||
user={user}
|
||||
onRefreshAuth={refreshAuth}
|
||||
onRequestVerification={requestVerification}
|
||||
/>
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={user}
|
||||
theme={theme}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
|
|
@ -340,6 +379,7 @@ export default function App() {
|
|||
onExportStateChange={setExportState}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
initialPostcode={urlState.postcode}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
|
|
@ -349,6 +389,10 @@ export default function App() {
|
|||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onSaveProperty={user ? savedProperties.saveProperty : undefined}
|
||||
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
|
||||
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
|
||||
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
|
||||
/>
|
||||
)}
|
||||
{showAuthModal && (
|
||||
|
|
@ -368,7 +412,7 @@ export default function App() {
|
|||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }}
|
||||
onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }}
|
||||
saving={savedSearches.saving}
|
||||
error={savedSearches.error}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,100 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { formatRelativeTime } from '../../lib/format';
|
||||
import { formatRelativeTime, formatNumber } from '../../lib/format';
|
||||
import { summarizeParams } from '../../lib/url-state';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { HouseIcon } from '../ui/icons/HouseIcon';
|
||||
import { TrashIcon } from '../ui/icons/TrashIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { SubNav } from '../ui/SubNav';
|
||||
|
||||
type AccountTab = 'saved' | 'settings';
|
||||
function PageLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-warm-900 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ACCOUNT_TABS = [
|
||||
{ key: 'saved', label: 'Saved Searches' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
];
|
||||
function DeleteDialog({
|
||||
title,
|
||||
message,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">{message}</p>
|
||||
<div className="flex gap-3 justify-end px-5 pb-5">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedSearchesContent({
|
||||
function formatPropertyPrice(data: SavedPropertyData): string | null {
|
||||
if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`;
|
||||
if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`;
|
||||
if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`;
|
||||
if (data.price) return `£${formatNumber(data.price)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatPropertyDetails(data: SavedPropertyData): string {
|
||||
const parts: string[] = [];
|
||||
if (data.propertySubType) parts.push(data.propertySubType);
|
||||
else if (data.propertyType) parts.push(data.propertyType);
|
||||
if (data.bedrooms) parts.push(`${data.bedrooms} bed`);
|
||||
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`);
|
||||
if (data.energyRating) parts.push(`EPC ${data.energyRating}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function SavedSearchesTab({
|
||||
searches,
|
||||
loading,
|
||||
onDelete,
|
||||
|
|
@ -60,13 +134,16 @@ function SavedSearchesContent({
|
|||
}
|
||||
}, [doCopy]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
</div>
|
||||
) : searches.length === 0 ? (
|
||||
);
|
||||
}
|
||||
|
||||
if (searches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
|
||||
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
|
||||
|
|
@ -76,7 +153,11 @@ function SavedSearchesContent({
|
|||
Save your dashboard filters and view to quickly return to them later.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{searches.map((search) => (
|
||||
<div
|
||||
|
|
@ -134,74 +215,319 @@ function SavedSearchesContent({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
{deleteConfirmId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Delete search</h2>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
|
||||
Are you sure you want to delete this saved search? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end px-5 pb-5">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteDialog
|
||||
title="Delete search"
|
||||
message="Are you sure you want to delete this saved search? This cannot be undone."
|
||||
onCancel={() => setDeleteConfirmId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsContent({
|
||||
user,
|
||||
onRefreshAuth,
|
||||
onRequestVerification,
|
||||
function SavedPropertiesTab({
|
||||
properties,
|
||||
loading,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onRefreshAuth: () => Promise<void>;
|
||||
onRequestVerification: (email: string) => Promise<void>;
|
||||
properties: SavedProperty[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onOpen: (postcode: string) => void;
|
||||
}) {
|
||||
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
||||
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Verification state
|
||||
const [verificationSending, setVerificationSending] = useState(false);
|
||||
const [verificationSent, setVerificationSent] = useState(false);
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
await onDelete(deleteConfirmId);
|
||||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
// Invite state — keyed by invite type for admins (who can create both kinds)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (properties.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<HouseIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
|
||||
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
|
||||
No saved properties yet
|
||||
</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-500">
|
||||
Click the bookmark icon on any property in the dashboard to save it here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{properties.map((prop) => {
|
||||
const price = formatPropertyPrice(prop.data);
|
||||
const details = formatPropertyDetails(prop.data);
|
||||
return (
|
||||
<div
|
||||
key={prop.id}
|
||||
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
|
||||
{prop.address}
|
||||
</h3>
|
||||
<BookmarkIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400 mt-0.5" filled />
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
|
||||
{price && (
|
||||
<p className="text-lg font-bold text-teal-700 dark:text-teal-400 mb-1">{price}</p>
|
||||
)}
|
||||
{details && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">{details}</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 mb-3">
|
||||
{formatRelativeTime(prop.created)}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onOpen(prop.postcode)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
>
|
||||
Open postcode
|
||||
</button>
|
||||
{prop.data.listingUrl && (
|
||||
<a
|
||||
href={prop.data.listingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
title="View listing"
|
||||
>
|
||||
View listing →
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(prop.id)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId && (
|
||||
<DeleteDialog
|
||||
title="Delete property"
|
||||
message="Are you sure you want to delete this saved property? This cannot be undone."
|
||||
onCancel={() => setDeleteConfirmId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SavedPage({
|
||||
searches,
|
||||
searchesLoading,
|
||||
onDeleteSearch,
|
||||
onOpenSearch,
|
||||
savedProperties,
|
||||
propertiesLoading,
|
||||
onDeleteProperty,
|
||||
onOpenProperty,
|
||||
}: {
|
||||
searches: SavedSearch[];
|
||||
searchesLoading: boolean;
|
||||
onDeleteSearch: (id: string) => Promise<void>;
|
||||
onOpenSearch: (params: string) => void;
|
||||
savedProperties: SavedProperty[];
|
||||
propertiesLoading: boolean;
|
||||
onDeleteProperty: (id: string) => Promise<void>;
|
||||
onOpenProperty: (postcode: string) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>('searches');
|
||||
|
||||
const tabClass = (tab: string) =>
|
||||
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-teal-600 dark:border-teal-400 text-teal-600 dark:text-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex border-b border-warm-200 dark:border-warm-700 mb-6">
|
||||
<button className={tabClass('searches')} onClick={() => setActiveTab('searches')}>
|
||||
Searches
|
||||
{searches.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
|
||||
{searches.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className={tabClass('properties')} onClick={() => setActiveTab('properties')}>
|
||||
Properties
|
||||
{savedProperties.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
|
||||
{savedProperties.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'searches' ? (
|
||||
<SavedSearchesTab
|
||||
searches={searches}
|
||||
loading={searchesLoading}
|
||||
onDelete={onDeleteSearch}
|
||||
onOpen={onOpenSearch}
|
||||
/>
|
||||
) : (
|
||||
<SavedPropertiesTab
|
||||
properties={savedProperties}
|
||||
loading={propertiesLoading}
|
||||
onDelete={onDeleteProperty}
|
||||
onOpen={onOpenProperty}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface InviteListItem {
|
||||
code: string;
|
||||
url: string;
|
||||
invite_type: string;
|
||||
used: boolean;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function InviteTable({
|
||||
invites,
|
||||
loading,
|
||||
title,
|
||||
}: {
|
||||
invites: InviteListItem[];
|
||||
loading: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = (url: string, code: string) => {
|
||||
copyToClipboard(url, () => {
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-warm-200 dark:border-warm-700">
|
||||
<h3 className="text-sm font-medium text-navy-950 dark:text-warm-100">{title}</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
</div>
|
||||
) : invites.length === 0 ? (
|
||||
<p className="px-5 py-6 text-sm text-warm-500 dark:text-warm-400 text-center">
|
||||
No invites generated yet
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Status</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Created</th>
|
||||
<th className="px-5 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
|
||||
{invites.map((inv) => (
|
||||
<tr key={inv.code}>
|
||||
<td className="px-5 py-2.5 text-navy-950 dark:text-warm-200 font-mono text-xs truncate max-w-[200px]">
|
||||
{inv.code}
|
||||
</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<span
|
||||
className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
inv.used
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{inv.used ? 'Redeemed' : 'Pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
|
||||
{formatRelativeTime(inv.created)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => handleCopy(inv.url, inv.code)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Copy invite link"
|
||||
>
|
||||
{copiedCode === inv.code ? (
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
) : (
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvitesPage({ user }: { user: AuthUser }) {
|
||||
const [creatingInvite, setCreatingInvite] = useState<Record<string, boolean>>({});
|
||||
const [inviteUrl, setInviteUrl] = useState<Record<string, string>>({});
|
||||
const [inviteError, setInviteError] = useState<Record<string, string>>({});
|
||||
const [inviteCopied, setInviteCopied] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [inviteHistory, setInviteHistory] = useState<InviteListItem[]>([]);
|
||||
const [inviteHistoryLoading, setInviteHistoryLoading] = useState(false);
|
||||
|
||||
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
||||
|
||||
const fetchInviteHistory = useCallback(async () => {
|
||||
setInviteHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(apiUrl('invites'), authHeaders());
|
||||
assertOk(res, 'Fetch invites');
|
||||
const data = await res.json();
|
||||
setInviteHistory(data.invites);
|
||||
} catch {
|
||||
// Silent — non-critical
|
||||
} finally {
|
||||
setInviteHistoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLicensed) fetchInviteHistory();
|
||||
}, [isLicensed, fetchInviteHistory]);
|
||||
|
||||
const handleCreateInvite = async (type: string) => {
|
||||
setCreatingInvite((prev) => ({ ...prev, [type]: true }));
|
||||
setInviteError((prev) => {
|
||||
|
|
@ -226,6 +552,7 @@ function SettingsContent({
|
|||
assertOk(res, 'Create invite');
|
||||
const data = await res.json();
|
||||
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
|
||||
fetchInviteHistory();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to create invite';
|
||||
setInviteError((prev) => ({ ...prev, [type]: msg }));
|
||||
|
|
@ -243,14 +570,119 @@ function SettingsContent({
|
|||
});
|
||||
};
|
||||
|
||||
if (!isLicensed) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Invite links are available for licensed users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const adminInvites = inviteHistory.filter((i) => i.invite_type === 'admin');
|
||||
const referralInvites = inviteHistory.filter((i) => i.invite_type === 'referral');
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="max-w-lg mx-auto space-y-6">
|
||||
{/* Generate invite links */}
|
||||
<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">
|
||||
{(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
|
||||
<div key={type} className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
|
||||
</p>
|
||||
{inviteUrl[type] ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteUrl[type]}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopyInvite(type)}
|
||||
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
|
||||
>
|
||||
{inviteCopied[type] ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
)}
|
||||
{inviteCopied[type] ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleCreateInvite(type)}
|
||||
disabled={!!creatingInvite[type]}
|
||||
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-wait flex items-center gap-2"
|
||||
>
|
||||
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{type === 'admin' ? 'Generate free invite link' : 'Generate referral link'}
|
||||
</button>
|
||||
)}
|
||||
{inviteError[type] && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError[type]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invite history tables */}
|
||||
{user.isAdmin && (
|
||||
<>
|
||||
<InviteTable
|
||||
invites={adminInvites}
|
||||
loading={inviteHistoryLoading}
|
||||
title="Admin invites (100% off)"
|
||||
/>
|
||||
<InviteTable
|
||||
invites={referralInvites}
|
||||
loading={inviteHistoryLoading}
|
||||
title="Referral invites (30% off)"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!user.isAdmin && referralInvites.length > 0 && (
|
||||
<InviteTable
|
||||
invites={referralInvites}
|
||||
loading={inviteHistoryLoading}
|
||||
title="Your invite links"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPage({
|
||||
user,
|
||||
onRefreshAuth,
|
||||
onRequestVerification,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onRefreshAuth: () => Promise<void>;
|
||||
onRequestVerification: (email: string) => Promise<void>;
|
||||
}) {
|
||||
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
||||
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
||||
|
||||
const [verificationSending, setVerificationSending] = useState(false);
|
||||
const [verificationSent, setVerificationSent] = useState(false);
|
||||
|
||||
const badgeColor =
|
||||
user.subscription === 'licensed'
|
||||
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
||||
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
|
||||
|
||||
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="max-w-lg mx-auto space-y-6">
|
||||
<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 */}
|
||||
|
|
@ -341,50 +773,6 @@ function SettingsContent({
|
|||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{newsletterError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invite friends */}
|
||||
{isLicensed &&
|
||||
(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
|
||||
<div key={type} className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
|
||||
</p>
|
||||
{inviteUrl[type] ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteUrl[type]}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopyInvite(type)}
|
||||
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
|
||||
>
|
||||
{inviteCopied[type] ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
)}
|
||||
{inviteCopied[type] ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleCreateInvite(type)}
|
||||
disabled={!!creatingInvite[type]}
|
||||
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-wait flex items-center gap-2"
|
||||
>
|
||||
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{type === 'admin' ? 'Generate free invite link' : 'Generate referral link'}
|
||||
</button>
|
||||
)}
|
||||
{inviteError[type] && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError[type]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
|
|
@ -401,73 +789,6 @@ function SettingsContent({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountPage({
|
||||
user,
|
||||
onRefreshAuth,
|
||||
onRequestVerification,
|
||||
searches,
|
||||
searchesLoading,
|
||||
onDeleteSearch,
|
||||
onOpenSearch,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onRefreshAuth: () => Promise<void>;
|
||||
onRequestVerification: (email: string) => Promise<void>;
|
||||
searches: SavedSearch[];
|
||||
searchesLoading: boolean;
|
||||
onDeleteSearch: (id: string) => Promise<void>;
|
||||
onOpenSearch: (params: string) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<AccountTab>(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
return hash === 'settings' ? 'settings' : 'saved';
|
||||
});
|
||||
|
||||
// Sync hash with tab
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash === 'settings') setActiveTab('settings');
|
||||
else setActiveTab('saved');
|
||||
};
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const switchTab = (key: string) => {
|
||||
const tab = key as AccountTab;
|
||||
setActiveTab(tab);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`/account#${tab}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-warm-900 flex flex-col">
|
||||
<SubNav tabs={ACCOUNT_TABS} activeTab={activeTab} onTabChange={switchTab} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
{activeTab === 'saved' ? (
|
||||
<SavedSearchesContent
|
||||
searches={searches}
|
||||
loading={searchesLoading}
|
||||
onDelete={onDeleteSearch}
|
||||
onOpen={onOpenSearch}
|
||||
/>
|
||||
) : (
|
||||
<SettingsContent
|
||||
user={user}
|
||||
onRefreshAuth={onRefreshAuth}
|
||||
onRequestVerification={onRequestVerification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default function HomePage({
|
|||
House hunting? Make your biggest investment your smartest move.
|
||||
</p>
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-xl">
|
||||
So many options — choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
|
||||
So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
|
||||
fit.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { apiUrl, assertOk, authHeaders, isAbortError } from '../../lib/api';
|
||||
import { apiUrl, assertOk, authHeaders, isAbortError, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { zoomToResolution } from '../../lib/map-utils';
|
||||
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
|
||||
|
|
@ -248,7 +248,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
})
|
||||
.catch((err) => {
|
||||
if (!isAbortError(err)) {
|
||||
console.error('Failed to fetch story hexagons:', err);
|
||||
logNonAbortError('Failed to fetch story hexagons', err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import HexCanvas from '../home/HexCanvas';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
||||
interface InvitePageProps {
|
||||
code: string;
|
||||
user: AuthUser | null;
|
||||
theme: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onLicenseGranted: () => void;
|
||||
|
|
@ -16,11 +19,68 @@ interface InviteInfo {
|
|||
valid: boolean;
|
||||
invite_type: string;
|
||||
used: boolean;
|
||||
invited_by: string | null;
|
||||
}
|
||||
|
||||
const CONFETTI_COLORS = ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
|
||||
function Confetti() {
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 2,
|
||||
duration: 2 + Math.random() * 2,
|
||||
color: CONFETTI_COLORS[Math.floor(Math.random() * 6)],
|
||||
size: 6 + Math.random() * 6,
|
||||
isCircle: Math.random() > 0.5,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 2 }}>
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
top: '-10px',
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: p.isCircle ? '50%' : '2px',
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<style>{`
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-confetti {
|
||||
animation: confetti-fall linear forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvitePage({
|
||||
code,
|
||||
user,
|
||||
theme,
|
||||
screenshotMode = false,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onLicenseGranted,
|
||||
|
|
@ -32,6 +92,15 @@ export default function InvitePage({
|
|||
const [redeemed, setRedeemed] = useState(false);
|
||||
const [pricePence, setPricePence] = useState<number | null>(null);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Signal screenshot readiness once loading completes
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !loading) {
|
||||
window.__screenshot_ready = true;
|
||||
}
|
||||
}, [screenshotMode, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
|
|
@ -86,20 +155,75 @@ export default function InvitePage({
|
|||
}
|
||||
}, [code, user, onLicenseGranted]);
|
||||
|
||||
if (screenshotMode && loading) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900" />
|
||||
);
|
||||
}
|
||||
|
||||
if (screenshotMode) {
|
||||
const isAdminInvite = invite?.valid && !invite.used && invite.invite_type === 'admin';
|
||||
const isValid = invite?.valid && !invite.used;
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900">
|
||||
<div className="w-[85%] bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-xl overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-12 py-14 text-center">
|
||||
<h2 className="text-4xl font-bold text-white mb-4">
|
||||
{isValid
|
||||
? isAdminInvite
|
||||
? "You\u2019re invited!"
|
||||
: 'Special offer!'
|
||||
: 'Perfect Postcode'}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-lg">
|
||||
{isValid && invite.invited_by
|
||||
? isAdminInvite
|
||||
? `${invite.invited_by} has invited you to get free lifetime access.`
|
||||
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
|
||||
: isValid
|
||||
? isAdminInvite
|
||||
? 'You have been invited to get free lifetime access.'
|
||||
: 'A friend has shared a 30% discount on lifetime access.'
|
||||
: 'Explore every neighbourhood in England'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-12 py-10 text-center">
|
||||
{isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && (
|
||||
<div className="mb-6">
|
||||
<span className="text-warm-400 dark:text-warm-500 line-through text-2xl mr-3">
|
||||
{`\u00A3${pricePence / 100}`}
|
||||
</span>
|
||||
<span className="text-5xl font-extrabold text-teal-600 dark:text-teal-400">
|
||||
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-2 text-xl">/once</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-warm-600 dark:text-warm-400 text-lg">
|
||||
Property prices, energy ratings, crime stats, school ratings & more
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
|
||||
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
|
||||
<HexCanvas isDark={isDark} />
|
||||
<SpinnerIcon className="w-8 h-8 text-teal-400 animate-spin relative z-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !invite) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">Invalid invite</p>
|
||||
<p className="text-warm-500 dark:text-warm-400">{error}</p>
|
||||
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
|
||||
<HexCanvas isDark={isDark} />
|
||||
<div className="text-center relative z-10">
|
||||
<p className="text-lg font-medium text-white mb-2">Invalid invite</p>
|
||||
<p className="text-warm-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -107,12 +231,13 @@ export default function InvitePage({
|
|||
|
||||
if (!invite?.valid || invite.used) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
|
||||
<div className="text-center max-w-sm mx-4">
|
||||
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
|
||||
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
|
||||
<HexCanvas isDark={isDark} />
|
||||
<div className="text-center max-w-sm mx-4 relative z-10">
|
||||
<p className="text-lg font-medium text-white mb-2">
|
||||
{invite?.used ? 'Invite already used' : 'Invalid invite link'}
|
||||
</p>
|
||||
<p className="text-warm-500 dark:text-warm-400">
|
||||
<p className="text-warm-400">
|
||||
{invite?.used
|
||||
? 'This invite link has already been redeemed.'
|
||||
: 'This invite link is invalid or has expired.'}
|
||||
|
|
@ -124,15 +249,17 @@ export default function InvitePage({
|
|||
|
||||
if (redeemed) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
|
||||
<div className="text-center max-w-sm mx-4">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
|
||||
<CheckIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
|
||||
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
|
||||
<HexCanvas isDark={isDark} />
|
||||
<Confetti />
|
||||
<div className="text-center max-w-sm mx-4 relative z-10">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-900/30 flex items-center justify-center">
|
||||
<CheckIcon className="w-8 h-8 text-teal-400" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
|
||||
<p className="text-lg font-medium text-white mb-2">
|
||||
License activated!
|
||||
</p>
|
||||
<p className="text-warm-500 dark:text-warm-400">
|
||||
<p className="text-warm-400">
|
||||
You now have full access to Perfect Postcode.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -143,25 +270,25 @@ export default function InvitePage({
|
|||
const isAdminInvite = invite.invite_type === 'admin';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
|
||||
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
|
||||
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
|
||||
<HexCanvas isDark={isDark} />
|
||||
<Confetti />
|
||||
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden relative z-10">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
{isAdminInvite ? "You're invited!" : 'Special offer!'}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-sm">
|
||||
{isAdminInvite
|
||||
{invite.invited_by
|
||||
? isAdminInvite
|
||||
? `${invite.invited_by} has invited you to get free lifetime access.`
|
||||
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
|
||||
: isAdminInvite
|
||||
? 'You have been invited to get free lifetime access.'
|
||||
: 'A friend has shared a 30% discount on lifetime access.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
{isAdminInvite && (
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime access</span>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-warm-400 dark:text-warm-500 line-through text-xl mr-2">
|
||||
|
|
@ -174,7 +301,15 @@ export default function InvitePage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
{user?.subscription === 'licensed' ? (
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
|
||||
<CheckIcon className="w-6 h-6 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<p className="text-warm-700 dark:text-warm-300 font-medium">You already have a license</p>
|
||||
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">Your account already has full access.</p>
|
||||
</div>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={redeeming}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const DATA_SOURCES = [
|
|||
id: 'epc',
|
||||
name: 'Energy Performance Certificates (EPC)',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
|
||||
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
|
||||
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||
license: 'Open Government Licence v3.0',
|
||||
|
|
@ -140,7 +140,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'What is the "Estimated current price" and how is it calculated?',
|
||||
answer:
|
||||
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time — this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records — such as extensions or renovations — also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
|
||||
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time - this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records - such as extensions or renovations - also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
|
||||
},
|
||||
{
|
||||
question: 'How are current for-sale and for-rent listings found?',
|
||||
|
|
@ -150,7 +150,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'What area does this cover?',
|
||||
answer:
|
||||
'England. The core datasets — Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax — all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
|
||||
'England. The core datasets - Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax - all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
|
||||
},
|
||||
{
|
||||
question: 'Why is data missing for my property?',
|
||||
|
|
@ -160,7 +160,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'How do I find areas that match what I\'m looking for?',
|
||||
answer:
|
||||
'Use the Filters panel on the left. Add filters for the features you care about — for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source — this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
|
||||
'Use the Filters panel on the left. Add filters for the features you care about - for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source - this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
|
||||
},
|
||||
{
|
||||
question: 'How does the travel time feature work?',
|
||||
|
|
@ -180,7 +180,7 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'How reliable is the crime data at this scale?',
|
||||
answer:
|
||||
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA — an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
|
||||
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA - an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
|
||||
},
|
||||
{
|
||||
question: 'What does the school rating represent?',
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
import { memo, useState, useCallback } from 'react';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
||||
function SparklesIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
|
||||
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
|
||||
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
import { SparklesIcon } from '../ui/icons/SparklesIcon';
|
||||
|
||||
interface AiFilterInputProps {
|
||||
loading: boolean;
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export default function AreaPane({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { FeatureLabel } from '../ui/FeatureLabel';
|
|||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
|
||||
car: CarIcon,
|
||||
|
|
@ -89,9 +89,9 @@ export default function FeatureBrowser({
|
|||
name="Travel Time"
|
||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{TRANSPORT_MODES.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
@ -109,7 +109,7 @@ export default function FeatureBrowser({
|
|||
{MODE_LABELS[mode]}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Filter by journey time to a destination
|
||||
{MODE_DESCRIPTIONS[mode]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -131,9 +131,9 @@ export default function FeatureBrowser({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
|
|||
|
|
@ -313,16 +313,16 @@ export default memo(function Filters({
|
|||
name="Travel Time"
|
||||
expanded={!collapsedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{travelTimeEntries.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{!collapsedGroups.has('Travel Time') && (
|
||||
<div className="px-2 py-1 space-y-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
|
||||
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
|
|
@ -357,9 +357,9 @@ export default memo(function Filters({
|
|||
name={group.name}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
|
|
@ -373,7 +373,7 @@ export default memo(function Filters({
|
|||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
|
||||
|
|
@ -430,7 +430,7 @@ export default memo(function Filters({
|
|||
<div
|
||||
key={feature.name}
|
||||
data-filter-name={feature.name}
|
||||
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
import Map from './Map';
|
||||
|
|
@ -56,9 +56,14 @@ interface MapPageProps {
|
|||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
initialPostcode?: string;
|
||||
user?: { id: string; subscription: string } | null;
|
||||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -78,9 +83,14 @@ export default function MapPage({
|
|||
ogMode,
|
||||
isMobile = false,
|
||||
initialTravelTime,
|
||||
initialPostcode,
|
||||
user,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onSaveProperty,
|
||||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: MapPageProps) {
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
useState<Set<string>>(initialPOICategories);
|
||||
|
|
@ -202,6 +212,31 @@ export default function MapPage({
|
|||
selection.setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Navigate to a specific postcode on mount (e.g. from saved properties)
|
||||
useEffect(() => {
|
||||
if (!initialPostcode) return;
|
||||
// Strip the `pc` param from the URL so it doesn't persist
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('pc');
|
||||
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
|
||||
window.history.replaceState(window.history.state, '', newUrl);
|
||||
|
||||
// Fetch postcode geometry and fly to it
|
||||
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Postcode not found');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => {
|
||||
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
|
||||
selection.handleLocationSearch(data.postcode, data.geometry);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail — postcode might not exist
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Prevent browser back/forward navigation from horizontal trackpad swipes
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
|
|
@ -381,6 +416,10 @@ export default function MapPage({
|
|||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onSaveProperty={onSaveProperty}
|
||||
onUnsaveProperty={onUnsaveProperty}
|
||||
isPropertySaved={isPropertySaved}
|
||||
getSavedPropertyId={getSavedPropertyId}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
|
|
@ -6,6 +6,7 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -14,6 +15,10 @@ interface PropertiesPaneProps {
|
|||
hexagonId: string | null;
|
||||
onLoadMore: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
}
|
||||
|
||||
export function PropertiesPane({
|
||||
|
|
@ -23,6 +28,10 @@ export function PropertiesPane({
|
|||
hexagonId,
|
||||
onLoadMore,
|
||||
onNavigateToSource,
|
||||
onSaveProperty,
|
||||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: PropertiesPaneProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
|
@ -70,7 +79,7 @@ export function PropertiesPane({
|
|||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
|
||||
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
|
||||
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
|
||||
ratings, construction year, and tenure from EPC surveys, plus the most recent sale price
|
||||
from the Land Registry.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
|
|
@ -91,7 +100,14 @@ export function PropertiesPane({
|
|||
) : (
|
||||
<>
|
||||
{filtered.map((property, idx) => (
|
||||
<PropertyCard key={idx} property={property} />
|
||||
<PropertyCard
|
||||
key={idx}
|
||||
property={property}
|
||||
onSave={onSaveProperty}
|
||||
onUnsave={onUnsaveProperty}
|
||||
isSaved={isPropertySaved?.(property.address, property.postcode)}
|
||||
savedId={getSavedPropertyId?.(property.address, property.postcode)}
|
||||
/>
|
||||
))}
|
||||
{properties.length < total && (
|
||||
<button
|
||||
|
|
@ -135,14 +151,33 @@ function PropertyLoadingSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
function PropertyCard({
|
||||
property,
|
||||
onSave,
|
||||
onUnsave,
|
||||
isSaved,
|
||||
savedId,
|
||||
}: {
|
||||
property: Property;
|
||||
onSave?: (property: Property) => void;
|
||||
onUnsave?: (id: string) => void;
|
||||
isSaved?: boolean;
|
||||
savedId?: string;
|
||||
}) {
|
||||
const handleToggleSave = useCallback(() => {
|
||||
if (isSaved && savedId && onUnsave) {
|
||||
onUnsave(savedId);
|
||||
} else if (onSave) {
|
||||
onSave(property);
|
||||
}
|
||||
}, [isSaved, savedId, onSave, onUnsave, property]);
|
||||
const price = getNum(property, 'Last known price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm');
|
||||
const estPricePerSqm = getNum(property, 'Est. price per sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)');
|
||||
const rooms = getNum(property, 'Number of bedrooms & living rooms');
|
||||
const age = getNum(property, 'Construction age');
|
||||
const age = getNum(property, 'Construction year');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const askingPrice = getNum(property, 'Asking price');
|
||||
const askingRent = getNum(property, 'Asking rent (monthly)');
|
||||
|
|
@ -152,12 +187,27 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
</div>
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={handleToggleSave}
|
||||
className={`shrink-0 p-1 rounded ${
|
||||
isSaved
|
||||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
|
||||
}`}
|
||||
title={isSaved ? 'Unsave property' : 'Save property'}
|
||||
>
|
||||
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.property_sub_type && (
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { CarIcon } from '../ui/icons/CarIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
|
|
@ -52,6 +51,7 @@ export function TravelTimeCard({
|
|||
onRemove,
|
||||
}: TravelTimeCardProps) {
|
||||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||
|
||||
const handleDestinationSelect = useCallback(
|
||||
|
|
@ -78,6 +78,9 @@ export function TravelTimeCard({
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
|
|
@ -90,28 +93,14 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
{slug && label ? (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
|
||||
<span className="text-xs text-navy-950 dark:text-warm-200 flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onSetDestination('', '')}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear destination"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<DestinationDropdown
|
||||
destinations={destinations}
|
||||
loading={destinationsLoading}
|
||||
onSelect={handleDestinationSelect}
|
||||
value={label || undefined}
|
||||
onClear={() => onSetDestination('', '')}
|
||||
placeholder="Select destination..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
|
|
@ -123,10 +112,26 @@ export function TravelTimeCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={() => setShowInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Shows how long it takes to reach the selected destination from each area
|
||||
{mode === 'transit'
|
||||
? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.'
|
||||
: mode === 'car'
|
||||
? ' by car, based on typical road speeds and the road network.'
|
||||
: mode === 'bicycle'
|
||||
? ' by bicycle, using cycle-friendly routes.'
|
||||
: ' on foot, using pedestrian paths and pavements.'}
|
||||
{' '}Use the slider to filter areas within your preferred commute time.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Uses the <strong>5th percentile</strong> travel time — the fastest realistic journey
|
||||
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey
|
||||
if you time your departure to catch optimal connections. The default uses the{' '}
|
||||
<strong>median</strong>, representing a typical journey regardless of when you leave.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
|
|||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { logNonAbortError } from '../../lib/api';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ export default function PricingPage({
|
|||
return res.json();
|
||||
})
|
||||
.then(setPricing)
|
||||
.catch((err) => console.error('Failed to load pricing:', err))
|
||||
.catch((err) => logNonAbortError('Failed to load pricing', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ export default function PricingPage({
|
|||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free access'
|
||||
: `Get started — ${formatPrice(currentPrice)}`}
|
||||
: `Get started - ${formatPrice(currentPrice)}`}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ export function CollapsibleGroupHeader({
|
|||
children,
|
||||
}: CollapsibleGroupHeaderProps) {
|
||||
return (
|
||||
<button onClick={onToggle} className={`w-full flex items-center justify-between ${className}`}>
|
||||
<button onClick={onToggle} className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}>
|
||||
<span>{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
|
||||
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-4 h-4" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,18 +3,21 @@ import {
|
|||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Destination } from '../../hooks/useTravelDestinations';
|
||||
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
|
||||
import { MapPinIcon } from './icons/MapPinIcon';
|
||||
import { ChevronIcon } from './icons/ChevronIcon';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
|
||||
interface DestinationDropdownProps {
|
||||
destinations: Destination[];
|
||||
loading: boolean;
|
||||
onSelect: (slug: string, label: string) => void;
|
||||
onClear?: () => void;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
|
|
@ -22,19 +25,18 @@ export function DestinationDropdown({
|
|||
destinations,
|
||||
loading,
|
||||
onSelect,
|
||||
onClear,
|
||||
value,
|
||||
placeholder = 'Select destination...',
|
||||
}: DestinationDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
const pos = useDropdownPosition(containerRef, open);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return destinations;
|
||||
|
|
@ -46,31 +48,14 @@ export function DestinationDropdown({
|
|||
);
|
||||
}, [destinations, filter]);
|
||||
|
||||
// Position the dropdown portal
|
||||
const updatePos = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updatePos();
|
||||
window.addEventListener('scroll', updatePos, true);
|
||||
window.addEventListener('resize', updatePos);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePos, true);
|
||||
window.removeEventListener('resize', updatePos);
|
||||
};
|
||||
}, [open, updatePos]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
!containerRef.current.contains(e.target as Node) &&
|
||||
!dropdownRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
|
|
@ -129,6 +114,7 @@ export function DestinationDropdown({
|
|||
|
||||
const dropdown = open && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
|
||||
style={
|
||||
pos
|
||||
|
|
@ -199,22 +185,37 @@ export function DestinationDropdown({
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 hover:border-warm-300 dark:hover:border-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
className="flex items-center gap-1.5 flex-1 min-w-0 outline-none"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
|
||||
) : (
|
||||
<MapPinIcon className="w-3 h-3 shrink-0" />
|
||||
<MapPinIcon className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`} />
|
||||
)}
|
||||
<span className="flex-1 text-left truncate">{placeholder}</span>
|
||||
<span className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
</button>
|
||||
{value && onClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear destination"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
) : (
|
||||
<ChevronIcon
|
||||
direction={open ? 'up' : 'down'}
|
||||
className="w-3 h-3 shrink-0"
|
||||
className="w-3 h-3 shrink-0 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && createPortal(dropdown, document.body)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,18 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'invite';
|
||||
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite';
|
||||
|
||||
export const PAGE_PATHS: Record<Page, string> = {
|
||||
home: '/',
|
||||
dashboard: '/dashboard',
|
||||
learn: '/learn',
|
||||
pricing: '/pricing',
|
||||
saved: '/saved',
|
||||
invites: '/invites',
|
||||
account: '/account',
|
||||
invite: '/invite',
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -88,6 +99,12 @@ export default function Header({
|
|||
}
|
||||
}, [doCopy]);
|
||||
|
||||
const navLink = (page: Page, e: React.MouseEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
};
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
activePage === page
|
||||
|
|
@ -99,35 +116,41 @@ export default function Header({
|
|||
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
||||
{/* Left: Logo + nav */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
onClick={() => onPageChange('home')}
|
||||
onClick={(e) => navLink('home', e)}
|
||||
>
|
||||
<LogoIcon className="w-5 h-5 text-teal-400" />
|
||||
<span className="font-semibold text-lg">Perfect Postcode</span>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
{!isMobile && (
|
||||
<nav className="flex items-center gap-2">
|
||||
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
|
||||
<a href={PAGE_PATHS.dashboard} className={tabClass('dashboard')} onClick={(e) => navLink('dashboard', e)}>
|
||||
Dashboard
|
||||
</button>
|
||||
</a>
|
||||
{user && (
|
||||
<button
|
||||
className={tabClass('account')}
|
||||
onClick={() => onPageChange('account')}
|
||||
>
|
||||
<>
|
||||
<a href={PAGE_PATHS.saved} className={tabClass('saved')} onClick={(e) => navLink('saved', e)}>
|
||||
Saved
|
||||
</a>
|
||||
<a href={PAGE_PATHS.invites} className={tabClass('invites')} onClick={(e) => navLink('invites', e)}>
|
||||
Invite
|
||||
</a>
|
||||
<a href={PAGE_PATHS.account} className={tabClass('account')} onClick={(e) => navLink('account', e)}>
|
||||
Account
|
||||
</button>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
|
||||
<a href={PAGE_PATHS.learn} className={tabClass('learn')} onClick={(e) => navLink('learn', e)}>
|
||||
Learn
|
||||
</button>
|
||||
</a>
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && (
|
||||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||
<a href={PAGE_PATHS.pricing} className={tabClass('pricing')} onClick={(e) => navLink('pricing', e)}>
|
||||
Pricing
|
||||
</button>
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Page } from './Header';
|
||||
import { PAGE_PATHS } from './Header';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { BookmarkIcon } from './icons/BookmarkIcon';
|
||||
|
|
@ -45,20 +46,23 @@ export default function MobileMenu({
|
|||
copied,
|
||||
}: MobileMenuProps) {
|
||||
const mobileNavItem = (page: Page, label: string) => (
|
||||
<button
|
||||
<a
|
||||
key={page}
|
||||
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
|
||||
href={PAGE_PATHS[page]}
|
||||
className={`block w-full text-left px-4 py-3 text-base font-medium rounded ${
|
||||
activePage === page
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -82,6 +86,8 @@ export default function MobileMenu({
|
|||
{mobileNavItem('dashboard', 'Dashboard')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
|
||||
{user && mobileNavItem('saved', 'Saved')}
|
||||
{user && mobileNavItem('invites', 'Invite')}
|
||||
{user && mobileNavItem('account', 'Account')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useRef, useCallback, useLayoutEffect, useState as useStateR } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type React from 'react';
|
||||
import type { SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
|
||||
import { SearchIcon } from './icons/SearchIcon';
|
||||
import { MapPinIcon } from './icons/MapPinIcon';
|
||||
|
||||
|
|
@ -31,32 +32,6 @@ interface PlaceSearchInputProps {
|
|||
portal?: boolean;
|
||||
}
|
||||
|
||||
function useDropdownPosition(
|
||||
anchorRef: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
) {
|
||||
const [pos, setPos] = useStateR<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!anchorRef.current) return;
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, [anchorRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
update();
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [open, update]);
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
||||
export function PlaceSearchInput({
|
||||
search,
|
||||
onSelect,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
||||
interface UpgradeModalProps {
|
||||
isLoggedIn: boolean;
|
||||
|
|
@ -28,7 +28,7 @@ export default function UpgradeModal({
|
|||
.then((data) => {
|
||||
if (data) setPricePence(data.current_price_pence);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
|
||||
}, []);
|
||||
|
||||
const priceLabel =
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
filled?: boolean;
|
||||
}
|
||||
|
||||
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
export function BookmarkIcon({ className = 'w-3.5 h-3.5', filled = false }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
|
|
|
|||
13
frontend/src/components/ui/icons/SparklesIcon.tsx
Normal file
13
frontend/src/components/ui/icons/SparklesIcon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SparklesIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
|
||||
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
|
||||
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,34 @@
|
|||
export { CloseIcon } from './CloseIcon';
|
||||
export { InfoIcon } from './InfoIcon';
|
||||
export { EyeIcon } from './EyeIcon';
|
||||
export { PlusIcon } from './PlusIcon';
|
||||
export { ChevronIcon } from './ChevronIcon';
|
||||
export { FilterIcon } from './FilterIcon';
|
||||
export { LightbulbIcon } from './LightbulbIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
export { RouteIcon } from './RouteIcon';
|
||||
export { CarIcon } from './CarIcon';
|
||||
export { BicycleIcon } from './BicycleIcon';
|
||||
export { WalkingIcon } from './WalkingIcon';
|
||||
export { TransitIcon } from './TransitIcon';
|
||||
export { HouseIcon } from './HouseIcon';
|
||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||
export { BookmarkIcon } from './BookmarkIcon';
|
||||
export { CarIcon } from './CarIcon';
|
||||
export { ChartBarIcon } from './ChartBarIcon';
|
||||
export { CheckIcon } from './CheckIcon';
|
||||
export { ChevronIcon } from './ChevronIcon';
|
||||
export { ClipboardIcon } from './ClipboardIcon';
|
||||
export { CloseIcon } from './CloseIcon';
|
||||
export { DownloadIcon } from './DownloadIcon';
|
||||
export { EyeIcon } from './EyeIcon';
|
||||
export { FilterIcon } from './FilterIcon';
|
||||
export { GoogleIcon } from './GoogleIcon';
|
||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||
export { HouseIcon } from './HouseIcon';
|
||||
export { InfoIcon } from './InfoIcon';
|
||||
export { LightbulbIcon } from './LightbulbIcon';
|
||||
export { LogoIcon } from './LogoIcon';
|
||||
export { MapPinIcon } from './MapPinIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
export { MoonIcon } from './MoonIcon';
|
||||
export { PlusIcon } from './PlusIcon';
|
||||
export { RouteIcon } from './RouteIcon';
|
||||
export { SearchIcon } from './SearchIcon';
|
||||
export { ShieldIcon } from './ShieldIcon';
|
||||
export { UsersIcon } from './UsersIcon';
|
||||
export { ShoppingBagIcon } from './ShoppingBagIcon';
|
||||
export { TreeIcon } from './TreeIcon';
|
||||
export { SparklesIcon } from './SparklesIcon';
|
||||
export { SpinnerIcon } from './SpinnerIcon';
|
||||
export { SunIcon } from './SunIcon';
|
||||
export { TagIcon } from './TagIcon';
|
||||
export { TrashIcon } from './TrashIcon';
|
||||
export { TransitIcon } from './TransitIcon';
|
||||
export { TreeIcon } from './TreeIcon';
|
||||
export { UsersIcon } from './UsersIcon';
|
||||
export { WalkingIcon } from './WalkingIcon';
|
||||
|
|
|
|||
28
frontend/src/hooks/useDropdownPosition.ts
Normal file
28
frontend/src/hooks/useDropdownPosition.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback, useLayoutEffect, useState } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
export function useDropdownPosition(
|
||||
anchorRef: React.RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
) {
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!anchorRef.current) return;
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, [anchorRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
update();
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [open, update]);
|
||||
|
||||
return pos;
|
||||
}
|
||||
|
|
@ -340,7 +340,6 @@ export function useMapData({
|
|||
|
||||
return {
|
||||
data,
|
||||
rawData,
|
||||
postcodeData: effectivePostcodeData,
|
||||
resolution,
|
||||
bounds,
|
||||
|
|
|
|||
145
frontend/src/hooks/useSavedProperties.ts
Normal file
145
frontend/src/hooks/useSavedProperties.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type { Property } from '../types';
|
||||
import { getNum } from '../lib/property-fields';
|
||||
|
||||
export interface SavedPropertyData {
|
||||
propertyType?: string;
|
||||
propertySubType?: string;
|
||||
builtForm?: string;
|
||||
duration?: string;
|
||||
energyRating?: string;
|
||||
price?: number;
|
||||
estimatedPrice?: number;
|
||||
askingPrice?: number;
|
||||
askingRent?: number;
|
||||
bedrooms?: number;
|
||||
floorArea?: number;
|
||||
listingUrl?: string;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
id: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
data: SavedPropertyData;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export function useSavedProperties(userId: string | null) {
|
||||
const [properties, setProperties] = useState<SavedProperty[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProperties = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const records = await pb.collection('saved_properties').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
setProperties(
|
||||
records.map((r) => {
|
||||
const raw = r as Record<string, unknown>;
|
||||
let data: SavedPropertyData = {};
|
||||
try {
|
||||
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
|
||||
} catch {
|
||||
// Invalid JSON — use empty data
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
address: raw.address as string,
|
||||
postcode: raw.postcode as string,
|
||||
data,
|
||||
created: r.created,
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load saved properties');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveProperty = useCallback(
|
||||
async (property: Property) => {
|
||||
if (!userId) return;
|
||||
setError(null);
|
||||
try {
|
||||
const data: SavedPropertyData = {
|
||||
propertyType: property.property_type,
|
||||
propertySubType: property.property_sub_type,
|
||||
builtForm: property.built_form,
|
||||
duration: property.duration,
|
||||
energyRating: property.current_energy_rating,
|
||||
price: getNum(property, 'Last known price'),
|
||||
estimatedPrice: getNum(property, 'Estimated current price'),
|
||||
askingPrice: getNum(property, 'Asking price'),
|
||||
askingRent: getNum(property, 'Asking rent (monthly)'),
|
||||
bedrooms: getNum(property, 'Bedrooms'),
|
||||
floorArea: getNum(property, 'Total floor area (sqm)'),
|
||||
listingUrl: property.listing_url || undefined,
|
||||
};
|
||||
|
||||
await pb.collection('saved_properties').create({
|
||||
user: userId,
|
||||
address: property.address || 'Unknown',
|
||||
postcode: property.postcode || '',
|
||||
data: JSON.stringify(data),
|
||||
});
|
||||
trackEvent('Property Save');
|
||||
await fetchProperties();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save property';
|
||||
setError(msg);
|
||||
}
|
||||
},
|
||||
[userId, fetchProperties]
|
||||
);
|
||||
|
||||
const deleteProperty = useCallback(async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_properties').delete(id);
|
||||
trackEvent('Property Delete');
|
||||
setProperties((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete property');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedPropertyKeys = useMemo(
|
||||
() => new Set(properties.map((p) => `${p.address}|${p.postcode}`)),
|
||||
[properties]
|
||||
);
|
||||
|
||||
const isPropertySaved = useCallback(
|
||||
(address?: string, postcode?: string) =>
|
||||
savedPropertyKeys.has(`${address || ''}|${postcode || ''}`),
|
||||
[savedPropertyKeys]
|
||||
);
|
||||
|
||||
const getSavedPropertyId = useCallback(
|
||||
(address?: string, postcode?: string) => {
|
||||
const key = `${address || ''}|${postcode || ''}`;
|
||||
return properties.find((p) => `${p.address}|${p.postcode}` === key)?.id;
|
||||
},
|
||||
[properties]
|
||||
);
|
||||
|
||||
return {
|
||||
properties,
|
||||
loading,
|
||||
error,
|
||||
fetchProperties,
|
||||
saveProperty,
|
||||
deleteProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
};
|
||||
}
|
||||
|
|
@ -52,23 +52,32 @@ export function useSavedSearches(userId: string | null) {
|
|||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Capture a screenshot via the screenshot endpoint
|
||||
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
|
||||
const screenshotRes = await fetch(screenshotUrl, authHeaders());
|
||||
if (!screenshotRes.ok) {
|
||||
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
|
||||
}
|
||||
const screenshotBlob = await screenshotRes.blob();
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
formData.append('user', userId);
|
||||
formData.append('name', name);
|
||||
formData.append('params', params);
|
||||
formData.append('screenshot', screenshotBlob, 'screenshot.png');
|
||||
|
||||
await pb.collection('saved_searches').create(formData);
|
||||
const record = await pb.collection('saved_searches').create(formData);
|
||||
trackEvent('Search Save');
|
||||
await fetchSearches();
|
||||
|
||||
// Capture screenshot in background and attach it to the record
|
||||
const screenshotParams = new URLSearchParams(params);
|
||||
const screenshotUrl = apiUrl('screenshot', screenshotParams);
|
||||
fetch(screenshotUrl, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const patch = new FormData();
|
||||
patch.append('screenshot', blob, 'screenshot.png');
|
||||
return pb.collection('saved_searches').update(record.id, patch);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Background screenshot failed:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save search';
|
||||
setError(msg);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ export const MODE_LABELS: Record<TransportMode, string> = {
|
|||
transit: 'Transit',
|
||||
};
|
||||
|
||||
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
|
||||
car: 'Drive time via the fastest road route',
|
||||
bicycle: 'Cycling time using bike-friendly routes',
|
||||
walking: 'Walking time along pedestrian paths and pavements',
|
||||
transit: 'Journey time by train, tube, and bus',
|
||||
};
|
||||
|
||||
export interface TravelTimeEntry {
|
||||
mode: TransportMode;
|
||||
slug: string;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Perfect Postcode — Every neighbourhood in England</title>
|
||||
<title>Perfect Postcode - Every neighbourhood in England</title>
|
||||
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
||||
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east:
|
|||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
||||
zoom: 15,
|
||||
zoom: 14.5,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</>
|
||||
),
|
||||
'Construction age': (
|
||||
'Construction year': (
|
||||
<>
|
||||
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
|
||||
</>
|
||||
|
|
@ -365,35 +365,42 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
),
|
||||
'% South Asian': (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
'% East Asian': (
|
||||
<>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
'% Black': (
|
||||
<>
|
||||
<path d="M21.21 15.89A10 10 0 118 2.83" />
|
||||
<path d="M22 12A10 10 0 0012 2v10z" />
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
'% Mixed': (
|
||||
<>
|
||||
<circle cx="9" cy="12" r="7" />
|
||||
<circle cx="15" cy="12" r="7" />
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
'% Other': (
|
||||
<>
|
||||
<line x1="4" y1="9" x2="20" y2="9" />
|
||||
<line x1="4" y1="15" x2="20" y2="15" />
|
||||
<line x1="10" y1="3" x2="8" y2="21" />
|
||||
<line x1="16" y1="3" x2="14" y2="21" />
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 010 7.75" />
|
||||
</>
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function parseUrlState(): {
|
|||
poiCategories?: Set<string>;
|
||||
tab?: 'properties' | 'area';
|
||||
travelTime?: TravelTimeInitial;
|
||||
postcode?: string;
|
||||
} {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: ReturnType<typeof parseUrlState> = {};
|
||||
|
|
@ -70,6 +71,12 @@ export function parseUrlState(): {
|
|||
result.tab = tab;
|
||||
}
|
||||
|
||||
// Navigate-to-postcode: one-time param for opening a saved property
|
||||
const pc = params.get('pc');
|
||||
if (pc) {
|
||||
result.postcode = pc;
|
||||
}
|
||||
|
||||
// Travel time: repeated `tt` params
|
||||
// Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
|
||||
const ttParams = params.getAll('tt');
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ def main() -> None:
|
|||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with tempfile.TemporaryDirectory(delete=False) as cache_dir:
|
||||
with tempfile.TemporaryDirectory() as cache_dir:
|
||||
cache = Path(cache_dir)
|
||||
zip_path = cache / "broadband_performance.zip"
|
||||
extract_dir = cache / "extracted"
|
||||
|
|
@ -53,7 +53,6 @@ def main() -> None:
|
|||
|
||||
download(PERFORMANCE_URL, zip_path)
|
||||
extract_zip(zip_path, extract_dir)
|
||||
print(list((extract_dir / "202507_fixed_coverage_r01").glob("*")))
|
||||
extract_zip(
|
||||
extract_dir
|
||||
/ "202507_fixed_coverage_r01"
|
||||
|
|
|
|||
|
|
@ -14,19 +14,7 @@ from tqdm import tqdm
|
|||
|
||||
from .pois import UK_BBOX_EAST, UK_BBOX_NORTH, UK_BBOX_SOUTH, UK_BBOX_WEST
|
||||
|
||||
PLACE_TYPES = {
|
||||
"city",
|
||||
# "borough",
|
||||
# "town",
|
||||
# "suburb",
|
||||
# "quarter",
|
||||
# "neighbourhood",
|
||||
# "village",
|
||||
# "hamlet",
|
||||
# "locality",
|
||||
# "island",
|
||||
# "isolated_dwelling",
|
||||
}
|
||||
PLACE_TYPES = {"city"}
|
||||
|
||||
# Suffixes to strip from raw station names before appending the typed suffix.
|
||||
_STATION_STRIP = (
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ def _build(
|
|||
.rename(
|
||||
{
|
||||
"date_of_transfer": "Date of last transaction",
|
||||
"construction_age_band": "Construction age",
|
||||
"construction_age_band": "Construction year",
|
||||
"is_construction_date_approximate": "Is construction date approximate",
|
||||
"pp_address": "Address per Property Register",
|
||||
"epc_address": "Address per EPC",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from collections import defaultdict
|
|||
|
||||
import numpy as np
|
||||
from scipy.spatial import Voronoi
|
||||
from scipy.spatial.qhull import QhullError
|
||||
from shapely import make_valid
|
||||
from shapely.geometry import MultiPolygon, Polygon
|
||||
from shapely.ops import unary_union
|
||||
|
|
@ -67,7 +68,7 @@ def compute_voronoi_regions(
|
|||
|
||||
try:
|
||||
vor = Voronoi(all_points)
|
||||
except Exception:
|
||||
except (ValueError, QhullError):
|
||||
# Fallback: split boundary equally among all postcodes present
|
||||
all_pcs = list(dict.fromkeys(unique_pcs))
|
||||
if len(all_pcs) == 1:
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ Source: Ordnance Survey GeoSure (Open Data)
|
|||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
from pathlib import Path
|
||||
import shapefile as shp
|
||||
from pyproj import Transformer
|
||||
from shapely import STRtree, points
|
||||
from shapely.geometry import shape as to_shapely
|
||||
|
||||
_GEOSURE_RISKS = [
|
||||
("CollapsibleDeposits", "collapsible_deposits_risk"),
|
||||
|
|
@ -25,11 +29,6 @@ _GEOSURE_RISKS = [
|
|||
|
||||
def transform(geosure_dir: Path, arcgis_path: Path) -> pl.DataFrame:
|
||||
"""Spatial-join GeoSure 5km hex grid risk data to postcode centroids."""
|
||||
import shapefile as shp
|
||||
from pyproj import Transformer
|
||||
from shapely import STRtree, points
|
||||
from shapely.geometry import shape as to_shapely
|
||||
|
||||
to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
|
||||
|
||||
print("Loading postcode centroids for GeoSure join...")
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export class ScreenshotCache {
|
|||
normalized.tab = params.get('tab')!;
|
||||
}
|
||||
|
||||
if (params.get('path')) {
|
||||
normalized.path = params.get('path')!;
|
||||
}
|
||||
|
||||
const input = JSON.stringify(normalized);
|
||||
const hash = createHash('sha256').update(input).digest('hex').substring(0, 16);
|
||||
return hash;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,12 @@ app.get('/screenshot', async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Extract path param (used for non-root pages like /invite/CODE)
|
||||
const pagePath = typeof req.query.path === 'string' && req.query.path ? req.query.path : '/';
|
||||
if (pagePath !== '/') qs.set('path', pagePath);
|
||||
|
||||
const cacheKey = cache.buildKey(qs);
|
||||
qs.delete('path');
|
||||
|
||||
// Check cache first
|
||||
const cached = cache.get(cacheKey);
|
||||
|
|
@ -67,7 +72,7 @@ app.get('/screenshot', async (req, res) => {
|
|||
|
||||
// Build the URL for the frontend in screenshot mode
|
||||
qs.set('screenshot', '1');
|
||||
const url = `${APP_URL}/?${qs}`;
|
||||
const url = `${APP_URL}${pagePath}?${qs}`;
|
||||
|
||||
console.log(`Taking screenshot: ${url}`);
|
||||
const png = await takeScreenshot(url);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ pub const SERVER_ADDRESS: &str = "0.0.0.0:8001";
|
|||
|
||||
pub const GRID_CELL_SIZE: f32 = 0.01;
|
||||
pub const MAX_POIS_PER_REQUEST: usize = 10000;
|
||||
pub const MAX_CELLS_PER_REQUEST: usize = 5000;
|
||||
pub const MAX_CELLS_PER_REQUEST: usize = 50000;
|
||||
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
|
||||
pub const MAX_PROPERTIES_LIMIT: usize = 500;
|
||||
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
|
||||
|
|
|
|||
|
|
@ -47,40 +47,6 @@ pub struct Histogram {
|
|||
pub counts: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Histogram {
|
||||
/// Width of each middle bin (between p1 and p99).
|
||||
#[allow(dead_code)]
|
||||
pub fn middle_bin_width(&self) -> f32 {
|
||||
let num_bins = self.counts.len();
|
||||
if num_bins <= 2 {
|
||||
return self.p99 - self.p1;
|
||||
}
|
||||
(self.p99 - self.p1) / (num_bins - 2) as f32
|
||||
}
|
||||
|
||||
/// Get the bin index for a value.
|
||||
#[allow(dead_code)]
|
||||
pub fn bin_for_value(&self, value: f32) -> usize {
|
||||
let num_bins = self.counts.len();
|
||||
if num_bins == 0 {
|
||||
return 0;
|
||||
}
|
||||
if value < self.p1 {
|
||||
return 0; // Low outlier bin
|
||||
}
|
||||
if value >= self.p99 {
|
||||
return num_bins - 1; // High outlier bin
|
||||
}
|
||||
// Middle bins
|
||||
let middle_width = self.middle_bin_width();
|
||||
if middle_width <= 0.0 {
|
||||
return num_bins / 2;
|
||||
}
|
||||
let middle_bin = ((value - self.p1) / middle_width) as usize;
|
||||
// Bins 1 to n-2 are the middle bins
|
||||
(1 + middle_bin).min(num_bins - 2)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FeatureStats {
|
||||
pub slider_min: f32,
|
||||
|
|
|
|||
|
|
@ -218,14 +218,14 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
linked: "",
|
||||
},
|
||||
FeatureConfig {
|
||||
name: "Construction age",
|
||||
name: "Construction year",
|
||||
bounds: Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 2026.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Estimated year of construction from the EPC",
|
||||
detail: "The approximate year of construction as recorded in the Energy Performance Certificate. Derived from the construction age band (e.g. '1930-1949') by taking the midpoint. May be approximate, especially for older buildings.",
|
||||
detail: "The approximate construction year as recorded in the Energy Performance Certificate. Derived from the construction age band (e.g. '1930-1949') by taking the midpoint. May be approximate, especially for older buildings.",
|
||||
source: "epc",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ use crate::consts::FREE_ZONE_BOUNDS;
|
|||
/// Check whether the user is allowed to query data at the given bounds.
|
||||
/// Licensed users and admins bypass the check entirely.
|
||||
/// Free/anonymous users get 403 if bounds exceed the free zone.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_license_bounds(
|
||||
user: &Option<PocketBaseUser>,
|
||||
bounds: (f64, f64, f64, f64),
|
||||
) -> Result<(), (StatusCode, axum::response::Response)> {
|
||||
// Licensed users and admins can query anywhere
|
||||
) -> Result<(), axum::response::Response> {
|
||||
if let Some(u) = user {
|
||||
if u.is_admin || u.subscription == "licensed" {
|
||||
return Ok(());
|
||||
|
|
@ -22,7 +22,6 @@ pub fn check_license_bounds(
|
|||
let (south, west, north, east) = bounds;
|
||||
let (fz_south, fz_west, fz_north, fz_east) = FREE_ZONE_BOUNDS;
|
||||
|
||||
// Check if requested bounds are fully within the free zone
|
||||
if south >= fz_south && west >= fz_west && north <= fz_north && east <= fz_east {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -38,18 +37,15 @@ pub fn check_license_bounds(
|
|||
}
|
||||
});
|
||||
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
(StatusCode::FORBIDDEN, axum::Json(body)).into_response(),
|
||||
))
|
||||
Err((StatusCode::FORBIDDEN, axum::Json(body)).into_response())
|
||||
}
|
||||
|
||||
/// Convenience wrapper that takes a point (lat, lon) instead of bounds.
|
||||
/// Used for endpoints that operate on a single location (e.g. postcode stats).
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_license_point(
|
||||
user: &Option<PocketBaseUser>,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
) -> Result<(), (StatusCode, axum::response::Response)> {
|
||||
) -> Result<(), axum::response::Response> {
|
||||
check_license_bounds(user, (lat, lon, lat, lon))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
let state_checkout = state.clone();
|
||||
let state_stripe_webhook = state.clone();
|
||||
let state_pricing = state.clone();
|
||||
let state_invites_list = state.clone();
|
||||
let state_invites_create = state.clone();
|
||||
let state_invite_get = state.clone();
|
||||
let state_redeem_invite = state.clone();
|
||||
|
|
@ -533,7 +534,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.route(
|
||||
"/api/invites",
|
||||
post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
|
||||
get(move |ext| routes::get_invites(state_invites_list.clone(), ext))
|
||||
.post(move |ext, body| routes::post_invites(state_invites_create.clone(), ext, body)),
|
||||
)
|
||||
.route(
|
||||
"/api/invite/{code}",
|
||||
|
|
|
|||
|
|
@ -46,22 +46,49 @@ pub async fn og_middleware(request: Request, next: Next) -> Response {
|
|||
};
|
||||
|
||||
// Build OG-injected HTML (og=1 triggers heading overlay on screenshot)
|
||||
let og_image_url = if query_string.is_empty() {
|
||||
let is_invite = path.starts_with("/invite/");
|
||||
|
||||
let og_image_url = if is_invite {
|
||||
// Include path= so the screenshot service navigates to /invite/CODE
|
||||
if query_string.is_empty() {
|
||||
format!(
|
||||
"{}/api/screenshot?og=1&path={}",
|
||||
state.public_url, path
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}/api/screenshot?og=1&path={}&{}",
|
||||
state.public_url, path, query_string
|
||||
)
|
||||
}
|
||||
} else if query_string.is_empty() {
|
||||
format!("{}/api/screenshot?og=1", state.public_url)
|
||||
} else {
|
||||
format!("{}/api/screenshot?og=1&{}", state.public_url, query_string)
|
||||
};
|
||||
|
||||
let (og_title, og_description) = if is_invite {
|
||||
(
|
||||
"You\u{2019}re invited to Perfect Postcode",
|
||||
"Accept your invitation to explore property prices, energy ratings, crime stats, school ratings, and more across England.",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Perfect Postcode \u{2014} Every neighbourhood in England",
|
||||
"Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.",
|
||||
)
|
||||
};
|
||||
|
||||
let og_tags = format!(
|
||||
r#"<meta property="og:title" content="Perfect Postcode — Every neighbourhood in England" />
|
||||
<meta property="og:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
|
||||
r#"<meta property="og:title" content="{og_title}" />
|
||||
<meta property="og:description" content="{og_description}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="{og_image_url}" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Perfect Postcode — Every neighbourhood in England" />
|
||||
<meta name="twitter:description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />"#
|
||||
<meta name="twitter:title" content="{og_title}" />
|
||||
<meta name="twitter:description" content="{og_description}" />"#
|
||||
);
|
||||
|
||||
let html = index_html.replace(OG_PLACEHOLDER, &og_tags);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ struct Field {
|
|||
max_size: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mime_types: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
on_create: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
on_update: Option<bool>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
|
|
@ -62,6 +66,8 @@ impl Field {
|
|||
collection_id: None,
|
||||
max_size: None,
|
||||
mime_types: None,
|
||||
on_create: None,
|
||||
on_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +80,8 @@ impl Field {
|
|||
collection_id: None,
|
||||
max_size: Some(10 * 1024 * 1024), // 10 MB
|
||||
mime_types: Some(mime_types.into_iter().map(String::from).collect()),
|
||||
on_create: None,
|
||||
on_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +94,22 @@ impl Field {
|
|||
collection_id: Some(collection_id.to_string()),
|
||||
max_size: None,
|
||||
mime_types: None,
|
||||
on_create: None,
|
||||
on_update: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn autodate(name: &str, on_create: bool, on_update: bool) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
r#type: "autodate".to_string(),
|
||||
required: None,
|
||||
max_select: None,
|
||||
collection_id: None,
|
||||
max_size: None,
|
||||
mime_types: None,
|
||||
on_create: Some(on_create),
|
||||
on_update: Some(on_update),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -146,7 +170,7 @@ async fn create_collection(
|
|||
) -> anyhow::Result<()> {
|
||||
let name = collection.name.clone();
|
||||
let resp = client
|
||||
.post(&format!("{base_url}/api/collections"))
|
||||
.post(format!("{base_url}/api/collections"))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&collection)
|
||||
.send()
|
||||
|
|
@ -262,13 +286,14 @@ async fn ensure_user_fields(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the `saved_searches` collection has API rules allowing users to manage their own records.
|
||||
async fn ensure_saved_searches_rules(
|
||||
/// Ensure a collection has API rules allowing users to manage their own records.
|
||||
async fn ensure_user_owned_rules(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
collection_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = format!("{base_url}/api/collections/saved_searches");
|
||||
let url = format!("{base_url}/api/collections/{collection_name}");
|
||||
let user_only = "user = @request.auth.id";
|
||||
let resp = client
|
||||
.patch(&url)
|
||||
|
|
@ -286,10 +311,89 @@ async fn ensure_saved_searches_rules(
|
|||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to update saved_searches API rules ({status}): {text}");
|
||||
anyhow::bail!("Failed to update {collection_name} API rules ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("PocketBase collection 'saved_searches' API rules updated");
|
||||
info!("PocketBase collection '{collection_name}' API rules updated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the `saved_searches` collection has API rules allowing users to manage their own records.
|
||||
async fn ensure_saved_searches_rules(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
ensure_user_owned_rules(client, base_url, token, "saved_searches").await
|
||||
}
|
||||
|
||||
/// Ensure a collection has `created` and `updated` autodate fields.
|
||||
/// PocketBase 0.23+ no longer adds these automatically — they must be explicit.
|
||||
async fn ensure_autodate_fields(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
collection_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = format!("{base_url}/api/collections/{collection_name}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}");
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let fields = body["fields"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?;
|
||||
|
||||
let has_created = fields.iter().any(|f| f["name"] == "created");
|
||||
let has_updated = fields.iter().any(|f| f["name"] == "updated");
|
||||
|
||||
if has_created && has_updated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut new_fields = fields.clone();
|
||||
|
||||
if !has_created {
|
||||
new_fields.push(serde_json::json!({
|
||||
"name": "created",
|
||||
"type": "autodate",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
}));
|
||||
}
|
||||
|
||||
if !has_updated {
|
||||
new_fields.push(serde_json::json!({
|
||||
"name": "updated",
|
||||
"type": "autodate",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
}));
|
||||
}
|
||||
|
||||
let patch_resp = client
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&serde_json::json!({ "fields": new_fields }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !patch_resp.status().is_success() {
|
||||
let status = patch_resp.status();
|
||||
let text = patch_resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Failed to add autodate fields to {collection_name} ({status}): {text}");
|
||||
}
|
||||
|
||||
info!("Added created/updated autodate fields to PocketBase collection '{collection_name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -324,6 +428,8 @@ pub async fn ensure_collections(
|
|||
Field::text("name", true),
|
||||
Field::text("params", true),
|
||||
Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: user_only.clone(),
|
||||
view_rule: user_only.clone(),
|
||||
|
|
@ -335,6 +441,38 @@ pub async fn ensure_collections(
|
|||
.await?;
|
||||
} else {
|
||||
ensure_saved_searches_rules(client, base_url, &token).await?;
|
||||
ensure_autodate_fields(client, base_url, &token, "saved_searches").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "saved_properties") {
|
||||
let users_id = find_users_collection_id(client, base_url, &token).await?;
|
||||
let user_only = Some("user = @request.auth.id".to_string());
|
||||
create_collection(
|
||||
client,
|
||||
base_url,
|
||||
&token,
|
||||
CreateCollection {
|
||||
name: "saved_properties".to_string(),
|
||||
r#type: "base".to_string(),
|
||||
fields: vec![
|
||||
Field::relation("user", &users_id),
|
||||
Field::text("address", true),
|
||||
Field::text("postcode", true),
|
||||
Field::text("data", false),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: user_only.clone(),
|
||||
view_rule: user_only.clone(),
|
||||
create_rule: user_only.clone(),
|
||||
update_rule: user_only.clone(),
|
||||
delete_rule: user_only,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
|
||||
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "invites") {
|
||||
|
|
@ -351,6 +489,8 @@ pub async fn ensure_collections(
|
|||
Field::text("invite_type", true),
|
||||
Field::text("used_by_id", false),
|
||||
Field::text("used_at", false),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: None,
|
||||
view_rule: None,
|
||||
|
|
@ -361,7 +501,7 @@ pub async fn ensure_collections(
|
|||
)
|
||||
.await?;
|
||||
} else {
|
||||
info!("PocketBase collection 'invites' already exists");
|
||||
ensure_autodate_fields(client, base_url, &token, "invites").await?;
|
||||
}
|
||||
|
||||
if !existing.iter().any(|n| n == "short_urls") {
|
||||
|
|
@ -375,6 +515,8 @@ pub async fn ensure_collections(
|
|||
fields: vec![
|
||||
Field::text("code", true),
|
||||
Field::text("params", true),
|
||||
Field::autodate("created", true, false),
|
||||
Field::autodate("updated", true, true),
|
||||
],
|
||||
list_rule: None,
|
||||
view_rule: None,
|
||||
|
|
@ -385,7 +527,7 @@ pub async fn ensure_collections(
|
|||
)
|
||||
.await?;
|
||||
} else {
|
||||
info!("PocketBase collection 'short_urls' already exists");
|
||||
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub use properties::get_hexagon_properties;
|
|||
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
||||
pub use shorten::{get_short_url, post_shorten};
|
||||
pub use streetview::get_streetview;
|
||||
pub use invites::{get_invite, post_invites, post_redeem_invite};
|
||||
pub use invites::{get_invite, get_invites, post_invites, post_redeem_invite};
|
||||
pub use journey::get_journey;
|
||||
pub use newsletter::patch_newsletter;
|
||||
pub use pricing::get_pricing;
|
||||
|
|
|
|||
|
|
@ -129,17 +129,16 @@ pub async fn get_export(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let fields_str = params.fields.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let filters_str = params.filters;
|
||||
let fields_str = params.fields;
|
||||
|
||||
let public_url = state.public_url.clone();
|
||||
|
||||
|
|
|
|||
|
|
@ -97,10 +97,9 @@ pub async fn get_hexagon_stats(
|
|||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
|
||||
let h3_str = params.h3.clone();
|
||||
let filters_str = params.filters.clone();
|
||||
let h3_str = params.h3;
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -108,6 +107,7 @@ pub async fn get_hexagon_stats(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ use crate::parsing::{
|
|||
bounds_intersect, cell_for_row, h3_cell_bounds, needs_parent, parse_field_indices,
|
||||
parse_filters, require_bounds, row_passes_filters, validate_h3_resolution,
|
||||
};
|
||||
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
|
||||
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -141,11 +141,9 @@ pub async fn get_hexagons(
|
|||
|
||||
let is_demo_view = (south, west, north, east) == DEMO_BOUNDS;
|
||||
if !is_demo_view {
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
}
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -153,19 +151,13 @@ pub async fn get_hexagons(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| (err.0, err.1).into_response())?;
|
||||
|
||||
// Parse travel entries
|
||||
let travel_entries = params
|
||||
.travel
|
||||
.as_deref()
|
||||
.filter(|val| !val.is_empty())
|
||||
.map(parse_travel_entries)
|
||||
.transpose()
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||
.unwrap_or_default();
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<HexagonsResponse, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -18,11 +18,26 @@ struct InviteResponse {
|
|||
invite_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteListItem {
|
||||
code: String,
|
||||
url: String,
|
||||
invite_type: String,
|
||||
used: bool,
|
||||
created: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteListResponse {
|
||||
invites: Vec<InviteListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteValidation {
|
||||
valid: bool,
|
||||
invite_type: String,
|
||||
used: bool,
|
||||
invited_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -147,6 +162,10 @@ pub async fn post_invites(
|
|||
}
|
||||
}
|
||||
|
||||
/// Dev-only fake invite code (12 alphanumeric chars, passes validation).
|
||||
/// Only recognized when `--dist` is not set (i.e., dev mode).
|
||||
const DEV_INVITE_CODE: &str = "devdevdevdev";
|
||||
|
||||
/// Validate an invite code. Public endpoint — codes are 12-char random alphanumeric
|
||||
/// so enumeration is impractical, and the response only reveals valid/invalid + type.
|
||||
pub async fn get_invite(
|
||||
|
|
@ -158,6 +177,17 @@ pub async fn get_invite(
|
|||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
// Dev-only: return a fake valid admin invite without hitting PocketBase
|
||||
if state.index_html.is_none() && code == DEV_INVITE_CODE {
|
||||
return Json(InviteValidation {
|
||||
valid: true,
|
||||
invite_type: "admin".to_string(),
|
||||
used: false,
|
||||
invited_by: Some("Developer".to_string()),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
|
|
@ -201,10 +231,38 @@ pub async fn get_invite(
|
|||
let invite_type = invite["invite_type"].as_str().unwrap_or("").to_string();
|
||||
let used_by = invite["used_by_id"].as_str().unwrap_or("");
|
||||
let used = !used_by.is_empty();
|
||||
let created_by = invite["created_by"].as_str().unwrap_or("");
|
||||
|
||||
// Look up inviter's name (email local part)
|
||||
let invited_by = if !created_by.is_empty() {
|
||||
let user_url =
|
||||
format!("{pb_url}/api/collections/users/records/{created_by}");
|
||||
match state
|
||||
.http_client
|
||||
.get(&user_url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let user_body: serde_json::Value =
|
||||
resp.json().await.unwrap_or_default();
|
||||
user_body["email"]
|
||||
.as_str()
|
||||
.and_then(|e| e.split('@').next())
|
||||
.map(String::from)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Json(InviteValidation {
|
||||
valid: true,
|
||||
invite_type,
|
||||
used,
|
||||
invited_by,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
|
@ -212,6 +270,7 @@ pub async fn get_invite(
|
|||
valid: false,
|
||||
invite_type: String::new(),
|
||||
used: false,
|
||||
invited_by: None,
|
||||
})
|
||||
.into_response(),
|
||||
}
|
||||
|
|
@ -234,6 +293,16 @@ pub async fn post_redeem_invite(
|
|||
return (StatusCode::BAD_REQUEST, msg).into_response();
|
||||
}
|
||||
|
||||
// Dev-only: fake redeem — just return "licensed" without touching PocketBase
|
||||
if state.index_html.is_none() && req.code == DEV_INVITE_CODE {
|
||||
info!(user_id = %user.id, "Dev invite redeemed (no-op)");
|
||||
return Json(RedeemResponse {
|
||||
result: "licensed".to_string(),
|
||||
checkout_url: None,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await
|
||||
|
|
@ -290,7 +359,7 @@ pub async fn post_redeem_invite(
|
|||
};
|
||||
let _ = state
|
||||
.http_client
|
||||
.patch(&format!(
|
||||
.patch(format!(
|
||||
"{pb_url}/api/collections/invites/records/{invite_id}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
|
|
@ -391,3 +460,97 @@ pub async fn post_redeem_invite(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List invites. Admins see all invites; licensed users see only their own.
|
||||
pub async fn get_invites(
|
||||
state: Arc<AppState>,
|
||||
Extension(user): Extension<OptionalUser>,
|
||||
) -> Response {
|
||||
let user = match user.0 {
|
||||
Some(u) => u,
|
||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
|
||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||
|
||||
let token = match auth_superuser(
|
||||
&state.http_client,
|
||||
pb_url,
|
||||
&state.pocketbase_admin_email,
|
||||
&state.pocketbase_admin_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let filter = if user.is_admin {
|
||||
String::new()
|
||||
} else {
|
||||
format!("created_by=\"{}\"", user.id)
|
||||
};
|
||||
|
||||
let mut url = format!(
|
||||
"{pb_url}/api/collections/invites/records?sort=-created&perPage=200"
|
||||
);
|
||||
if !filter.is_empty() {
|
||||
url.push_str(&format!("&filter={}", urlencoding::encode(&filter)));
|
||||
}
|
||||
|
||||
let res = match state
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
warn!("Failed to list invites: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
warn!("PocketBase list invites failed ({status}): {text}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
|
||||
let body: serde_json::Value = match res.json().await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse invites response: {err}");
|
||||
return StatusCode::BAD_GATEWAY.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let public_url = &state.public_url;
|
||||
let invites: Vec<InviteListItem> = body["items"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|inv| {
|
||||
let code = inv["code"].as_str().unwrap_or("").to_string();
|
||||
let invite_type = inv["invite_type"].as_str().unwrap_or("").to_string();
|
||||
let used_by = inv["used_by_id"].as_str().unwrap_or("");
|
||||
let created = inv["created"].as_str().unwrap_or("").to_string();
|
||||
InviteListItem {
|
||||
url: format!("{public_url}/invite/{code}"),
|
||||
code,
|
||||
invite_type,
|
||||
used: !used_by.is_empty(),
|
||||
created,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Json(InviteListResponse { invites }).into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ pub async fn get_pois(
|
|||
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
|
||||
let (south, west, north, east) = require_bounds(params.bounds)?;
|
||||
|
||||
let categories_str = params.categories.clone();
|
||||
let category_filter: Option<rustc_hash::FxHashSet<String>> = params
|
||||
.categories
|
||||
.as_deref()
|
||||
|
|
@ -51,6 +50,7 @@ pub async fn get_pois(
|
|||
.map(|part| part.trim().to_string())
|
||||
.collect()
|
||||
});
|
||||
let categories_raw = params.categories;
|
||||
|
||||
let num_categories = category_filter.as_ref().map(|cats| cats.len()).unwrap_or(0);
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ pub async fn get_pois(
|
|||
results = pois.len(),
|
||||
candidates = row_indices.len(),
|
||||
categories = num_categories,
|
||||
categories_raw = categories_str.as_deref().unwrap_or("-"),
|
||||
categories_raw = categories_raw.as_deref().unwrap_or("-"),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/pois"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,10 +44,8 @@ pub async fn get_postcode_properties(
|
|||
};
|
||||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -55,8 +53,9 @@ pub async fn get_postcode_properties(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let postcode_str = normalized.clone();
|
||||
let postcode_str = normalized;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -48,10 +48,8 @@ pub async fn get_postcode_stats(
|
|||
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
|
||||
|
||||
// License check using postcode centroid
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_point(&user.0, centroid_lat as f64, centroid_lon as f64)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -59,10 +57,11 @@ pub async fn get_postcode_stats(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let (fields_specified, field_set) = parse_field_set(params.fields.as_deref());
|
||||
|
||||
let postcode_str = normalized.clone();
|
||||
let postcode_str = normalized;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::licensing::check_license_bounds;
|
|||
use crate::parsing::{
|
||||
bounds_intersect, parse_field_indices, parse_filters, require_bounds, row_passes_filters,
|
||||
};
|
||||
use crate::routes::travel_time::{parse_travel_entries, TravelTimeAgg};
|
||||
use crate::routes::travel_time::{parse_optional_travel, TravelTimeAgg};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::normalize_postcode;
|
||||
|
||||
|
|
@ -74,10 +74,8 @@ pub async fn get_postcodes(
|
|||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
check_license_bounds(&user.0, (south, west, north, east))
|
||||
.map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, (south, west, north, east))?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -85,19 +83,13 @@ pub async fn get_postcodes(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let field_indices = parse_field_indices(params.fields.as_deref(), &state.feature_name_to_index)
|
||||
.map_err(|err| (err.0, err.1).into_response())?;
|
||||
|
||||
// Parse travel entries
|
||||
let travel_entries = params
|
||||
.travel
|
||||
.as_deref()
|
||||
.filter(|val| !val.is_empty())
|
||||
.map(parse_travel_entries)
|
||||
.transpose()
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?
|
||||
.unwrap_or_default();
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
|
|
|||
|
|
@ -172,10 +172,9 @@ pub async fn get_hexagon_properties(
|
|||
|
||||
// License check using H3 cell bounds
|
||||
let h3_bounds = h3_cell_bounds(cell, 0.0);
|
||||
check_license_bounds(&user.0, h3_bounds).map_err(|(_, resp)| resp)?;
|
||||
check_license_bounds(&user.0, h3_bounds)?;
|
||||
|
||||
let h3_str = params.h3.clone();
|
||||
let filters_str = params.filters.clone();
|
||||
let h3_str = params.h3;
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
|
|
@ -183,6 +182,7 @@ pub async fn get_hexagon_properties(
|
|||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
let filters_str = params.filters;
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
/// Parse the optional `travel` query param, returning an empty Vec when absent or empty.
|
||||
pub fn parse_optional_travel(travel: Option<&str>) -> Result<Vec<TravelEntry>, String> {
|
||||
match travel.filter(|val| !val.is_empty()) {
|
||||
Some(s) => parse_travel_entries(s),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed travel time entry from the `travel` query parameter.
|
||||
pub struct TravelEntry {
|
||||
pub mode: String,
|
||||
|
|
@ -9,7 +17,7 @@ pub struct TravelEntry {
|
|||
|
||||
/// Parse `travel` param into a list of travel entries.
|
||||
/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max`
|
||||
pub fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
fn parse_travel_entries(travel_str: &str) -> Result<Vec<TravelEntry>, String> {
|
||||
let mut entries = Vec::new();
|
||||
let mut seen_keys = Vec::new();
|
||||
for segment in travel_str.split('|') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue