diff --git a/.sisyphus/PHASE_2_HUMAN_ACTIONS.md b/.sisyphus/PHASE_2_HUMAN_ACTIONS.md new file mode 100644 index 0000000..2b8fcb9 --- /dev/null +++ b/.sisyphus/PHASE_2_HUMAN_ACTIONS.md @@ -0,0 +1,136 @@ +# Phase 2 — Human Actions Required Before Cutover + +> Stacked on PR #3 (Phase 1). Merge order: PR #1 → #2 → #3 → #4. + +## 1. Affiliate program signups + +You must register MoodTrip with each affiliate network before any clicks can be attributed to you: + +### Traveloka (primary — 14-day cookie, 6.4% VN hotel upsize) +- Apply via Ecomobi: https://ecomobi.com/how-to-join-traveloka-affiliate-on-ecomobi-passio/ +- Or directly: https://www.traveloka.com/en-en/p/affiliate +- After approval, get your **AID** (replaces `MOODTRIP_TVL` in `services/affiliate.ts`) +- Wait time: typically 5-10 business days + +### Klook (Klook Kreator program) +- Apply: https://affiliate.klook.com/ +- Get your **AID** (replaces `MOODTRIPKK`) + +### Agoda (lowest priority — session-only cookie limits monetization fit) +- Apply: https://www.agoda.com/press/agoda-ambassador/ +- Get your **CID** (replaces `MOODTRIPAG`) + +Once approved, edit `services/affiliate.ts` (the `AFFILIATE_IDS` constant) with the real IDs and open a follow-up PR. + +## 2. Tax / business entity decision (required before first payout) + +Vietnamese affiliate income to a personal account vs LLC vs Singapore entity has 5-20% tax difference. Per Metis critique: + +- **Personal account:** subject to Vietnamese PIT (5-35%) +- **Vietnamese LLC:** business income tax (20%) + dividend tax +- **Singapore Pte Ltd:** 17% corporate tax, easier for cross-border affiliate payments + +Discuss with an accountant before activating affiliate links in production. + +## 3. Edge proxy variables + +No changes needed to the Worker for Phase 2 — affiliate click tracking is fully client-side (PostHog event + window.open redirect). + +## 4. Vercel env + +No new env vars required. Existing `VITE_POSTHOG_KEY` (from Phase 0b checklist) covers affiliate event tracking. + +## 5. Sunday Dream timezone notes + +The Sunday Dream window opens at **16:00 local time** (user's device clock), Sundays only. There's no server-side scheduling; the banner shows when `getSundayWindow().isOpen` is true. + +For users who don't open the app during the Sunday afternoon window, the banner never shows that week — that's by design (the ritual is meant to be opt-in, not push). + +## 6. Smoke test in production + +After PR #4 is merged + deployed: + +1. **Affiliate button:** generate a trip, find any venue, manually inject a Traveloka URL test via dev tools, verify: + - Click on non-allowed domain → blocked with reason `BLOCKED_DOMAIN`. + - Click on allowed domain without consent → shows consent prompt. + - After accepting consent → opens new tab with decorated URL (verify `aid=...&sub_id=...` query params present). +2. **Map:** view a trip with `google_maps_link` containing `?q=lat,lng` or `@lat,lng` — markers should appear at the right places, numbered by day. +3. **Đường về quê:** click "🏡 Về quê" button (top-right), pick a province, verify form pre-fills with the emotional prompt + cultural moods. +4. **Sunday Dream:** if it's Sunday afternoon, banner appears bottom-right. If not, manually set system clock to Sunday 17:00 and reload — should appear. Tap "Mơ ngay" → goes to card-pull, streak counter increments. +5. **TikTok deep-link:** on map popup, click "Xem TikTok về địa điểm" → opens TikTok search in new tab. +6. **Map performance:** verify the map only loads when a trip is shown (lazy-imported `maplibre-gl` + CSS). + +## What this PR ships + +### F4 — Traveloka Affiliate (primary), Klook, Agoda +- `services/affiliate.ts`: + - **Domain allowlist** per partner (https-only, exact-hostname match) + - **Click ID** generated via `crypto.getRandomValues` (16 hex chars, ~10^19 space) + - URL decoration with the right query param per partner (Traveloka `aid+sub_id`, Klook `aid+aff_label`, Agoda `cid+tag`) + - **Consent gate**: tied to `ai_generation_cross_border` scope; if user hasn't accepted, shows in-banner consent UI before redirecting + - PostHog event `affiliate_click_` fires with click_id, productType, venueName, destination, tripId +- `components/AffiliateButton.tsx`: drop-in component, two variants (primary / subtle), handles the consent dance + new-tab redirect + +### F3-Lite — Static Map + Venue Resolver +- `services/venueResolver.ts`: + - Parses both `?q=lat,lng` and `@lat,lng` formats from Google Maps URLs + - Builds TikTok search deep-link per venue (Vietnamese-friendly: appends destination) + - `computeBounds()` for auto-fit on map load +- `components/TripMap.tsx`: + - MapLibre GL with OSM tiles (FREE — no Google Maps API cost, librarian-validated) + - Lazy-imported (CSS + JS only when result view shown) + - Markers numbered by day, click → popup with "Mở Google Maps" + "Xem TikTok" + - Fallback state when no coords resolve +- Mounted under ItineraryDisplay in result view + +### A3 — Đường Về Quê (Ancestral Hometown Mode) +- `services/duongVeQue.ts`: + - Seeded database of 17 provinces across 5 regions (north / central / south / mekong / highlands) + - Each province has emotional prompts, landmarks, signature dishes + - `buildQueSeed()` picks a random emotional prompt for variety + - `buildQuePersonalNote()` produces a Mơ-style note: "Chuyến đi về quê {province}. {prompt} ..." +- `components/DuongVeQueModal.tsx`: searchable province list, click → seeds form with destination + personalNote + cultural moods +- Top-right "🏡 Về quê" button entry point +- **Cultural moat note**: this is the feature foreign competitors will not build. Tet diaspora + family heritage trips = a long-tail user segment. + +### F-Sunday — Sunday Dream Ritual +- `services/sundayDream.ts`: + - Window detection: Sundays after 16:00 local time + - Streak counter (consecutive Sundays, resets if a Sunday is skipped) + - Local-storage only — no server dependency + - Idempotent (same Sunday recorded twice doesn't double-count) +- `components/SundayDreamBanner.tsx`: bottom-right corner banner, gradient purple/pink for distinction, "🔥 Chuỗi N chủ nhật" streak display +- Tap → Card-pull onboarding + +### Tiếng Vùng — Regional Dialect +- Already implemented at the persona level (`services/moPersona.ts` regional dialect injection from Phase 0b) +- Added `services/preferencesApi.setRegionDialect()` for explicit user override (UI for it deferred to Phase 3 to keep this PR scoped) + +### Tests +- `services/__tests__/affiliate.test.ts` (10 tests): domain allowlist, URL decoration, consent gating, click ID uniqueness +- `services/__tests__/venueResolver.test.ts` (7 tests): both Google Maps URL formats, bounds calculation, TikTok query encoding +- `services/__tests__/duongVeQue.test.ts` (7 tests): regional coverage, prompt randomness, case-insensitive lookup +- `services/__tests__/sundayDream.test.ts` (7 tests): window detection by day-of-week + hour, streak increment/reset/idempotency + +### Verification +- **Client: 87/87 tests pass** (up from 50) +- **Worker: 40/40 tests pass** (unchanged from Phase 1) +- Frontend typecheck clean except 2 pre-existing errors +- Build succeeds + +## What this PR does NOT yet do + +- **UI for "Tiếng Vùng" preference toggle** — the data field is wired in preferences table + service, but no settings page exists yet. Phase 3 work. +- **Real affiliate IDs** — placeholders only. Replace after partner approvals. +- **Affiliate revenue dashboard** — PostHog events fire, but no aggregation view yet. +- **Sunday Dream push notification** — currently banner-only when app is open. Phase 3 work (requires service worker push setup). +- **Đường về quê illustration** — text-only. Awaiting illustrator hire. +- **F1 share button in ItineraryDisplay** — still deferred from Phase 1. Easy follow-up. + +## Risks (Phase 2 specific) + +1. **Affiliate ID rotation** — when you get real partner IDs, you'll need to redeploy. Click IDs in flight at rotation time may not attribute correctly. Plan a low-traffic deploy window. +2. **Province name normalization** — Vietnamese diacritics. Currently doing case-insensitive exact match. "huế" matches "Huế" but "Hue" (no diacritic) doesn't. Phase 3 can add diacritic-fold normalization. +3. **Map tile costs at scale** — using OSM tiles (free, but bound by acceptable use policy: ~2 req/s/IP). At 100K MAU you may hit limits. Migration path: Maptiler ($25/mo for 100k tiles) or self-hosted vector tiles. +4. **MapLibre bundle size** — ~150KB gzipped. Lazy-loaded so doesn't affect LCP. Verify via Lighthouse after deploy. +5. **DST in Sunday Dream** — Vietnam doesn't observe DST, but if you i18n to a DST country later, the 16:00 cutoff will shift twice a year. Trivial fix when i18n happens. diff --git a/App.tsx b/App.tsx index a65325d..7c00304 100644 --- a/App.tsx +++ b/App.tsx @@ -17,6 +17,9 @@ import { ConsentBanner } from './components/ConsentBanner'; import { PWAInstallPrompt } from './components/PWAInstallPrompt'; import { CardPullOnboarding } from './components/CardPullOnboarding'; import { SharedTripView } from './components/SharedTripView'; +import { DuongVeQueModal } from './components/DuongVeQueModal'; +import { SundayDreamBanner } from './components/SundayDreamBanner'; +import { TripMap } from './components/TripMap'; import { useAuth } from './services/useAuth'; import { loadPreferences, savePreferencesFromTrip } from './services/preferencesApi'; import { parseCurrentRoute, type Route } from './services/sharedTripRouter'; @@ -94,6 +97,7 @@ export default function App() { const [route, setRoute] = useState(() => parseCurrentRoute()); const [cardPullPrefill, setCardPullPrefill] = useState | null>(null); const [preferenceDefaults, setPreferenceDefaults] = useState | null>(null); + const [queModalOpen, setQueModalOpen] = useState(false); const { user } = useAuth(); useEffect(() => { @@ -519,6 +523,10 @@ export default function App() { isSaved={isSaved || isSharedView} isExportingPDF={isExportingPDF} /> +
+

Bản đồ hành trình

+ +
); } @@ -631,13 +639,22 @@ export default function App() { - +
+ + +
{/* Main Content — inline styles ensure visibility even if CSS fails */}
+ setView('card-pull')} /> + setQueModalOpen(false)} + onSeed={(prefill) => { + setCardPullPrefill(prefill); + setView('form'); + }} + /> setAuthModalOpen(false)} /> {/* Toast Notification */} diff --git a/components/AffiliateButton.tsx b/components/AffiliateButton.tsx new file mode 100644 index 0000000..39e376e --- /dev/null +++ b/components/AffiliateButton.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { motion } from 'motion/react'; +import { + acceptAffiliateConsent, + recordAffiliateClickIntent, + type AffiliateLinkInput, +} from '../services/affiliate'; + +interface AffiliateButtonProps extends AffiliateLinkInput { + label: string; + variant?: 'primary' | 'subtle'; +} + +type State = 'idle' | 'awaiting-consent' | 'redirecting' | 'blocked'; + +const PARTNER_LABELS: Record = { + traveloka: 'Traveloka', + klook: 'Klook', + agoda: 'Agoda', +}; + +export function AffiliateButton({ label, variant = 'primary', ...input }: AffiliateButtonProps) { + const [state, setState] = useState('idle'); + const [reason, setReason] = useState(null); + + async function handleClick() { + const result = await recordAffiliateClickIntent(input, { requireConsent: true }); + if (!result.ok) { + if (result.reason === 'BLOCKED_DOMAIN') { + setReason('Liên kết này không an toàn.'); + setState('blocked'); + return; + } + if (result.reason === 'CONSENT_REQUIRED') { + setState('awaiting-consent'); + return; + } + } + if (result.redirectUrl) { + setState('redirecting'); + window.open(result.redirectUrl, '_blank', 'noopener,noreferrer'); + setTimeout(() => setState('idle'), 1500); + } + } + + async function handleAcceptConsent() { + await acceptAffiliateConsent(); + setState('idle'); + void handleClick(); + } + + const baseCls = + variant === 'primary' + ? 'inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-teal-500 to-cyan-500 text-white font-semibold text-sm shadow-md shadow-teal-500/30 hover:opacity-95' + : 'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-teal-300 text-xs'; + + if (state === 'awaiting-consent') { + return ( + +

+ Liên kết này sẽ gửi bạn đến {PARTNER_LABELS[input.partner]}. MoodTrip nhận hoa hồng nếu + bạn đặt qua liên kết — không ảnh hưởng giá bạn trả. +

+
+ + +
+
+ ); + } + + if (state === 'blocked') { + return

{reason}

; + } + + return ( + + ); +} diff --git a/components/DuongVeQueModal.tsx b/components/DuongVeQueModal.tsx new file mode 100644 index 0000000..0797271 --- /dev/null +++ b/components/DuongVeQueModal.tsx @@ -0,0 +1,104 @@ +import { useMemo, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { IconX } from './icons'; +import { + PROVINCE_LANDMARKS, + buildQueSeed, + buildQuePersonalNote, + type Province, +} from '../services/duongVeQue'; +import type { FormData } from '../types'; + +interface DuongVeQueModalProps { + open: boolean; + onClose: () => void; + onSeed: (prefill: Partial) => void; +} + +export function DuongVeQueModal({ open, onClose, onSeed }: DuongVeQueModalProps) { + const [query, setQuery] = useState(''); + const provinces = useMemo( + () => + PROVINCE_LANDMARKS.filter((p) => + p.province.toLowerCase().includes(query.toLowerCase()), + ), + [query], + ); + + function handlePick(province: Province) { + const seed = buildQueSeed(province); + if (!seed) return; + onSeed({ + destination: seed.province, + personalNote: buildQuePersonalNote(seed), + moods: ['cultural', 'relax'], + }); + onClose(); + } + + if (!open) return null; + + return ( + + + e.stopPropagation()} + > + + +
+

Đường về quê

+

Quê bạn ở đâu?

+

+ Mơ sẽ gợi ý một chuyến về thăm quê — không phải tour du khách, mà là về với cảm giác. +

+ setQuery(e.target.value)} + placeholder="Tìm tỉnh / thành phố…" + className="w-full px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white placeholder:text-slate-500 focus:outline-none focus:border-teal-500/50 text-sm" + /> +
+ +
    + {provinces.length === 0 && ( +
  • + Không có tỉnh phù hợp. Mơ chưa biết về quê này — gửi cho team nhé. +
  • + )} + {provinces.map((p) => ( +
  • + +
  • + ))} +
+
+
+
+ ); +} diff --git a/components/SundayDreamBanner.tsx b/components/SundayDreamBanner.tsx new file mode 100644 index 0000000..22d52c7 --- /dev/null +++ b/components/SundayDreamBanner.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { getSundayWindow, readStreak, recordDream } from '../services/sundayDream'; + +interface SundayDreamBannerProps { + onAcceptDream: () => void; +} + +const DISMISSED_LS_KEY = 'moodtrip_sunday_dream_dismissed_v1'; + +export function SundayDreamBanner({ onAcceptDream }: SundayDreamBannerProps) { + const [visible, setVisible] = useState(false); + const [streak, setStreak] = useState(0); + + useEffect(() => { + const window = getSundayWindow(); + if (!window.isOpen) return; + try { + const dismissedAt = localStorage.getItem(DISMISSED_LS_KEY); + if (dismissedAt) { + const last = Number(dismissedAt); + const now = Date.now(); + if (now - last < 6 * 3600 * 1000) return; + } + } catch { + void 0; + } + setStreak(readStreak().count); + setVisible(true); + }, []); + + if (!visible) return null; + + function handleAccept() { + const updated = recordDream(); + setStreak(updated.count); + setVisible(false); + onAcceptDream(); + } + + function handleDismiss() { + try { + localStorage.setItem(DISMISSED_LS_KEY, String(Date.now())); + } catch { + void 0; + } + setVisible(false); + } + + return ( + + +

☕ Chiều chủ nhật

+

+ Pha một ly cà phê. Mình mơ một chuyến đi nhé? + {streak > 0 && ( + + 🔥 Chuỗi {streak} chủ nhật liên tiếp + + )} +

+
+ + +
+
+
+ ); +} diff --git a/components/TripMap.tsx b/components/TripMap.tsx new file mode 100644 index 0000000..d740f57 --- /dev/null +++ b/components/TripMap.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ItineraryPlan } from '../types'; +import { computeBounds, resolveVenues, type ResolvedVenue } from '../services/venueResolver'; + +interface TripMapProps { + itinerary: ItineraryPlan; +} + +const OSM_STYLE = { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + }, + }, + layers: [{ id: 'osm', type: 'raster', source: 'osm' }], +}; + +export function TripMap({ itinerary }: TripMapProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const [venues, setVenues] = useState([]); + const [selected, setSelected] = useState(null); + const [mapReady, setMapReady] = useState(false); + + useEffect(() => { + setVenues(resolveVenues(itinerary)); + }, [itinerary]); + + useEffect(() => { + if (!containerRef.current || venues.length === 0) return; + let cancelled = false; + (async () => { + type MarkerLike = { + setLngLat: (ll: [number, number]) => MarkerLike; + addTo: (map: unknown) => MarkerLike; + getElement: () => HTMLElement; + }; + const mod = (await import('maplibre-gl')) as unknown as { + default: { + Map: new (opts: unknown) => { + fitBounds: (b: number[][], opts?: unknown) => void; + remove: () => void; + on: (ev: string, fn: () => void) => void; + addControl: (c: unknown) => void; + }; + NavigationControl: new (opts?: unknown) => unknown; + Marker: new (opts?: unknown) => MarkerLike; + }; + }; + await import('maplibre-gl/dist/maplibre-gl.css'); + if (cancelled || !containerRef.current) return; + + const bounds = computeBounds(venues); + const center = bounds?.center ?? { lat: 16.0, lng: 107.5 }; + + const map = new mod.default.Map({ + container: containerRef.current, + style: OSM_STYLE, + center: [center.lng, center.lat], + zoom: bounds ? 10 : 5, + attributionControl: true, + }); + mapRef.current = map; + map.addControl(new mod.default.NavigationControl({ showCompass: false })); + + map.on('load', () => { + if (cancelled) return; + for (const v of venues) { + if (v.lat == null || v.lng == null) continue; + const el = document.createElement('button'); + el.className = + 'w-7 h-7 rounded-full bg-teal-500 border-2 border-white shadow-md text-white text-xs font-bold flex items-center justify-center'; + el.textContent = String(v.day); + el.setAttribute('aria-label', `${v.name} - Ngày ${v.day}`); + el.onclick = () => setSelected(v); + new mod.default.Marker({ element: el }).setLngLat([v.lng, v.lat]).addTo(map); + } + if (bounds && venues.filter((v) => v.lat != null).length > 1) { + map.fitBounds( + [ + [bounds.west, bounds.south], + [bounds.east, bounds.north], + ], + { padding: 48, maxZoom: 13, duration: 0 }, + ); + } + setMapReady(true); + }); + })(); + return () => { + cancelled = true; + const m = mapRef.current as { remove: () => void } | null; + if (m?.remove) m.remove(); + mapRef.current = null; + }; + }, [venues]); + + if (venues.filter((v) => v.lat != null).length === 0) { + return ( +
+ Không có toạ độ địa điểm trong lịch trình này — bản đồ chưa hiển thị được. +
+ ); + } + + return ( +
+
+ {!mapReady && ( +
+ Đang tải bản đồ… +
+ )} + {selected && ( +
+

+ Ngày {selected.day} · {selected.time} +

+

{selected.name}

+
+ {selected.mapsLink && ( + + Mở Google Maps ↗ + + )} + {selected.tiktokQuery && ( + + Xem TikTok về địa điểm ↗ + + )} + +
+
+ )} +
+ ); +} diff --git a/package-lock.json b/package-lock.json index eff3710..d70262f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/three": "^0.183.1", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "maplibre-gl": "^4.7.0", "motion": "^12.34.3", "posthog-js": "^1.180.0", "react": "^19.1.0", @@ -2105,6 +2106,89 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/@mediapipe/tasks-vision": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", @@ -3369,6 +3453,21 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3378,6 +3477,23 @@ "@types/unist": "*" } }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3408,6 +3524,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3449,6 +3571,15 @@ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/three": { "version": "0.183.1", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", @@ -4566,6 +4697,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5158,6 +5295,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", + "license": "ISC" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5204,6 +5347,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5222,6 +5377,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -5247,6 +5408,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -5574,6 +5773,15 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -6230,6 +6438,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6287,6 +6501,21 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6669,6 +6898,53 @@ "node": ">=10" } }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7553,6 +7829,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -7610,6 +7895,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7809,6 +8100,19 @@ "node": ">= 14.16" } }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7965,6 +8269,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7981,6 +8291,12 @@ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8258,6 +8574,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rollup": { "version": "4.44.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", @@ -8297,6 +8622,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -8967,6 +9298,15 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9275,6 +9615,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", @@ -10990,6 +11336,17 @@ } } }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, "node_modules/web-vitals": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", diff --git a/package.json b/package.json index 26d2652..3b71b97 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "posthog-js": "^1.180.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "maplibre-gl": "^4.7.0", "motion": "^12.34.3", "react": "^19.1.0", "react-dom": "^19.2.4", diff --git a/services/__tests__/affiliate.test.ts b/services/__tests__/affiliate.test.ts new file mode 100644 index 0000000..40d2435 --- /dev/null +++ b/services/__tests__/affiliate.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + decorateAffiliateUrl, + isAllowedAffiliateUrl, + newClickId, + recordAffiliateClickIntent, +} from '../affiliate'; +import { recordConsent } from '../consent'; + +beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('isAllowedAffiliateUrl', () => { + it('accepts traveloka.com over https', () => { + expect(isAllowedAffiliateUrl('traveloka', 'https://www.traveloka.com/hotel/abc')).toBe(true); + }); + + it('rejects non-https', () => { + expect(isAllowedAffiliateUrl('traveloka', 'http://traveloka.com/x')).toBe(false); + }); + + it('rejects unknown domain', () => { + expect(isAllowedAffiliateUrl('klook', 'https://evil.com/?ref=klook')).toBe(false); + }); + + it('rejects javascript: URLs', () => { + expect(isAllowedAffiliateUrl('traveloka', 'javascript:alert(1)')).toBe(false); + }); + + it('rejects malformed URLs', () => { + expect(isAllowedAffiliateUrl('agoda', 'not a url')).toBe(false); + }); +}); + +describe('decorateAffiliateUrl', () => { + it('adds aid + sub_id for Traveloka', () => { + const url = decorateAffiliateUrl( + { partner: 'traveloka', url: 'https://www.traveloka.com/x', context: {} }, + 'click-abc', + ); + const u = new URL(url); + expect(u.searchParams.get('aid')).toBe('MOODTRIP_TVL'); + expect(u.searchParams.get('sub_id')).toBe('click-abc'); + }); + + it('adds cid + tag for Agoda', () => { + const url = decorateAffiliateUrl( + { partner: 'agoda', url: 'https://www.agoda.com/x', context: {} }, + 'cid-1', + ); + const u = new URL(url); + expect(u.searchParams.get('cid')).toBe('MOODTRIPAG'); + expect(u.searchParams.get('tag')).toBe('cid-1'); + }); + + it('preserves pre-existing query params', () => { + const url = decorateAffiliateUrl( + { partner: 'klook', url: 'https://www.klook.com/x?source=foo', context: {} }, + 'c', + ); + const u = new URL(url); + expect(u.searchParams.get('source')).toBe('foo'); + expect(u.searchParams.get('aid')).toBe('MOODTRIPKK'); + }); +}); + +describe('newClickId', () => { + it('returns 16 hex chars', () => { + expect(newClickId()).toMatch(/^[0-9a-f]{16}$/); + }); + it('produces unique values', () => { + const set = new Set(); + for (let i = 0; i < 1000; i++) set.add(newClickId()); + expect(set.size).toBeGreaterThan(995); + }); +}); + +describe('recordAffiliateClickIntent', () => { + it('blocks unsafe URLs before requiring consent', async () => { + const result = await recordAffiliateClickIntent( + { partner: 'traveloka', url: 'https://evil.example.com/x', context: {} }, + { requireConsent: false }, + ); + expect(result.ok).toBe(false); + expect(result.reason).toBe('BLOCKED_DOMAIN'); + }); + + it('asks for consent when required and not yet given', async () => { + const result = await recordAffiliateClickIntent( + { partner: 'klook', url: 'https://www.klook.com/x', context: {} }, + { requireConsent: true }, + ); + expect(result.ok).toBe(false); + expect(result.reason).toBe('CONSENT_REQUIRED'); + }); + + it('allows click after consent recorded', async () => { + await recordConsent(['ai_generation_cross_border']); + const result = await recordAffiliateClickIntent( + { partner: 'klook', url: 'https://www.klook.com/x', context: { tripId: 't1' } }, + { requireConsent: true }, + ); + expect(result.ok).toBe(true); + expect(result.redirectUrl).toContain('aid=MOODTRIPKK'); + expect(result.clickId).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/services/__tests__/duongVeQue.test.ts b/services/__tests__/duongVeQue.test.ts new file mode 100644 index 0000000..f2b0a33 --- /dev/null +++ b/services/__tests__/duongVeQue.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + PROVINCE_LANDMARKS, + buildQuePersonalNote, + buildQueSeed, + listProvinces, +} from '../duongVeQue'; + +describe('PROVINCE_LANDMARKS', () => { + it('covers a minimum spread across 5 regions', () => { + const regions = new Set(PROVINCE_LANDMARKS.map((p) => p.region)); + expect(regions.has('north')).toBe(true); + expect(regions.has('central')).toBe(true); + expect(regions.has('south')).toBe(true); + expect(regions.has('mekong')).toBe(true); + expect(regions.has('highlands')).toBe(true); + }); + + it('every province has at least one emotional prompt and one dish', () => { + for (const p of PROVINCE_LANDMARKS) { + expect(p.emotionalPrompts.length).toBeGreaterThanOrEqual(1); + expect(p.signatureDishes.length).toBeGreaterThanOrEqual(1); + expect(p.signatureLandmarks.length).toBeGreaterThanOrEqual(1); + } + }); +}); + +describe('buildQueSeed', () => { + it('builds a seed for a known province', () => { + const seed = buildQueSeed('Huế'); + expect(seed).not.toBeNull(); + expect(seed?.province).toBe('Huế'); + expect(seed?.region).toBe('central'); + expect(seed?.prompt.length).toBeGreaterThan(0); + }); + + it('is case-insensitive', () => { + expect(buildQueSeed('hà nội')).not.toBeNull(); + }); + + it('returns null for unknown province', () => { + expect(buildQueSeed('Paris')).toBeNull(); + }); +}); + +describe('buildQuePersonalNote', () => { + it('mentions the province and emotional prompt', () => { + const seed = buildQueSeed('Đà Lạt')!; + const note = buildQuePersonalNote(seed); + expect(note).toContain('Đà Lạt'); + expect(note).toContain(seed.prompt); + }); + + it('lists at most 3 landmarks', () => { + const seed = buildQueSeed('Hà Nội')!; + const note = buildQuePersonalNote(seed); + const landmarkBlock = note.split('Ưu tiên ghé:')[1] ?? ''; + const landmarks = landmarkBlock.split('.')[0]?.split(',').length ?? 0; + expect(landmarks).toBeLessThanOrEqual(3); + }); +}); + +describe('listProvinces', () => { + it('returns all provinces in seed', () => { + expect(listProvinces().length).toBe(PROVINCE_LANDMARKS.length); + }); +}); diff --git a/services/__tests__/sundayDream.test.ts b/services/__tests__/sundayDream.test.ts new file mode 100644 index 0000000..7643c3e --- /dev/null +++ b/services/__tests__/sundayDream.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { getSundayWindow, readStreak, recordDream } from '../sundayDream'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('getSundayWindow', () => { + it('reports closed on a Monday', () => { + const monday = new Date('2026-05-25T10:00:00'); + const w = getSundayWindow(monday); + expect(w.isOpen).toBe(false); + }); + + it('reports open on Sunday after 16:00 local', () => { + const sundayLate = new Date('2026-05-31T17:30:00'); + const w = getSundayWindow(sundayLate); + expect(w.isOpen).toBe(true); + }); + + it('reports closed on Sunday morning', () => { + const sundayEarly = new Date('2026-05-31T08:00:00'); + const w = getSundayWindow(sundayEarly); + expect(w.isOpen).toBe(false); + }); +}); + +describe('recordDream + readStreak', () => { + it('increments streak when dreams happen on consecutive Sundays', () => { + const firstSunday = new Date('2026-05-24T17:00:00'); + const secondSunday = new Date('2026-05-31T17:00:00'); + recordDream(firstSunday); + expect(readStreak().count).toBe(1); + recordDream(secondSunday); + expect(readStreak().count).toBe(2); + }); + + it('does not increment when the same Sunday is recorded twice', () => { + const sunday = new Date('2026-05-31T17:00:00'); + recordDream(sunday); + recordDream(sunday); + expect(readStreak().count).toBe(1); + }); + + it('resets streak when a Sunday is skipped', () => { + const oldSunday = new Date('2026-05-17T17:00:00'); + const newSunday = new Date('2026-05-31T17:00:00'); + recordDream(oldSunday); + recordDream(newSunday); + expect(readStreak().count).toBe(1); + }); + + it('ignores dreams outside Sunday window', () => { + const monday = new Date('2026-05-25T17:00:00'); + recordDream(monday); + expect(readStreak().count).toBe(0); + }); +}); diff --git a/services/__tests__/venueResolver.test.ts b/services/__tests__/venueResolver.test.ts new file mode 100644 index 0000000..d87f361 --- /dev/null +++ b/services/__tests__/venueResolver.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { buildTiktokQuery, computeBounds, resolveVenues } from '../venueResolver'; +import type { ItineraryPlan } from '../../types'; + +const sampleItinerary: ItineraryPlan = { + destination: 'Đà Lạt', + overview: 'x', + timeline: [ + { + day: 'Ngày 1', + title: 'Khám phá', + schedule: [ + { + time: '08:00', + activity: 'Cà phê', + venue: 'Tiệm cà phê 6am', + google_maps_link: 'https://maps.google.com/?q=11.9404,108.4583', + }, + { + time: '14:00', + activity: 'Hồ Tuyền Lâm', + venue: 'Hồ Tuyền Lâm', + google_maps_link: 'https://maps.google.com/maps/place/Lake/@11.8761,108.4147,15z', + }, + { + time: '19:00', + activity: 'Chợ đêm', + }, + ], + }, + ], + food: [], + accommodation: [], + tips: [], +}; + +describe('resolveVenues', () => { + it('extracts coords from ?q= style URLs', () => { + const venues = resolveVenues(sampleItinerary); + expect(venues[0]?.lat).toBeCloseTo(11.9404, 4); + expect(venues[0]?.lng).toBeCloseTo(108.4583, 4); + }); + + it('extracts coords from @lat,lng style URLs', () => { + const venues = resolveVenues(sampleItinerary); + expect(venues[1]?.lat).toBeCloseTo(11.8761, 4); + expect(venues[1]?.lng).toBeCloseTo(108.4147, 4); + }); + + it('leaves lat/lng undefined when no maps link', () => { + const venues = resolveVenues(sampleItinerary); + expect(venues[2]?.lat).toBeUndefined(); + expect(venues[2]?.lng).toBeUndefined(); + }); + + it('attaches tiktok search URL to every venue', () => { + const venues = resolveVenues(sampleItinerary); + for (const v of venues) { + expect(v.tiktokQuery).toContain('tiktok.com/search'); + expect(v.tiktokQuery).toContain(encodeURIComponent('Đà Lạt')); + } + }); + + it('tags venues with day number', () => { + const venues = resolveVenues(sampleItinerary); + expect(venues.every((v) => v.day === 1)).toBe(true); + }); +}); + +describe('computeBounds', () => { + it('returns null when no located venues', () => { + expect(computeBounds([])).toBeNull(); + }); + + it('computes center as midpoint of N/S and E/W extremes', () => { + const bounds = computeBounds([ + { name: 'a', day: 1, time: '08', lat: 10, lng: 100 }, + { name: 'b', day: 1, time: '09', lat: 12, lng: 102 }, + ]); + expect(bounds?.center.lat).toBe(11); + expect(bounds?.center.lng).toBe(101); + }); +}); + +describe('buildTiktokQuery', () => { + it('builds URL-encoded search', () => { + const q = buildTiktokQuery('Cà phê 6am', 'Đà Lạt'); + expect(q).toContain('tiktok.com/search?q='); + expect(q).toContain(encodeURIComponent('Cà phê 6am')); + }); + + it('strips quotes from venue name', () => { + const q = buildTiktokQuery('"Cafe"', 'X'); + expect(q).not.toContain(encodeURIComponent('"')); + }); +}); diff --git a/services/affiliate.ts b/services/affiliate.ts new file mode 100644 index 0000000..45c6adf --- /dev/null +++ b/services/affiliate.ts @@ -0,0 +1,103 @@ +import { hasConsented, recordConsent, type ConsentScope } from './consent'; +import { trackEvent } from './analytics'; + +export type AffiliatePartner = 'traveloka' | 'klook' | 'agoda'; + +export interface AffiliateLinkInput { + partner: AffiliatePartner; + url: string; + context: { + tripId?: string; + destination?: string; + venueName?: string; + productType?: 'hotel' | 'activity' | 'esim' | 'transport'; + }; +} + +const ALLOWED_DOMAINS: Record = { + traveloka: ['traveloka.com', 'www.traveloka.com'], + klook: ['klook.com', 'www.klook.com', 'affiliate.klook.com'], + agoda: ['agoda.com', 'www.agoda.com'], +}; + +export const AFFILIATE_CONSENT_SCOPE: ConsentScope = 'ai_generation_cross_border'; + +export function isAllowedAffiliateUrl(partner: AffiliatePartner, rawUrl: string): boolean { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return false; + } + if (parsed.protocol !== 'https:') return false; + return ALLOWED_DOMAINS[partner].some((host) => parsed.hostname === host); +} + +const AFFILIATE_IDS: Record = { + traveloka: 'MOODTRIP_TVL', + klook: 'MOODTRIPKK', + agoda: 'MOODTRIPAG', +}; + +const CLICK_ID_PARAM: Record = { + traveloka: 'aid', + klook: 'aid', + agoda: 'cid', +}; + +const SUB_ID_PARAM: Record = { + traveloka: 'sub_id', + klook: 'aff_label', + agoda: 'tag', +}; + +export function decorateAffiliateUrl(input: AffiliateLinkInput, clickId: string): string { + const url = new URL(input.url); + url.searchParams.set(CLICK_ID_PARAM[input.partner], AFFILIATE_IDS[input.partner]); + url.searchParams.set(SUB_ID_PARAM[input.partner], clickId); + return url.toString(); +} + +export function newClickId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export interface AffiliateClickOutcome { + ok: boolean; + reason?: 'BLOCKED_DOMAIN' | 'CONSENT_REQUIRED' | 'BLOCKED_BY_USER'; + redirectUrl?: string; + clickId?: string; +} + +export async function recordAffiliateClickIntent( + input: AffiliateLinkInput, + opts: { requireConsent?: boolean } = {}, +): Promise { + if (!isAllowedAffiliateUrl(input.partner, input.url)) { + return { ok: false, reason: 'BLOCKED_DOMAIN' }; + } + if (opts.requireConsent && !hasConsented(AFFILIATE_CONSENT_SCOPE)) { + return { ok: false, reason: 'CONSENT_REQUIRED' }; + } + + const clickId = newClickId(); + const redirectUrl = decorateAffiliateUrl(input, clickId); + + void trackEvent(`affiliate_click_${input.partner}`, { + clickId, + productType: input.context.productType ?? null, + venue: input.context.venueName ?? null, + destination: input.context.destination ?? null, + tripId: input.context.tripId ?? null, + }); + + return { ok: true, redirectUrl, clickId }; +} + +export async function acceptAffiliateConsent(): Promise { + await recordConsent([AFFILIATE_CONSENT_SCOPE]); +} diff --git a/services/duongVeQue.ts b/services/duongVeQue.ts new file mode 100644 index 0000000..d698723 --- /dev/null +++ b/services/duongVeQue.ts @@ -0,0 +1,173 @@ +export type Province = string; + +export interface ProvinceLandmark { + province: Province; + region: 'north' | 'central' | 'south' | 'mekong' | 'highlands'; + emotionalPrompts: string[]; + signatureLandmarks: string[]; + signatureDishes: string[]; +} + +export const PROVINCE_LANDMARKS: ProvinceLandmark[] = [ + { + province: 'Hà Nội', + region: 'north', + emotionalPrompts: [ + 'Đi bộ vòng quanh Hồ Gươm lúc 5 giờ sáng khi phố còn vắng.', + 'Ngồi trên hè một quán phở gia truyền của bà ngoại.', + 'Tìm lại cây bàng trước cửa nhà cũ.', + ], + signatureLandmarks: ['Hồ Hoàn Kiếm', 'Phố cổ', 'Văn Miếu', 'Chợ Đồng Xuân'], + signatureDishes: ['Phở bò', 'Bún chả', 'Bánh cuốn', 'Chả cá Lã Vọng'], + }, + { + province: 'Nam Định', + region: 'north', + emotionalPrompts: [ + 'Thăm cây đa của làng và nghe cụ bà kể lại chuyện gia tộc.', + 'Đến phủ Dầy đúng dịp giỗ mẫu.', + ], + signatureLandmarks: ['Đền Trần', 'Phủ Dầy', 'Nhà thờ Bùi Chu'], + signatureDishes: ['Phở bò Nam Định', 'Bánh xíu páo', 'Nem nắm'], + }, + { + province: 'Hải Phòng', + region: 'north', + emotionalPrompts: ['Sáng sớm ăn bánh đa cua, chiều ngồi cảng nghe còi tàu.'], + signatureLandmarks: ['Đảo Cát Bà', 'Khu Đồ Sơn', 'Nhà hát thành phố'], + signatureDishes: ['Bánh đa cua', 'Nem cua bể', 'Bánh mì cay'], + }, + { + province: 'Sapa', + region: 'north', + emotionalPrompts: ['Đi bộ một mình giữa ruộng bậc thang trong sương sớm.'], + signatureLandmarks: ['Đỉnh Fansipan', 'Bản Cát Cát', 'Thác Bạc'], + signatureDishes: ['Thắng cố', 'Cá hồi Sapa', 'Lợn cắp nách'], + }, + { + province: 'Huế', + region: 'central', + emotionalPrompts: [ + 'Mặc áo dài đi vào lăng tẩm và nghe nhã nhạc cung đình.', + 'Đứng trên cầu Trường Tiền ngắm sông Hương lúc nắng nhẹ.', + ], + signatureLandmarks: ['Đại Nội', 'Lăng Tự Đức', 'Chùa Thiên Mụ', 'Sông Hương'], + signatureDishes: ['Bún bò Huế', 'Cơm hến', 'Bánh khoái', 'Chè Huế'], + }, + { + province: 'Đà Nẵng', + region: 'central', + emotionalPrompts: ['Lái xe lên đèo Hải Vân lúc bình minh.'], + signatureLandmarks: ['Cầu Rồng', 'Bà Nà Hills', 'Bán đảo Sơn Trà', 'Ngũ Hành Sơn'], + signatureDishes: ['Mì Quảng', 'Bún chả cá', 'Bánh tráng cuốn thịt heo'], + }, + { + province: 'Hội An', + region: 'central', + emotionalPrompts: ['Đi giữa phố cổ khi đèn lồng vừa thắp.'], + signatureLandmarks: ['Phố cổ Hội An', 'Chùa Cầu', 'Biển Cửa Đại', 'Cù Lao Chàm'], + signatureDishes: ['Cao lầu', 'Cơm gà Hội An', 'Bánh mì Phượng', 'Bánh bao bánh vạc'], + }, + { + province: 'Quảng Bình', + region: 'central', + emotionalPrompts: ['Lặng nhìn cửa hang Sơn Đoòng sáng lên trong nắng đầu ngày.'], + signatureLandmarks: ['Phong Nha - Kẻ Bàng', 'Hang Én', 'Đèo Ngang'], + signatureDishes: ['Bánh bột lọc Quảng Bình', 'Cháo canh', 'Bánh xèo Quảng Bình'], + }, + { + province: 'Quảng Nam', + region: 'central', + emotionalPrompts: ['Đến Mỹ Sơn vào sớm khi sương còn đọng trên gạch Chàm.'], + signatureLandmarks: ['Mỹ Sơn', 'Cù Lao Chàm', 'Biển An Bàng'], + signatureDishes: ['Mì Quảng Quế Sơn', 'Bê thui Cầu Mống'], + }, + { + province: 'Đà Lạt', + region: 'highlands', + emotionalPrompts: ['Ngồi bên bờ hồ Tuyền Lâm lúc 6h sáng, uống một ly cà phê nóng.'], + signatureLandmarks: ['Hồ Xuân Hương', 'Đồi chè Cầu Đất', 'Ga Đà Lạt cũ', 'Nhà thờ Domaine'], + signatureDishes: ['Bánh tráng nướng', 'Lẩu gà lá é', 'Sữa đậu nành Đà Lạt'], + }, + { + province: 'Pleiku', + region: 'highlands', + emotionalPrompts: ['Đứng bên Biển Hồ và nghe gió Tây Nguyên thổi.'], + signatureLandmarks: ['Biển Hồ (T\'Nưng)', 'Chùa Minh Thành'], + signatureDishes: ['Phở khô Gia Lai', 'Bún mắm cua'], + }, + { + province: 'TP. Hồ Chí Minh', + region: 'south', + emotionalPrompts: ['Ngồi ban công nhà bà uống cà phê sữa đá nghe vọng cổ.'], + signatureLandmarks: ['Nhà thờ Đức Bà', 'Phố đi bộ Nguyễn Huệ', 'Chợ Bến Thành', 'Chợ Lớn'], + signatureDishes: ['Cơm tấm', 'Hủ tiếu', 'Bánh xèo', 'Phá lấu'], + }, + { + province: 'Vũng Tàu', + region: 'south', + emotionalPrompts: ['Đi xe xuống Bãi Sau nghe sóng từ thuở bé.'], + signatureLandmarks: ['Tượng Chúa Kitô', 'Bạch Dinh', 'Ngọn hải đăng'], + signatureDishes: ['Bánh khọt', 'Bánh bông lan trứng muối'], + }, + { + province: 'Cần Thơ', + region: 'mekong', + emotionalPrompts: ['Đi chợ nổi Cái Răng từ 5h sáng và ăn hủ tiếu trên ghe.'], + signatureLandmarks: ['Chợ nổi Cái Răng', 'Bến Ninh Kiều', 'Nhà cổ Bình Thủy'], + signatureDishes: ['Bánh tét lá cẩm', 'Bún cá Cần Thơ', 'Lẩu mắm'], + }, + { + province: 'Bến Tre', + region: 'mekong', + emotionalPrompts: ['Đi xuồng nhỏ qua các con rạch dừa.'], + signatureLandmarks: ['Cồn Phụng', 'Vườn dừa', 'Khu lưu niệm Đồng Khởi'], + signatureDishes: ['Kẹo dừa', 'Đuông dừa', 'Cá kèo kho rau răm'], + }, + { + province: 'An Giang', + region: 'mekong', + emotionalPrompts: ['Đến Rừng tràm Trà Sư mùa nước nổi.'], + signatureLandmarks: ['Rừng tràm Trà Sư', 'Núi Cấm', 'Châu Đốc'], + signatureDishes: ['Bún cá Châu Đốc', 'Mắm Châu Đốc', 'Bánh xèo rau núi'], + }, + { + province: 'Phú Quốc', + region: 'south', + emotionalPrompts: ['Đi câu mực đêm với ngư dân.'], + signatureLandmarks: ['Bãi Sao', 'Vinpearl Safari', 'Chợ đêm Dinh Cậu'], + signatureDishes: ['Gỏi cá trích', 'Bún quậy', 'Nhum biển nướng'], + }, +]; + +export interface QueTripSeed { + province: Province; + region: ProvinceLandmark['region']; + prompt: string; + landmarks: string[]; + dishes: string[]; +} + +export function buildQueSeed(province: Province): QueTripSeed | null { + const data = PROVINCE_LANDMARKS.find( + (p) => p.province.toLowerCase() === province.toLowerCase(), + ); + if (!data) return null; + const pick = (arr: T[]): T | null => (arr.length === 0 ? null : (arr[Math.floor(Math.random() * arr.length)] as T)); + const prompt = pick(data.emotionalPrompts) ?? ''; + return { + province: data.province, + region: data.region, + prompt, + landmarks: data.signatureLandmarks, + dishes: data.signatureDishes, + }; +} + +export function listProvinces(): Province[] { + return PROVINCE_LANDMARKS.map((p) => p.province); +} + +export function buildQuePersonalNote(seed: QueTripSeed): string { + return `Chuyến đi về quê ${seed.province}. ${seed.prompt} Hãy gợi ý lịch trình mang cảm xúc trở về (không phải tour du khách). Ưu tiên ghé: ${seed.landmarks.slice(0, 3).join(', ')}. Nhớ giới thiệu món ${seed.dishes.slice(0, 2).join(' và ')}.`; +} diff --git a/services/preferencesApi.ts b/services/preferencesApi.ts index 6489749..3f9df22 100644 --- a/services/preferencesApi.ts +++ b/services/preferencesApi.ts @@ -41,6 +41,12 @@ export async function loadPreferences(userId: string): Promise { + const supabase = getSupabase(); + if (!supabase) return; + await supabase.from('preferences').update({ region_dialect: dialect }).eq('user_id', userId); +} + export async function savePreferencesFromTrip( userId: string, input: { diff --git a/services/sundayDream.ts b/services/sundayDream.ts new file mode 100644 index 0000000..43a07fb --- /dev/null +++ b/services/sundayDream.ts @@ -0,0 +1,100 @@ +const LAST_DREAM_LS_KEY = 'moodtrip_sunday_dream_last_v1'; +const STREAK_LS_KEY = 'moodtrip_sunday_dream_streak_v1'; + +export interface SundayStreak { + count: number; + lastDreamDateIso: string | null; +} + +export function readStreak(): SundayStreak { + try { + const last = localStorage.getItem(LAST_DREAM_LS_KEY); + const countRaw = localStorage.getItem(STREAK_LS_KEY); + return { + count: countRaw ? Number(countRaw) : 0, + lastDreamDateIso: last, + }; + } catch { + return { count: 0, lastDreamDateIso: null }; + } +} + +function localDateKey(date: Date = new Date()): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function previousSundayKey(date: Date): string { + const d = new Date(date); + const diff = (d.getDay() + 7) % 7; + d.setDate(d.getDate() - diff); + return localDateKey(d); +} + +export interface SundayWindow { + isOpen: boolean; + nextOpenAt: Date; + windowEnd: Date; +} + +export function getSundayWindow(now: Date = new Date()): SundayWindow { + const candidate = new Date(now); + candidate.setHours(16, 0, 0, 0); + + const dayOfWeek = candidate.getDay(); + const daysUntilSunday = (7 - dayOfWeek) % 7; + candidate.setDate(candidate.getDate() + daysUntilSunday); + + const windowEnd = new Date(candidate); + windowEnd.setHours(23, 59, 59, 999); + + const isOpen = now.getDay() === 0 && now.getHours() >= 16; + + if (now > windowEnd) { + const nextSun = new Date(now); + nextSun.setDate(nextSun.getDate() + ((7 - now.getDay()) % 7 || 7)); + nextSun.setHours(16, 0, 0, 0); + const nextEnd = new Date(nextSun); + nextEnd.setHours(23, 59, 59, 999); + return { isOpen, nextOpenAt: nextSun, windowEnd: nextEnd }; + } + return { isOpen, nextOpenAt: candidate, windowEnd }; +} + +export function recordDream(date: Date = new Date()): SundayStreak { + const todayKey = localDateKey(date); + const expectedThisSunday = previousSundayKey(date); + const expectedLastSunday = (() => { + const d = new Date(date); + const diff = (d.getDay() + 7) % 7; + d.setDate(d.getDate() - diff - 7); + return localDateKey(d); + })(); + + if (todayKey !== expectedThisSunday) { + const current = readStreak(); + return current; + } + + const stored = readStreak(); + let nextCount = 1; + if (stored.lastDreamDateIso === expectedLastSunday) { + nextCount = stored.count + 1; + } else if (stored.lastDreamDateIso === expectedThisSunday) { + nextCount = stored.count; + } + + try { + localStorage.setItem(LAST_DREAM_LS_KEY, expectedThisSunday); + localStorage.setItem(STREAK_LS_KEY, String(nextCount)); + } catch { + void 0; + } + return { count: nextCount, lastDreamDateIso: expectedThisSunday }; +} + +export function isStreakActive(now: Date = new Date()): boolean { + const stored = readStreak(); + if (!stored.lastDreamDateIso) return false; + const expectedLastSunday = previousSundayKey(now); + return stored.lastDreamDateIso === expectedLastSunday; +} diff --git a/services/venueResolver.ts b/services/venueResolver.ts new file mode 100644 index 0000000..72dc403 --- /dev/null +++ b/services/venueResolver.ts @@ -0,0 +1,89 @@ +import type { ItineraryPlan } from '../types'; + +export interface ResolvedVenue { + name: string; + day: number; + time: string; + lat?: number; + lng?: number; + mapsLink?: string; + tiktokQuery?: string; +} + +const GOOGLE_MAPS_COORD_RE = /[?&](?:q|ll)=(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/; +const GOOGLE_MAPS_AT_RE = /@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/; + +function parseLatLng(url: string | undefined): { lat: number; lng: number } | null { + if (!url) return null; + const m1 = url.match(GOOGLE_MAPS_COORD_RE); + if (m1 && m1[1] && m1[2]) { + const lat = Number(m1[1]); + const lng = Number(m1[2]); + if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng }; + } + const m2 = url.match(GOOGLE_MAPS_AT_RE); + if (m2 && m2[1] && m2[2]) { + const lat = Number(m2[1]); + const lng = Number(m2[2]); + if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng }; + } + return null; +} + +export function resolveVenues(itinerary: ItineraryPlan): ResolvedVenue[] { + const venues: ResolvedVenue[] = []; + itinerary.timeline.forEach((day, dayIdx) => { + day.schedule.forEach((item) => { + const venueName = item.venue || item.activity; + const coords = parseLatLng(item.google_maps_link); + venues.push({ + name: venueName, + day: dayIdx + 1, + time: item.time, + lat: coords?.lat, + lng: coords?.lng, + mapsLink: item.google_maps_link, + tiktokQuery: buildTiktokQuery(venueName, itinerary.destination), + }); + }); + }); + return venues; +} + +export function buildTiktokQuery(venueName: string, destination: string): string { + const cleaned = venueName.replace(/["']/g, '').trim(); + const query = `${cleaned} ${destination}`.trim(); + return `https://www.tiktok.com/search?q=${encodeURIComponent(query)}`; +} + +export interface MapBounds { + west: number; + south: number; + east: number; + north: number; + center: { lat: number; lng: number }; +} + +export function computeBounds(venues: ResolvedVenue[]): MapBounds | null { + const located = venues.filter( + (v): v is ResolvedVenue & { lat: number; lng: number } => v.lat != null && v.lng != null, + ); + if (located.length === 0) return null; + let north = -Infinity; + let south = Infinity; + let east = -Infinity; + let west = Infinity; + for (const v of located) { + if (v.lat > north) north = v.lat; + if (v.lat < south) south = v.lat; + if (v.lng > east) east = v.lng; + if (v.lng < west) west = v.lng; + } + return { + west, + south, + east, + north, + center: { lat: (north + south) / 2, lng: (east + west) / 2 }, + }; +} diff --git a/yarn.lock b/yarn.lock index c78b3b5..e8689df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -910,6 +910,59 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@mapbox/geojson-rewind@^0.5.2": + version "0.5.2" + resolved "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz" + integrity sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA== + dependencies: + get-stream "^6.0.1" + minimist "^1.2.6" + +"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2": + version "2.0.2" + resolved "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz" + integrity sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ== + +"@mapbox/point-geometry@^0.1.0", "@mapbox/point-geometry@~0.1.0", "@mapbox/point-geometry@0.1.0": + version "0.1.0" + resolved "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz" + integrity sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ== + +"@mapbox/tiny-sdf@^2.0.6": + version "2.2.0" + resolved "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz" + integrity sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg== + +"@mapbox/unitbezier@^0.0.1": + version "0.0.1" + resolved "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz" + integrity sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw== + +"@mapbox/vector-tile@^1.3.1": + version "1.3.1" + resolved "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz" + integrity sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw== + dependencies: + "@mapbox/point-geometry" "~0.1.0" + +"@mapbox/whoots-js@^3.1.0": + version "3.1.0" + resolved "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz" + integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== + +"@maplibre/maplibre-gl-style-spec@^20.3.1": + version "20.4.0" + resolved "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz" + integrity sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw== + dependencies: + "@mapbox/jsonlint-lines-primitives" "~2.0.2" + "@mapbox/unitbezier" "^0.0.1" + json-stringify-pretty-compact "^4.0.0" + minimist "^1.2.8" + quickselect "^2.0.0" + rw "^1.3.3" + tinyqueue "^3.0.0" + "@mediapipe/tasks-vision@0.10.17": version "0.10.17" resolved "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz" @@ -1435,6 +1488,18 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/geojson-vt@3.2.5": + version "3.2.5" + resolved "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz" + integrity sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g== + dependencies: + "@types/geojson" "*" + +"@types/geojson@*", "@types/geojson@^7946.0.14": + version "7946.0.16" + resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/hast@^3.0.0": version "3.0.4" resolved "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz" @@ -1442,6 +1507,20 @@ dependencies: "@types/unist" "*" +"@types/mapbox__point-geometry@*", "@types/mapbox__point-geometry@^0.1.4": + version "0.1.4" + resolved "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz" + integrity sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA== + +"@types/mapbox__vector-tile@^1.3.4": + version "1.3.4" + resolved "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz" + integrity sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg== + dependencies: + "@types/geojson" "*" + "@types/mapbox__point-geometry" "*" + "@types/pbf" "*" + "@types/mdast@^4.0.0": version "4.0.4" resolved "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz" @@ -1466,6 +1545,11 @@ resolved "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz" integrity sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A== +"@types/pbf@*", "@types/pbf@^3.0.5": + version "3.0.5" + resolved "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz" + integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA== + "@types/react-dom@^19.2.3": version "19.2.3" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz" @@ -1493,6 +1577,13 @@ resolved "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz" integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== +"@types/supercluster@^7.1.3": + version "7.1.3" + resolved "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz" + integrity sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA== + dependencies: + "@types/geojson" "*" + "@types/three@*", "@types/three@^0.183.1", "@types/three@>=0.134.0": version "0.183.1" resolved "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz" @@ -2102,6 +2193,11 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" +earcut@^3.0.0: + version "3.0.2" + resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz" + integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" @@ -2490,6 +2586,11 @@ gensync@^1.0.0-beta.2: resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== +geojson-vt@^4.0.2: + version "4.0.3" + resolved "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz" + integrity sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA== + get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" @@ -2519,6 +2620,11 @@ get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" +get-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + get-symbol-description@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz" @@ -2528,6 +2634,11 @@ get-symbol-description@^1.1.0: es-errors "^1.3.0" get-intrinsic "^1.2.6" +gl-matrix@^3.4.3: + version "3.4.4" + resolved "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz" + integrity sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ== + glob@^10.4.1: version "10.5.0" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" @@ -2552,6 +2663,15 @@ glob@^11.0.1: package-json-from-dist "^1.0.0" path-scurry "^2.0.0" +global-prefix@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz" + integrity sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA== + dependencies: + ini "^4.1.3" + kind-of "^6.0.3" + which "^4.0.0" + globalthis@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" @@ -2720,7 +2840,7 @@ idb@^7.0.1: resolved "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz" integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== -ieee754@^1.2.1: +ieee754@^1.1.12, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -2730,6 +2850,11 @@ immediate@~3.0.5: resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== +ini@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz" + integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== + inline-style-parser@0.2.4: version "0.2.4" resolved "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz" @@ -2978,6 +3103,11 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isexe@^3.1.1: + version "3.1.5" + resolved "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz" + integrity sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w== + istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: version "3.2.2" resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz" @@ -3073,6 +3203,11 @@ json-schema@^0.4.0: resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz" integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== +json-stringify-pretty-compact@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz" + integrity sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q== + json5@^2.2.0, json5@^2.2.3: version "2.2.3" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" @@ -3109,6 +3244,16 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +kdbush@^4.0.2: + version "4.1.0" + resolved "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz" + integrity sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ== + +kind-of@^6.0.3: + version "6.0.3" + resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -3254,6 +3399,38 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" +maplibre-gl@^4.7.0: + version "4.7.1" + resolved "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz" + integrity sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA== + dependencies: + "@mapbox/geojson-rewind" "^0.5.2" + "@mapbox/jsonlint-lines-primitives" "^2.0.2" + "@mapbox/point-geometry" "^0.1.0" + "@mapbox/tiny-sdf" "^2.0.6" + "@mapbox/unitbezier" "^0.0.1" + "@mapbox/vector-tile" "^1.3.1" + "@mapbox/whoots-js" "^3.1.0" + "@maplibre/maplibre-gl-style-spec" "^20.3.1" + "@types/geojson" "^7946.0.14" + "@types/geojson-vt" "3.2.5" + "@types/mapbox__point-geometry" "^0.1.4" + "@types/mapbox__vector-tile" "^1.3.4" + "@types/pbf" "^3.0.5" + "@types/supercluster" "^7.1.3" + earcut "^3.0.0" + geojson-vt "^4.0.2" + gl-matrix "^3.4.3" + global-prefix "^4.0.0" + kdbush "^4.0.2" + murmurhash-js "^1.0.0" + pbf "^3.3.0" + potpack "^2.0.0" + quickselect "^3.0.0" + supercluster "^8.0.1" + tinyqueue "^3.0.0" + vt-pbf "^3.1.3" + markdown-table@^3.0.0: version "3.0.4" resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz" @@ -3748,6 +3925,11 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.2" +minimist@^1.2.6, minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: version "7.1.3" resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz" @@ -3778,6 +3960,11 @@ ms@^2.1.3: resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +murmurhash-js@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz" + integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw== + nanoid@^3.3.11: version "3.3.11" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" @@ -3880,6 +4067,14 @@ pathval@^2.0.0: resolved "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz" integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== +pbf@^3.2.1, pbf@^3.3.0: + version "3.3.0" + resolved "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz" + integrity sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q== + dependencies: + ieee754 "^1.1.12" + resolve-protobuf-schema "^2.1.0" + picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" @@ -3933,6 +4128,11 @@ potpack@^1.0.1: resolved "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz" integrity sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ== +potpack@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz" + integrity sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ== + preact@^10.28.2: version "10.29.2" resolved "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz" @@ -3979,6 +4179,11 @@ protobufjs@^7.3.0: "@types/node" ">=13.7.0" long "^5.3.2" +protocol-buffers-schema@^3.3.1: + version "3.6.1" + resolved "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz" + integrity sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ== + punycode@^2.1.0: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" @@ -3989,6 +4194,16 @@ query-selector-shadow-dom@^1.0.1: resolved "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz" integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw== +quickselect@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz" + integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw== + +quickselect@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz" + integrity sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz" @@ -4144,6 +4359,13 @@ require-from-string@^2.0.2: resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +resolve-protobuf-schema@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz" + integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ== + dependencies: + protocol-buffers-schema "^3.3.1" + resolve@^1.22.1, resolve@^1.22.11: version "1.22.11" resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz" @@ -4189,6 +4411,11 @@ rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^2.0.0||^3.0.0||^4.0.0, rollup@^2 "@rollup/rollup-win32-x64-msvc" "4.44.2" fsevents "~2.3.2" +rw@^1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz" + integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== + safe-array-concat@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz" @@ -4543,6 +4770,13 @@ style-to-object@1.0.9: dependencies: inline-style-parser "0.2.4" +supercluster@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz" + integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ== + dependencies: + kdbush "^4.0.2" + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" @@ -4654,6 +4888,11 @@ tinypool@^1.0.1: resolved "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz" integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== +tinyqueue@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz" + integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g== + tinyrainbow@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz" @@ -4991,6 +5230,15 @@ vitest@^2.1.0, vitest@2.1.9: vite-node "2.1.9" why-is-node-running "^2.3.0" +vt-pbf@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz" + integrity sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA== + dependencies: + "@mapbox/point-geometry" "0.1.0" + "@mapbox/vector-tile" "^1.3.1" + pbf "^3.2.1" + web-vitals@^5.1.0: version "5.2.0" resolved "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz" @@ -5103,6 +5351,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +which@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/which/-/which-4.0.0.tgz" + integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== + dependencies: + isexe "^3.1.1" + why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz"