Replace entire repo content with code from /root/shahikitchen-google/
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { isGothenburgDeliveryAddress } from '@/domain/delivery/gothenburg-address';
|
||||
import {
|
||||
disableGeoapifyAutocomplete,
|
||||
isGeoapifyApiKeyConfigured,
|
||||
isGeoapifyAutocompleteDisabled,
|
||||
mapGeoapifyHttpStatus,
|
||||
type GeoapifyUnavailableReason,
|
||||
} from './geoapify-availability';
|
||||
import {
|
||||
getCachedGeoapifyFeatures,
|
||||
getInFlightGeoapifyRequest,
|
||||
setCachedGeoapifyFeatures,
|
||||
trackInFlightGeoapifyRequest,
|
||||
waitForGeoapifyRateLimit,
|
||||
} from './geoapify-request-cache';
|
||||
import type { GeoapifyAutocompleteResponse, GeoapifyFeature } from './geoapify-types';
|
||||
|
||||
/** Backaplan branch — bias autocomplete toward Gothenburg. */
|
||||
const GOTHENBURG_BIAS = 'proximity:11.944,57.699';
|
||||
|
||||
/** Approximate Göteborg municipality bounding box (minLon,minLat,maxLon,maxLat). */
|
||||
const GOTHENBURG_RECT_FILTER = 'rect:11.74,57.60,12.15,57.82';
|
||||
|
||||
export type GeoapifyAutocompleteResult =
|
||||
| { status: 'ok'; features: GeoapifyFeature[] }
|
||||
| { status: 'unavailable'; reason: GeoapifyUnavailableReason; features: [] };
|
||||
|
||||
export function isDeliverableGeoapifyFeature(feature: GeoapifyFeature): boolean {
|
||||
const { properties } = feature;
|
||||
return isGothenburgDeliveryAddress({
|
||||
city: properties.city,
|
||||
municipality: properties.municipality,
|
||||
postcode: properties.postcode,
|
||||
countryCode: properties.country_code,
|
||||
formatted: properties.formatted,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldUseGeoapifyAutocomplete(): boolean {
|
||||
return isGeoapifyApiKeyConfigured() && !isGeoapifyAutocompleteDisabled();
|
||||
}
|
||||
|
||||
function unavailable(reason: GeoapifyUnavailableReason): GeoapifyAutocompleteResult {
|
||||
disableGeoapifyAutocomplete();
|
||||
return { status: 'unavailable', reason, features: [] };
|
||||
}
|
||||
|
||||
async function requestGeoapifyAddressSuggestions(
|
||||
query: string,
|
||||
lang: 'sv' | 'en',
|
||||
): Promise<GeoapifyFeature[]> {
|
||||
const cached = getCachedGeoapifyFeatures(query, lang);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlight = getInFlightGeoapifyRequest(query, lang);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const request = (async () => {
|
||||
await waitForGeoapifyRateLimit();
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY!.trim();
|
||||
const params = new URLSearchParams({
|
||||
text: query.trim(),
|
||||
format: 'geojson',
|
||||
lang,
|
||||
limit: '8',
|
||||
filter: `countrycode:se|${GOTHENBURG_RECT_FILTER}`,
|
||||
bias: GOTHENBURG_BIAS,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.geoapify.com/v1/geocode/autocomplete?${params.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GeoapifyRequestError(mapGeoapifyHttpStatus(response.status));
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GeoapifyAutocompleteResponse;
|
||||
const features = (data.features ?? []).filter(isDeliverableGeoapifyFeature);
|
||||
setCachedGeoapifyFeatures(query, lang, features);
|
||||
return features;
|
||||
})();
|
||||
|
||||
return trackInFlightGeoapifyRequest(query, lang, request);
|
||||
}
|
||||
|
||||
class GeoapifyRequestError extends Error {
|
||||
constructor(public readonly reason: GeoapifyUnavailableReason) {
|
||||
super(reason);
|
||||
this.name = 'GeoapifyRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGeoapifyAddressSuggestions(
|
||||
query: string,
|
||||
lang: 'sv' | 'en' = 'sv',
|
||||
): Promise<GeoapifyAutocompleteResult> {
|
||||
if (!isGeoapifyApiKeyConfigured()) {
|
||||
return unavailable('missing_key');
|
||||
}
|
||||
|
||||
if (isGeoapifyAutocompleteDisabled()) {
|
||||
return unavailable('quota_exceeded');
|
||||
}
|
||||
|
||||
if (query.trim().length < 3) {
|
||||
return { status: 'ok', features: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const features = await requestGeoapifyAddressSuggestions(query, lang);
|
||||
return { status: 'ok', features };
|
||||
} catch (error) {
|
||||
if (error instanceof GeoapifyRequestError) {
|
||||
return unavailable(error.reason);
|
||||
}
|
||||
return unavailable('network');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
const SESSION_STORAGE_KEY = 'shahi-geoapify-disabled';
|
||||
|
||||
export type GeoapifyUnavailableReason =
|
||||
| 'missing_key'
|
||||
| 'quota_exceeded'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'rate_limited'
|
||||
| 'payment_required'
|
||||
| 'network'
|
||||
| 'server_error';
|
||||
|
||||
export function isGeoapifyApiKeyConfigured(): boolean {
|
||||
const key = process.env.NEXT_PUBLIC_GEOAPIFY_API_KEY;
|
||||
return typeof key === 'string' && key.trim().length > 0;
|
||||
}
|
||||
|
||||
export function isGeoapifyAutocompleteDisabled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return sessionStorage.getItem(SESSION_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableGeoapifyAutocomplete(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// sessionStorage may be unavailable in private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGeoapifyHttpStatus(status: number): GeoapifyUnavailableReason {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return 'unauthorized';
|
||||
case 402:
|
||||
return 'payment_required';
|
||||
case 403:
|
||||
return 'forbidden';
|
||||
case 429:
|
||||
return 'rate_limited';
|
||||
default:
|
||||
if (status >= 500) return 'server_error';
|
||||
return 'quota_exceeded';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { GeoapifyFeature } from './geoapify-types';
|
||||
|
||||
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_MEMORY_ENTRIES = 80;
|
||||
const MAX_SESSION_ENTRIES = 40;
|
||||
const MIN_REQUEST_INTERVAL_MS = 750;
|
||||
const SESSION_CACHE_KEY = 'shahi-geoapify-cache';
|
||||
|
||||
interface CacheEntry {
|
||||
features: GeoapifyFeature[];
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, Promise<GeoapifyFeature[]>>();
|
||||
let lastNetworkRequestAt = 0;
|
||||
let sessionHydrated = false;
|
||||
|
||||
export function buildGeoapifyCacheKey(query: string, lang: string): string {
|
||||
return `${lang}:${query.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFresh(entry: CacheEntry): boolean {
|
||||
return Date.now() - entry.cachedAt < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function trimMemoryCache(): void {
|
||||
while (memoryCache.size > MAX_MEMORY_ENTRIES) {
|
||||
const oldestKey = memoryCache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
memoryCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromSessionStorage(): void {
|
||||
if (sessionHydrated || typeof window === 'undefined') return;
|
||||
sessionHydrated = true;
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, CacheEntry>;
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (isFresh(entry)) {
|
||||
memoryCache.set(key, entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupt cache payloads
|
||||
}
|
||||
}
|
||||
|
||||
function persistToSessionStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const payload: Record<string, CacheEntry> = {};
|
||||
const entries = [...memoryCache.entries()]
|
||||
.filter(([, entry]) => isFresh(entry))
|
||||
.slice(-MAX_SESSION_ENTRIES);
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
payload[key] = entry;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
// sessionStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedGeoapifyFeatures(
|
||||
query: string,
|
||||
lang: string,
|
||||
): GeoapifyFeature[] | null {
|
||||
hydrateFromSessionStorage();
|
||||
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
const entry = memoryCache.get(key);
|
||||
if (!entry || !isFresh(entry)) {
|
||||
if (entry) memoryCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.features;
|
||||
}
|
||||
|
||||
export function setCachedGeoapifyFeatures(
|
||||
query: string,
|
||||
lang: string,
|
||||
features: GeoapifyFeature[],
|
||||
): void {
|
||||
hydrateFromSessionStorage();
|
||||
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
memoryCache.set(key, { features, cachedAt: Date.now() });
|
||||
trimMemoryCache();
|
||||
persistToSessionStorage();
|
||||
}
|
||||
|
||||
export function getInFlightGeoapifyRequest(
|
||||
query: string,
|
||||
lang: string,
|
||||
): Promise<GeoapifyFeature[]> | null {
|
||||
return inFlight.get(buildGeoapifyCacheKey(query, lang)) ?? null;
|
||||
}
|
||||
|
||||
export function trackInFlightGeoapifyRequest(
|
||||
query: string,
|
||||
lang: string,
|
||||
request: Promise<GeoapifyFeature[]>,
|
||||
): Promise<GeoapifyFeature[]> {
|
||||
const key = buildGeoapifyCacheKey(query, lang);
|
||||
inFlight.set(key, request);
|
||||
return request.finally(() => {
|
||||
if (inFlight.get(key) === request) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForGeoapifyRateLimit(): Promise<void> {
|
||||
const elapsed = Date.now() - lastNetworkRequestAt;
|
||||
const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
lastNetworkRequestAt = Date.now();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface GeoapifyAddressProperties {
|
||||
formatted?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
municipality?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
export interface GeoapifyFeature {
|
||||
type: 'Feature';
|
||||
properties: GeoapifyAddressProperties;
|
||||
}
|
||||
|
||||
export interface GeoapifyAutocompleteResponse {
|
||||
type: string;
|
||||
features: GeoapifyFeature[];
|
||||
}
|
||||
Reference in New Issue
Block a user