Replace entire repo content with code from /root/shahikitchen-google/

This commit is contained in:
root
2026-07-03 02:51:15 +00:00
parent c8b7dc95e4
commit 8ccf033329
79 changed files with 7611 additions and 439 deletions
@@ -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[];
}