deploy
This commit is contained in:
parent
273d7a83ee
commit
084117cea8
48 changed files with 2283 additions and 890 deletions
|
|
@ -58,6 +58,7 @@ services:
|
||||||
PORT: "8002"
|
PORT: "8002"
|
||||||
APP_URL: http://frontend:3001
|
APP_URL: http://frontend:3001
|
||||||
CACHE_DIR: /cache
|
CACHE_DIR: /cache
|
||||||
|
SCREENSHOT_CACHE_ENABLED: "false"
|
||||||
SCREENSHOT_CONCURRENCY: "3"
|
SCREENSHOT_CONCURRENCY: "3"
|
||||||
SCREENSHOT_RATE_WINDOW_MS: "60000"
|
SCREENSHOT_RATE_WINDOW_MS: "60000"
|
||||||
SCREENSHOT_RATE_LIMIT: "30"
|
SCREENSHOT_RATE_LIMIT: "30"
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,16 @@ import {
|
||||||
} from './lib/seoRoutes';
|
} from './lib/seoRoutes';
|
||||||
import Header, { type Page } from './components/ui/Header';
|
import Header, { type Page } from './components/ui/Header';
|
||||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
import { useLicense } from './hooks/useLicense';
|
||||||
import { useTelemetry } from './hooks/useTelemetry';
|
import { useTelemetry } from './hooks/useTelemetry';
|
||||||
import { useSavedSearches } from './hooks/useSavedSearches';
|
import { useSavedSearches } from './hooks/useSavedSearches';
|
||||||
import { useSavedProperties } from './hooks/useSavedProperties';
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -39,9 +39,6 @@ const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
||||||
const SavedPage = lazy(() =>
|
const SavedPage = lazy(() =>
|
||||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||||
);
|
);
|
||||||
const InvitesPage = lazy(() =>
|
|
||||||
import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage }))
|
|
||||||
);
|
|
||||||
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
||||||
const MapPage = lazy(() => import('./components/map/MapPage'));
|
const MapPage = lazy(() => import('./components/map/MapPage'));
|
||||||
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
||||||
|
|
@ -52,6 +49,49 @@ function PageFallback() {
|
||||||
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
|
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RouteMatch {
|
||||||
|
page: Page;
|
||||||
|
inviteCode?: string;
|
||||||
|
hash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostAuthIntent = 'checkout';
|
||||||
|
type LicenseSuccessStatus = 'hidden' | 'verifying' | 'success' | 'delayed';
|
||||||
|
|
||||||
|
const LICENSE_VERIFICATION_ATTEMPTS = 8;
|
||||||
|
const LICENSE_VERIFICATION_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
function hasFullAccess(user?: { subscription?: string; isAdmin?: boolean } | null): boolean {
|
||||||
|
return user?.subscription === 'licensed' || user?.isAdmin === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHash(hash?: string | null): string {
|
||||||
|
return hash?.replace(/^#/, '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentRelativePath(): string {
|
||||||
|
return `${window.location.pathname}${window.location.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtectedPage(page: Page): boolean {
|
||||||
|
return page === 'account' || page === 'saved';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
|
||||||
|
const normalizedHash = normalizeHash(hash);
|
||||||
|
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHash(hash: string) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
document.getElementById(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function unavailableAuthAction(): never {
|
function unavailableAuthAction(): never {
|
||||||
throw new Error('Authentication actions are not available in this render mode');
|
throw new Error('Authentication actions are not available in this render mode');
|
||||||
}
|
}
|
||||||
|
|
@ -79,8 +119,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
return SEO_CONTENT_PATHS[page];
|
return SEO_CONTENT_PATHS[page];
|
||||||
case 'saved':
|
case 'saved':
|
||||||
return '/saved';
|
return '/saved';
|
||||||
case 'invites':
|
|
||||||
return '/invites';
|
|
||||||
case 'account':
|
case 'account':
|
||||||
return '/account';
|
return '/account';
|
||||||
case 'invite':
|
case 'invite':
|
||||||
|
|
@ -93,10 +131,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
|
function pathToPage(pathname: string): RouteMatch | null {
|
||||||
if (pathname === '/dashboard') return { page: 'dashboard' };
|
if (pathname === '/dashboard') return { page: 'dashboard' };
|
||||||
if (pathname === '/saved') return { page: 'saved' };
|
if (pathname === '/saved') return { page: 'saved' };
|
||||||
if (pathname === '/invites') return { page: 'invites' };
|
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
|
||||||
if (pathname === '/learn') return { page: 'learn' };
|
if (pathname === '/learn') return { page: 'learn' };
|
||||||
if (pathname === '/pricing') return { page: 'pricing' };
|
if (pathname === '/pricing') return { page: 'pricing' };
|
||||||
const seoLandingPage = getSeoLandingPage(pathname);
|
const seoLandingPage = getSeoLandingPage(pathname);
|
||||||
|
|
@ -123,7 +161,14 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const urlState = useMemo(() => parseUrlState(), []);
|
const urlState = useMemo(() => parseUrlState(), []);
|
||||||
|
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||||
|
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
||||||
|
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||||
|
);
|
||||||
|
const [dashboardParams, setDashboardParams] = useState(() =>
|
||||||
|
window.location.pathname === '/dashboard' ? window.location.search.replace(/^\?/, '') : ''
|
||||||
|
);
|
||||||
const dashboardSearchRef = useRef(
|
const dashboardSearchRef = useRef(
|
||||||
window.location.pathname === '/dashboard' ? window.location.search : ''
|
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||||
);
|
);
|
||||||
|
|
@ -147,15 +192,16 @@ export default function App() {
|
||||||
const [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||||
const [inviteCode, setInviteCode] = useState<string | null>(() => {
|
const [inviteCode, setInviteCode] = useState<string | null>(() => {
|
||||||
const fromPath = pathToPage(window.location.pathname);
|
return initialRoute?.inviteCode ?? null;
|
||||||
return fromPath?.inviteCode ?? null;
|
|
||||||
});
|
});
|
||||||
|
const [routeHash, setRouteHash] = useState(
|
||||||
|
() => initialRoute?.hash ?? normalizeHash(window.location.hash)
|
||||||
|
);
|
||||||
const [activePage, setActivePage] = useState<Page>(() => {
|
const [activePage, setActivePage] = useState<Page>(() => {
|
||||||
if (isScreenshotMode) return 'dashboard';
|
if (isScreenshotMode) return 'dashboard';
|
||||||
|
|
||||||
// Derive page from URL pathname
|
// Derive page from URL pathname
|
||||||
const fromPath = pathToPage(window.location.pathname);
|
if (initialRoute) return initialRoute.page;
|
||||||
if (fromPath) return fromPath.page;
|
|
||||||
|
|
||||||
// Restore from history state (e.g. popstate)
|
// Restore from history state (e.g. popstate)
|
||||||
if (window.history.state?.page) return window.history.state.page;
|
if (window.history.state?.page) return window.history.state.page;
|
||||||
|
|
@ -182,26 +228,95 @@ export default function App() {
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
const { startCheckout: startPostAuthCheckout } = useLicense();
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||||
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
|
const [postAuthIntent, setPostAuthIntent] = useState<PostAuthIntent | null>(null);
|
||||||
|
const postAuthCheckoutReturnPathRef = useRef<string | null>(null);
|
||||||
|
const authCompletedRef = useRef(false);
|
||||||
|
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
|
||||||
|
|
||||||
|
const openAuthModal = useCallback(
|
||||||
|
(
|
||||||
|
tab: 'login' | 'register',
|
||||||
|
intent: PostAuthIntent | null = null,
|
||||||
|
checkoutReturnPath?: string
|
||||||
|
) => {
|
||||||
|
authCompletedRef.current = false;
|
||||||
|
postAuthCheckoutReturnPathRef.current =
|
||||||
|
intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null;
|
||||||
|
setPostAuthIntent(intent);
|
||||||
|
setAuthModalTab(tab);
|
||||||
|
setShowAuthModal(true);
|
||||||
|
clearError();
|
||||||
|
},
|
||||||
|
[clearError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeAuthModal = useCallback(() => {
|
||||||
|
setShowAuthModal(false);
|
||||||
|
const completed = authCompletedRef.current;
|
||||||
|
if (!completed) {
|
||||||
|
setPostAuthIntent(null);
|
||||||
|
postAuthCheckoutReturnPathRef.current = null;
|
||||||
|
if (isProtectedPage(activePageRef.current)) {
|
||||||
|
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||||
|
setRouteHash('');
|
||||||
|
setActivePage('home');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authCompletedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('license_success') === '1') {
|
const returnedFromCheckout = params.get('license_success') === '1';
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (returnedFromCheckout) {
|
||||||
params.delete('license_success');
|
params.delete('license_success');
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${window.location.pathname}?${params.toString()}`
|
? `${window.location.pathname}?${params.toString()}`
|
||||||
: window.location.pathname;
|
: window.location.pathname;
|
||||||
window.history.replaceState({}, '', newUrl);
|
window.history.replaceState({}, '', newUrl);
|
||||||
trackEvent('Purchase');
|
|
||||||
setShowLicenseSuccess(true);
|
|
||||||
}
|
}
|
||||||
// Always refresh auth on startup to pick up server-side subscription changes
|
|
||||||
refreshAuth().catch(() => {});
|
async function refreshOnStartup() {
|
||||||
|
if (!returnedFromCheckout) {
|
||||||
|
// Always refresh auth on startup to pick up server-side subscription changes.
|
||||||
|
refreshAuth().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLicenseSuccessStatus('verifying');
|
||||||
|
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const refreshedUser = await refreshAuth();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (hasFullAccess(refreshedUser)) {
|
||||||
|
trackEvent('Purchase');
|
||||||
|
setLicenseSuccessStatus('success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logNonAbortError('Failed to verify license activation', error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(LICENSE_VERIFICATION_DELAY_MS);
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) setLicenseSuccessStatus('delayed');
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshOnStartup();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||||
const savedProperties = useSavedProperties(user?.id ?? null);
|
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -241,6 +356,7 @@ export default function App() {
|
||||||
|
|
||||||
const navigateTo = useCallback(
|
const navigateTo = useCallback(
|
||||||
(page: Page, hash?: string, infoFeature?: string) => {
|
(page: Page, hash?: string, infoFeature?: string) => {
|
||||||
|
const targetHash = normalizeHash(hash);
|
||||||
// Save dashboard search params before navigating away
|
// Save dashboard search params before navigating away
|
||||||
if (activePageRef.current === 'dashboard') {
|
if (activePageRef.current === 'dashboard') {
|
||||||
dashboardSearchRef.current = window.location.search;
|
dashboardSearchRef.current = window.location.search;
|
||||||
|
|
@ -248,38 +364,67 @@ export default function App() {
|
||||||
if (infoFeature) {
|
if (infoFeature) {
|
||||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||||
}
|
}
|
||||||
const path = pageToPath(page, inviteCode ?? undefined);
|
|
||||||
// Restore dashboard search params when navigating back
|
// Restore dashboard search params when navigating back
|
||||||
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
|
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
|
||||||
const url = hash ? `${path}${search}#${hash}` : `${path}${search}`;
|
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
|
||||||
window.history.pushState({ page }, '', url);
|
window.history.pushState({ page, hash: targetHash }, '', url);
|
||||||
if (page === 'dashboard') {
|
if (page === 'dashboard') {
|
||||||
setMapUrlState(parseUrlState());
|
setMapUrlState(parseUrlState());
|
||||||
|
setDashboardRouteKey(window.location.search);
|
||||||
}
|
}
|
||||||
|
setRouteHash(targetHash);
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
|
if (targetHash) scrollToHash(targetHash);
|
||||||
},
|
},
|
||||||
[inviteCode]
|
[inviteCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading || !user || postAuthIntent !== 'checkout') return;
|
||||||
|
|
||||||
|
setPostAuthIntent(null);
|
||||||
|
setShowAuthModal(false);
|
||||||
|
const checkoutReturnPath = postAuthCheckoutReturnPathRef.current ?? undefined;
|
||||||
|
postAuthCheckoutReturnPathRef.current = null;
|
||||||
|
if (hasFullAccess(user)) {
|
||||||
|
if (checkoutReturnPath?.startsWith('/dashboard')) {
|
||||||
|
window.history.pushState({ page: 'dashboard', hash: '' }, '', checkoutReturnPath);
|
||||||
|
setMapUrlState(parseUrlState());
|
||||||
|
setDashboardRouteKey(window.location.search);
|
||||||
|
setRouteHash('');
|
||||||
|
setActivePage('dashboard');
|
||||||
|
} else {
|
||||||
|
navigateTo('dashboard');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPostAuthCheckout(checkoutReturnPath).catch((error) => {
|
||||||
|
logNonAbortError('Failed to resume checkout after auth', error);
|
||||||
|
navigateTo('pricing');
|
||||||
|
});
|
||||||
|
}, [authLoading, navigateTo, postAuthIntent, startPostAuthCheckout, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activePageRef.current = activePage;
|
activePageRef.current = activePage;
|
||||||
}, [activePage]);
|
}, [activePage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.history.state?.page) {
|
if (!window.history.state?.page) {
|
||||||
|
const hash = routeHash || normalizeHash(window.location.hash);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{ page: activePage },
|
{ page: activePage, hash },
|
||||||
'',
|
'',
|
||||||
pageToPath(activePage, inviteCode ?? undefined) +
|
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
|
||||||
window.location.search +
|
|
||||||
window.location.hash
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const handlePopState = (e: PopStateEvent) => {
|
const handlePopState = (e: PopStateEvent) => {
|
||||||
let page: Page;
|
let page: Page;
|
||||||
|
const hash = normalizeHash(window.location.hash);
|
||||||
if (e.state?.page) {
|
if (e.state?.page) {
|
||||||
page = e.state.page;
|
page = e.state.page;
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
|
setRouteHash(hash || e.state.hash || '');
|
||||||
if (e.state.infoFeature) {
|
if (e.state.infoFeature) {
|
||||||
setPendingInfoFeature(e.state.infoFeature);
|
setPendingInfoFeature(e.state.infoFeature);
|
||||||
}
|
}
|
||||||
|
|
@ -287,11 +432,13 @@ export default function App() {
|
||||||
const parsed = pathToPage(window.location.pathname);
|
const parsed = pathToPage(window.location.pathname);
|
||||||
page = parsed?.page || 'home';
|
page = parsed?.page || 'home';
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
|
setRouteHash(parsed?.hash ?? hash);
|
||||||
if (parsed?.inviteCode) setInviteCode(parsed.inviteCode);
|
if (parsed?.inviteCode) setInviteCode(parsed.inviteCode);
|
||||||
}
|
}
|
||||||
// Re-parse URL state when returning to dashboard via back/forward
|
// Re-parse URL state when returning to dashboard via back/forward
|
||||||
if (page === 'dashboard') {
|
if (page === 'dashboard') {
|
||||||
setMapUrlState(parseUrlState());
|
setMapUrlState(parseUrlState());
|
||||||
|
setDashboardRouteKey(window.location.search);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
|
@ -299,30 +446,22 @@ export default function App() {
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const { fetchSearches } = savedSearches;
|
const { fetchSearches } = savedSearches;
|
||||||
const { fetchProperties: fetchSavedProperties } = savedProperties;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activePage === 'saved') {
|
if (activePage === 'saved') {
|
||||||
fetchSearches();
|
fetchSearches();
|
||||||
fetchSavedProperties();
|
|
||||||
}
|
}
|
||||||
if (activePage === 'dashboard' && user) {
|
}, [activePage, fetchSearches]);
|
||||||
fetchSavedProperties();
|
|
||||||
}
|
|
||||||
}, [activePage, fetchSearches, fetchSavedProperties, user]);
|
|
||||||
|
|
||||||
const isAuthRequiredPage =
|
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved';
|
||||||
activePage === 'account' || activePage === 'saved' || activePage === 'invites';
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (isAuthRequiredPage && !user) {
|
if (isAuthRequiredPage && !user) {
|
||||||
setAuthModalTab('login');
|
openAuthModal('login');
|
||||||
setShowAuthModal(true);
|
|
||||||
navigateTo('home');
|
|
||||||
}
|
}
|
||||||
if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) {
|
if (activePage === 'pricing' && hasFullAccess(user)) {
|
||||||
navigateTo('dashboard');
|
navigateTo('dashboard');
|
||||||
}
|
}
|
||||||
}, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]);
|
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
|
||||||
|
|
||||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||||
|
|
||||||
|
|
@ -372,21 +511,17 @@ export default function App() {
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<Header
|
<Header
|
||||||
activePage={activePage}
|
activePage={activePage}
|
||||||
|
activeHash={routeHash}
|
||||||
onPageChange={navigateTo}
|
onPageChange={navigateTo}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
exportState={activePage === 'dashboard' ? exportState : null}
|
exportState={activePage === 'dashboard' ? exportState : null}
|
||||||
|
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
|
||||||
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
||||||
savingSearch={savedSearches.saving}
|
savingSearch={savedSearches.saving}
|
||||||
user={user}
|
user={user}
|
||||||
onLoginClick={() => {
|
onLoginClick={() => openAuthModal('login')}
|
||||||
setAuthModalTab('login');
|
onRegisterClick={() => openAuthModal('register')}
|
||||||
setShowAuthModal(true);
|
|
||||||
}}
|
|
||||||
onRegisterClick={() => {
|
|
||||||
setAuthModalTab('register');
|
|
||||||
setShowAuthModal(true);
|
|
||||||
}}
|
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
@ -402,14 +537,8 @@ export default function App() {
|
||||||
<PricingPage
|
<PricingPage
|
||||||
onOpenDashboard={() => navigateTo('dashboard')}
|
onOpenDashboard={() => navigateTo('dashboard')}
|
||||||
user={user}
|
user={user}
|
||||||
onLoginClick={() => {
|
onLoginClick={() => openAuthModal('login', 'checkout', '/pricing')}
|
||||||
setAuthModalTab('login');
|
onRegisterClick={() => openAuthModal('register', 'checkout', '/pricing')}
|
||||||
setShowAuthModal(true);
|
|
||||||
}}
|
|
||||||
onRegisterClick={() => {
|
|
||||||
setAuthModalTab('register');
|
|
||||||
setShowAuthModal(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : activePage === 'learn' ? (
|
) : activePage === 'learn' ? (
|
||||||
<LearnPage />
|
<LearnPage />
|
||||||
|
|
@ -427,38 +556,32 @@ export default function App() {
|
||||||
onOpenSearch={(params) => {
|
onOpenSearch={(params) => {
|
||||||
window.location.href = `/dashboard?${params}`;
|
window.location.href = `/dashboard?${params}`;
|
||||||
}}
|
}}
|
||||||
savedProperties={savedProperties.properties}
|
|
||||||
propertiesLoading={savedProperties.loading}
|
|
||||||
onDeleteProperty={savedProperties.deleteProperty}
|
|
||||||
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
|
|
||||||
onOpenProperty={(postcode) => {
|
|
||||||
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : activePage === 'invites' && user ? (
|
|
||||||
<InvitesPage user={user} />
|
|
||||||
) : activePage === 'account' && user ? (
|
) : activePage === 'account' && user ? (
|
||||||
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
<AccountPage
|
||||||
|
user={user}
|
||||||
|
onRefreshAuth={async () => {
|
||||||
|
await refreshAuth();
|
||||||
|
}}
|
||||||
|
scrollTarget={routeHash}
|
||||||
|
/>
|
||||||
|
) : isAuthRequiredPage && !user ? (
|
||||||
|
<PageFallback />
|
||||||
) : activePage === 'invite' && inviteCode ? (
|
) : activePage === 'invite' && inviteCode ? (
|
||||||
<InvitePage
|
<InvitePage
|
||||||
code={inviteCode}
|
code={inviteCode}
|
||||||
user={user}
|
user={user}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onLoginClick={() => {
|
onLoginClick={() => openAuthModal('login')}
|
||||||
setAuthModalTab('login');
|
onRegisterClick={() => openAuthModal('register')}
|
||||||
setShowAuthModal(true);
|
|
||||||
}}
|
|
||||||
onRegisterClick={() => {
|
|
||||||
setAuthModalTab('register');
|
|
||||||
setShowAuthModal(true);
|
|
||||||
}}
|
|
||||||
onLicenseGranted={() => {
|
onLicenseGranted={() => {
|
||||||
setShowLicenseSuccess(true);
|
setLicenseSuccessStatus('success');
|
||||||
refreshAuth();
|
refreshAuth();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MapPage
|
<MapPage
|
||||||
|
key={dashboardRouteKey}
|
||||||
features={features}
|
features={features}
|
||||||
poiCategoryGroups={poiCategoryGroups}
|
poiCategoryGroups={poiCategoryGroups}
|
||||||
initialFilters={mapUrlState.filters}
|
initialFilters={mapUrlState.filters}
|
||||||
|
|
@ -471,24 +594,19 @@ export default function App() {
|
||||||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||||
onNavigateTo={navigateTo}
|
onNavigateTo={navigateTo}
|
||||||
onExportStateChange={setExportState}
|
onExportStateChange={setExportState}
|
||||||
|
onDashboardParamsChange={setDashboardParams}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
initialTravelTime={mapUrlState.travelTime}
|
initialTravelTime={mapUrlState.travelTime}
|
||||||
initialPostcode={mapUrlState.postcode}
|
initialPostcode={mapUrlState.postcode}
|
||||||
shareCode={mapUrlState.share}
|
shareCode={mapUrlState.share}
|
||||||
user={user}
|
user={user}
|
||||||
onLoginClick={() => {
|
onLoginClick={() => openAuthModal('login')}
|
||||||
setAuthModalTab('login');
|
onRegisterClick={() => openAuthModal('register')}
|
||||||
setShowAuthModal(true);
|
onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)}
|
||||||
}}
|
onCheckoutRegisterClick={(returnPath) =>
|
||||||
onRegisterClick={() => {
|
openAuthModal('register', 'checkout', returnPath)
|
||||||
setAuthModalTab('register');
|
}
|
||||||
setShowAuthModal(true);
|
deferTutorial={licenseSuccessStatus !== 'hidden'}
|
||||||
}}
|
|
||||||
onSaveProperty={user ? savedProperties.saveProperty : undefined}
|
|
||||||
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
|
|
||||||
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
|
|
||||||
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
|
|
||||||
deferTutorial={showLicenseSuccess}
|
|
||||||
onSaveSearch={user ? savedSearches.saveSearch : undefined}
|
onSaveSearch={user ? savedSearches.saveSearch : undefined}
|
||||||
savingSearch={savedSearches.saving}
|
savingSearch={savedSearches.saving}
|
||||||
/>
|
/>
|
||||||
|
|
@ -497,7 +615,10 @@ export default function App() {
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{showAuthModal && (
|
{showAuthModal && (
|
||||||
<AuthModal
|
<AuthModal
|
||||||
onClose={() => setShowAuthModal(false)}
|
onClose={closeAuthModal}
|
||||||
|
onAuthenticated={() => {
|
||||||
|
authCompletedRef.current = true;
|
||||||
|
}}
|
||||||
onLogin={login}
|
onLogin={login}
|
||||||
onRegister={register}
|
onRegister={register}
|
||||||
onOAuthLogin={loginWithOAuth}
|
onOAuthLogin={loginWithOAuth}
|
||||||
|
|
@ -511,7 +632,7 @@ export default function App() {
|
||||||
{showSaveModal && (
|
{showSaveModal && (
|
||||||
<SaveSearchModal
|
<SaveSearchModal
|
||||||
onClose={() => setShowSaveModal(false)}
|
onClose={() => setShowSaveModal(false)}
|
||||||
onSave={savedSearches.saveSearch}
|
onSave={(name) => savedSearches.saveSearch(name, dashboardParams)}
|
||||||
onViewSearches={() => {
|
onViewSearches={() => {
|
||||||
setShowSaveModal(false);
|
setShowSaveModal(false);
|
||||||
navigateTo('saved');
|
navigateTo('saved');
|
||||||
|
|
@ -520,11 +641,13 @@ export default function App() {
|
||||||
error={savedSearches.error}
|
error={savedSearches.error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showLicenseSuccess && (
|
{licenseSuccessStatus !== 'hidden' && (
|
||||||
<LicenseSuccessModal
|
<LicenseSuccessModal
|
||||||
|
status={licenseSuccessStatus}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowLicenseSuccess(false);
|
const shouldOpenDashboard = licenseSuccessStatus === 'success';
|
||||||
navigateTo('dashboard');
|
setLicenseSuccessStatus('hidden');
|
||||||
|
if (shouldOpenDashboard) navigateTo('dashboard');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { AuthUser } from '../../hooks/useAuth';
|
import type { AuthUser } from '../../hooks/useAuth';
|
||||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||||
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
|
|
||||||
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
|
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||||
import { copyToClipboard } from '../../lib/clipboard';
|
import { copyToClipboard } from '../../lib/clipboard';
|
||||||
import { formatRelativeTime, formatNumber } from '../../lib/format';
|
import { formatRelativeTime, formatNumber } from '../../lib/format';
|
||||||
|
|
@ -11,7 +10,6 @@ import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||||
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
|
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
|
||||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||||
import { HouseIcon } from '../ui/icons/HouseIcon';
|
|
||||||
import { TrashIcon } from '../ui/icons/TrashIcon';
|
import { TrashIcon } from '../ui/icons/TrashIcon';
|
||||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
|
|
@ -126,24 +124,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPropertyPrice(data: SavedPropertyData): string | null {
|
|
||||||
if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`;
|
|
||||||
if (data.price) return `£${formatNumber(data.price)}`;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPropertyDetails(
|
|
||||||
data: SavedPropertyData,
|
|
||||||
t: { (key: 'savedPage.bed'): string; (key: 'savedPage.epc'): string }
|
|
||||||
): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (data.propertySubType) parts.push(data.propertySubType);
|
|
||||||
else if (data.propertyType) parts.push(data.propertyType);
|
|
||||||
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`);
|
|
||||||
if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`);
|
|
||||||
return parts.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
|
function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
@ -355,115 +335,6 @@ function SavedSearchesTab({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SavedPropertiesTab({
|
|
||||||
properties,
|
|
||||||
loading,
|
|
||||||
onDelete,
|
|
||||||
onUpdateNotes,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
properties: SavedProperty[];
|
|
||||||
loading: boolean;
|
|
||||||
onDelete: (id: string) => Promise<void>;
|
|
||||||
onUpdateNotes: (id: string, notes: string) => void;
|
|
||||||
onOpen: (postcode: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleDeleteConfirm = useCallback(async () => {
|
|
||||||
if (!deleteConfirmId) return;
|
|
||||||
await onDelete(deleteConfirmId);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
}, [deleteConfirmId, onDelete]);
|
|
||||||
|
|
||||||
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">
|
|
||||||
{t('savedPage.noSavedProperties')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-warm-500 dark:text-warm-500">
|
|
||||||
{t('savedPage.noSavedPropertiesDesc')}
|
|
||||||
</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, t);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={prop.id}
|
|
||||||
className="flex flex-col bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
|
||||||
>
|
|
||||||
<div className="mb-1">
|
|
||||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
|
|
||||||
{prop.address}
|
|
||||||
</h3>
|
|
||||||
</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-2">
|
|
||||||
{formatRelativeTime(prop.created)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-3 flex-1">
|
|
||||||
<NotesInput value={prop.notes} onSave={(notes) => onUpdateNotes(prop.id, notes)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{t('savedPage.openPostcode')}
|
|
||||||
</button>
|
|
||||||
<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={t('common.delete')}
|
|
||||||
>
|
|
||||||
<TrashIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deleteConfirmId && (
|
|
||||||
<DeleteDialog
|
|
||||||
title={t('savedPage.deleteProperty')}
|
|
||||||
message={t('savedPage.deletePropertyConfirm')}
|
|
||||||
onCancel={() => setDeleteConfirmId(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SavedPage({
|
export function SavedPage({
|
||||||
searches,
|
searches,
|
||||||
searchesLoading,
|
searchesLoading,
|
||||||
|
|
@ -471,11 +342,6 @@ export function SavedPage({
|
||||||
onUpdateSearchNotes,
|
onUpdateSearchNotes,
|
||||||
onUpdateSearchName,
|
onUpdateSearchName,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
savedProperties,
|
|
||||||
propertiesLoading,
|
|
||||||
onDeleteProperty,
|
|
||||||
onUpdatePropertyNotes,
|
|
||||||
onOpenProperty,
|
|
||||||
}: {
|
}: {
|
||||||
searches: SavedSearch[];
|
searches: SavedSearch[];
|
||||||
searchesLoading: boolean;
|
searchesLoading: boolean;
|
||||||
|
|
@ -483,15 +349,10 @@ export function SavedPage({
|
||||||
onUpdateSearchNotes: (id: string, notes: string) => void;
|
onUpdateSearchNotes: (id: string, notes: string) => void;
|
||||||
onUpdateSearchName: (id: string, name: string) => void;
|
onUpdateSearchName: (id: string, name: string) => void;
|
||||||
onOpenSearch: (params: string) => void;
|
onOpenSearch: (params: string) => void;
|
||||||
savedProperties: SavedProperty[];
|
|
||||||
propertiesLoading: boolean;
|
|
||||||
onDeleteProperty: (id: string) => Promise<void>;
|
|
||||||
onUpdatePropertyNotes: (id: string, notes: string) => void;
|
|
||||||
onOpenProperty: (postcode: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
|
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(
|
||||||
window.location.hash === '#properties' ? 'properties' : 'searches'
|
window.location.hash === '#shared-links' ? 'shared-links' : 'searches'
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabClass = (tab: string) =>
|
const tabClass = (tab: string) =>
|
||||||
|
|
@ -512,13 +373,8 @@ export function SavedPage({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button className={tabClass('properties')} onClick={() => setActiveTab('properties')}>
|
<button className={tabClass('shared-links')} onClick={() => setActiveTab('shared-links')}>
|
||||||
{t('common.properties')}
|
{t('accountPage.shareLinksTitle')}
|
||||||
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -532,13 +388,7 @@ export function SavedPage({
|
||||||
onOpen={onOpenSearch}
|
onOpen={onOpenSearch}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SavedPropertiesTab
|
<ShareLinksSection showTitle={false} />
|
||||||
properties={savedProperties}
|
|
||||||
loading={propertiesLoading}
|
|
||||||
onDelete={onDeleteProperty}
|
|
||||||
onUpdateNotes={onUpdatePropertyNotes}
|
|
||||||
onOpen={onOpenProperty}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|
@ -655,7 +505,7 @@ function InviteTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareLinksSection() {
|
function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [links, setLinks] = useState<ShareLinkListItem[]>([]);
|
const [links, setLinks] = useState<ShareLinkListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -698,9 +548,11 @@ function ShareLinksSection() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h2 className="text-base font-semibold text-navy-950 dark:text-warm-100">
|
{showTitle && (
|
||||||
{t('accountPage.shareLinksTitle')}
|
<h2 className="text-base font-semibold text-navy-950 dark:text-warm-100">
|
||||||
</h2>
|
{t('accountPage.shareLinksTitle')}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
|
|
@ -1048,8 +900,6 @@ export default function AccountPage({
|
||||||
<InviteSection user={user} />
|
<InviteSection user={user} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ShareLinksSection />
|
|
||||||
|
|
||||||
{/* Support */}
|
{/* Support */}
|
||||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
<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 mb-2">{t('accountPage.needHelp')}</p>
|
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const HOME_SECTION_HEADING_CLASS =
|
||||||
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
|
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
|
||||||
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
||||||
const HOME_PRIMARY_BUTTON_CLASS =
|
const HOME_PRIMARY_BUTTON_CLASS =
|
||||||
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
|
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center';
|
||||||
|
|
||||||
export default function HomeFinalCta({
|
export default function HomeFinalCta({
|
||||||
onOpenDashboard,
|
onOpenDashboard,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
|
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import HexCanvas from './HexCanvas';
|
import HexCanvas from './HexCanvas';
|
||||||
import HomeFinalCta from './HomeFinalCta';
|
import HomeFinalCta from './HomeFinalCta';
|
||||||
import BottomIllustration from './BottomIllustration';
|
import BottomIllustration from './BottomIllustration';
|
||||||
|
|
@ -15,7 +16,7 @@ const HOME_SECTION_HEADING_CLASS =
|
||||||
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
|
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
|
||||||
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
||||||
const HOME_PRIMARY_BUTTON_CLASS =
|
const HOME_PRIMARY_BUTTON_CLASS =
|
||||||
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
|
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center';
|
||||||
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
|
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
|
||||||
en: 'recording',
|
en: 'recording',
|
||||||
de: 'recording-de',
|
de: 'recording-de',
|
||||||
|
|
@ -34,9 +35,13 @@ function ProductShowcaseFallback({ className = '' }: { className?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProductDemoSlug(language: string | undefined): string {
|
function getProductDemoSlug(language: string | undefined, isMobile: boolean): string {
|
||||||
const code = language?.toLowerCase().split('-')[0] ?? 'en';
|
const code = language?.toLowerCase().split('-')[0] ?? 'en';
|
||||||
return PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
|
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
|
||||||
|
// Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside
|
||||||
|
// the 16:9 desktop cuts. The recorder pipeline writes both every render —
|
||||||
|
// see video/src/storyboard.ts.
|
||||||
|
return isMobile ? `${base}-mobile` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightBrandText(text: string) {
|
function highlightBrandText(text: string) {
|
||||||
|
|
@ -57,12 +62,13 @@ function highlightBrandText(text: string) {
|
||||||
|
|
||||||
function ProductDemoVideo() {
|
function ProductDemoVideo() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const sectionRef = useRef<HTMLDivElement | null>(null);
|
const sectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const currentVideoSrcRef = useRef<string | null>(null);
|
const currentVideoSrcRef = useRef<string | null>(null);
|
||||||
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
|
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
|
||||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||||
const productDemoSlug = getProductDemoSlug(i18n.language);
|
const productDemoSlug = getProductDemoSlug(i18n.language, isMobile);
|
||||||
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
|
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
|
||||||
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
|
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
|
||||||
|
|
||||||
|
|
@ -123,7 +129,11 @@ function ProductDemoVideo() {
|
||||||
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
|
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
|
||||||
{t('home.productDemoLabel')}
|
{t('home.productDemoLabel')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700 ${
|
||||||
|
isMobile ? 'mx-auto max-w-sm' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
|
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
|
||||||
|
|
@ -131,7 +141,9 @@ function ProductDemoVideo() {
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload={shouldLoadVideo ? 'metadata' : 'none'}
|
preload={shouldLoadVideo ? 'metadata' : 'none'}
|
||||||
className="block aspect-video w-full bg-navy-950 object-contain"
|
className={`block w-full bg-navy-950 object-contain ${
|
||||||
|
isMobile ? 'aspect-[9/16]' : 'aspect-video'
|
||||||
|
}`}
|
||||||
aria-label={t('home.productDemoLabel')}
|
aria-label={t('home.productDemoLabel')}
|
||||||
onPlay={() => setIsVideoPlaying(true)}
|
onPlay={() => setIsVideoPlaying(true)}
|
||||||
onPause={() => setIsVideoPlaying(false)}
|
onPause={() => setIsVideoPlaying(false)}
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,7 @@ export default function InvitePage({
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onRegisterClick}
|
onClick={onRegisterClick}
|
||||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
|
||||||
>
|
>
|
||||||
{t('invitePage.registerToClaim')}
|
{t('invitePage.registerToClaim')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||||
|
import HomeFinalCta from '../home/HomeFinalCta';
|
||||||
|
import { usePageMeta } from '../../hooks/usePageMeta';
|
||||||
import {
|
import {
|
||||||
SEO_CONTENT_PAGES,
|
getLocalizedSeoContentPage,
|
||||||
type SeoContentKey,
|
type SeoContentKey,
|
||||||
type SeoFaq,
|
type SeoFaq,
|
||||||
type SeoLink,
|
type SeoLink,
|
||||||
type SeoSection,
|
type SeoSection,
|
||||||
} from '../../lib/seoLandingPages';
|
} from '../../lib/seoLandingPages';
|
||||||
|
import { safeJsonLd } from '../../lib/json-ld';
|
||||||
|
|
||||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||||
|
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
|
||||||
|
|
||||||
|
function ProductShowcaseFallback() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[28rem] max-w-6xl rounded-lg bg-navy-900" aria-hidden="true" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function JsonLd({ data }: { data: unknown }) {
|
function JsonLd({ data }: { data: unknown }) {
|
||||||
return (
|
return (
|
||||||
<script
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(data) }} />
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,9 +99,10 @@ export default function SeoContentPage({
|
||||||
pageKey: SeoContentKey;
|
pageKey: SeoContentKey;
|
||||||
onOpenDashboard: () => void;
|
onOpenDashboard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const page = SEO_CONTENT_PAGES[pageKey];
|
const page = getLocalizedSeoContentPage(pageKey, i18n.language);
|
||||||
const url = `${PUBLIC_URL}${page.path}`;
|
const url = `${PUBLIC_URL}${page.path}`;
|
||||||
|
usePageMeta(page.metaTitle, page.metaDescription);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
||||||
|
|
@ -136,7 +145,7 @@ export default function SeoContentPage({
|
||||||
{page.cta && (
|
{page.cta && (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenDashboard}
|
onClick={onOpenDashboard}
|
||||||
className="mt-8 rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
|
className="mt-8 rounded-lg border border-[#d27a11] bg-[#f09a22] px-6 py-3 font-semibold text-navy-950 shadow-lg shadow-[#7a3905]/25 transition-colors hover:bg-[#df8614]"
|
||||||
>
|
>
|
||||||
{page.cta}
|
{page.cta}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -144,6 +153,14 @@ export default function SeoContentPage({
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-navy-950 px-6 py-12 md:px-10 md:py-16">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<Suspense fallback={<ProductShowcaseFallback />}>
|
||||||
|
<ProductShowcase className="mx-auto" />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
|
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
|
||||||
<SectionList sections={page.sections} />
|
<SectionList sections={page.sections} />
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -178,6 +195,13 @@ export default function SeoContentPage({
|
||||||
<RelatedLinks links={page.relatedLinks} />
|
<RelatedLinks links={page.relatedLinks} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-5xl px-6 pb-16 md:px-10">
|
||||||
|
<HomeFinalCta
|
||||||
|
onOpenDashboard={onOpenDashboard}
|
||||||
|
trackingLocation={`seo_${pageKey}_bottom`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
|
import HomeFinalCta from '../home/HomeFinalCta';
|
||||||
|
import { usePageMeta } from '../../hooks/usePageMeta';
|
||||||
import {
|
import {
|
||||||
SEO_LANDING_PAGES,
|
getLocalizedSeoLandingPage,
|
||||||
type SeoFaq,
|
type SeoFaq,
|
||||||
type SeoLandingKey,
|
type SeoLandingKey,
|
||||||
type SeoLink,
|
type SeoLink,
|
||||||
type SeoSection,
|
type SeoSection,
|
||||||
} from '../../lib/seoLandingPages';
|
} from '../../lib/seoLandingPages';
|
||||||
|
import { safeJsonLd } from '../../lib/json-ld';
|
||||||
|
|
||||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||||
|
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
|
||||||
|
|
||||||
|
function ProductShowcaseFallback() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-[28rem] max-w-6xl rounded-lg bg-navy-900" aria-hidden="true" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function JsonLd({ data }: { data: unknown }) {
|
function JsonLd({ data }: { data: unknown }) {
|
||||||
return (
|
return (
|
||||||
<script
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(data) }} />
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,9 +99,10 @@ export default function SeoLandingPage({
|
||||||
pageKey: SeoLandingKey;
|
pageKey: SeoLandingKey;
|
||||||
onOpenDashboard: () => void;
|
onOpenDashboard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const page = SEO_LANDING_PAGES[pageKey];
|
const page = getLocalizedSeoLandingPage(pageKey, i18n.language);
|
||||||
const url = `${PUBLIC_URL}${page.path}`;
|
const url = `${PUBLIC_URL}${page.path}`;
|
||||||
|
usePageMeta(page.metaTitle, page.metaDescription);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
||||||
|
|
@ -138,7 +147,7 @@ export default function SeoLandingPage({
|
||||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
<button
|
<button
|
||||||
onClick={onOpenDashboard}
|
onClick={onOpenDashboard}
|
||||||
className="rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
|
className="rounded-lg border border-[#d27a11] bg-[#f09a22] px-6 py-3 font-semibold text-navy-950 shadow-lg shadow-[#7a3905]/25 transition-colors hover:bg-[#df8614]"
|
||||||
>
|
>
|
||||||
{page.cta}
|
{page.cta}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -152,6 +161,14 @@ export default function SeoLandingPage({
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-navy-950 px-6 py-12 md:px-10 md:py-16">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<Suspense fallback={<ProductShowcaseFallback />}>
|
||||||
|
<ProductShowcase className="mx-auto" />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mx-auto grid max-w-6xl gap-8 px-6 py-12 md:grid-cols-[0.85fr_1.15fr] md:px-10 md:py-16">
|
<section className="mx-auto grid max-w-6xl gap-8 px-6 py-12 md:grid-cols-[0.85fr_1.15fr] md:px-10 md:py-16">
|
||||||
<div>
|
<div>
|
||||||
<div className="inline-flex items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-3 py-1.5 text-sm font-semibold text-teal-700 dark:border-teal-400/30 dark:bg-teal-400/10 dark:text-teal-200">
|
<div className="inline-flex items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-3 py-1.5 text-sm font-semibold text-teal-700 dark:border-teal-400/30 dark:bg-teal-400/10 dark:text-teal-200">
|
||||||
|
|
@ -228,6 +245,13 @@ export default function SeoLandingPage({
|
||||||
</div>
|
</div>
|
||||||
<LinkGrid links={page.relatedLinks} />
|
<LinkGrid links={page.relatedLinks} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
|
||||||
|
<HomeFinalCta
|
||||||
|
onOpenDashboard={onOpenDashboard}
|
||||||
|
trackingLocation={`seo_${pageKey}_bottom`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { tDynamic } from '../../i18n';
|
import { tDynamic } from '../../i18n';
|
||||||
|
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
|
||||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||||
import { SubNav } from '../ui/SubNav';
|
import { SubNav } from '../ui/SubNav';
|
||||||
|
|
||||||
type LearnTab = 'data-sources' | 'faq' | 'support';
|
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
|
||||||
|
|
||||||
interface DataSourceDef {
|
interface DataSourceDef {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -173,15 +174,26 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LearnPage() {
|
export default function LearnPage() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [tab, setTab] = useState<LearnTab>('faq');
|
const [tab, setTab] = useState<LearnTab>('faq');
|
||||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const seoPageLinks = useMemo(
|
||||||
|
() =>
|
||||||
|
getLocalizedSeoPages(i18n.language).map((page) => ({
|
||||||
|
path: page.path,
|
||||||
|
eyebrow: page.eyebrow,
|
||||||
|
title: page.title,
|
||||||
|
description: page.metaDescription,
|
||||||
|
})),
|
||||||
|
[i18n.language]
|
||||||
|
);
|
||||||
|
|
||||||
const LEARN_TABS = [
|
const LEARN_TABS = [
|
||||||
{ key: 'faq', label: t('learnPage.faq') },
|
{ key: 'faq', label: t('learnPage.faq') },
|
||||||
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
||||||
|
{ key: 'articles', label: t('learnPage.articles') },
|
||||||
{ key: 'support', label: t('learnPage.support') },
|
{ key: 'support', label: t('learnPage.support') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -239,6 +251,9 @@ export default function LearnPage() {
|
||||||
if (hash === 'faq') {
|
if (hash === 'faq') {
|
||||||
setTab('faq');
|
setTab('faq');
|
||||||
setHighlightedId(null);
|
setHighlightedId(null);
|
||||||
|
} else if (hash === 'articles') {
|
||||||
|
setTab('articles');
|
||||||
|
setHighlightedId(null);
|
||||||
} else if (hash === 'support') {
|
} else if (hash === 'support') {
|
||||||
setTab('support');
|
setTab('support');
|
||||||
setHighlightedId(null);
|
setHighlightedId(null);
|
||||||
|
|
@ -406,6 +421,32 @@ export default function LearnPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : tab === 'articles' ? (
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
|
{t('learnPage.articles')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{seoPageLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.path}
|
||||||
|
href={link.path}
|
||||||
|
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
||||||
|
{link.eyebrow}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
||||||
|
{link.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||||
|
{link.description}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ interface AreaPaneProps {
|
||||||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
||||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||||
travelTimeEntries?: TravelTimeEntry[];
|
travelTimeEntries?: TravelTimeEntry[];
|
||||||
|
shareCode?: string;
|
||||||
isGroupExpanded: (name: string) => boolean;
|
isGroupExpanded: (name: string) => boolean;
|
||||||
onToggleGroup: (name: string) => void;
|
onToggleGroup: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +75,7 @@ export default function AreaPane({
|
||||||
onStatsUseFiltersChange,
|
onStatsUseFiltersChange,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
travelTimeEntries,
|
travelTimeEntries,
|
||||||
|
shareCode,
|
||||||
isGroupExpanded,
|
isGroupExpanded,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
}: AreaPaneProps) {
|
}: AreaPaneProps) {
|
||||||
|
|
@ -226,6 +228,7 @@ export default function AreaPane({
|
||||||
postcode={journeyPostcode}
|
postcode={journeyPostcode}
|
||||||
entries={travelTimeEntries}
|
entries={travelTimeEntries}
|
||||||
label={!isPostcode ? journeyPostcode : undefined}
|
label={!isPostcode ? journeyPostcode : undefined}
|
||||||
|
shareCode={shareCode}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { JourneyLeg } from '../../types';
|
import type { JourneyLeg } from '../../types';
|
||||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
|
||||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface JourneyInstructionsProps {
|
||||||
presetJourneys?: JourneyInstructionPreset[];
|
presetJourneys?: JourneyInstructionPreset[];
|
||||||
className?: string;
|
className?: string;
|
||||||
showGoogleMapsLink?: boolean;
|
showGoogleMapsLink?: boolean;
|
||||||
|
shareCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JourneyData {
|
interface JourneyData {
|
||||||
|
|
@ -25,6 +26,8 @@ interface JourneyData {
|
||||||
minutes: number | null;
|
minutes: number | null;
|
||||||
/** Best-case (5th percentile) total travel time from R5. */
|
/** Best-case (5th percentile) total travel time from R5. */
|
||||||
bestMinutes: number | null;
|
bestMinutes: number | null;
|
||||||
|
/** Whether the dashboard filter is currently using best-case time. */
|
||||||
|
useBest: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +39,7 @@ export interface JourneyInstructionPreset {
|
||||||
minutes: number | null;
|
minutes: number | null;
|
||||||
/** Best-case (5th percentile) total travel time. */
|
/** Best-case (5th percentile) total travel time. */
|
||||||
bestMinutes?: number | null;
|
bestMinutes?: number | null;
|
||||||
|
useBest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Official TfL line colors + other known London transit
|
// Official TfL line colors + other known London transit
|
||||||
|
|
@ -90,15 +94,15 @@ function nextMondayAt730(): number {
|
||||||
return Math.floor(monday.getTime() / 1000);
|
return Math.floor(monday.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function googleMapsUrl(postcode: string, destination: string): string {
|
function googleMapsUrl(origin: string, destination: string): string {
|
||||||
const ts = nextMondayAt730();
|
const ts = nextMondayAt730();
|
||||||
const origin = encodeURIComponent(postcode);
|
const encodedOrigin = encodeURIComponent(origin);
|
||||||
const dest = encodeURIComponent(destination);
|
const encodedDestination = encodeURIComponent(destination);
|
||||||
// The official api=1 URL scheme doesn't support departure_time.
|
// The official api=1 URL scheme doesn't support departure_time.
|
||||||
// Use the undocumented data= path parameter with protobuf-like encoding:
|
// Use the undocumented data= path parameter with protobuf-like encoding:
|
||||||
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
|
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
|
||||||
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
|
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
|
||||||
return `https://www.google.com/maps/dir/${origin}/${dest}/data=${data}`;
|
return `https://www.google.com/maps/dir/${encodedOrigin}/${encodedDestination}/data=${data}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||||
|
|
@ -181,12 +185,16 @@ export default function JourneyInstructions({
|
||||||
presetJourneys,
|
presetJourneys,
|
||||||
className,
|
className,
|
||||||
showGoogleMapsLink = true,
|
showGoogleMapsLink = true,
|
||||||
|
shareCode,
|
||||||
}: JourneyInstructionsProps) {
|
}: JourneyInstructionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||||
|
|
||||||
// Only transit entries with a destination set
|
// Only transit entries with a destination set
|
||||||
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
|
const transitEntries = useMemo(
|
||||||
|
() => entries.filter((e) => e.mode === 'transit' && e.slug !== ''),
|
||||||
|
[entries]
|
||||||
|
);
|
||||||
const hasPresetJourneys = Boolean(presetJourneys?.length);
|
const hasPresetJourneys = Boolean(presetJourneys?.length);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -207,6 +215,7 @@ export default function JourneyInstructions({
|
||||||
legs: null,
|
legs: null,
|
||||||
minutes: null,
|
minutes: null,
|
||||||
bestMinutes: null,
|
bestMinutes: null,
|
||||||
|
useBest: e.useBest,
|
||||||
loading: true,
|
loading: true,
|
||||||
}));
|
}));
|
||||||
setJourneys([...results]);
|
setJourneys([...results]);
|
||||||
|
|
@ -217,7 +226,8 @@ export default function JourneyInstructions({
|
||||||
mode: 'transit',
|
mode: 'transit',
|
||||||
slug: entry.slug,
|
slug: entry.slug,
|
||||||
});
|
});
|
||||||
fetch(apiUrl('journey', params), { signal: controller.signal })
|
if (shareCode) params.set('share', shareCode);
|
||||||
|
fetch(apiUrl('journey', params), authHeaders({ signal: controller.signal }))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
@ -232,12 +242,12 @@ export default function JourneyInstructions({
|
||||||
prev.map((j, i) =>
|
prev.map((j, i) =>
|
||||||
i === idx
|
i === idx
|
||||||
? {
|
? {
|
||||||
...j,
|
...j,
|
||||||
legs: data.journey,
|
legs: data.journey,
|
||||||
minutes: data.minutes,
|
minutes: data.minutes,
|
||||||
bestMinutes: data.best_minutes,
|
bestMinutes: data.best_minutes,
|
||||||
loading: false,
|
loading: false,
|
||||||
}
|
}
|
||||||
: j
|
: j
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -250,19 +260,20 @@ export default function JourneyInstructions({
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [postcode, hasPresetJourneys, transitEntries, shareCode]);
|
||||||
|
|
||||||
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
|
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
|
||||||
|
|
||||||
const displayedJourneys: JourneyData[] = hasPresetJourneys
|
const displayedJourneys: JourneyData[] = hasPresetJourneys
|
||||||
? (presetJourneys ?? []).map((journey) => ({
|
? (presetJourneys ?? []).map((journey) => ({
|
||||||
slug: journey.slug,
|
slug: journey.slug,
|
||||||
label: journey.label,
|
label: journey.label,
|
||||||
legs: journey.legs,
|
legs: journey.legs,
|
||||||
minutes: journey.minutes,
|
minutes: journey.minutes,
|
||||||
bestMinutes: journey.bestMinutes ?? null,
|
bestMinutes: journey.bestMinutes ?? null,
|
||||||
loading: false,
|
useBest: journey.useBest ?? false,
|
||||||
}))
|
loading: false,
|
||||||
|
}))
|
||||||
: journeys;
|
: journeys;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -272,19 +283,22 @@ export default function JourneyInstructions({
|
||||||
{t('areaPane.journeysFrom', { label })}
|
{t('areaPane.journeysFrom', { label })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{displayedJourneys.map((j) => {
|
{displayedJourneys.map((j, index) => {
|
||||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
|
||||||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||||
const totalMin = j.minutes ?? legSum;
|
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
|
||||||
|
const isBestCase = j.useBest && j.bestMinutes != null;
|
||||||
|
const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null;
|
||||||
|
const destination = j.label || j.slug;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
<div key={`${j.slug}-${index}`} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||||
<div className="flex items-baseline justify-between mb-2">
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||||
{t('areaPane.to', { destination: j.label || j.slug })}
|
{t('areaPane.to', { destination })}
|
||||||
</span>
|
</span>
|
||||||
{!j.loading && totalMin > 0 && (
|
{!j.loading && totalMin > 0 && (
|
||||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||||
|
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
|
||||||
{totalMin} {t('common.min')}
|
{totalMin} {t('common.min')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -303,7 +317,7 @@ export default function JourneyInstructions({
|
||||||
))}
|
))}
|
||||||
{showGoogleMapsLink && (
|
{showGoogleMapsLink && (
|
||||||
<a
|
<a
|
||||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
href={googleMapsUrl(postcode, destination)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||||
|
|
@ -325,17 +339,20 @@ export default function JourneyInstructions({
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : j.minutes != null ? (
|
) : totalMin > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 py-0.5">
|
<div className="flex items-center gap-1.5 py-0.5">
|
||||||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
{!isBestCase && (
|
||||||
|
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||||
|
)}
|
||||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||||
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
|
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
|
||||||
|
{t('common.min')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{showGoogleMapsLink && (
|
{showGoogleMapsLink && (
|
||||||
<a
|
<a
|
||||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
href={googleMapsUrl(postcode, destination)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { PostcodeGeometry, Property } from '../../types';
|
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||||
import type { SearchedLocation } from './LocationSearch';
|
import type { SearchedLocation } from './LocationSearch';
|
||||||
import { useMapData } from '../../hooks/useMapData';
|
import { useMapData } from '../../hooks/useMapData';
|
||||||
import { usePOIData } from '../../hooks/usePOIData';
|
import { usePOIData } from '../../hooks/usePOIData';
|
||||||
|
|
@ -19,6 +19,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||||
import { useLicense } from '../../hooks/useLicense';
|
import { useLicense } from '../../hooks/useLicense';
|
||||||
|
import { stateToParams } from '../../lib/url-state';
|
||||||
import {
|
import {
|
||||||
AreaPane,
|
AreaPane,
|
||||||
Filters,
|
Filters,
|
||||||
|
|
@ -30,7 +31,7 @@ import { PaneFallback } from './map-page/Fallbacks';
|
||||||
import { DesktopMapPage } from './map-page/DesktopMapPage';
|
import { DesktopMapPage } from './map-page/DesktopMapPage';
|
||||||
import { MobileMapPage } from './map-page/MobileMapPage';
|
import { MobileMapPage } from './map-page/MobileMapPage';
|
||||||
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
|
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
|
||||||
import { BookmarkToast, ExportToast } from './map-page/Toasts';
|
import { ExportToast } from './map-page/Toasts';
|
||||||
import { MobileMapLegend } from './map-page/MobileMapLegend';
|
import { MobileMapLegend } from './map-page/MobileMapLegend';
|
||||||
import { useExportController } from './map-page/useExportController';
|
import { useExportController } from './map-page/useExportController';
|
||||||
import {
|
import {
|
||||||
|
|
@ -66,6 +67,7 @@ export default function MapPage({
|
||||||
onClearPendingInfoFeature,
|
onClearPendingInfoFeature,
|
||||||
onNavigateTo,
|
onNavigateTo,
|
||||||
onExportStateChange,
|
onExportStateChange,
|
||||||
|
onDashboardParamsChange,
|
||||||
screenshotMode,
|
screenshotMode,
|
||||||
ogMode,
|
ogMode,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
|
|
@ -75,10 +77,8 @@ export default function MapPage({
|
||||||
user,
|
user,
|
||||||
onLoginClick,
|
onLoginClick,
|
||||||
onRegisterClick,
|
onRegisterClick,
|
||||||
onSaveProperty,
|
onCheckoutLoginClick,
|
||||||
onUnsaveProperty,
|
onCheckoutRegisterClick,
|
||||||
isPropertySaved,
|
|
||||||
getSavedPropertyId,
|
|
||||||
deferTutorial = false,
|
deferTutorial = false,
|
||||||
onSaveSearch,
|
onSaveSearch,
|
||||||
savingSearch,
|
savingSearch,
|
||||||
|
|
@ -92,19 +92,6 @@ export default function MapPage({
|
||||||
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
|
||||||
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
|
|
||||||
|
|
||||||
const handleSavePropertyWithToast = useCallback(
|
|
||||||
(property: Property) => {
|
|
||||||
onSaveProperty?.(property);
|
|
||||||
if (!bookmarkToastDismissed.current) {
|
|
||||||
setShowBookmarkToast(true);
|
|
||||||
bookmarkToastDismissed.current = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onSaveProperty]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
|
|
@ -155,6 +142,22 @@ export default function MapPage({
|
||||||
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
||||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||||
|
|
||||||
|
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
|
||||||
|
if (!isMobile) return undefined;
|
||||||
|
|
||||||
|
const panelRect = mobileDrawerPanelRectRef.current;
|
||||||
|
if (mobileDrawerOpen && panelRect) {
|
||||||
|
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||||
|
if (bottomInset > 0) {
|
||||||
|
return { visibleViewportArea: { bottom: bottomInset } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mobileBottomSheetHeight > 0
|
||||||
|
? { visibleArea: { bottom: mobileBottomSheetHeight } }
|
||||||
|
: undefined;
|
||||||
|
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
|
||||||
|
|
||||||
const mapData = useMapData({
|
const mapData = useMapData({
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
|
|
@ -209,7 +212,8 @@ export default function MapPage({
|
||||||
mapFlyToRef.current?.(
|
mapFlyToRef.current?.(
|
||||||
destination.lat,
|
destination.lat,
|
||||||
destination.lon,
|
destination.lon,
|
||||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
|
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
||||||
|
getMobileMapFlyToOptions()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -220,6 +224,7 @@ export default function MapPage({
|
||||||
activeEntries,
|
activeEntries,
|
||||||
fetchAiFilters,
|
fetchAiFilters,
|
||||||
filters,
|
filters,
|
||||||
|
getMobileMapFlyToOptions,
|
||||||
handleSetEntries,
|
handleSetEntries,
|
||||||
handleSetFilters,
|
handleSetFilters,
|
||||||
mapData.currentView?.zoom,
|
mapData.currentView?.zoom,
|
||||||
|
|
@ -251,17 +256,22 @@ export default function MapPage({
|
||||||
[handleDragEndNoCommit, handleTimeRangeChange]
|
[handleDragEndNoCommit, handleTimeRangeChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
|
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
|
||||||
const license = useLicense();
|
const license = useLicense();
|
||||||
|
|
||||||
const handleTravelTimeSetDestination = useCallback(
|
const handleTravelTimeSetDestination = useCallback(
|
||||||
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
||||||
handleSetDestination(index, slug, label);
|
handleSetDestination(index, slug, label);
|
||||||
if (slug) {
|
if (slug) {
|
||||||
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
|
mapFlyToRef.current?.(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
||||||
|
getMobileMapFlyToOptions()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSetDestination, mapData.currentView?.zoom]
|
[getMobileMapFlyToOptions, handleSetDestination, mapData.currentView?.zoom]
|
||||||
);
|
);
|
||||||
|
|
||||||
const journeyDest = useJourneyDestination(entries);
|
const journeyDest = useJourneyDestination(entries);
|
||||||
|
|
@ -430,9 +440,11 @@ export default function MapPage({
|
||||||
useScreenshotReadySignal({
|
useScreenshotReadySignal({
|
||||||
screenshotMode,
|
screenshotMode,
|
||||||
loading: mapData.loading,
|
loading: mapData.loading,
|
||||||
|
boundsReady: mapData.bounds != null,
|
||||||
dataLength: mapData.data.length,
|
dataLength: mapData.data.length,
|
||||||
postcodeDataLength: mapData.postcodeData.length,
|
postcodeDataLength: mapData.postcodeData.length,
|
||||||
usePostcodeView: mapData.usePostcodeView,
|
usePostcodeView: mapData.usePostcodeView,
|
||||||
|
licenseRequired: mapData.licenseRequired,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMobileHexagonClick = useCallback(
|
const handleMobileHexagonClick = useCallback(
|
||||||
|
|
@ -462,10 +474,42 @@ export default function MapPage({
|
||||||
bounds: mapData.bounds,
|
bounds: mapData.bounds,
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
|
travelTimeEntries: entries,
|
||||||
|
shareCode,
|
||||||
t,
|
t,
|
||||||
onExportStateChange,
|
onExportStateChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dashboardParams = useMemo(
|
||||||
|
() =>
|
||||||
|
stateToParams(
|
||||||
|
mapData.currentView,
|
||||||
|
filters,
|
||||||
|
features,
|
||||||
|
selectedPOICategories,
|
||||||
|
rightPaneTab,
|
||||||
|
entries,
|
||||||
|
shareCode
|
||||||
|
).toString(),
|
||||||
|
[
|
||||||
|
entries,
|
||||||
|
features,
|
||||||
|
filters,
|
||||||
|
mapData.currentView,
|
||||||
|
rightPaneTab,
|
||||||
|
selectedPOICategories,
|
||||||
|
shareCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const checkoutReturnPath = useMemo(
|
||||||
|
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
||||||
|
[dashboardParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDashboardParamsChange?.(dashboardParams);
|
||||||
|
}, [dashboardParams, onDashboardParamsChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||||
}, [mapData.licenseRequired]);
|
}, [mapData.licenseRequired]);
|
||||||
|
|
@ -501,6 +545,7 @@ export default function MapPage({
|
||||||
statsUseFilters={areaStatsUseFilters}
|
statsUseFilters={areaStatsUseFilters}
|
||||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||||
travelTimeEntries={activeEntries}
|
travelTimeEntries={activeEntries}
|
||||||
|
shareCode={shareCode}
|
||||||
isGroupExpanded={isAreaGroupExpanded}
|
isGroupExpanded={isAreaGroupExpanded}
|
||||||
onToggleGroup={toggleAreaGroup}
|
onToggleGroup={toggleAreaGroup}
|
||||||
/>
|
/>
|
||||||
|
|
@ -515,10 +560,6 @@ export default function MapPage({
|
||||||
loading={loadingProperties}
|
loading={loadingProperties}
|
||||||
hexagonId={selectedHexagon?.id || null}
|
hexagonId={selectedHexagon?.id || null}
|
||||||
onLoadMore={handleLoadMoreProperties}
|
onLoadMore={handleLoadMoreProperties}
|
||||||
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
|
|
||||||
onUnsaveProperty={onUnsaveProperty}
|
|
||||||
isPropertySaved={isPropertySaved}
|
|
||||||
getSavedPropertyId={getSavedPropertyId}
|
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
@ -589,40 +630,25 @@ export default function MapPage({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookmarkToast = (
|
|
||||||
<BookmarkToast
|
|
||||||
show={showBookmarkToast}
|
|
||||||
onViewSaved={() => {
|
|
||||||
setShowBookmarkToast(false);
|
|
||||||
onNavigateTo('saved', 'properties');
|
|
||||||
}}
|
|
||||||
onDismissForever={() => {
|
|
||||||
setShowBookmarkToast(false);
|
|
||||||
localStorage.setItem('bookmark_toast_dismissed', '1');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const exportToast = (
|
const exportToast = (
|
||||||
<ExportToast
|
<ExportToast
|
||||||
notice={exportNotice}
|
notice={exportNotice}
|
||||||
offsetForBookmark={showBookmarkToast}
|
|
||||||
closeLabel={t('common.close')}
|
closeLabel={t('common.close')}
|
||||||
onClose={clearExportNotice}
|
onClose={clearExportNotice}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const toasts = (
|
const toasts = exportToast;
|
||||||
<>
|
|
||||||
{bookmarkToast}
|
|
||||||
{exportToast}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const upgradeModal = mapData.licenseRequired ? (
|
const upgradeModal = mapData.licenseRequired ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<UpgradeModal
|
<UpgradeModal
|
||||||
isLoggedIn={!!user}
|
isLoggedIn={!!user}
|
||||||
onLoginClick={onLoginClick}
|
onLoginClick={() =>
|
||||||
onRegisterClick={onRegisterClick}
|
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||||
onStartCheckout={() => license.startCheckout()}
|
}
|
||||||
|
onRegisterClick={() =>
|
||||||
|
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||||
|
}
|
||||||
|
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||||
onZoomToFreeZone={handleZoomToFreeZone}
|
onZoomToFreeZone={handleZoomToFreeZone}
|
||||||
isShareReturn={!!shareReturnViewRef.current}
|
isShareReturn={!!shareReturnViewRef.current}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState, useCallback } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Property } from '../../types';
|
import { Property } from '../../types';
|
||||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||||
|
|
@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
|
||||||
import { SearchInput } from '../ui/SearchInput';
|
import { SearchInput } from '../ui/SearchInput';
|
||||||
import { EmptyState } from '../ui/EmptyState';
|
import { EmptyState } from '../ui/EmptyState';
|
||||||
import { InfoIcon } from '../ui/icons';
|
import { InfoIcon } from '../ui/icons';
|
||||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
|
||||||
import { ts } from '../../i18n/server';
|
import { ts } from '../../i18n/server';
|
||||||
|
|
||||||
interface PropertiesPaneProps {
|
interface PropertiesPaneProps {
|
||||||
|
|
@ -17,10 +16,6 @@ interface PropertiesPaneProps {
|
||||||
hexagonId: string | null;
|
hexagonId: string | null;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onNavigateToSource?: (slug: string) => 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({
|
export function PropertiesPane({
|
||||||
|
|
@ -30,15 +25,15 @@ export function PropertiesPane({
|
||||||
hexagonId,
|
hexagonId,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
onSaveProperty,
|
|
||||||
onUnsaveProperty,
|
|
||||||
isPropertySaved,
|
|
||||||
getSavedPropertyId,
|
|
||||||
}: PropertiesPaneProps) {
|
}: PropertiesPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch('');
|
||||||
|
}, [hexagonId]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
return query
|
return query
|
||||||
|
|
@ -100,14 +95,7 @@ export function PropertiesPane({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{filtered.map((property, idx) => (
|
{filtered.map((property, idx) => (
|
||||||
<PropertyCard
|
<PropertyCard key={idx} property={property} />
|
||||||
key={idx}
|
|
||||||
property={property}
|
|
||||||
onSave={onSaveProperty}
|
|
||||||
onUnsave={onUnsaveProperty}
|
|
||||||
isSaved={isPropertySaved?.(property.address, property.postcode)}
|
|
||||||
savedId={getSavedPropertyId?.(property.address, property.postcode)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{properties.length < total && (
|
{properties.length < total && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -151,27 +139,8 @@ function PropertyLoadingSkeleton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyCard({
|
function PropertyCard({ property }: { property: Property }) {
|
||||||
property,
|
|
||||||
onSave,
|
|
||||||
onUnsave,
|
|
||||||
isSaved,
|
|
||||||
savedId,
|
|
||||||
}: {
|
|
||||||
property: Property;
|
|
||||||
onSave?: (property: Property) => void;
|
|
||||||
onUnsave?: (id: string) => void;
|
|
||||||
isSaved?: boolean;
|
|
||||||
savedId?: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
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 price = getNum(property, 'Last known price');
|
||||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||||
const pricePerSqm = getNum(property, 'Price per sqm');
|
const pricePerSqm = getNum(property, 'Price per sqm');
|
||||||
|
|
@ -197,19 +166,6 @@ function PropertyCard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 ? t('propertyCard.unsaveProperty') : t('propertyCard.saveProperty')}
|
|
||||||
>
|
|
||||||
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{property.property_sub_type && (
|
{property.property_sub_type && (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { Slider } from '../ui/Slider';
|
import { Slider } from '../ui/Slider';
|
||||||
import { IconButton } from '../ui/IconButton';
|
import { IconButton } from '../ui/IconButton';
|
||||||
import { PillToggle } from '../ui/PillToggle';
|
import { PillToggle } from '../ui/PillToggle';
|
||||||
|
|
@ -134,10 +134,9 @@ export function TravelTimeCard({
|
||||||
|
|
||||||
{showBestInfo && (
|
{showBestInfo && (
|
||||||
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
|
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
|
||||||
<p
|
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||||
className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"
|
<Trans i18nKey="travel.bestCaseDesc" components={{ strong: <strong /> }} />
|
||||||
dangerouslySetInnerHTML={{ __html: t('travel.bestCaseDesc') }}
|
</p>
|
||||||
/>
|
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,22 @@
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { ExportNotice } from './types';
|
import type { ExportNotice } from './types';
|
||||||
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
|
|
||||||
import { CheckIcon } from '../../ui/icons/CheckIcon';
|
import { CheckIcon } from '../../ui/icons/CheckIcon';
|
||||||
import { CloseIcon } from '../../ui/icons/CloseIcon';
|
import { CloseIcon } from '../../ui/icons/CloseIcon';
|
||||||
import { InfoIcon } from '../../ui/icons/InfoIcon';
|
import { InfoIcon } from '../../ui/icons/InfoIcon';
|
||||||
|
|
||||||
interface BookmarkToastProps {
|
|
||||||
show: boolean;
|
|
||||||
onViewSaved: () => void;
|
|
||||||
onDismissForever: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
|
||||||
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
|
|
||||||
<span>{t('toasts.propertySaved')}</span>
|
|
||||||
<button
|
|
||||||
onClick={onViewSaved}
|
|
||||||
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t('toasts.viewSaved')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDismissForever}
|
|
||||||
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{t('toasts.dontShowAgain')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportToastProps {
|
interface ExportToastProps {
|
||||||
notice: ExportNotice | null;
|
notice: ExportNotice | null;
|
||||||
offsetForBookmark: boolean;
|
|
||||||
closeLabel: string;
|
closeLabel: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportToast({ notice, offsetForBookmark, closeLabel, onClose }: ExportToastProps) {
|
export function ExportToast({ notice, closeLabel, onClose }: ExportToastProps) {
|
||||||
if (!notice) return null;
|
if (!notice) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={notice.kind === 'error' ? 'alert' : 'status'}
|
role={notice.kind === 'error' ? 'alert' : 'status'}
|
||||||
aria-live={notice.kind === 'error' ? 'assertive' : 'polite'}
|
aria-live={notice.kind === 'error' ? 'assertive' : 'polite'}
|
||||||
className={`fixed ${offsetForBookmark ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
|
className="fixed bottom-6 left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in"
|
||||||
>
|
>
|
||||||
{notice.kind === 'success' ? (
|
{notice.kind === 'success' ? (
|
||||||
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
|
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { MapFlyTo } from './types';
|
||||||
|
|
||||||
type MapData = ReturnType<typeof useMapData>;
|
type MapData = ReturnType<typeof useMapData>;
|
||||||
type RightPaneTab = 'properties' | 'area';
|
type RightPaneTab = 'properties' | 'area';
|
||||||
|
const SCREENSHOT_MAP_IDLE_FALLBACK_MS = 1000;
|
||||||
|
|
||||||
export function useInitialMapPageView(
|
export function useInitialMapPageView(
|
||||||
mapData: MapData,
|
mapData: MapData,
|
||||||
|
|
@ -110,36 +111,72 @@ export function useMobileBackNavigationGuard(isMobile: boolean) {
|
||||||
interface UseScreenshotReadySignalOptions {
|
interface UseScreenshotReadySignalOptions {
|
||||||
screenshotMode?: boolean;
|
screenshotMode?: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
boundsReady: boolean;
|
||||||
dataLength: number;
|
dataLength: number;
|
||||||
postcodeDataLength: number;
|
postcodeDataLength: number;
|
||||||
usePostcodeView: boolean;
|
usePostcodeView: boolean;
|
||||||
|
licenseRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScreenshotReadySignal({
|
export function useScreenshotReadySignal({
|
||||||
screenshotMode,
|
screenshotMode,
|
||||||
loading,
|
loading,
|
||||||
|
boundsReady,
|
||||||
dataLength,
|
dataLength,
|
||||||
postcodeDataLength,
|
postcodeDataLength,
|
||||||
usePostcodeView,
|
usePostcodeView,
|
||||||
|
licenseRequired,
|
||||||
}: UseScreenshotReadySignalOptions) {
|
}: UseScreenshotReadySignalOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screenshotMode && !loading) {
|
if (!screenshotMode || loading || !boundsReady) return;
|
||||||
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
|
||||||
if (hasData) {
|
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
||||||
// Wait for both deck.gl data and MapLibre base map tile rendering.
|
if (!hasData && !licenseRequired) return;
|
||||||
const waitAndSignal = () => {
|
|
||||||
if (window.__map_idle) {
|
let cancelled = false;
|
||||||
requestAnimationFrame(() => {
|
let signalled = false;
|
||||||
requestAnimationFrame(() => {
|
let frameId: number | null = null;
|
||||||
window.__screenshot_ready = true;
|
let timeoutId: number | null = null;
|
||||||
});
|
|
||||||
});
|
const signalReady = () => {
|
||||||
} else {
|
if (cancelled || signalled) return;
|
||||||
requestAnimationFrame(waitAndSignal);
|
signalled = true;
|
||||||
}
|
if (timeoutId != null) window.clearTimeout(timeoutId);
|
||||||
};
|
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||||
waitAndSignal();
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!cancelled) window.__screenshot_ready = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitAndSignal = () => {
|
||||||
|
if (window.__map_idle) {
|
||||||
|
signalReady();
|
||||||
|
} else {
|
||||||
|
frameId = requestAnimationFrame(waitAndSignal);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [screenshotMode, loading, dataLength, postcodeDataLength, usePostcodeView]);
|
|
||||||
|
// In webpack dev mode MapLibre's idle event can be delayed by the dev
|
||||||
|
// client/HMR churn even after data has rendered. Keep production-quality
|
||||||
|
// waiting when idle fires, but avoid forcing the screenshot service to hit
|
||||||
|
// its much longer timeout in local development.
|
||||||
|
timeoutId = window.setTimeout(signalReady, SCREENSHOT_MAP_IDLE_FALLBACK_MS);
|
||||||
|
waitAndSignal();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timeoutId != null) window.clearTimeout(timeoutId);
|
||||||
|
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
screenshotMode,
|
||||||
|
loading,
|
||||||
|
boundsReady,
|
||||||
|
dataLength,
|
||||||
|
postcodeDataLength,
|
||||||
|
usePostcodeView,
|
||||||
|
licenseRequired,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
MapFlyToOptions,
|
MapFlyToOptions,
|
||||||
POICategoryGroup,
|
POICategoryGroup,
|
||||||
Property,
|
|
||||||
ViewState,
|
ViewState,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||||
|
|
@ -33,6 +32,7 @@ export interface MapPageProps {
|
||||||
onClearPendingInfoFeature: () => void;
|
onClearPendingInfoFeature: () => void;
|
||||||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||||
onExportStateChange?: (state: ExportState) => void;
|
onExportStateChange?: (state: ExportState) => void;
|
||||||
|
onDashboardParamsChange?: (params: string) => void;
|
||||||
screenshotMode?: boolean;
|
screenshotMode?: boolean;
|
||||||
ogMode?: boolean;
|
ogMode?: boolean;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
|
@ -42,10 +42,8 @@ export interface MapPageProps {
|
||||||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||||
onLoginClick: () => void;
|
onLoginClick: () => void;
|
||||||
onRegisterClick: () => void;
|
onRegisterClick: () => void;
|
||||||
onSaveProperty?: (property: Property) => void;
|
onCheckoutLoginClick?: (returnPath?: string) => void;
|
||||||
onUnsaveProperty?: (id: string) => void;
|
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
||||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
|
||||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
|
||||||
deferTutorial?: boolean;
|
deferTutorial?: boolean;
|
||||||
onSaveSearch?: (name: string) => Promise<void>;
|
onSaveSearch?: (name: string) => Promise<void>;
|
||||||
savingSearch?: boolean;
|
savingSearch?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
|
||||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
||||||
import { trackEvent } from '../../../lib/analytics';
|
import { trackEvent } from '../../../lib/analytics';
|
||||||
import type { ExportNotice, ExportState } from './types';
|
import type { ExportNotice, ExportState } from './types';
|
||||||
|
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||||
|
import { buildTravelParam } from '../../../lib/travel-params';
|
||||||
|
|
||||||
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
||||||
const EXPORT_TIMEOUT_MS = 150_000;
|
const EXPORT_TIMEOUT_MS = 150_000;
|
||||||
|
|
@ -65,10 +67,24 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.slug) continue;
|
||||||
|
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||||
|
if (entry.useBest) value += ':b';
|
||||||
|
if (entry.timeRange) {
|
||||||
|
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||||
|
}
|
||||||
|
params.append('tt', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface UseExportControllerOptions {
|
interface UseExportControllerOptions {
|
||||||
bounds: Bounds | null;
|
bounds: Bounds | null;
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
|
travelTimeEntries: TravelTimeEntry[];
|
||||||
|
shareCode?: string;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
onExportStateChange?: (state: ExportState) => void;
|
onExportStateChange?: (state: ExportState) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +93,8 @@ export function useExportController({
|
||||||
bounds,
|
bounds,
|
||||||
filters,
|
filters,
|
||||||
features,
|
features,
|
||||||
|
travelTimeEntries,
|
||||||
|
shareCode,
|
||||||
t,
|
t,
|
||||||
onExportStateChange,
|
onExportStateChange,
|
||||||
}: UseExportControllerOptions) {
|
}: UseExportControllerOptions) {
|
||||||
|
|
@ -126,6 +144,10 @@ export function useExportController({
|
||||||
});
|
});
|
||||||
const filterStr = buildFilterString(filters, features);
|
const filterStr = buildFilterString(filters, features);
|
||||||
if (filterStr) params.set('filters', filterStr);
|
if (filterStr) params.set('filters', filterStr);
|
||||||
|
const travelParam = buildTravelParam(travelTimeEntries);
|
||||||
|
if (travelParam) params.set('travel', travelParam);
|
||||||
|
appendTravelStateParams(params, travelTimeEntries);
|
||||||
|
if (shareCode) params.set('share', shareCode);
|
||||||
const url = apiUrl('export', params);
|
const url = apiUrl('export', params);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
@ -161,7 +183,17 @@ export function useExportController({
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [bounds, clearExportNotice, exporting, features, filters, showExportNotice, t]);
|
}, [
|
||||||
|
bounds,
|
||||||
|
clearExportNotice,
|
||||||
|
exporting,
|
||||||
|
features,
|
||||||
|
filters,
|
||||||
|
shareCode,
|
||||||
|
showExportNotice,
|
||||||
|
t,
|
||||||
|
travelTimeEntries,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export default function PricingPage({
|
||||||
<button
|
<button
|
||||||
onClick={() => license.startCheckout()}
|
onClick={() => license.startCheckout()}
|
||||||
disabled={license.checkingOut}
|
disabled={license.checkingOut}
|
||||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
className="w-full mt-auto px-5 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||||
{license.checkingOut
|
{license.checkingOut
|
||||||
|
|
@ -129,7 +129,7 @@ export default function PricingPage({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onRegisterClick}
|
onClick={onRegisterClick}
|
||||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
className="w-full mt-auto px-5 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25"
|
||||||
>
|
>
|
||||||
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ type View = 'login' | 'register' | 'forgot';
|
||||||
|
|
||||||
export default function AuthModal({
|
export default function AuthModal({
|
||||||
onClose,
|
onClose,
|
||||||
|
onAuthenticated,
|
||||||
onLogin,
|
onLogin,
|
||||||
onRegister,
|
onRegister,
|
||||||
onOAuthLogin,
|
onOAuthLogin,
|
||||||
|
|
@ -18,6 +19,7 @@ export default function AuthModal({
|
||||||
initialTab = 'login',
|
initialTab = 'login',
|
||||||
}: {
|
}: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onAuthenticated?: () => void;
|
||||||
onLogin: (email: string, password: string) => Promise<void>;
|
onLogin: (email: string, password: string) => Promise<void>;
|
||||||
onRegister: (email: string, password: string) => Promise<void>;
|
onRegister: (email: string, password: string) => Promise<void>;
|
||||||
onOAuthLogin: (provider: string) => Promise<void>;
|
onOAuthLogin: (provider: string) => Promise<void>;
|
||||||
|
|
@ -52,9 +54,11 @@ export default function AuthModal({
|
||||||
try {
|
try {
|
||||||
if (view === 'login') {
|
if (view === 'login') {
|
||||||
await onLogin(email, password);
|
await onLogin(email, password);
|
||||||
|
onAuthenticated?.();
|
||||||
onClose();
|
onClose();
|
||||||
} else if (view === 'register') {
|
} else if (view === 'register') {
|
||||||
await onRegister(email, password);
|
await onRegister(email, password);
|
||||||
|
onAuthenticated?.();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
await onForgotPassword(email);
|
await onForgotPassword(email);
|
||||||
|
|
@ -64,19 +68,20 @@ export default function AuthModal({
|
||||||
// Error is handled by the hook
|
// Error is handled by the hook
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
|
[view, email, password, onLogin, onRegister, onForgotPassword, onAuthenticated, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOAuth = useCallback(
|
const handleOAuth = useCallback(
|
||||||
async (provider: string) => {
|
async (provider: string) => {
|
||||||
try {
|
try {
|
||||||
await onOAuthLogin(provider);
|
await onOAuthLogin(provider);
|
||||||
|
onAuthenticated?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
// Error is handled by the hook
|
// Error is handled by the hook
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onOAuthLogin, onClose]
|
[onOAuthLogin, onAuthenticated, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
const title =
|
const title =
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ export type Page =
|
||||||
| 'privacy-security'
|
| 'privacy-security'
|
||||||
| 'account'
|
| 'account'
|
||||||
| 'saved'
|
| 'saved'
|
||||||
| 'invites'
|
|
||||||
| 'invite';
|
| 'invite';
|
||||||
|
|
||||||
export interface HeaderExportState {
|
export interface HeaderExportState {
|
||||||
|
|
@ -59,7 +58,6 @@ export const PAGE_PATHS: Record<Page, string> = {
|
||||||
methodology: '/methodology',
|
methodology: '/methodology',
|
||||||
'privacy-security': '/privacy-security',
|
'privacy-security': '/privacy-security',
|
||||||
saved: '/saved',
|
saved: '/saved',
|
||||||
invites: '/invites',
|
|
||||||
account: '/account',
|
account: '/account',
|
||||||
invite: '/invite',
|
invite: '/invite',
|
||||||
};
|
};
|
||||||
|
|
@ -68,10 +66,12 @@ const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023p
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
activePage,
|
activePage,
|
||||||
|
activeHash,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
exportState,
|
exportState,
|
||||||
|
dashboardParams,
|
||||||
onSaveSearch,
|
onSaveSearch,
|
||||||
savingSearch,
|
savingSearch,
|
||||||
user,
|
user,
|
||||||
|
|
@ -81,10 +81,12 @@ export default function Header({
|
||||||
isMobile,
|
isMobile,
|
||||||
}: {
|
}: {
|
||||||
activePage: Page;
|
activePage: Page;
|
||||||
onPageChange: (page: Page) => void;
|
activeHash: string;
|
||||||
|
onPageChange: (page: Page, hash?: string) => void;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
exportState: HeaderExportState | null;
|
exportState: HeaderExportState | null;
|
||||||
|
dashboardParams: string;
|
||||||
onSaveSearch: (() => void) | null;
|
onSaveSearch: (() => void) | null;
|
||||||
savingSearch: boolean;
|
savingSearch: boolean;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
|
|
@ -132,7 +134,8 @@ export default function Header({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShare = useCallback(async () => {
|
const handleShare = useCallback(async () => {
|
||||||
const params = window.location.search.replace(/^\?/, '');
|
const params =
|
||||||
|
activePage === 'dashboard' ? dashboardParams : window.location.search.replace(/^\?/, '');
|
||||||
if (!params) {
|
if (!params) {
|
||||||
doCopy(window.location.href);
|
doCopy(window.location.href);
|
||||||
return;
|
return;
|
||||||
|
|
@ -147,17 +150,18 @@ export default function Header({
|
||||||
} finally {
|
} finally {
|
||||||
setSharing(false);
|
setSharing(false);
|
||||||
}
|
}
|
||||||
}, [doCopy]);
|
}, [activePage, dashboardParams, doCopy]);
|
||||||
|
|
||||||
const navLink = (page: Page, e: React.MouseEvent) => {
|
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onPageChange(page);
|
onPageChange(page, hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabClass = (page: Page) =>
|
const tabClass = (page: Page, hash?: string) =>
|
||||||
`inline-flex cursor-pointer items-center px-4 py-1.5 rounded text-sm font-medium transition-colors ${
|
`inline-flex cursor-pointer items-center px-4 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||||
activePage === page
|
activePage === page &&
|
||||||
|
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites')
|
||||||
? 'bg-navy-700 text-white'
|
? 'bg-navy-700 text-white'
|
||||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||||
}`;
|
}`;
|
||||||
|
|
@ -188,9 +192,9 @@ export default function Header({
|
||||||
</a>
|
</a>
|
||||||
{user && (
|
{user && (
|
||||||
<a
|
<a
|
||||||
href={PAGE_PATHS.invites}
|
href={`${PAGE_PATHS.account}#invites`}
|
||||||
className={tabClass('invites')}
|
className={tabClass('account', 'invites')}
|
||||||
onClick={(e) => navLink('invites', e)}
|
onClick={(e) => navLink('account', e, 'invites')}
|
||||||
>
|
>
|
||||||
{t('header.inviteFriends')}
|
{t('header.inviteFriends')}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -354,6 +358,7 @@ export default function Header({
|
||||||
{useSidebarNav && menuOpen && (
|
{useSidebarNav && menuOpen && (
|
||||||
<MobileMenu
|
<MobileMenu
|
||||||
activePage={activePage}
|
activePage={activePage}
|
||||||
|
activeHash={activeHash}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
|
|
||||||
interface LicenseSuccessModalProps {
|
interface LicenseSuccessModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
status?: 'verifying' | 'success' | 'delayed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
|
export default function LicenseSuccessModal({
|
||||||
|
onClose,
|
||||||
|
status = 'success',
|
||||||
|
}: LicenseSuccessModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isSuccess = status === 'success';
|
||||||
|
const isVerifying = status === 'verifying';
|
||||||
const particles = useMemo(
|
const particles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Array.from({ length: 40 }, (_, i) => ({
|
Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
|
@ -24,47 +31,75 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isSuccess) return;
|
||||||
const timer = setTimeout(onClose, 8000);
|
const timer = setTimeout(onClose, 8000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [onClose]);
|
}, [isSuccess, onClose]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
status === 'verifying'
|
||||||
|
? t('licenseSuccess.verifyingTitle')
|
||||||
|
: status === 'delayed'
|
||||||
|
? t('licenseSuccess.activationDelayedTitle')
|
||||||
|
: t('licenseSuccess.title');
|
||||||
|
const subtitle =
|
||||||
|
status === 'verifying'
|
||||||
|
? t('licenseSuccess.verifyingSubtitle')
|
||||||
|
: status === 'delayed'
|
||||||
|
? t('licenseSuccess.activationDelayedSubtitle')
|
||||||
|
: t('licenseSuccess.subtitle');
|
||||||
|
const description =
|
||||||
|
status === 'verifying'
|
||||||
|
? t('licenseSuccess.verifyingDescription')
|
||||||
|
: status === 'delayed'
|
||||||
|
? t('licenseSuccess.activationDelayedDescription')
|
||||||
|
: t('licenseSuccess.description');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
{isSuccess && (
|
||||||
{particles.map((p) => (
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
<div
|
{particles.map((p) => (
|
||||||
key={p.id}
|
<div
|
||||||
className="absolute animate-confetti"
|
key={p.id}
|
||||||
style={{
|
className="absolute animate-confetti"
|
||||||
left: `${p.left}%`,
|
style={{
|
||||||
top: '-10px',
|
left: `${p.left}%`,
|
||||||
width: `${p.size}px`,
|
top: '-10px',
|
||||||
height: `${p.size}px`,
|
width: `${p.size}px`,
|
||||||
backgroundColor: p.color,
|
height: `${p.size}px`,
|
||||||
borderRadius: p.isCircle ? '50%' : '2px',
|
backgroundColor: p.color,
|
||||||
animationDelay: `${p.delay}s`,
|
borderRadius: p.isCircle ? '50%' : '2px',
|
||||||
animationDuration: `${p.duration}s`,
|
animationDelay: `${p.delay}s`,
|
||||||
}}
|
animationDuration: `${p.duration}s`,
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
||||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||||
<div className="text-5xl mb-3">🎉</div>
|
<div className="h-14 mb-3 flex items-center justify-center">
|
||||||
<h2 className="text-2xl font-bold text-white">{t('licenseSuccess.title')}</h2>
|
{isVerifying ? (
|
||||||
<p className="text-warm-300 text-sm mt-2">{t('licenseSuccess.subtitle')}</p>
|
<SpinnerIcon className="w-10 h-10 animate-spin text-teal-300" />
|
||||||
|
) : (
|
||||||
|
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
||||||
|
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-6">
|
<div className="px-6 py-6">
|
||||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
|
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">{description}</p>
|
||||||
{t('licenseSuccess.description')}
|
{!isVerifying && (
|
||||||
</p>
|
<button
|
||||||
<button
|
onClick={onClose}
|
||||||
onClick={onClose}
|
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||||
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
>
|
||||||
>
|
{isSuccess ? t('licenseSuccess.startExploring') : t('licenseSuccess.stayOnPricing')}
|
||||||
{t('licenseSuccess.startExploring')}
|
</button>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||||
|
|
||||||
interface MobileMenuProps {
|
interface MobileMenuProps {
|
||||||
activePage: Page;
|
activePage: Page;
|
||||||
onPageChange: (page: Page) => void;
|
activeHash: string;
|
||||||
|
onPageChange: (page: Page, hash?: string) => void;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
exportState: HeaderExportState | null;
|
exportState: HeaderExportState | null;
|
||||||
|
|
@ -32,6 +33,7 @@ interface MobileMenuProps {
|
||||||
|
|
||||||
export default function MobileMenu({
|
export default function MobileMenu({
|
||||||
activePage,
|
activePage,
|
||||||
|
activeHash,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
|
|
@ -52,25 +54,30 @@ export default function MobileMenu({
|
||||||
const emailLocal = emailParts?.[0] ?? '';
|
const emailLocal = emailParts?.[0] ?? '';
|
||||||
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
|
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
|
||||||
|
|
||||||
const mobileNavItem = (page: Page, label: string) => (
|
const mobileNavItem = (page: Page, label: string, hash?: string) => {
|
||||||
<a
|
const isActive =
|
||||||
key={page}
|
activePage === page &&
|
||||||
href={PAGE_PATHS[page]}
|
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites');
|
||||||
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
|
const href = hash ? `${PAGE_PATHS[page]}#${hash}` : PAGE_PATHS[page];
|
||||||
activePage === page
|
|
||||||
? 'bg-navy-700 text-white'
|
return (
|
||||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
<a
|
||||||
}`}
|
key={hash ? `${page}-${hash}` : page}
|
||||||
onClick={(e) => {
|
href={href}
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
|
||||||
e.preventDefault();
|
isActive ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||||
onPageChange(page);
|
}`}
|
||||||
onClose();
|
onClick={(e) => {
|
||||||
}}
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||||
>
|
e.preventDefault();
|
||||||
{label}
|
onPageChange(page, hash);
|
||||||
</a>
|
onClose();
|
||||||
);
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const dashboardActionClass =
|
const dashboardActionClass =
|
||||||
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
|
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
|
||||||
|
|
@ -169,7 +176,7 @@ export default function MobileMenu({
|
||||||
{user?.subscription !== 'licensed' &&
|
{user?.subscription !== 'licensed' &&
|
||||||
!user?.isAdmin &&
|
!user?.isAdmin &&
|
||||||
mobileNavItem('pricing', t('header.pricing'))}
|
mobileNavItem('pricing', t('header.pricing'))}
|
||||||
{user && mobileNavItem('invites', t('header.inviteFriends'))}
|
{user && mobileNavItem('account', t('header.inviteFriends'), 'invites')}
|
||||||
{user && mobileNavItem('account', t('userMenu.account'))}
|
{user && mobileNavItem('account', t('userMenu.account'))}
|
||||||
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
|
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function SubNav({ tabs, activeTab, onTabChange }: SubNavProps) {
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${
|
className={`cursor-pointer px-4 py-2 text-sm font-medium border-b-2 ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'border-teal-500 text-teal-700 dark:text-teal-400'
|
? 'border-teal-500 text-teal-700 dark:text-teal-400'
|
||||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export default function UpgradeModal({
|
||||||
<button
|
<button
|
||||||
onClick={handleUpgrade}
|
onClick={handleUpgrade}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||||
{loading
|
{loading
|
||||||
|
|
@ -110,7 +110,7 @@ export default function UpgradeModal({
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onRegisterClick}
|
onClick={onRegisterClick}
|
||||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
|
||||||
>
|
>
|
||||||
{t('upgrade.registerAndUpgrade')}
|
{t('upgrade.registerAndUpgrade')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } fr
|
||||||
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
||||||
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
||||||
import type { TravelTimeEntry } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
|
import { buildTravelParam } from '../lib/travel-params';
|
||||||
|
|
||||||
interface SelectedHexagon {
|
interface SelectedHexagon {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -91,19 +92,7 @@ export function useHexagonSelection({
|
||||||
return propertiesRequestIdRef.current === requestId;
|
return propertiesRequestIdRef.current === requestId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const travelParam = useMemo(() => {
|
const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
|
||||||
const segments: string[] = [];
|
|
||||||
for (const entry of travelTimeEntries) {
|
|
||||||
if (!entry.slug) continue;
|
|
||||||
let segment = `${entry.mode}:${entry.slug}`;
|
|
||||||
if (entry.useBest) segment += ':best';
|
|
||||||
if (entry.timeRange) {
|
|
||||||
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
|
||||||
}
|
|
||||||
segments.push(segment);
|
|
||||||
}
|
|
||||||
return segments.join('|');
|
|
||||||
}, [travelTimeEntries]);
|
|
||||||
|
|
||||||
const fetchHexagonStats = useCallback(
|
const fetchHexagonStats = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
@ -165,9 +154,9 @@ export function useHexagonSelection({
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchUnfilteredAreaCount = useCallback(
|
const fetchUnfilteredAreaCount = useCallback(
|
||||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
|
||||||
if (!hasStatsFilters) {
|
if (!hasStatsFilters) {
|
||||||
setUnfilteredAreaCount(null);
|
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,9 +164,9 @@ export function useHexagonSelection({
|
||||||
selection.type === 'postcode'
|
selection.type === 'postcode'
|
||||||
? await fetchPostcodeStats(selection.id, signal, false)
|
? await fetchPostcodeStats(selection.id, signal, false)
|
||||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||||
setUnfilteredAreaCount(stats.count);
|
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
|
||||||
},
|
},
|
||||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters]
|
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshUnfilteredAreaCount = useCallback(
|
const refreshUnfilteredAreaCount = useCallback(
|
||||||
|
|
@ -185,18 +174,19 @@ export function useHexagonSelection({
|
||||||
selection: SelectedHexagon,
|
selection: SelectedHexagon,
|
||||||
statsCount: number,
|
statsCount: number,
|
||||||
includeFilters: boolean,
|
includeFilters: boolean,
|
||||||
|
requestId: number,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
|
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
|
||||||
setUnfilteredAreaCount(null);
|
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
|
fetchUnfilteredAreaCount(selection, requestId, signal).catch((error) =>
|
||||||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[fetchUnfilteredAreaCount, hasStatsFilters]
|
[fetchUnfilteredAreaCount, hasStatsFilters, isCurrentAreaRequest]
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||||
|
|
@ -321,6 +311,7 @@ export function useHexagonSelection({
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setPropertiesTotal(0);
|
setPropertiesTotal(0);
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
setAreaStats(null);
|
||||||
setUnfilteredAreaCount(null);
|
setUnfilteredAreaCount(null);
|
||||||
setRightPaneTab('area');
|
setRightPaneTab('area');
|
||||||
|
|
||||||
|
|
@ -330,7 +321,7 @@ export function useHexagonSelection({
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
if (!isCurrentAreaRequest(requestId)) return;
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||||
})
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -342,7 +333,7 @@ export function useHexagonSelection({
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
if (!isCurrentAreaRequest(requestId)) return;
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||||
})
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -514,6 +505,7 @@ export function useHexagonSelection({
|
||||||
nextSelection,
|
nextSelection,
|
||||||
nextStats.count,
|
nextStats.count,
|
||||||
areaStatsUseFilters,
|
areaStatsUseFilters,
|
||||||
|
requestId,
|
||||||
controller.signal
|
controller.signal
|
||||||
);
|
);
|
||||||
refreshProperties(nextSelection);
|
refreshProperties(nextSelection);
|
||||||
|
|
@ -569,6 +561,9 @@ export function useHexagonSelection({
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setPropertiesTotal(0);
|
setPropertiesTotal(0);
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
invalidatePropertyRequests();
|
||||||
|
setAreaStats(null);
|
||||||
|
setUnfilteredAreaCount(null);
|
||||||
|
|
||||||
setLoadingAreaStats(true);
|
setLoadingAreaStats(true);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -589,7 +584,7 @@ export function useHexagonSelection({
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters);
|
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId);
|
||||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||||
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
||||||
if (selectedHexagon.type === 'postcode') {
|
if (selectedHexagon.type === 'postcode') {
|
||||||
|
|
@ -620,6 +615,7 @@ export function useHexagonSelection({
|
||||||
fetchHexagonProperties,
|
fetchHexagonProperties,
|
||||||
fetchPostcodeProperties,
|
fetchPostcodeProperties,
|
||||||
invalidateAreaRequests,
|
invalidateAreaRequests,
|
||||||
|
invalidatePropertyRequests,
|
||||||
isCurrentAreaRequest,
|
isCurrentAreaRequest,
|
||||||
refreshUnfilteredAreaCount,
|
refreshUnfilteredAreaCount,
|
||||||
]);
|
]);
|
||||||
|
|
@ -656,7 +652,7 @@ export function useHexagonSelection({
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
if (!isCurrentAreaRequest(requestId)) return;
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||||
if (openProperties && stats.count > 0) {
|
if (openProperties && stats.count > 0) {
|
||||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||||
}
|
}
|
||||||
|
|
@ -696,6 +692,7 @@ export function useHexagonSelection({
|
||||||
setProperties([]);
|
setProperties([]);
|
||||||
setPropertiesTotal(0);
|
setPropertiesTotal(0);
|
||||||
setPropertiesOffset(0);
|
setPropertiesOffset(0);
|
||||||
|
setAreaStats(null);
|
||||||
setUnfilteredAreaCount(null);
|
setUnfilteredAreaCount(null);
|
||||||
setRightPaneTab('area');
|
setRightPaneTab('area');
|
||||||
setLoadingAreaStats(true);
|
setLoadingAreaStats(true);
|
||||||
|
|
@ -710,7 +707,7 @@ export function useHexagonSelection({
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (!isCurrentAreaRequest(requestId)) return;
|
if (!isCurrentAreaRequest(requestId)) return;
|
||||||
setAreaStats(stats);
|
setAreaStats(stats);
|
||||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||||
})
|
})
|
||||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,14 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
||||||
const [pois, setPois] = useState<POI[]>([]);
|
const [pois, setPois] = useState<POI[]>([]);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
requestIdRef.current += 1;
|
||||||
|
const requestId = requestIdRef.current;
|
||||||
|
|
||||||
if (!bounds || selectedCategories.size === 0) {
|
if (!bounds || selectedCategories.size === 0) {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
setPois([]);
|
setPois([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +45,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
|
||||||
const json: POIResponse = await res.json();
|
const json: POIResponse = await res.json();
|
||||||
|
if (requestIdRef.current !== requestId) return;
|
||||||
setPois(json.pois || []);
|
setPois(json.pois || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logNonAbortError('Failed to fetch POIs', err);
|
logNonAbortError('Failed to fetch POIs', err);
|
||||||
|
|
@ -50,6 +56,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
||||||
if (debounceRef.current) {
|
if (debounceRef.current) {
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, [bounds, selectedCategories]);
|
}, [bounds, selectedCategories]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
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;
|
|
||||||
floorArea?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SavedProperty {
|
|
||||||
id: string;
|
|
||||||
address: string;
|
|
||||||
postcode: string;
|
|
||||||
data: SavedPropertyData;
|
|
||||||
notes: string;
|
|
||||||
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,
|
|
||||||
notes: (raw.notes as string) || '',
|
|
||||||
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'),
|
|
||||||
floorArea: getNum(property, 'Total floor area (sqm)'),
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[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]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePropertyNotes = useCallback(async (id: string, notes: string) => {
|
|
||||||
try {
|
|
||||||
await pb.collection('saved_properties').update(id, { notes });
|
|
||||||
setProperties((prev) => prev.map((p) => (p.id === id ? { ...p, notes } : p)));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update notes');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
properties,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
fetchProperties,
|
|
||||||
saveProperty,
|
|
||||||
deleteProperty,
|
|
||||||
isPropertySaved,
|
|
||||||
getSavedPropertyId,
|
|
||||||
updatePropertyNotes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -102,12 +102,12 @@ export function useSavedSearches(userId: string | null) {
|
||||||
}, [userId, fetchRecords, startPolling, stopPolling]);
|
}, [userId, fetchRecords, startPolling, stopPolling]);
|
||||||
|
|
||||||
const saveSearch = useCallback(
|
const saveSearch = useCallback(
|
||||||
async (name: string) => {
|
async (name: string, paramsOverride?: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = window.location.search.replace(/^\?/, '');
|
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||||
|
|
||||||
// Create record immediately without screenshot
|
// Create record immediately without screenshot
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
|
||||||
|
|
@ -842,7 +842,7 @@ const de: Translations = {
|
||||||
viewPropertiesShort: 'Immobilien ansehen',
|
viewPropertiesShort: 'Immobilien ansehen',
|
||||||
priceHistory: 'Preisentwicklung',
|
priceHistory: 'Preisentwicklung',
|
||||||
journeysFrom: 'Reisezeiten für {{label}}',
|
journeysFrom: 'Reisezeiten für {{label}}',
|
||||||
to: 'Von {{destination}}',
|
to: 'Nach {{destination}}',
|
||||||
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
||||||
viewOnGoogleMaps: 'Auf Google Maps ansehen',
|
viewOnGoogleMaps: 'Auf Google Maps ansehen',
|
||||||
walk: 'Zu Fuß',
|
walk: 'Zu Fuß',
|
||||||
|
|
|
||||||
|
|
@ -815,7 +815,7 @@ const en = {
|
||||||
viewPropertiesShort: 'View properties',
|
viewPropertiesShort: 'View properties',
|
||||||
priceHistory: 'Price History',
|
priceHistory: 'Price History',
|
||||||
journeysFrom: 'Journey times for {{label}}',
|
journeysFrom: 'Journey times for {{label}}',
|
||||||
to: 'From {{destination}}',
|
to: 'To {{destination}}',
|
||||||
noJourneyData: 'No journey data available',
|
noJourneyData: 'No journey data available',
|
||||||
viewOnGoogleMaps: 'View on Google Maps',
|
viewOnGoogleMaps: 'View on Google Maps',
|
||||||
walk: 'Walk',
|
walk: 'Walk',
|
||||||
|
|
|
||||||
|
|
@ -847,7 +847,7 @@ const fr: Translations = {
|
||||||
viewPropertiesShort: 'Voir les propriétés',
|
viewPropertiesShort: 'Voir les propriétés',
|
||||||
priceHistory: 'Historique des prix',
|
priceHistory: 'Historique des prix',
|
||||||
journeysFrom: 'Temps de trajet pour {{label}}',
|
journeysFrom: 'Temps de trajet pour {{label}}',
|
||||||
to: 'Depuis {{destination}}',
|
to: 'Vers {{destination}}',
|
||||||
noJourneyData: 'Aucune donnée de trajet disponible',
|
noJourneyData: 'Aucune donnée de trajet disponible',
|
||||||
viewOnGoogleMaps: 'Voir sur Google Maps',
|
viewOnGoogleMaps: 'Voir sur Google Maps',
|
||||||
walk: 'Marche',
|
walk: 'Marche',
|
||||||
|
|
|
||||||
|
|
@ -804,7 +804,7 @@ const hi: Translations = {
|
||||||
viewPropertiesShort: 'संपत्तियां देखें',
|
viewPropertiesShort: 'संपत्तियां देखें',
|
||||||
priceHistory: 'कीमत इतिहास',
|
priceHistory: 'कीमत इतिहास',
|
||||||
journeysFrom: '{{label}} के लिए यात्रा समय',
|
journeysFrom: '{{label}} के लिए यात्रा समय',
|
||||||
to: '{{destination}} से',
|
to: '{{destination}} तक',
|
||||||
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
|
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
|
||||||
viewOnGoogleMaps: 'Google Maps पर देखें',
|
viewOnGoogleMaps: 'Google Maps पर देखें',
|
||||||
walk: 'पैदल',
|
walk: 'पैदल',
|
||||||
|
|
|
||||||
|
|
@ -828,7 +828,7 @@ const hu: Translations = {
|
||||||
viewPropertiesShort: 'Ingatlanok megtekintése',
|
viewPropertiesShort: 'Ingatlanok megtekintése',
|
||||||
priceHistory: 'Ártörténet',
|
priceHistory: 'Ártörténet',
|
||||||
journeysFrom: 'Utazási idők ehhez: {{label}}',
|
journeysFrom: 'Utazási idők ehhez: {{label}}',
|
||||||
to: 'Innen: {{destination}}',
|
to: 'Ide: {{destination}}',
|
||||||
noJourneyData: 'Nincs elérhető utazási adat',
|
noJourneyData: 'Nincs elérhető utazási adat',
|
||||||
viewOnGoogleMaps: 'Megtekintés a Google Maps-en',
|
viewOnGoogleMaps: 'Megtekintés a Google Maps-en',
|
||||||
walk: 'Gyalog',
|
walk: 'Gyalog',
|
||||||
|
|
|
||||||
|
|
@ -775,7 +775,7 @@ const zh: Translations = {
|
||||||
viewPropertiesShort: '查看房产',
|
viewPropertiesShort: '查看房产',
|
||||||
priceHistory: '价格历史',
|
priceHistory: '价格历史',
|
||||||
journeysFrom: '{{label}} 的出行时间',
|
journeysFrom: '{{label}} 的出行时间',
|
||||||
to: '从 {{destination}} 出发',
|
to: '前往 {{destination}}',
|
||||||
noJourneyData: '暂无出行数据',
|
noJourneyData: '暂无出行数据',
|
||||||
viewOnGoogleMaps: '在 Google Maps 上查看',
|
viewOnGoogleMaps: '在 Google Maps 上查看',
|
||||||
walk: '步行',
|
walk: '步行',
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ public class Router {
|
||||||
private static final int DEPARTURE_TO_TIME = 8 * 3600 + 30 * 60; // 08:30
|
private static final int DEPARTURE_TO_TIME = 8 * 3600 + 30 * 60; // 08:30
|
||||||
private static final int MAX_TRIP_DURATION_MINUTES = 90;
|
private static final int MAX_TRIP_DURATION_MINUTES = 90;
|
||||||
|
|
||||||
private static final int PATH_MAX_DESTINATIONS = 10000;
|
private static final int PATH_MAX_DESTINATIONS = 5000;
|
||||||
|
|
||||||
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
|
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
|
||||||
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
|
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
|
||||||
|
|
|
||||||
22
screenshot/src/network-cache.test.ts
Normal file
22
screenshot/src/network-cache.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { NetworkCache } from './network-cache.js';
|
||||||
|
|
||||||
|
test('NetworkCache skips mutable webpack dev bundles', () => {
|
||||||
|
const cache = new NetworkCache();
|
||||||
|
|
||||||
|
assert.equal(cache.shouldCache('http://frontend:3001/bundle.js'), false);
|
||||||
|
assert.equal(
|
||||||
|
cache.shouldCache('http://frontend:3001/src_components_map_MapPage_tsx.bundle.js'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NetworkCache keeps fingerprinted production assets cacheable', () => {
|
||||||
|
const cache = new NetworkCache();
|
||||||
|
|
||||||
|
assert.equal(cache.shouldCache('https://example.com/main.abcdef12.js'), true);
|
||||||
|
assert.equal(cache.shouldCache('https://example.com/main.abcdef12.css'), true);
|
||||||
|
assert.equal(cache.shouldCache('https://example.com/assets/sprites/light.json'), true);
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,7 @@ interface CacheEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ENTRIES = 2000;
|
const MAX_ENTRIES = 2000;
|
||||||
|
const FINGERPRINTED_JS_OR_CSS_RE = /(?:^|\/)[^/?]+\.[a-f0-9]{8,}\.(?:js|css)$/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory cache for network responses (tiles, JS/CSS bundles, font glyphs).
|
* In-memory cache for network responses (tiles, JS/CSS bundles, font glyphs).
|
||||||
|
|
@ -19,12 +20,17 @@ export class NetworkCache {
|
||||||
misses = 0;
|
misses = 0;
|
||||||
|
|
||||||
shouldCache(url: string): boolean {
|
shouldCache(url: string): boolean {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
|
||||||
// Vector map tiles (protobuf)
|
// Vector map tiles (protobuf)
|
||||||
if (url.includes('/api/tiles/')) return true;
|
if (pathname.includes('/api/tiles/')) return true;
|
||||||
|
// Webpack dev assets are mutable at stable URLs (`bundle.js`, chunk
|
||||||
|
// `.bundle.js` files), so only cache fingerprinted production JS/CSS.
|
||||||
|
if (/\.(js|css)$/i.test(pathname)) return FINGERPRINTED_JS_OR_CSS_RE.test(pathname);
|
||||||
// Static assets by extension
|
// Static assets by extension
|
||||||
if (/\.(js|css|woff2?|ttf|png|jpe?g|svg|ico|pbf|json)(\?|$)/i.test(url)) return true;
|
if (/\.(woff2?|ttf|png|jpe?g|svg|ico|pbf|json)$/i.test(pathname)) return true;
|
||||||
// Font glyphs and emoji sprites under /assets/
|
// Font glyphs and emoji sprites under /assets/
|
||||||
if (url.includes('/assets/')) return true;
|
if (pathname.includes('/assets/')) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ pub enum CheckoutCompletion {
|
||||||
pub struct VerifiedCheckout {
|
pub struct VerifiedCheckout {
|
||||||
pub reservation_id: String,
|
pub reservation_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
pub stripe_session_id: String,
|
||||||
|
pub payment_intent_id: String,
|
||||||
pub paid_amount_pence: u64,
|
pub paid_amount_pence: u64,
|
||||||
pub referral_invite_id: String,
|
pub referral_invite_id: String,
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +48,7 @@ struct PendingCheckout {
|
||||||
id: String,
|
id: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
stripe_session_id: String,
|
stripe_session_id: String,
|
||||||
|
stripe_payment_intent_id: String,
|
||||||
checkout_url: String,
|
checkout_url: String,
|
||||||
amount_pence: u64,
|
amount_pence: u64,
|
||||||
expected_total_pence: u64,
|
expected_total_pence: u64,
|
||||||
|
|
@ -149,6 +152,21 @@ async fn start_license_checkout_locked(
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if let Some(invite_id) = referral_invite_id.filter(|id| !id.is_empty()) {
|
||||||
|
if let Err(err) =
|
||||||
|
reserve_referral_invite(state, invite_id, &user.id, &reservation_id, expires_at_unix)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Err(mark_err) = mark_checkout_status(state, &reservation_id, "failed").await {
|
||||||
|
warn!(
|
||||||
|
reservation_id,
|
||||||
|
"Failed to mark checkout reservation failed: {mark_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let stripe_result = create_stripe_session(
|
let stripe_result = create_stripe_session(
|
||||||
state,
|
state,
|
||||||
user,
|
user,
|
||||||
|
|
@ -170,6 +188,17 @@ async fn start_license_checkout_locked(
|
||||||
"Failed to mark checkout reservation failed: {mark_err}"
|
"Failed to mark checkout reservation failed: {mark_err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(invite_id) = referral_invite_id.filter(|id| !id.is_empty()) {
|
||||||
|
if let Err(release_err) =
|
||||||
|
release_referral_invite_reservation(state, invite_id, &reservation_id).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
reservation_id,
|
||||||
|
referral_invite_id = invite_id,
|
||||||
|
"Failed to release referral invite reservation: {release_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -182,6 +211,17 @@ async fn start_license_checkout_locked(
|
||||||
"Failed to mark checkout reservation failed: {mark_err}"
|
"Failed to mark checkout reservation failed: {mark_err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(invite_id) = referral_invite_id.filter(|id| !id.is_empty()) {
|
||||||
|
if let Err(release_err) =
|
||||||
|
release_referral_invite_reservation(state, invite_id, &reservation_id).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
reservation_id,
|
||||||
|
referral_invite_id = invite_id,
|
||||||
|
"Failed to release referral invite reservation: {release_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +240,14 @@ pub async fn verify_checkout_completion(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let payment_intent_id = match session["payment_intent"].as_str() {
|
||||||
|
Some(id) if is_safe_stripe_session_id(id) => id,
|
||||||
|
_ => {
|
||||||
|
return Ok(CheckoutCompletion::Rejected(
|
||||||
|
"missing or invalid payment intent id".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let checkout = match find_checkout_by_stripe_session(state, session_id).await? {
|
let checkout = match find_checkout_by_stripe_session(state, session_id).await? {
|
||||||
Some(checkout) => checkout,
|
Some(checkout) => checkout,
|
||||||
|
|
@ -287,6 +335,8 @@ pub async fn verify_checkout_completion(
|
||||||
Ok(CheckoutCompletion::Grant(VerifiedCheckout {
|
Ok(CheckoutCompletion::Grant(VerifiedCheckout {
|
||||||
reservation_id: checkout.id,
|
reservation_id: checkout.id,
|
||||||
user_id: checkout.user_id,
|
user_id: checkout.user_id,
|
||||||
|
stripe_session_id: session_id.to_string(),
|
||||||
|
payment_intent_id: payment_intent_id.to_string(),
|
||||||
paid_amount_pence: amount_total,
|
paid_amount_pence: amount_total,
|
||||||
referral_invite_id: checkout.referral_invite_id,
|
referral_invite_id: checkout.referral_invite_id,
|
||||||
}))
|
}))
|
||||||
|
|
@ -296,7 +346,11 @@ pub async fn mark_checkout_completed(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
reservation_id: &str,
|
reservation_id: &str,
|
||||||
paid_amount_pence: u64,
|
paid_amount_pence: u64,
|
||||||
|
payment_intent_id: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||||
|
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||||
|
}
|
||||||
let token = get_superuser_token(state).await?;
|
let token = get_superuser_token(state).await?;
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||||
|
|
@ -308,6 +362,7 @@ pub async fn mark_checkout_completed(
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"paid_amount_pence": paid_amount_pence,
|
"paid_amount_pence": paid_amount_pence,
|
||||||
"completed_at_unix": now_unix_secs().to_string(),
|
"completed_at_unix": now_unix_secs().to_string(),
|
||||||
|
"stripe_payment_intent_id": payment_intent_id,
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -317,7 +372,157 @@ pub async fn mark_checkout_completed(
|
||||||
.context("PocketBase checkout completion update failed")
|
.context("PocketBase checkout completion update failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn complete_verified_checkout(
|
||||||
|
state: &AppState,
|
||||||
|
checkout: &VerifiedCheckout,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||||
|
let pricing_lock = acquire_pocketbase_lock(
|
||||||
|
state,
|
||||||
|
CHECKOUT_PRICING_LOCK_NAME,
|
||||||
|
CHECKOUT_PRICING_LOCK_TTL_SECS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let result = complete_verified_checkout_locked(state, checkout).await;
|
||||||
|
if let Err(err) = pricing_lock.release().await {
|
||||||
|
warn!("Failed to release checkout pricing lock: {err}");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_verified_checkout_locked(
|
||||||
|
state: &AppState,
|
||||||
|
checkout: &VerifiedCheckout,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let live_checkout = find_checkout_by_stripe_session(state, &checkout.stripe_session_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow!("checkout reservation disappeared before completion"))?;
|
||||||
|
|
||||||
|
if live_checkout.status == "completed" {
|
||||||
|
if !checkout.referral_invite_id.is_empty() {
|
||||||
|
mark_referral_invite_used(
|
||||||
|
state,
|
||||||
|
&checkout.referral_invite_id,
|
||||||
|
&checkout.user_id,
|
||||||
|
&checkout.reservation_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if live_checkout.id != checkout.reservation_id
|
||||||
|
|| live_checkout.user_id != checkout.user_id
|
||||||
|
|| live_checkout.referral_invite_id != checkout.referral_invite_id
|
||||||
|
{
|
||||||
|
mark_checkout_status(state, &checkout.reservation_id, "invalid").await?;
|
||||||
|
return Err(anyhow!("checkout reservation changed before completion"));
|
||||||
|
}
|
||||||
|
if live_checkout.status != "pending" && live_checkout.status != "expired" {
|
||||||
|
return Err(anyhow!("checkout reservation is {}", live_checkout.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
grant_license(state, &checkout.user_id).await?;
|
||||||
|
mark_checkout_completed(
|
||||||
|
state,
|
||||||
|
&checkout.reservation_id,
|
||||||
|
checkout.paid_amount_pence,
|
||||||
|
&checkout.payment_intent_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !checkout.referral_invite_id.is_empty() {
|
||||||
|
mark_referral_invite_used(
|
||||||
|
state,
|
||||||
|
&checkout.referral_invite_id,
|
||||||
|
&checkout.user_id,
|
||||||
|
&checkout.reservation_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn grant_license_with_pricing_lock(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||||
|
let pricing_lock = acquire_pocketbase_lock(
|
||||||
|
state,
|
||||||
|
CHECKOUT_PRICING_LOCK_NAME,
|
||||||
|
CHECKOUT_PRICING_LOCK_TTL_SECS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let result = grant_license(state, user_id).await;
|
||||||
|
if let Err(err) = pricing_lock.release().await {
|
||||||
|
warn!("Failed to release checkout pricing lock: {err}");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reverse_license_for_payment_intent(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
if !is_safe_stripe_session_id(payment_intent_id) {
|
||||||
|
return Err(anyhow!("invalid Stripe payment intent id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = CHECKOUT_RESERVATION_LOCK.lock().await;
|
||||||
|
let pricing_lock = acquire_pocketbase_lock(
|
||||||
|
state,
|
||||||
|
CHECKOUT_PRICING_LOCK_NAME,
|
||||||
|
CHECKOUT_PRICING_LOCK_TTL_SECS,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let result = reverse_license_for_payment_intent_locked(state, payment_intent_id, reason).await;
|
||||||
|
if let Err(err) = pricing_lock.release().await {
|
||||||
|
warn!("Failed to release checkout pricing lock: {err}");
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reverse_license_for_payment_intent_locked(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
let Some(checkout) = find_checkout_by_payment_intent(state, payment_intent_id).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if checkout.stripe_payment_intent_id != payment_intent_id {
|
||||||
|
return Err(anyhow!("checkout payment intent mismatch"));
|
||||||
|
}
|
||||||
|
if checkout.status == "refunded" || checkout.status == "disputed" {
|
||||||
|
return Ok(Some(checkout.user_id));
|
||||||
|
}
|
||||||
|
if checkout.status != "completed" {
|
||||||
|
return Ok(Some(checkout.user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reversed_status = if reason.contains("dispute") {
|
||||||
|
"disputed"
|
||||||
|
} else {
|
||||||
|
"refunded"
|
||||||
|
};
|
||||||
|
revoke_license(state, &checkout.user_id).await?;
|
||||||
|
mark_checkout_reversed(state, &checkout.id, reversed_status, reason).await?;
|
||||||
|
Ok(Some(checkout.user_id))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||||
|
set_user_subscription(state, user_id, "licensed").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_license(state: &AppState, user_id: &str) -> anyhow::Result<()> {
|
||||||
|
set_user_subscription(state, user_id, "free").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_user_subscription(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
subscription: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let token = get_superuser_token(state).await?;
|
let token = get_superuser_token(state).await?;
|
||||||
|
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
|
@ -326,7 +531,7 @@ pub async fn grant_license(state: &AppState, user_id: &str) -> anyhow::Result<()
|
||||||
.http_client
|
.http_client
|
||||||
.patch(&url)
|
.patch(&url)
|
||||||
.header("Authorization", format!("Bearer {token}"))
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
.json(&serde_json::json!({ "subscription": subscription }))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -342,23 +547,36 @@ pub async fn mark_referral_invite_used(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
invite_id: &str,
|
invite_id: &str,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
reservation_id: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if invite_id.is_empty() {
|
if invite_id.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if !is_safe_pocketbase_id(invite_id) || !is_safe_pocketbase_id(user_id) {
|
if !is_safe_pocketbase_id(invite_id)
|
||||||
|
|| !is_safe_pocketbase_id(user_id)
|
||||||
|
|| !is_safe_pocketbase_id(reservation_id)
|
||||||
|
{
|
||||||
return Err(anyhow!("invalid PocketBase id"));
|
return Err(anyhow!("invalid PocketBase id"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = get_superuser_token(state).await?;
|
let token = get_superuser_token(state).await?;
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
let existing_used_by = fetch_invite_used_by(state, pb_url, &token, invite_id).await?;
|
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
|
||||||
|
let existing_used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
||||||
if existing_used_by == user_id {
|
if existing_used_by == user_id {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if !existing_used_by.is_empty() {
|
if !existing_used_by.is_empty() {
|
||||||
return Err(anyhow!("referral invite already used by another account"));
|
return Err(anyhow!("referral invite already used by another account"));
|
||||||
}
|
}
|
||||||
|
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
|
||||||
|
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
||||||
|
if !reserved_by_id.is_empty() && reserved_by_id != user_id {
|
||||||
|
return Err(anyhow!("referral invite reserved by another account"));
|
||||||
|
}
|
||||||
|
if !reserved_checkout_id.is_empty() && reserved_checkout_id != reservation_id {
|
||||||
|
return Err(anyhow!("referral invite reserved by another checkout"));
|
||||||
|
}
|
||||||
|
|
||||||
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||||
let resp = state
|
let resp = state
|
||||||
|
|
@ -368,6 +586,9 @@ pub async fn mark_referral_invite_used(
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"used_by_id": user_id,
|
"used_by_id": user_id,
|
||||||
"used_at": now_unix_secs().to_string(),
|
"used_at": now_unix_secs().to_string(),
|
||||||
|
"reserved_by_id": "",
|
||||||
|
"reserved_checkout_id": "",
|
||||||
|
"reserved_until_unix": 0,
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -377,12 +598,12 @@ pub async fn mark_referral_invite_used(
|
||||||
.context("PocketBase invite usage update failed")
|
.context("PocketBase invite usage update failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_invite_used_by(
|
async fn fetch_invite_record(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
pb_url: &str,
|
pb_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
invite_id: &str,
|
invite_id: &str,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<Value> {
|
||||||
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||||
let resp = state
|
let resp = state
|
||||||
.http_client
|
.http_client
|
||||||
|
|
@ -393,8 +614,98 @@ async fn fetch_invite_used_by(
|
||||||
|
|
||||||
ensure_success_ref(&resp).await?;
|
ensure_success_ref(&resp).await?;
|
||||||
|
|
||||||
let body: Value = resp.json().await?;
|
resp.json().await.map_err(Into::into)
|
||||||
Ok(body["used_by_id"].as_str().unwrap_or_default().to_string())
|
}
|
||||||
|
|
||||||
|
async fn reserve_referral_invite(
|
||||||
|
state: &AppState,
|
||||||
|
invite_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
reservation_id: &str,
|
||||||
|
reserved_until_unix: u64,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if !is_safe_pocketbase_id(invite_id)
|
||||||
|
|| !is_safe_pocketbase_id(user_id)
|
||||||
|
|| !is_safe_pocketbase_id(reservation_id)
|
||||||
|
{
|
||||||
|
return Err(anyhow!("invalid PocketBase id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = get_superuser_token(state).await?;
|
||||||
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
|
||||||
|
let used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
||||||
|
if !used_by.is_empty() {
|
||||||
|
return Err(anyhow!("referral invite already used"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = now_unix_secs();
|
||||||
|
let reserved_by_id = invite["reserved_by_id"].as_str().unwrap_or_default();
|
||||||
|
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
||||||
|
let existing_reserved_until = number_field(&invite, "reserved_until_unix").unwrap_or(0);
|
||||||
|
let reservation_is_live = existing_reserved_until >= now;
|
||||||
|
if reservation_is_live
|
||||||
|
&& !reserved_checkout_id.is_empty()
|
||||||
|
&& reserved_checkout_id != reservation_id
|
||||||
|
{
|
||||||
|
return Err(anyhow!("referral invite already has an active checkout"));
|
||||||
|
}
|
||||||
|
if reservation_is_live && !reserved_by_id.is_empty() && reserved_by_id != user_id {
|
||||||
|
return Err(anyhow!("referral invite reserved by another account"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.patch(&url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"reserved_by_id": user_id,
|
||||||
|
"reserved_checkout_id": reservation_id,
|
||||||
|
"reserved_until_unix": reserved_until_unix,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success(resp)
|
||||||
|
.await
|
||||||
|
.context("PocketBase invite reservation update failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_referral_invite_reservation(
|
||||||
|
state: &AppState,
|
||||||
|
invite_id: &str,
|
||||||
|
reservation_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if !is_safe_pocketbase_id(invite_id) || !is_safe_pocketbase_id(reservation_id) {
|
||||||
|
return Err(anyhow!("invalid PocketBase id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = get_superuser_token(state).await?;
|
||||||
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
let invite = fetch_invite_record(state, pb_url, &token, invite_id).await?;
|
||||||
|
let used_by = invite["used_by_id"].as_str().unwrap_or_default();
|
||||||
|
let reserved_checkout_id = invite["reserved_checkout_id"].as_str().unwrap_or_default();
|
||||||
|
if !used_by.is_empty() || reserved_checkout_id != reservation_id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.patch(&url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"reserved_by_id": "",
|
||||||
|
"reserved_checkout_id": "",
|
||||||
|
"reserved_until_unix": 0,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success(resp)
|
||||||
|
.await
|
||||||
|
.context("PocketBase invite reservation release failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn active_referral_checkout_user(
|
pub async fn active_referral_checkout_user(
|
||||||
|
|
@ -457,8 +768,8 @@ async fn count_active_pending_checkouts(state: &AppState, now: u64) -> anyhow::R
|
||||||
async fn find_active_checkout_for_user(
|
async fn find_active_checkout_for_user(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
discount_coupon_id: &str,
|
_discount_coupon_id: &str,
|
||||||
referral_invite_id: &str,
|
_referral_invite_id: &str,
|
||||||
now: u64,
|
now: u64,
|
||||||
) -> anyhow::Result<Option<PendingCheckout>> {
|
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||||
if !is_safe_pocketbase_id(user_id) {
|
if !is_safe_pocketbase_id(user_id) {
|
||||||
|
|
@ -468,8 +779,8 @@ async fn find_active_checkout_for_user(
|
||||||
let token = get_superuser_token(state).await?;
|
let token = get_superuser_token(state).await?;
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
let filter = format!(
|
let filter = format!(
|
||||||
"status=\"pending\" && expires_at_unix>={now} && user=\"{}\" && discount_coupon_id=\"{}\" && referral_invite_id=\"{}\"",
|
"status=\"pending\" && expires_at_unix>={now} && user=\"{}\"",
|
||||||
user_id, discount_coupon_id, referral_invite_id
|
user_id
|
||||||
);
|
);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||||
|
|
@ -515,13 +826,28 @@ async fn expire_stale_pending_checkouts(state: &AppState, now: u64) -> anyhow::R
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
for id in items.iter().filter_map(|item| item["id"].as_str()) {
|
for item in items {
|
||||||
|
let Some(id) = item["id"].as_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if let Err(err) = mark_checkout_status(state, id, "expired").await {
|
if let Err(err) = mark_checkout_status(state, id, "expired").await {
|
||||||
warn!(
|
warn!(
|
||||||
reservation_id = id,
|
reservation_id = id,
|
||||||
"Failed to expire checkout reservation: {err}"
|
"Failed to expire checkout reservation: {err}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(invite_id) = item["referral_invite_id"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|invite_id| !invite_id.is_empty())
|
||||||
|
{
|
||||||
|
if let Err(err) = release_referral_invite_reservation(state, invite_id, id).await {
|
||||||
|
warn!(
|
||||||
|
reservation_id = id,
|
||||||
|
referral_invite_id = invite_id,
|
||||||
|
"Failed to release expired referral invite reservation: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -551,6 +877,7 @@ async fn create_pending_checkout(
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"user": input.user_id,
|
"user": input.user_id,
|
||||||
"stripe_session_id": "",
|
"stripe_session_id": "",
|
||||||
|
"stripe_payment_intent_id": "",
|
||||||
"checkout_url": "",
|
"checkout_url": "",
|
||||||
"amount_pence": input.amount_pence,
|
"amount_pence": input.amount_pence,
|
||||||
"expected_total_pence": input.expected_total_pence,
|
"expected_total_pence": input.expected_total_pence,
|
||||||
|
|
@ -561,6 +888,7 @@ async fn create_pending_checkout(
|
||||||
"expires_at_unix": input.expires_at_unix,
|
"expires_at_unix": input.expires_at_unix,
|
||||||
"paid_amount_pence": 0,
|
"paid_amount_pence": 0,
|
||||||
"completed_at_unix": "",
|
"completed_at_unix": "",
|
||||||
|
"reversal_reason": "",
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -574,6 +902,53 @@ async fn create_pending_checkout(
|
||||||
.ok_or_else(|| anyhow!("PocketBase checkout reservation missing id"))
|
.ok_or_else(|| anyhow!("PocketBase checkout reservation missing id"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a Stripe coupon and ensure its `percent_off` matches the expected
|
||||||
|
/// referral discount AND that it has no `amount_off` override. This blocks a
|
||||||
|
/// misconfigured (or maliciously swapped) coupon ID from quietly granting a
|
||||||
|
/// larger discount than the server's pricing math assumed.
|
||||||
|
async fn verify_stripe_coupon_discount(state: &AppState, coupon_id: &str) -> anyhow::Result<()> {
|
||||||
|
if !is_safe_stripe_session_id(coupon_id) {
|
||||||
|
return Err(anyhow!("unsafe stripe coupon id"));
|
||||||
|
}
|
||||||
|
let url = format!(
|
||||||
|
"https://api.stripe.com/v1/coupons/{}",
|
||||||
|
urlencoding::encode(coupon_id)
|
||||||
|
);
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&state.stripe_secret_key, None::<&str>)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Stripe coupon fetch failed")?;
|
||||||
|
ensure_success_ref(&resp)
|
||||||
|
.await
|
||||||
|
.context("Stripe coupon fetch returned error")?;
|
||||||
|
let body: Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse Stripe coupon response")?;
|
||||||
|
|
||||||
|
let valid = body["valid"].as_bool().unwrap_or(false);
|
||||||
|
if !valid {
|
||||||
|
return Err(anyhow!("stripe coupon is not valid"));
|
||||||
|
}
|
||||||
|
if body["amount_off"].is_number() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"stripe coupon uses amount_off; only percent_off is permitted"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let percent_off = body["percent_off"]
|
||||||
|
.as_f64()
|
||||||
|
.ok_or_else(|| anyhow!("stripe coupon missing percent_off"))?;
|
||||||
|
if percent_off.is_nan() || (percent_off - REFERRAL_DISCOUNT_PERCENT as f64).abs() > 0.001 {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"stripe coupon percent_off ({percent_off}) does not match expected {REFERRAL_DISCOUNT_PERCENT}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn create_stripe_session(
|
async fn create_stripe_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|
@ -585,6 +960,10 @@ async fn create_stripe_session(
|
||||||
expires_at_unix: u64,
|
expires_at_unix: u64,
|
||||||
discount_coupon_id: Option<&str>,
|
discount_coupon_id: Option<&str>,
|
||||||
) -> anyhow::Result<(String, String)> {
|
) -> anyhow::Result<(String, String)> {
|
||||||
|
if let Some(coupon_id) = discount_coupon_id.filter(|id| !id.is_empty()) {
|
||||||
|
verify_stripe_coupon_discount(state, coupon_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut form_params = vec![
|
let mut form_params = vec![
|
||||||
("mode", "payment".to_string()),
|
("mode", "payment".to_string()),
|
||||||
("payment_method_types[0]", "card".to_string()),
|
("payment_method_types[0]", "card".to_string()),
|
||||||
|
|
@ -697,6 +1076,31 @@ async fn mark_checkout_status(
|
||||||
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
.with_context(|| format!("PocketBase checkout status update failed for {reservation_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mark_checkout_reversed(
|
||||||
|
state: &AppState,
|
||||||
|
reservation_id: &str,
|
||||||
|
status: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let token = get_superuser_token(state).await?;
|
||||||
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
let url = format!("{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records/{reservation_id}");
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.patch(&url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"reversal_reason": reason,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success(resp)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("PocketBase checkout reversal update failed for {reservation_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_checkout_by_stripe_session(
|
async fn find_checkout_by_stripe_session(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
stripe_session_id: &str,
|
stripe_session_id: &str,
|
||||||
|
|
@ -726,6 +1130,35 @@ async fn find_checkout_by_stripe_session(
|
||||||
item.map(parse_pending_checkout).transpose()
|
item.map(parse_pending_checkout).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn find_checkout_by_payment_intent(
|
||||||
|
state: &AppState,
|
||||||
|
payment_intent_id: &str,
|
||||||
|
) -> anyhow::Result<Option<PendingCheckout>> {
|
||||||
|
let token = get_superuser_token(state).await?;
|
||||||
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
let filter = format!("stripe_payment_intent_id=\"{}\"", payment_intent_id);
|
||||||
|
let url = format!(
|
||||||
|
"{pb_url}/api/collections/{CHECKOUT_COLLECTION}/records?filter={}&perPage=1",
|
||||||
|
urlencoding::encode(&filter)
|
||||||
|
);
|
||||||
|
let resp = state
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure_success_ref(&resp).await?;
|
||||||
|
|
||||||
|
let body: Value = resp.json().await?;
|
||||||
|
let item = body["items"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|items| items.first())
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
item.map(parse_pending_checkout).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||||
Ok(PendingCheckout {
|
Ok(PendingCheckout {
|
||||||
id: item["id"]
|
id: item["id"]
|
||||||
|
|
@ -740,6 +1173,10 @@ fn parse_pending_checkout(item: Value) -> anyhow::Result<PendingCheckout> {
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
stripe_payment_intent_id: item["stripe_payment_intent_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
checkout_url: item["checkout_url"]
|
checkout_url: item["checkout_url"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ const ADDRESS_SEARCH_MAX_POSTINGS_PER_TOKEN: usize = 250_000;
|
||||||
const ADDRESS_SEARCH_PREFIX_MIN_LEN: usize = 4;
|
const ADDRESS_SEARCH_PREFIX_MIN_LEN: usize = 4;
|
||||||
const ADDRESS_SEARCH_PREFIX_MAX_LEN: usize = 8;
|
const ADDRESS_SEARCH_PREFIX_MAX_LEN: usize = 8;
|
||||||
const NO_POI_METRIC_ROW: u32 = u32::MAX;
|
const NO_POI_METRIC_ROW: u32 = u32::MAX;
|
||||||
|
const MISSING_COORDINATE_SAMPLE_LIMIT: usize = 10;
|
||||||
|
const COUNTRY_COLUMN_CANDIDATES: &[&str] = &[
|
||||||
|
"ctry25cd",
|
||||||
|
"ctry24cd",
|
||||||
|
"ctry23cd",
|
||||||
|
"ctry22cd",
|
||||||
|
"country_code",
|
||||||
|
"Country code",
|
||||||
|
"country",
|
||||||
|
"Country",
|
||||||
|
];
|
||||||
|
const ENGLAND_COUNTRY_VALUES: &[&str] = &["E92000001", "England", "ENGLAND", "england"];
|
||||||
|
|
||||||
fn is_numeric_dtype(dtype: &DataType) -> bool {
|
fn is_numeric_dtype(dtype: &DataType) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
|
|
@ -38,6 +50,110 @@ fn is_datetime_dtype(dtype: &DataType) -> bool {
|
||||||
matches!(dtype, DataType::Datetime(_, _) | DataType::Date)
|
matches!(dtype, DataType::Datetime(_, _) | DataType::Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn find_country_column(schema: &Schema) -> Option<String> {
|
||||||
|
COUNTRY_COLUMN_CANDIDATES
|
||||||
|
.iter()
|
||||||
|
.find_map(|&name| match schema.get(name) {
|
||||||
|
Some(dtype) if matches!(dtype, DataType::String) || dtype.is_categorical() => {
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn england_country_expr(country_column: &str) -> Expr {
|
||||||
|
ENGLAND_COUNTRY_VALUES.iter().skip(1).fold(
|
||||||
|
col(country_column)
|
||||||
|
.cast(DataType::String)
|
||||||
|
.eq(lit(ENGLAND_COUNTRY_VALUES[0])),
|
||||||
|
|expr, value| expr.or(col(country_column).cast(DataType::String).eq(lit(*value))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct MissingCoordinateSummary {
|
||||||
|
row_count: usize,
|
||||||
|
unique_postcode_count: usize,
|
||||||
|
sample_postcodes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_missing_coordinate_postcodes<'a>(
|
||||||
|
postcodes: impl IntoIterator<Item = Option<&'a str>>,
|
||||||
|
) -> MissingCoordinateSummary {
|
||||||
|
let mut row_count = 0usize;
|
||||||
|
let mut unique_postcodes = FxHashSet::default();
|
||||||
|
|
||||||
|
for postcode in postcodes {
|
||||||
|
row_count += 1;
|
||||||
|
if let Some(postcode) = postcode {
|
||||||
|
let trimmed = postcode.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
unique_postcodes.insert(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sample_postcodes: Vec<String> = unique_postcodes.iter().cloned().collect();
|
||||||
|
sample_postcodes.sort_unstable();
|
||||||
|
sample_postcodes.truncate(MISSING_COORDINATE_SAMPLE_LIMIT);
|
||||||
|
|
||||||
|
MissingCoordinateSummary {
|
||||||
|
row_count,
|
||||||
|
unique_postcode_count: unique_postcodes.len(),
|
||||||
|
sample_postcodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_england_coordinates_error(
|
||||||
|
summary: &MissingCoordinateSummary,
|
||||||
|
country_column: &str,
|
||||||
|
) -> String {
|
||||||
|
let samples = if summary.sample_postcodes.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
summary.sample_postcodes.join(", ")
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"England property rows missing postcode coordinates after joining postcode data: {} rows across {} postcodes (country column '{}'). Sample postcodes: {}",
|
||||||
|
summary.row_count, summary.unique_postcode_count, country_column, samples
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_no_england_rows_missing_coordinates(
|
||||||
|
combined_lf: &LazyFrame,
|
||||||
|
schema: &Schema,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let Some(country_column) = find_country_column(schema) else {
|
||||||
|
bail!(
|
||||||
|
"Postcode feature parquet has no reliable country column; cannot verify that rows with missing coordinates are outside England. Regenerate postcode.parquet with pipeline.transform.merge so it includes ctry25cd."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let missing_coordinates = col("lat").is_null().or(col("lon").is_null());
|
||||||
|
let offending_df = combined_lf
|
||||||
|
.clone()
|
||||||
|
.filter(missing_coordinates.and(england_country_expr(&country_column)))
|
||||||
|
.select([col("Postcode")])
|
||||||
|
.collect()
|
||||||
|
.context("Failed to validate missing postcode coordinates")?;
|
||||||
|
|
||||||
|
let postcode_column = offending_df
|
||||||
|
.column("Postcode")
|
||||||
|
.context("Joined frame missing 'Postcode' during coordinate validation")?
|
||||||
|
.str()
|
||||||
|
.context("'Postcode' column is not a string during coordinate validation")?;
|
||||||
|
let summary = summarize_missing_coordinate_postcodes(postcode_column);
|
||||||
|
|
||||||
|
if summary.row_count > 0 {
|
||||||
|
bail!(
|
||||||
|
"{}",
|
||||||
|
missing_england_coordinates_error(&summary, &country_column)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct AddressTermGroup {
|
struct AddressTermGroup {
|
||||||
alternatives: Vec<String>,
|
alternatives: Vec<String>,
|
||||||
|
|
@ -1307,6 +1423,7 @@ impl PropertyData {
|
||||||
.clone()
|
.clone()
|
||||||
.collect_schema()
|
.collect_schema()
|
||||||
.context("Failed to collect joined schema")?;
|
.context("Failed to collect joined schema")?;
|
||||||
|
validate_no_england_rows_missing_coordinates(&combined_lf, &schema)?;
|
||||||
let numeric_names: Vec<String> = configured_numeric_names
|
let numeric_names: Vec<String> = configured_numeric_names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| (*name).to_string())
|
.map(|name| (*name).to_string())
|
||||||
|
|
@ -1974,6 +2091,110 @@ mod tests {
|
||||||
Bounds::Percentile { low, high }
|
Bounds::Percentile { low, high }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn country_column_detection_prefers_reliable_country_code() {
|
||||||
|
let df = df!(
|
||||||
|
"Postcode" => &["SW1A 1AA"],
|
||||||
|
"ctry25cd" => &["E92000001"],
|
||||||
|
"lat" => &[Some(51.501_f64)],
|
||||||
|
"lon" => &[Some(-0.141_f64)],
|
||||||
|
)
|
||||||
|
.expect("test dataframe should build");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
find_country_column(df.schema()).as_deref(),
|
||||||
|
Some("ctry25cd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_coordinate_summary_counts_rows_and_distinct_samples() {
|
||||||
|
let summary = summarize_missing_coordinate_postcodes([
|
||||||
|
Some("SW1A 1AA"),
|
||||||
|
Some("SW1A 1AA"),
|
||||||
|
Some("E14 2DG"),
|
||||||
|
Some(""),
|
||||||
|
None,
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
summary,
|
||||||
|
MissingCoordinateSummary {
|
||||||
|
row_count: 5,
|
||||||
|
unique_postcode_count: 2,
|
||||||
|
sample_postcodes: vec!["E14 2DG".to_string(), "SW1A 1AA".to_string()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_england_coordinates_error_includes_counts_and_samples() {
|
||||||
|
let summary = MissingCoordinateSummary {
|
||||||
|
row_count: 3,
|
||||||
|
unique_postcode_count: 2,
|
||||||
|
sample_postcodes: vec!["E14 2DG".to_string(), "SW1A 1AA".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = missing_england_coordinates_error(&summary, "ctry25cd");
|
||||||
|
|
||||||
|
assert!(message.contains("3 rows across 2 postcodes"));
|
||||||
|
assert!(message.contains("country column 'ctry25cd'"));
|
||||||
|
assert!(message.contains("E14 2DG, SW1A 1AA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coordinate_validation_errors_for_england_rows() {
|
||||||
|
let lf = df!(
|
||||||
|
"Postcode" => &["SW1A 1AA", "E14 2DG"],
|
||||||
|
"ctry25cd" => &["E92000001", "E92000001"],
|
||||||
|
"lat" => &[Some(51.501_f64), None],
|
||||||
|
"lon" => &[Some(-0.141_f64), Some(-0.001_f64)],
|
||||||
|
)
|
||||||
|
.expect("test dataframe should build")
|
||||||
|
.lazy();
|
||||||
|
let schema = lf.clone().collect_schema().expect("schema should collect");
|
||||||
|
|
||||||
|
let err = validate_no_england_rows_missing_coordinates(&lf, &schema)
|
||||||
|
.expect_err("England row with missing coordinate should error");
|
||||||
|
let message = err.to_string();
|
||||||
|
|
||||||
|
assert!(message.contains("1 rows across 1 postcodes"));
|
||||||
|
assert!(message.contains("E14 2DG"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coordinate_validation_allows_non_england_rows() {
|
||||||
|
let lf = df!(
|
||||||
|
"Postcode" => &["CF10 1AA", "SW1A 1AA"],
|
||||||
|
"ctry25cd" => &["W92000004", "E92000001"],
|
||||||
|
"lat" => &[None, Some(51.501_f64)],
|
||||||
|
"lon" => &[Some(-3.179_f64), Some(-0.141_f64)],
|
||||||
|
)
|
||||||
|
.expect("test dataframe should build")
|
||||||
|
.lazy();
|
||||||
|
let schema = lf.clone().collect_schema().expect("schema should collect");
|
||||||
|
|
||||||
|
validate_no_england_rows_missing_coordinates(&lf, &schema)
|
||||||
|
.expect("non-England row with missing coordinate should be skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coordinate_validation_requires_country_column() {
|
||||||
|
let lf = df!(
|
||||||
|
"Postcode" => &["SW1A 1AA"],
|
||||||
|
"lat" => &[None::<f64>],
|
||||||
|
"lon" => &[Some(-0.141_f64)],
|
||||||
|
)
|
||||||
|
.expect("test dataframe should build")
|
||||||
|
.lazy();
|
||||||
|
let schema = lf.clone().collect_schema().expect("schema should collect");
|
||||||
|
|
||||||
|
let err = validate_no_england_rows_missing_coordinates(&lf, &schema)
|
||||||
|
.expect_err("missing country provenance should error");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("no reliable country column"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_postcode_detection_accepts_common_formats() {
|
fn full_postcode_detection_accepts_common_formats() {
|
||||||
assert!(is_full_postcode_compact("SW1A1AA"));
|
assert!(is_full_postcode_compact("SW1A1AA"));
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ mod fields;
|
||||||
mod filters;
|
mod filters;
|
||||||
mod h3;
|
mod h3;
|
||||||
|
|
||||||
pub use bounds::{
|
pub use bounds::{bounds_intersect, h3_cell_bounds, parse_bounds, require_bounds};
|
||||||
bounds_intersect, h3_cell_bounds, parse_bounds, require_bounds, require_candidate_count,
|
|
||||||
};
|
|
||||||
pub use fields::{
|
pub use fields::{
|
||||||
parse_enum_dist, parse_field_indices, parse_field_indices_with_poi, parse_field_set,
|
parse_enum_dist, parse_field_indices, parse_field_indices_with_poi, parse_field_set,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -529,6 +529,14 @@ async fn ensure_checkout_sessions_fields(
|
||||||
"stripe_session_id",
|
"stripe_session_id",
|
||||||
serde_json::json!({ "name": "stripe_session_id", "type": "text", "required": false }),
|
serde_json::json!({ "name": "stripe_session_id", "type": "text", "required": false }),
|
||||||
);
|
);
|
||||||
|
add_field(
|
||||||
|
"stripe_payment_intent_id",
|
||||||
|
serde_json::json!({ "name": "stripe_payment_intent_id", "type": "text", "required": false }),
|
||||||
|
);
|
||||||
|
add_field(
|
||||||
|
"reversal_reason",
|
||||||
|
serde_json::json!({ "name": "reversal_reason", "type": "text", "required": false }),
|
||||||
|
);
|
||||||
add_field(
|
add_field(
|
||||||
"checkout_url",
|
"checkout_url",
|
||||||
serde_json::json!({ "name": "checkout_url", "type": "text", "required": false }),
|
serde_json::json!({ "name": "checkout_url", "type": "text", "required": false }),
|
||||||
|
|
@ -591,6 +599,66 @@ async fn ensure_checkout_sessions_fields(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_invites_fields(client: &Client, base_url: &str, token: &str) -> anyhow::Result<()> {
|
||||||
|
let url = format!("{base_url}/api/collections/invites");
|
||||||
|
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 invites collection ({status}): {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await?;
|
||||||
|
let fields = body["fields"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invites collection has no fields array"))?;
|
||||||
|
|
||||||
|
let mut new_fields = fields.clone();
|
||||||
|
let mut add_field = |name: &str, field: serde_json::Value| {
|
||||||
|
if !fields.iter().any(|f| f["name"] == name) {
|
||||||
|
new_fields.push(field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_field(
|
||||||
|
"reserved_by_id",
|
||||||
|
serde_json::json!({ "name": "reserved_by_id", "type": "text", "required": false }),
|
||||||
|
);
|
||||||
|
add_field(
|
||||||
|
"reserved_checkout_id",
|
||||||
|
serde_json::json!({ "name": "reserved_checkout_id", "type": "text", "required": false }),
|
||||||
|
);
|
||||||
|
add_field(
|
||||||
|
"reserved_until_unix",
|
||||||
|
serde_json::json!({ "name": "reserved_until_unix", "type": "number" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if new_fields.len() == fields.len() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 patch invites fields ({status}): {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("PocketBase invites collection fields updated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_checkout_locks_fields(
|
async fn ensure_checkout_locks_fields(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
|
@ -655,6 +723,72 @@ async fn ensure_checkout_locks_fields(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_short_urls_fields(
|
||||||
|
client: &Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let url = format!("{base_url}/api/collections/short_urls");
|
||||||
|
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 short_urls collection ({status}): {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp.json().await?;
|
||||||
|
let fields = body["fields"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("short_urls collection has no fields array"))?;
|
||||||
|
|
||||||
|
let mut new_fields = fields.clone();
|
||||||
|
let mut add_field = |name: &str, field: serde_json::Value| {
|
||||||
|
if !fields.iter().any(|f| f["name"] == name) {
|
||||||
|
new_fields.push(field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_field(
|
||||||
|
"created_by",
|
||||||
|
serde_json::json!({ "name": "created_by", "type": "text", "required": false }),
|
||||||
|
);
|
||||||
|
add_field(
|
||||||
|
"click_count",
|
||||||
|
serde_json::json!({ "name": "click_count", "type": "number" }),
|
||||||
|
);
|
||||||
|
for field in ["share_south", "share_west", "share_north", "share_east"] {
|
||||||
|
add_field(
|
||||||
|
field,
|
||||||
|
serde_json::json!({ "name": field, "type": "number" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_fields.len() == fields.len() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 patch short_urls fields ({status}): {text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("PocketBase short_urls collection fields updated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_collection_indexes(
|
async fn ensure_collection_indexes(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
|
@ -999,6 +1133,9 @@ pub async fn ensure_collections(
|
||||||
Field::text("invite_type", true),
|
Field::text("invite_type", true),
|
||||||
Field::text("used_by_id", false),
|
Field::text("used_by_id", false),
|
||||||
Field::text("used_at", false),
|
Field::text("used_at", false),
|
||||||
|
Field::text("reserved_by_id", false),
|
||||||
|
Field::text("reserved_checkout_id", false),
|
||||||
|
Field::number("reserved_until_unix"),
|
||||||
Field::autodate("created", true, false),
|
Field::autodate("created", true, false),
|
||||||
Field::autodate("updated", true, true),
|
Field::autodate("updated", true, true),
|
||||||
],
|
],
|
||||||
|
|
@ -1011,7 +1148,10 @@ pub async fn ensure_collections(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "invites").await?;
|
||||||
} else {
|
} else {
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "invites").await?;
|
||||||
|
ensure_invites_fields(client, base_url, &token).await?;
|
||||||
ensure_autodate_fields(client, base_url, &token, "invites").await?;
|
ensure_autodate_fields(client, base_url, &token, "invites").await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1027,6 +1167,7 @@ pub async fn ensure_collections(
|
||||||
fields: vec![
|
fields: vec![
|
||||||
Field::relation("user", &users_id),
|
Field::relation("user", &users_id),
|
||||||
Field::text("stripe_session_id", false),
|
Field::text("stripe_session_id", false),
|
||||||
|
Field::text("stripe_payment_intent_id", false),
|
||||||
Field::text("checkout_url", false),
|
Field::text("checkout_url", false),
|
||||||
Field::number("amount_pence"),
|
Field::number("amount_pence"),
|
||||||
Field::number("expected_total_pence"),
|
Field::number("expected_total_pence"),
|
||||||
|
|
@ -1037,6 +1178,7 @@ pub async fn ensure_collections(
|
||||||
Field::number("expires_at_unix"),
|
Field::number("expires_at_unix"),
|
||||||
Field::number("paid_amount_pence"),
|
Field::number("paid_amount_pence"),
|
||||||
Field::text("completed_at_unix", false),
|
Field::text("completed_at_unix", false),
|
||||||
|
Field::text("reversal_reason", false),
|
||||||
Field::autodate("created", true, false),
|
Field::autodate("created", true, false),
|
||||||
Field::autodate("updated", true, true),
|
Field::autodate("updated", true, true),
|
||||||
],
|
],
|
||||||
|
|
@ -1106,6 +1248,12 @@ pub async fn ensure_collections(
|
||||||
fields: vec![
|
fields: vec![
|
||||||
Field::text("code", true),
|
Field::text("code", true),
|
||||||
Field::text("params", true),
|
Field::text("params", true),
|
||||||
|
Field::text("created_by", false),
|
||||||
|
Field::number("click_count"),
|
||||||
|
Field::number("share_south"),
|
||||||
|
Field::number("share_west"),
|
||||||
|
Field::number("share_north"),
|
||||||
|
Field::number("share_east"),
|
||||||
Field::autodate("created", true, false),
|
Field::autodate("created", true, false),
|
||||||
Field::autodate("updated", true, true),
|
Field::autodate("updated", true, true),
|
||||||
],
|
],
|
||||||
|
|
@ -1118,7 +1266,10 @@ pub async fn ensure_collections(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "short_urls").await?;
|
||||||
} else {
|
} else {
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "short_urls").await?;
|
||||||
|
ensure_short_urls_fields(client, base_url, &token).await?;
|
||||||
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
|
ensure_autodate_fields(client, base_url, &token, "short_urls").await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1148,7 +1299,9 @@ pub async fn ensure_collections(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "location_logs").await?;
|
||||||
} else {
|
} else {
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "location_logs").await?;
|
||||||
ensure_autodate_fields(client, base_url, &token, "location_logs").await?;
|
ensure_autodate_fields(client, base_url, &token, "location_logs").await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1181,7 +1334,9 @@ pub async fn ensure_collections(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "ai_query_logs").await?;
|
||||||
} else {
|
} else {
|
||||||
|
ensure_server_only_rules(client, base_url, &token, "ai_query_logs").await?;
|
||||||
ensure_autodate_fields(client, base_url, &token, "ai_query_logs").await?;
|
ensure_autodate_fields(client, base_url, &token, "ai_query_logs").await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ pub use pricing::get_pricing;
|
||||||
pub use properties::get_hexagon_properties;
|
pub use properties::get_hexagon_properties;
|
||||||
pub use rightmove::get_rightmove_redirect;
|
pub use rightmove::get_rightmove_redirect;
|
||||||
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
pub use screenshot::{fetch_screenshot_bytes, get_screenshot};
|
||||||
pub use shorten::{get_short_url, post_shorten};
|
pub use shorten::{get_share_links, get_short_url, post_shorten};
|
||||||
pub use streetview::get_streetview;
|
pub use streetview::get_streetview;
|
||||||
pub use stripe_webhook::post_stripe_webhook;
|
pub use stripe_webhook::post_stripe_webhook;
|
||||||
pub use telemetry::post_telemetry;
|
pub use telemetry::post_telemetry;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::http::{header, HeaderMap, StatusCode};
|
use axum::http::{header, HeaderMap, StatusCode, Uri};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::Extension;
|
use axum::Extension;
|
||||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Image, Url, Workbook};
|
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Image, Url, Workbook};
|
||||||
|
|
@ -16,11 +16,14 @@ use crate::auth::OptionalUser;
|
||||||
use crate::consts::NAN_U16;
|
use crate::consts::NAN_U16;
|
||||||
use crate::data::{PostcodePoiMetrics, QuantRef};
|
use crate::data::{PostcodePoiMetrics, QuantRef};
|
||||||
use crate::features;
|
use crate::features;
|
||||||
use crate::licensing::check_license_bounds;
|
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||||
use crate::parsing::{
|
use crate::parsing::{
|
||||||
parse_field_indices_with_poi, parse_filters_with_poi, require_bounds, row_passes_filters,
|
parse_field_indices_with_poi, parse_filters_with_poi, require_bounds, row_passes_filters,
|
||||||
row_passes_poi_filters,
|
row_passes_poi_filters,
|
||||||
};
|
};
|
||||||
|
use crate::routes::travel_time::{
|
||||||
|
load_travel_data, parse_optional_travel, row_passes_travel_filters,
|
||||||
|
};
|
||||||
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
use crate::routes::{fetch_screenshot_bytes, FeatureInfo};
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
||||||
|
|
@ -29,11 +32,20 @@ const EXPORT_SCREENSHOT_TIMEOUT_SECS: u64 = 12;
|
||||||
/// Height (in pixels) reserved for the screenshot row
|
/// Height (in pixels) reserved for the screenshot row
|
||||||
const IMAGE_ROW_HEIGHT: f64 = 225.0;
|
const IMAGE_ROW_HEIGHT: f64 = 225.0;
|
||||||
|
|
||||||
|
/// Hard cap on the bounding-box area (in degrees²) that may be exported.
|
||||||
|
/// All of England fits inside ~6° × ~10° ≈ 60 deg². Anything substantially
|
||||||
|
/// larger is rejected to keep aggregation bounded for non-licensed users
|
||||||
|
/// who supply share grants outside their expected region, and to avoid
|
||||||
|
/// minutes-long requests that fan out to millions of rows.
|
||||||
|
const MAX_EXPORT_BBOX_AREA_DEG2: f64 = 80.0;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ExportParams {
|
pub struct ExportParams {
|
||||||
bounds: Option<String>,
|
bounds: Option<String>,
|
||||||
filters: Option<String>,
|
filters: Option<String>,
|
||||||
|
travel: Option<String>,
|
||||||
fields: Option<String>,
|
fields: Option<String>,
|
||||||
|
share: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-postcode accumulator for export aggregation (mean for numeric, mode for enum).
|
/// Per-postcode accumulator for export aggregation (mean for numeric, mode for enum).
|
||||||
|
|
@ -125,6 +137,8 @@ fn build_frontend_params(
|
||||||
center_lon: f64,
|
center_lon: f64,
|
||||||
zoom: f64,
|
zoom: f64,
|
||||||
filters_str: Option<&str>,
|
filters_str: Option<&str>,
|
||||||
|
travel_params: &[String],
|
||||||
|
share: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!("lat={:.4}", center_lat),
|
format!("lat={:.4}", center_lat),
|
||||||
|
|
@ -140,20 +154,53 @@ fn build_frontend_params(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for entry in travel_params {
|
||||||
|
if !entry.is_empty() {
|
||||||
|
parts.push(format!("tt={}", urlencoding::encode(entry.trim())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(share) = share.filter(|value| !value.is_empty()) {
|
||||||
|
parts.push(format!("share={}", urlencoding::encode(share)));
|
||||||
|
}
|
||||||
parts.join("&")
|
parts.join("&")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_travel_state_params(query: Option<&str>) -> Vec<String> {
|
||||||
|
query
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|qs| url::form_urlencoded::parse(qs.as_bytes()))
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
if key == "tt" && !value.is_empty() {
|
||||||
|
Some(value.into_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_export(
|
pub async fn get_export(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Extension(user): Extension<OptionalUser>,
|
Extension(user): Extension<OptionalUser>,
|
||||||
|
uri: Uri,
|
||||||
Query(params): Query<ExportParams>,
|
Query(params): Query<ExportParams>,
|
||||||
) -> Result<impl IntoResponse, axum::response::Response> {
|
) -> Result<impl IntoResponse, axum::response::Response> {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let (south, west, north, east) =
|
let (south, west, north, east) =
|
||||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||||
|
|
||||||
check_license_bounds(&user.0, (south, west, north, east), None)?;
|
let area_deg2 = (north - south).max(0.0) * (east - west).max(0.0);
|
||||||
|
if area_deg2 > MAX_EXPORT_BBOX_AREA_DEG2 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Export area is too large; zoom in further before exporting",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
|
||||||
|
check_license_bounds(&user.0, (south, west, north, east), share_bounds)?;
|
||||||
|
|
||||||
let quant = state.data.quant_ref();
|
let quant = state.data.quant_ref();
|
||||||
let poi_quant = state.data.poi_metrics.quant_ref();
|
let poi_quant = state.data.poi_metrics.quant_ref();
|
||||||
|
|
@ -168,7 +215,14 @@ pub async fn get_export(
|
||||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||||
let has_poi_filters = !parsed_poi_filters.is_empty();
|
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||||
let filters_str = params.filters;
|
let filters_str = params.filters;
|
||||||
|
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||||
|
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||||
|
let has_travel_filters = travel_entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.filter_min.is_some() && entry.filter_max.is_some());
|
||||||
|
let travel_state_params = collect_travel_state_params(uri.query());
|
||||||
let fields_str = params.fields;
|
let fields_str = params.fields;
|
||||||
|
let share_code = params.share;
|
||||||
|
|
||||||
let public_url = state.public_url.clone();
|
let public_url = state.public_url.clone();
|
||||||
|
|
||||||
|
|
@ -181,8 +235,14 @@ pub async fn get_export(
|
||||||
} else {
|
} else {
|
||||||
12.0
|
12.0
|
||||||
};
|
};
|
||||||
let frontend_params =
|
let frontend_params = build_frontend_params(
|
||||||
build_frontend_params(center_lat, center_lon, zoom, filters_str.as_deref());
|
center_lat,
|
||||||
|
center_lon,
|
||||||
|
zoom,
|
||||||
|
filters_str.as_deref(),
|
||||||
|
&travel_state_params,
|
||||||
|
share_code.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch screenshot (async, before spawn_blocking)
|
// Fetch screenshot (async, before spawn_blocking)
|
||||||
let auth_header = headers.get(header::AUTHORIZATION);
|
let auth_header = headers.get(header::AUTHORIZATION);
|
||||||
|
|
@ -235,14 +295,17 @@ pub async fn get_export(
|
||||||
let enum_values = &state.data.enum_values;
|
let enum_values = &state.data.enum_values;
|
||||||
let postcode_data = &state.postcode_data;
|
let postcode_data = &state.postcode_data;
|
||||||
let poi_metrics = &state.data.poi_metrics;
|
let poi_metrics = &state.data.poi_metrics;
|
||||||
|
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
|
||||||
let poi_offset = num_features;
|
let poi_offset = num_features;
|
||||||
let total_export_features = num_features + poi_metrics.num_features();
|
let total_export_features = num_features + poi_metrics.num_features();
|
||||||
|
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||||
|
|
||||||
// Build set of enum feature indices for quick lookup
|
// Build set of enum feature indices for quick lookup
|
||||||
let enum_indices: FxHashMap<usize, ()> = enum_values.keys().map(|&idx| (idx, ())).collect();
|
let enum_indices: FxHashMap<usize, ()> = enum_values.keys().map(|&idx| (idx, ())).collect();
|
||||||
|
|
||||||
// Group rows by postcode
|
// Aggregate directly by postcode so large requests don't retain every
|
||||||
let mut postcode_rows: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
|
// matching property row before sampling the exported postcodes.
|
||||||
|
let mut postcode_aggs: FxHashMap<usize, PostcodeExportAgg> = FxHashMap::default();
|
||||||
state
|
state
|
||||||
.grid
|
.grid
|
||||||
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||||
|
|
@ -260,31 +323,31 @@ pub async fn get_export(
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let postcode = state.data.postcode(row);
|
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||||
|
if has_travel_filters
|
||||||
|
&& !row_passes_travel_filters(postcode, &travel_entries, &travel_data)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
|
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
|
||||||
postcode_rows.entry(pc_idx).or_default().push(row);
|
postcode_aggs
|
||||||
|
.entry(pc_idx)
|
||||||
|
.or_insert_with(|| PostcodeExportAgg::new(total_export_features))
|
||||||
|
.add_row(
|
||||||
|
feature_data,
|
||||||
|
row,
|
||||||
|
num_features,
|
||||||
|
&enum_indices,
|
||||||
|
&quant,
|
||||||
|
poi_metrics,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Aggregate per postcode
|
let mut postcode_aggs: Vec<(usize, PostcodeExportAgg)> = postcode_aggs
|
||||||
let mut postcode_aggs: Vec<(usize, PostcodeExportAgg)> =
|
.into_iter()
|
||||||
Vec::with_capacity(postcode_rows.len());
|
.filter(|(_, agg)| agg.count > 0)
|
||||||
for (pc_idx, rows) in postcode_rows {
|
.collect();
|
||||||
let mut agg = PostcodeExportAgg::new(total_export_features);
|
|
||||||
for &row in &rows {
|
|
||||||
agg.add_row(
|
|
||||||
feature_data,
|
|
||||||
row,
|
|
||||||
num_features,
|
|
||||||
&enum_indices,
|
|
||||||
&quant,
|
|
||||||
poi_metrics,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if agg.count > 0 {
|
|
||||||
postcode_aggs.push((pc_idx, agg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by property count descending
|
// Sort by property count descending
|
||||||
postcode_aggs.sort_unstable_by_key(|agg| std::cmp::Reverse(agg.1.count));
|
postcode_aggs.sort_unstable_by_key(|agg| std::cmp::Reverse(agg.1.count));
|
||||||
|
|
@ -460,7 +523,11 @@ pub async fn get_export(
|
||||||
.set_align(FormatAlign::Left);
|
.set_align(FormatAlign::Left);
|
||||||
|
|
||||||
// Dashboard URL
|
// Dashboard URL
|
||||||
let dashboard_url = format!("{}/?{}", public_url, frontend_params);
|
let dashboard_url = format!(
|
||||||
|
"{}/dashboard?{}",
|
||||||
|
public_url.trim_end_matches('/'),
|
||||||
|
frontend_params
|
||||||
|
);
|
||||||
|
|
||||||
// Sheet 1: "Selected" (filter features only) with link + screenshot
|
// Sheet 1: "Selected" (filter features only) with link + screenshot
|
||||||
// Sheet 2: "All Data" (all features)
|
// Sheet 2: "All Data" (all features)
|
||||||
|
|
@ -680,3 +747,42 @@ pub async fn get_export(
|
||||||
bytes,
|
bytes,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_travel_state_params_accepts_single_tt_param() {
|
||||||
|
let entry = "transit:bank-tube-station:Bank%20tube%20station:0:52";
|
||||||
|
let query = format!("bounds=1,2,3,4&tt={}", urlencoding::encode(entry));
|
||||||
|
|
||||||
|
assert_eq!(collect_travel_state_params(Some(&query)), vec![entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_travel_state_params_preserves_repeated_tt_params() {
|
||||||
|
let bank = "transit:bank-tube-station:Bank%20tube%20station:0:52";
|
||||||
|
let kings_cross = "transit:kings-cross:Kings%20Cross:b:0:30";
|
||||||
|
let query = format!(
|
||||||
|
"tt={}&filter=Price%3A0%3A100&tt={}",
|
||||||
|
urlencoding::encode(bank),
|
||||||
|
urlencoding::encode(kings_cross)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
collect_travel_state_params(Some(&query)),
|
||||||
|
vec![bank, kings_cross]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_query_deserializes_when_tt_is_a_single_string() {
|
||||||
|
let uri: Uri = "/api/export?bounds=1,2,3,4&tt=transit%3Abank%3ABank%2520station%3A0%3A52"
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let Query(params) = Query::<ExportParams>::try_from_uri(&uri).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(params.bounds.as_deref(), Some("1,2,3,4"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::auth::{OptionalUser, PocketBaseUser};
|
use crate::auth::{OptionalUser, PocketBaseUser};
|
||||||
use crate::checkout_sessions::{
|
use crate::checkout_sessions::{
|
||||||
active_referral_checkout_user, start_license_checkout, CheckoutStart,
|
active_referral_checkout_user, grant_license_with_pricing_lock, start_license_checkout,
|
||||||
|
CheckoutStart,
|
||||||
};
|
};
|
||||||
use crate::pocketbase::get_superuser_token;
|
use crate::pocketbase::get_superuser_token;
|
||||||
use crate::pocketbase_locks::acquire_pocketbase_lock;
|
use crate::pocketbase_locks::acquire_pocketbase_lock;
|
||||||
|
|
@ -107,6 +108,25 @@ fn validate_invite_code(code: &str) -> Result<(), &'static str> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sanitize the inviter's display name returned to anonymous clients.
|
||||||
|
/// The value comes from the inviter's email local-part stored in PocketBase;
|
||||||
|
/// we don't trust it, so strip control chars and HTML-meaningful characters
|
||||||
|
/// and cap the length. Returns None if nothing usable remains.
|
||||||
|
fn sanitize_invited_by(raw: &str) -> Option<String> {
|
||||||
|
const MAX_LEN: usize = 40;
|
||||||
|
let cleaned: String = raw
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control() && !matches!(*c, '<' | '>' | '"' | '\'' | '&' | '\\'))
|
||||||
|
.take(MAX_LEN)
|
||||||
|
.collect();
|
||||||
|
let trimmed = cleaned.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_invite_code() -> String {
|
fn generate_invite_code() -> String {
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
|
|
@ -131,6 +151,48 @@ fn current_unix_secs_string() -> String {
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the live `is_admin` flag for a user, bypassing any cached token
|
||||||
|
/// claims. Returns Err with an HTTP response if PocketBase is unreachable
|
||||||
|
/// or returns an unexpected payload — the caller should propagate that.
|
||||||
|
async fn verify_is_admin(
|
||||||
|
state: &AppState,
|
||||||
|
pb_url: &str,
|
||||||
|
token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<bool, Response> {
|
||||||
|
if user_id.is_empty()
|
||||||
|
|| user_id.len() > 32
|
||||||
|
|| !user_id.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return Err(StatusCode::FORBIDDEN.into_response());
|
||||||
|
}
|
||||||
|
let url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
||||||
|
let resp = match state
|
||||||
|
.http_client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to verify is_admin: {err}");
|
||||||
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to parse user record for is_admin verify: {err}");
|
||||||
|
return Err(StatusCode::BAD_GATEWAY.into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(body["is_admin"].as_bool().unwrap_or(false))
|
||||||
|
}
|
||||||
|
|
||||||
async fn lookup_unused_invite(
|
async fn lookup_unused_invite(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
pb_url: &str,
|
pb_url: &str,
|
||||||
|
|
@ -217,35 +279,16 @@ async fn mark_invite_used(
|
||||||
|
|
||||||
async fn grant_license_for_invite(
|
async fn grant_license_for_invite(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
pb_url: &str,
|
_pb_url: &str,
|
||||||
token: &str,
|
_token: &str,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<(), Response> {
|
) -> Result<(), Response> {
|
||||||
let update_url = format!("{pb_url}/api/collections/users/records/{user_id}");
|
grant_license_with_pricing_lock(state, user_id)
|
||||||
let resp = match state
|
|
||||||
.http_client
|
|
||||||
.patch(&update_url)
|
|
||||||
.header("Authorization", format!("Bearer {token}"))
|
|
||||||
.json(&serde_json::json!({ "subscription": "licensed" }))
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|err| {
|
||||||
Ok(resp) => resp,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("Failed to update user subscription for admin invite: {err}");
|
warn!("Failed to update user subscription for admin invite: {err}");
|
||||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
StatusCode::BAD_GATEWAY.into_response()
|
||||||
}
|
})
|
||||||
};
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let text = resp.text().await.unwrap_or_default();
|
|
||||||
warn!("PocketBase user subscription update failed ({status}): {text}");
|
|
||||||
return Err(StatusCode::BAD_GATEWAY.into_response());
|
|
||||||
}
|
|
||||||
|
|
||||||
state.token_cache.invalidate_by_user_id(user_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_referral_checkout(
|
async fn create_referral_checkout(
|
||||||
|
|
@ -289,12 +332,32 @@ pub async fn post_invites(
|
||||||
None => return StatusCode::UNAUTHORIZED.into_response(),
|
None => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let invite_type = if user.is_admin {
|
// Cached token claims could be stale or, in the worst case, tampered with
|
||||||
match body.invite_type.as_deref() {
|
// upstream of us. For admin-only actions, re-fetch the live record from
|
||||||
Some("referral") => "referral",
|
// PocketBase and trust only that.
|
||||||
_ => "admin",
|
let wants_admin_invite =
|
||||||
|
user.is_admin && !matches!(body.invite_type.as_deref(), Some("referral"));
|
||||||
|
|
||||||
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
|
||||||
|
let token = match get_superuser_token(&state).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to auth as PocketBase superuser: {err}");
|
||||||
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
}
|
}
|
||||||
} else if user.subscription == "licensed" {
|
};
|
||||||
|
|
||||||
|
let invite_type = if wants_admin_invite {
|
||||||
|
match verify_is_admin(&state, pb_url, &token, &user.id).await {
|
||||||
|
Ok(true) => "admin",
|
||||||
|
Ok(false) => {
|
||||||
|
warn!(user_id = %user.id, "is_admin claim rejected by live PB lookup");
|
||||||
|
return (StatusCode::FORBIDDEN, "Not authorised").into_response();
|
||||||
|
}
|
||||||
|
Err(response) => return response,
|
||||||
|
}
|
||||||
|
} else if user.is_admin || user.subscription == "licensed" {
|
||||||
"referral"
|
"referral"
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
|
|
@ -305,15 +368,6 @@ pub async fn post_invites(
|
||||||
};
|
};
|
||||||
|
|
||||||
let code = generate_invite_code();
|
let code = generate_invite_code();
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
|
||||||
|
|
||||||
let token = match get_superuser_token(&state).await {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(err) => {
|
|
||||||
warn!("Failed to auth as PocketBase superuser: {err}");
|
|
||||||
return StatusCode::BAD_GATEWAY.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let create_url = format!("{pb_url}/api/collections/invites/records");
|
let create_url = format!("{pb_url}/api/collections/invites/records");
|
||||||
let res = state
|
let res = state
|
||||||
|
|
@ -429,7 +483,7 @@ pub async fn get_invite(
|
||||||
let used = !used_by.is_empty();
|
let used = !used_by.is_empty();
|
||||||
let created_by = invite["created_by"].as_str().unwrap_or("");
|
let created_by = invite["created_by"].as_str().unwrap_or("");
|
||||||
|
|
||||||
// Look up inviter's name (email local part)
|
// Look up inviter's name (email local part) — sanitized before returning.
|
||||||
let invited_by = if !created_by.is_empty() {
|
let invited_by = if !created_by.is_empty() {
|
||||||
let user_url = format!("{pb_url}/api/collections/users/records/{created_by}");
|
let user_url = format!("{pb_url}/api/collections/users/records/{created_by}");
|
||||||
match state
|
match state
|
||||||
|
|
@ -444,7 +498,7 @@ pub async fn get_invite(
|
||||||
user_body["email"]
|
user_body["email"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.and_then(|e| e.split('@').next())
|
.and_then(|e| e.split('@').next())
|
||||||
.map(String::from)
|
.and_then(sanitize_invited_by)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -565,11 +619,11 @@ pub async fn post_redeem_invite(
|
||||||
};
|
};
|
||||||
|
|
||||||
if invite_type == "admin" {
|
if invite_type == "admin" {
|
||||||
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(response) = grant_license_for_invite(&state, pb_url, &token, &user.id).await {
|
if let Err(response) = mark_invite_used(&state, pb_url, &token, invite_id, &user.id).await {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,24 @@ use std::sync::Arc;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
|
use axum::Extension;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::auth::OptionalUser;
|
||||||
|
use crate::licensing::{is_valid_share_bounds, share_bounds_from_params, ShareBounds};
|
||||||
use crate::pocketbase::get_superuser_token;
|
use crate::pocketbase::get_superuser_token;
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
||||||
const CODE_LEN: usize = 8;
|
const CODE_LEN: usize = 8;
|
||||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const MAX_QUERY_LEN: usize = 4096;
|
||||||
|
const MAX_QUERY_PAIRS: usize = 80;
|
||||||
|
const MAX_PARAM_KEY_LEN: usize = 64;
|
||||||
|
const MAX_PARAM_VALUE_LEN: usize = 512;
|
||||||
|
|
||||||
fn generate_code() -> String {
|
fn generate_code() -> String {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
|
|
@ -36,15 +44,178 @@ pub struct ShortenResponse {
|
||||||
struct PbRecord {
|
struct PbRecord {
|
||||||
code: String,
|
code: String,
|
||||||
params: String,
|
params: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
created_by: Option<String>,
|
||||||
|
click_count: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
share_south: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
share_west: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
share_north: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
share_east: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ShareLinkListItem {
|
||||||
|
code: String,
|
||||||
|
url: String,
|
||||||
|
og_image_url: String,
|
||||||
|
params: String,
|
||||||
|
click_count: u64,
|
||||||
|
created: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ShareLinksResponse {
|
||||||
|
links: Vec<ShareLinkListItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_number_as_u64(value: &serde_json::Value) -> u64 {
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.as_f64()
|
||||||
|
.filter(|n| n.is_finite() && *n > 0.0)
|
||||||
|
.map(|n| n as u64)
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitized_query_params(params: &str, keep_share: bool) -> Result<String, &'static str> {
|
||||||
|
let params = params.trim_start_matches('?');
|
||||||
|
if params.len() > MAX_QUERY_LEN {
|
||||||
|
return Err("query string is too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for (idx, (key, value)) in form_urlencoded::parse(params.as_bytes()).enumerate() {
|
||||||
|
if idx >= MAX_QUERY_PAIRS {
|
||||||
|
return Err("query string has too many parameters");
|
||||||
|
}
|
||||||
|
if key == "share" && !keep_share {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !is_allowed_param_key(&key) {
|
||||||
|
return Err("query string contains an unsupported parameter");
|
||||||
|
}
|
||||||
|
if key.len() > MAX_PARAM_KEY_LEN || value.len() > MAX_PARAM_VALUE_LEN {
|
||||||
|
return Err("query parameter is too long");
|
||||||
|
}
|
||||||
|
if key.chars().any(char::is_control) || value.chars().any(char::is_control) {
|
||||||
|
return Err("query parameter contains control characters");
|
||||||
|
}
|
||||||
|
pairs.push((key.into_owned(), value.into_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = form_urlencoded::Serializer::new(String::new());
|
||||||
|
for (key, value) in pairs {
|
||||||
|
out.append_pair(&key, &value);
|
||||||
|
}
|
||||||
|
Ok(out.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_allowed_param_key(key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
key,
|
||||||
|
"lat"
|
||||||
|
| "lon"
|
||||||
|
| "zoom"
|
||||||
|
| "filter"
|
||||||
|
| "school"
|
||||||
|
| "crime"
|
||||||
|
| "voteShare"
|
||||||
|
| "ethnicity"
|
||||||
|
| "amenityDistance"
|
||||||
|
| "transportDistance"
|
||||||
|
| "amenityCount2km"
|
||||||
|
| "amenityCount5km"
|
||||||
|
| "poi"
|
||||||
|
| "tab"
|
||||||
|
| "pc"
|
||||||
|
| "tt"
|
||||||
|
| "share"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_attr(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_can_create_share_grant(user: &OptionalUser) -> bool {
|
||||||
|
user.0
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|u| u.is_admin || u.subscription == "licensed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_fields(
|
||||||
|
bounds: Option<ShareBounds>,
|
||||||
|
) -> (Option<f64>, Option<f64>, Option<f64>, Option<f64>) {
|
||||||
|
match bounds {
|
||||||
|
Some(bounds) => (
|
||||||
|
Some(bounds.south),
|
||||||
|
Some(bounds.west),
|
||||||
|
Some(bounds.north),
|
||||||
|
Some(bounds.east),
|
||||||
|
),
|
||||||
|
None => (None, None, None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_share_bounds(item: &serde_json::Value) -> Option<ShareBounds> {
|
||||||
|
let bounds = ShareBounds {
|
||||||
|
south: item.get("share_south")?.as_f64()?,
|
||||||
|
west: item.get("share_west")?.as_f64()?,
|
||||||
|
north: item.get("share_north")?.as_f64()?,
|
||||||
|
east: item.get("share_east")?.as_f64()?,
|
||||||
|
};
|
||||||
|
is_valid_share_bounds(bounds).then_some(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dashboard_redirect_url(params: &str, code: &str, include_share: bool) -> String {
|
||||||
|
match (params.is_empty(), include_share) {
|
||||||
|
(true, false) => "/dashboard".to_string(),
|
||||||
|
(true, true) => format!("/dashboard?share={code}"),
|
||||||
|
(false, false) => format!("/dashboard?{params}"),
|
||||||
|
(false, true) => format!("/dashboard?{params}&share={code}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn og_image_url(public_url: &str, params: &str) -> String {
|
||||||
|
if params.is_empty() {
|
||||||
|
format!("{}/api/screenshot?og=1", public_url.trim_end_matches('/'))
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}/api/screenshot?og=1&{params}",
|
||||||
|
public_url.trim_end_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_shorten(
|
pub async fn post_shorten(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
|
Extension(user): Extension<OptionalUser>,
|
||||||
Json(req): Json<ShortenRequest>,
|
Json(req): Json<ShortenRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let state = shared.load_state();
|
let state = shared.load_state();
|
||||||
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
||||||
|
|
||||||
|
let can_create_share_grant = user_can_create_share_grant(&user);
|
||||||
|
let params = match sanitized_query_params(&req.params, !can_create_share_grant) {
|
||||||
|
Ok(params) => params,
|
||||||
|
Err(reason) => {
|
||||||
|
warn!("Rejected short URL params: {reason}");
|
||||||
|
return (StatusCode::BAD_REQUEST, reason).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let token = match get_superuser_token(&state).await {
|
let token = match get_superuser_token(&state).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
@ -54,10 +225,22 @@ pub async fn post_shorten(
|
||||||
};
|
};
|
||||||
|
|
||||||
let code = generate_code();
|
let code = generate_code();
|
||||||
|
let share_bounds = if can_create_share_grant {
|
||||||
|
share_bounds_from_params(¶ms)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let (share_south, share_west, share_north, share_east) = share_fields(share_bounds);
|
||||||
|
|
||||||
let record = PbRecord {
|
let record = PbRecord {
|
||||||
code: code.clone(),
|
code: code.clone(),
|
||||||
params: req.params,
|
params,
|
||||||
|
created_by: user.0.as_ref().map(|u| u.id.clone()),
|
||||||
|
click_count: 0,
|
||||||
|
share_south,
|
||||||
|
share_west,
|
||||||
|
share_north,
|
||||||
|
share_east,
|
||||||
};
|
};
|
||||||
|
|
||||||
let res = state
|
let res = state
|
||||||
|
|
@ -89,6 +272,85 @@ pub async fn post_shorten(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_share_links(
|
||||||
|
State(shared): State<Arc<SharedState>>,
|
||||||
|
Extension(user): Extension<OptionalUser>,
|
||||||
|
) -> Response {
|
||||||
|
let state = shared.load_state();
|
||||||
|
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 get_superuser_token(&state).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("PocketBase superuser auth failed: {err}");
|
||||||
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter = format!("created_by=\"{}\"", user.id);
|
||||||
|
let url = format!(
|
||||||
|
"{pb_url}/api/collections/short_urls/records?sort=-created&perPage=200&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 share links: {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 share links 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 share links response: {err}");
|
||||||
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let public_url = state.public_url.trim_end_matches('/');
|
||||||
|
let links: Vec<ShareLinkListItem> = body["items"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let code = item["code"].as_str().unwrap_or("").to_string();
|
||||||
|
let params = item["params"].as_str().unwrap_or("").to_string();
|
||||||
|
ShareLinkListItem {
|
||||||
|
url: format!("{public_url}/s/{code}"),
|
||||||
|
code,
|
||||||
|
og_image_url: og_image_url(public_url, ¶ms),
|
||||||
|
params,
|
||||||
|
click_count: json_number_as_u64(&item["click_count"]),
|
||||||
|
created: item["created"].as_str().unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Json(ShareLinksResponse { links }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_short_url(
|
pub async fn get_short_url(
|
||||||
State(shared): State<Arc<SharedState>>,
|
State(shared): State<Arc<SharedState>>,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
|
|
@ -132,22 +394,51 @@ pub async fn get_short_url(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let params = json["items"]
|
let item = json["items"].as_array().and_then(|items| items.first());
|
||||||
.as_array()
|
|
||||||
.and_then(|items| items.first())
|
|
||||||
.and_then(|item| item["params"].as_str());
|
|
||||||
|
|
||||||
match params {
|
match item.and_then(|item| item["params"].as_str().map(|params| (item, params))) {
|
||||||
Some(params) => {
|
Some((item, params)) => {
|
||||||
let redirect_url = if params.is_empty() {
|
let record_id = item["id"].as_str().unwrap_or("").to_string();
|
||||||
format!("/dashboard?share={code}")
|
let next_click_count =
|
||||||
} else {
|
json_number_as_u64(&item["click_count"]).saturating_add(1);
|
||||||
format!("/dashboard?{params}&share={code}")
|
let params = match sanitized_query_params(params, true) {
|
||||||
|
Ok(params) => params,
|
||||||
|
Err(reason) => {
|
||||||
|
warn!("Stored short URL params rejected for {code}: {reason}");
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
|
if !record_id.is_empty() {
|
||||||
let og_url = format!("{}/s/{code}", state.public_url);
|
let update_url =
|
||||||
|
format!("{pb_url}/api/collections/short_urls/records/{record_id}");
|
||||||
|
match state
|
||||||
|
.http_client
|
||||||
|
.patch(&update_url)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.json(&serde_json::json!({ "click_count": next_click_count }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(update_resp) if update_resp.status().is_success() => {}
|
||||||
|
Ok(update_resp) => {
|
||||||
|
let status = update_resp.status();
|
||||||
|
let text = update_resp.text().await.unwrap_or_default();
|
||||||
|
warn!("PocketBase click count update failed ({status}): {text}");
|
||||||
|
}
|
||||||
|
Err(err) => warn!("PocketBase click count update failed: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let redirect_url =
|
||||||
|
dashboard_redirect_url(¶ms, &code, record_share_bounds(item).is_some());
|
||||||
|
let og_image_url = og_image_url(&state.public_url, ¶ms);
|
||||||
|
let og_url = format!("{}/s/{code}", state.public_url.trim_end_matches('/'));
|
||||||
let og_title = "Perfect Postcode | Every neighbourhood in England";
|
let og_title = "Perfect Postcode | Every neighbourhood in England";
|
||||||
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
|
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";
|
||||||
|
let redirect_url = escape_attr(&redirect_url);
|
||||||
|
let og_image_url = escape_attr(&og_image_url);
|
||||||
|
let og_url = escape_attr(&og_url);
|
||||||
|
let og_title = escape_attr(og_title);
|
||||||
|
let og_description = escape_attr(og_description);
|
||||||
|
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!DOCTYPE html>
|
r#"<!DOCTYPE html>
|
||||||
|
|
@ -168,7 +459,13 @@ pub async fn get_short_url(
|
||||||
</head><body></body></html>"#
|
</head><body></body></html>"#
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
[(header::CACHE_CONTROL, "public, max-age=86400")],
|
[
|
||||||
|
(header::CACHE_CONTROL, "no-store"),
|
||||||
|
(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
"default-src 'none'; img-src https: data:; base-uri 'none'; form-action 'none'",
|
||||||
|
),
|
||||||
|
],
|
||||||
Html(html),
|
Html(html),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
|
|
@ -187,3 +484,37 @@ pub async fn get_short_url(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitizes_short_url_params_and_drops_share() {
|
||||||
|
let params = sanitized_query_params(
|
||||||
|
"lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2&share=oldcode",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&filter=price%3A1%3A2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_html_in_unsupported_params() {
|
||||||
|
assert!(sanitized_query_params("lat=51&x=%22%3E%3Cscript%3E", false).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn can_preserve_existing_share_grant() {
|
||||||
|
let params =
|
||||||
|
sanitized_query_params("lat=51.5&lon=-0.1&zoom=12&share=oldcode", true).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(params, "lat=51.5&lon=-0.1&zoom=12&share=oldcode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_html_attributes() {
|
||||||
|
assert_eq!(escape_attr(r#""'><&"#), ""'><&");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use sha2::Sha256;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::checkout_sessions::{
|
use crate::checkout_sessions::{
|
||||||
grant_license, mark_checkout_completed, mark_referral_invite_used, verify_checkout_completion,
|
complete_verified_checkout, reverse_license_for_payment_intent, verify_checkout_completion,
|
||||||
CheckoutCompletion,
|
CheckoutCompletion,
|
||||||
};
|
};
|
||||||
use crate::state::SharedState;
|
use crate::state::SharedState;
|
||||||
|
|
@ -54,16 +54,52 @@ fn verify_signature(payload: &[u8], sig_header: &str, secret: &str) -> bool {
|
||||||
signed_payload.push(b'.');
|
signed_payload.push(b'.');
|
||||||
signed_payload.extend_from_slice(payload);
|
signed_payload.extend_from_slice(payload);
|
||||||
|
|
||||||
signatures.into_iter().any(|sig_hex| {
|
// Verify every candidate signature without short-circuiting, so the total
|
||||||
|
// time taken doesn't depend on which (if any) signature matched.
|
||||||
|
let mut matched = false;
|
||||||
|
for sig_hex in signatures {
|
||||||
let Ok(sig_bytes) = hex::decode(sig_hex) else {
|
let Ok(sig_bytes) = hex::decode(sig_hex) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
||||||
return false;
|
continue;
|
||||||
};
|
};
|
||||||
mac.update(&signed_payload);
|
mac.update(&signed_payload);
|
||||||
mac.verify_slice(&sig_bytes).is_ok()
|
// verify_slice itself is constant-time.
|
||||||
})
|
if mac.verify_slice(&sig_bytes).is_ok() {
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payment_intent_id_from_object(object: &serde_json::Value) -> Option<&str> {
|
||||||
|
object["payment_intent"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|id| is_safe_stripe_id(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_safe_stripe_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 128
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reversal_event_is_actionable(event_type: &str, object: &serde_json::Value) -> bool {
|
||||||
|
match event_type {
|
||||||
|
"charge.refunded" => {
|
||||||
|
object["refunded"].as_bool().unwrap_or(false)
|
||||||
|
|| object["amount_refunded"].as_u64().unwrap_or(0) > 0
|
||||||
|
}
|
||||||
|
"charge.refund.updated" | "refund.created" | "refund.updated" => {
|
||||||
|
matches!(object["status"].as_str(), Some("succeeded"))
|
||||||
|
}
|
||||||
|
"charge.dispute.created" | "charge.dispute.funds_withdrawn" => true,
|
||||||
|
"charge.dispute.closed" => matches!(object["status"].as_str(), Some("lost")),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle Stripe webhook events.
|
/// Handle Stripe webhook events.
|
||||||
|
|
@ -109,40 +145,11 @@ pub async fn post_stripe_webhook(
|
||||||
let session = &event["data"]["object"];
|
let session = &event["data"]["object"];
|
||||||
match verify_checkout_completion(&state, session).await {
|
match verify_checkout_completion(&state, session).await {
|
||||||
Ok(CheckoutCompletion::Grant(checkout)) => {
|
Ok(CheckoutCompletion::Grant(checkout)) => {
|
||||||
if let Err(err) = mark_referral_invite_used(
|
if let Err(err) = complete_verified_checkout(&state, &checkout).await {
|
||||||
&state,
|
|
||||||
&checkout.referral_invite_id,
|
|
||||||
&checkout.user_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
warn!(
|
||||||
user_id = %checkout.user_id,
|
user_id = %checkout.user_id,
|
||||||
reservation_id = %checkout.reservation_id,
|
reservation_id = %checkout.reservation_id,
|
||||||
referral_invite_id = %checkout.referral_invite_id,
|
"Failed to complete verified Stripe checkout: {err:?}"
|
||||||
"Failed to mark referral invite used after Stripe checkout: {err:?}"
|
|
||||||
);
|
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
||||||
}
|
|
||||||
if let Err(err) = grant_license(&state, &checkout.user_id).await {
|
|
||||||
warn!(
|
|
||||||
user_id = %checkout.user_id,
|
|
||||||
reservation_id = %checkout.reservation_id,
|
|
||||||
"Failed to grant license after Stripe checkout: {err:?}"
|
|
||||||
);
|
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
||||||
}
|
|
||||||
if let Err(err) = mark_checkout_completed(
|
|
||||||
&state,
|
|
||||||
&checkout.reservation_id,
|
|
||||||
checkout.paid_amount_pence,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
user_id = %checkout.user_id,
|
|
||||||
reservation_id = %checkout.reservation_id,
|
|
||||||
"Failed to mark checkout completed after license grant: {err:?}"
|
|
||||||
);
|
);
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +170,52 @@ pub async fn post_stripe_webhook(
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if matches!(
|
||||||
|
event_type,
|
||||||
|
"charge.refunded"
|
||||||
|
| "charge.refund.updated"
|
||||||
|
| "refund.created"
|
||||||
|
| "refund.updated"
|
||||||
|
| "charge.dispute.created"
|
||||||
|
| "charge.dispute.closed"
|
||||||
|
| "charge.dispute.funds_withdrawn"
|
||||||
|
) {
|
||||||
|
let object = &event["data"]["object"];
|
||||||
|
let Some(payment_intent_id) = payment_intent_id_from_object(object) else {
|
||||||
|
warn!(
|
||||||
|
event_id,
|
||||||
|
event_type, "Stripe reversal event missing payment intent id"
|
||||||
|
);
|
||||||
|
return StatusCode::OK.into_response();
|
||||||
|
};
|
||||||
|
if !reversal_event_is_actionable(event_type, object) {
|
||||||
|
info!(
|
||||||
|
payment_intent_id,
|
||||||
|
event_type, "Ignoring non-final Stripe reversal event"
|
||||||
|
);
|
||||||
|
return StatusCode::OK.into_response();
|
||||||
|
}
|
||||||
|
match reverse_license_for_payment_intent(&state, payment_intent_id, event_type).await {
|
||||||
|
Ok(Some(user_id)) => {
|
||||||
|
info!(
|
||||||
|
user_id,
|
||||||
|
payment_intent_id, event_type, "Processed Stripe payment reversal event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
warn!(
|
||||||
|
payment_intent_id,
|
||||||
|
event_type, "Stripe reversal event had no matching checkout reservation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
payment_intent_id,
|
||||||
|
event_type, "Failed to process Stripe payment reversal event: {err:?}"
|
||||||
|
);
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusCode::OK.into_response()
|
StatusCode::OK.into_response()
|
||||||
|
|
|
||||||
|
|
@ -116,17 +116,15 @@ export async function installCursor(page: Page): Promise<void> {
|
||||||
body.__demo-aspect-horizontal #__demo-caption {
|
body.__demo-aspect-horizontal #__demo-caption {
|
||||||
bottom: 7%;
|
bottom: 7%;
|
||||||
}
|
}
|
||||||
/* Vertical default: upper-third. Sized DOWN deliberately — the
|
/* Vertical default: upper-third. Kept compact so the map remains the
|
||||||
caption must not occlude the map below it. ~30px on a 540-wide
|
primary visual in the social ad cuts. */
|
||||||
viewport sits at ~5.6% of viewport width per character; a single
|
|
||||||
line typically wraps in 2 rows of large bold text. */
|
|
||||||
body.__demo-aspect-vertical #__demo-caption {
|
body.__demo-aspect-vertical #__demo-caption {
|
||||||
top: 9%;
|
top: 7%;
|
||||||
max-width: min(880px, 84vw);
|
max-width: min(820px, 82vw);
|
||||||
font-size: 30px;
|
font-size: 27px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
padding: 14px 20px;
|
padding: 12px 18px;
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
#__demo-caption.visible {
|
#__demo-caption.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue