diff --git a/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js b/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js index 343c7c12..1ab9d8fa 100644 --- a/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js +++ b/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js @@ -1,12 +1,11 @@ // @ts-check -const functions = require('firebase-functions'); const stripe = require('../stripe'); const getFirebaseUserId = require('../getFirebaseUserId'); const { stripeSubscriptionKeys } = require('../constants'); const removeUndefined = require('../../util/removeUndefined'); const { db } = require('../../firebase'); const { sendSubscriptionRenewalEmail } = require('../../mail'); - +const { isWTMGInvoice } = require('./util'); /** * Handles the `invoice.created` event from Stripe. * Only handles WTMG subscription renewal invoices, ignores other invoices. @@ -20,13 +19,10 @@ module.exports = async (event, res) => { /** @type {import('stripe').Stripe.Invoice} */ const invoice = event.data.object; - const priceIdsObj = functions.config().stripe.price_ids; - const wtmgPriceIds = Object.values(priceIdsObj); - // NOTE: we can only rely on this price ID being accurate because we only look for subscription_cycle invoices // If we were to handle subscription_update invoices here, the price ID may be different, see [one-off-invoice] + const isWtmgSubscriptionInvoice = await isWTMGInvoice(invoice); const price = invoice.lines.data[0]?.price; - const isWtmgSubscriptionInvoice = wtmgPriceIds.includes(price?.id || ''); if (invoice.billing_reason !== 'subscription_cycle' || !isWtmgSubscriptionInvoice) { // Ignore invoices that were created for events not related // to WTMG subscription renewals diff --git a/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js b/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js index 61222a32..d7952cce 100644 --- a/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js +++ b/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js @@ -1,3 +1,7 @@ +// @ts-check + +const { isWTMGInvoice } = require('./util'); + /** * Handles the `invoice.finalized` event from Stripe * @param {*} event @@ -6,6 +10,10 @@ module.exports = async (event, res) => { console.log('Handling invoice.finalized'); const invoice = event.data.object; + if (!(await isWTMGInvoice(invoice))) { + console.log('Ignoring non-WTMG invoice'); + return res.sendStatus(200); + } // TODO send email with the finalized invoice!? // see "hosted_invoice_url" and "invoice_pdf" diff --git a/api/src/subscriptions/stripeEventHandlers/invoicePaid.js b/api/src/subscriptions/stripeEventHandlers/invoicePaid.js index 6440982c..673171ad 100644 --- a/api/src/subscriptions/stripeEventHandlers/invoicePaid.js +++ b/api/src/subscriptions/stripeEventHandlers/invoicePaid.js @@ -5,6 +5,7 @@ const { } = require('../../mail'); const { stripeSubscriptionKeys } = require('../constants'); const { db } = require('../../firebase'); +const { isWTMGInvoice } = require('./util'); const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys; @@ -19,6 +20,10 @@ const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys; module.exports = async (event, res) => { console.log('Handling invoice.paid'); const invoice = event.data.object; + if (!(await isWTMGInvoice(invoice))) { + console.log('Ignoring non-WTMG invoice'); + return res.sendStatus(200); + } const uid = await getFirebaseUserId(invoice.customer); // Set the user's latest invoice state diff --git a/api/src/subscriptions/stripeEventHandlers/paymentIntentProcessing.js b/api/src/subscriptions/stripeEventHandlers/paymentIntentProcessing.js index 0e04c994..44f59f45 100644 --- a/api/src/subscriptions/stripeEventHandlers/paymentIntentProcessing.js +++ b/api/src/subscriptions/stripeEventHandlers/paymentIntentProcessing.js @@ -8,13 +8,14 @@ const { const { stripeSubscriptionKeys } = require('../constants'); const { db } = require('../../firebase'); const stripe = require('../stripe'); +const { isWTMGInvoice } = require('./util'); const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys; /** * * Inform Firebase of an approved, processing payment + send membership confirmation email - * Special case handlig of SOFORT, where we consider an "network approved" initiated payment + * Special case handling of SOFORT, where we consider an "network approved" initiated payment * already fully closed & settled. * ("first thank you" email, or the "thank you for renewing" email) * @param {import('stripe').Stripe.Event} event @@ -23,12 +24,8 @@ const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys; module.exports = async (event, res) => { console.log('Handling payment_intent.processing'); const paymentIntent = /** @type {import('stripe').Stripe.PaymentIntent} */ (event.data.object); - const uid = await getFirebaseUserId(paymentIntent.customer); - - // Set the user's latest invoice state - const privateUserProfileDocRef = db.doc(`users-private/${uid}`); - const privateUserProfileData = (await privateUserProfileDocRef.get()).data(); + // --- Do pre-checks for the usefulness of this event for WTMG --- // Make sure charges are listed for the paymentIntent // @ts-ignore if (!(paymentIntent?.charges?.data instanceof Array)) { @@ -36,7 +33,7 @@ module.exports = async (event, res) => { } // Check if this payment was approved. - // Empirically, charges is part of the event object + // By checking, we've seen that charges is part of the event object const processingCharge = /** @type {import('stripe').Stripe.Charge[]} */ ( // @ts-ignore paymentIntent.charges.data @@ -52,9 +49,17 @@ module.exports = async (event, res) => { return res.sendStatus(200); } + // --- Qualify the event further based on the linked invoice --- // Fetch the related invoice const invoice = await stripe.invoices.retrieve(paymentIntent.invoice); + // Check if the invoice is a WTMG invoice + if (!(await isWTMGInvoice(invoice))) { + // Ignore invoices that were created for payment events not related to WTMG subscriptions + console.log('Ignoring non-WTMG payment processing event'); + return res.sendStatus(200); + } + // Check if the invoice is related to subscription creation if ( !( @@ -70,6 +75,13 @@ module.exports = async (event, res) => { return res.sendStatus(200); } + // --- Actually process the event --- + + // Get the Firebase user + const uid = await getFirebaseUserId(paymentIntent.customer); + const privateUserProfileDocRef = db.doc(`users-private/${uid}`); + const privateUserProfileData = (await privateUserProfileDocRef.get()).data(); + // Ensure the user is marked as a superfan, with a payment processing indication. const publicUserProfileDocRef = db.doc(`users/${uid}`); const publicUserProfileData = (await publicUserProfileDocRef.get()).data(); @@ -77,6 +89,7 @@ module.exports = async (event, res) => { superfan: true }); await privateUserProfileDocRef.update({ + // Set the user's latest invoice state // Empirically, we know that this status is "open" [latestInvoiceStatusKey]: invoice.status, [paymentProcessingKey]: true diff --git a/api/src/subscriptions/stripeEventHandlers/subscriptionDeleted.js b/api/src/subscriptions/stripeEventHandlers/subscriptionDeleted.js index dc92c682..222c5cb0 100644 --- a/api/src/subscriptions/stripeEventHandlers/subscriptionDeleted.js +++ b/api/src/subscriptions/stripeEventHandlers/subscriptionDeleted.js @@ -4,6 +4,7 @@ const { sendSubscriptionEndedEmail } = require('../../mail'); const removeUndefined = require('../../util/removeUndefined'); const { stripeSubscriptionKeys } = require('../constants'); const getFirebaseUserId = require('../getFirebaseUserId'); +const { isWTMGSubscription } = require('./util'); const { statusKey, cancelAtKey, canceledAtKey } = stripeSubscriptionKeys; @@ -20,6 +21,11 @@ module.exports = async (event, res) => { console.log('Handling customer.subscription.deleted'); /** @type {import('stripe').Stripe.Subscription} */ const subscription = event.data.object; + if (!isWTMGSubscription(subscription)) { + console.log('Ignoring non-WTMG subscription'); + return res.sendStatus(200); + } + const { customer, start_date: startDate, diff --git a/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js b/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js index 3468b568..3d382a5d 100644 --- a/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js +++ b/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js @@ -1,7 +1,10 @@ +// @ts-check +// const getFirebaseUserId = require('../getFirebaseUserId'); const { stripeSubscriptionKeys } = require('../constants'); const removeUndefined = require('../../util/removeUndefined'); const { db } = require('../../firebase'); +const { isWTMGSubscription } = require('./util'); const { priceIdKey, @@ -23,6 +26,11 @@ module.exports = async (event, res) => { console.log('Handling subscription.updated'); /** @type {import('stripe').Stripe.Subscription} */ const subscription = event.data.object; + if (!isWTMGSubscription(subscription)) { + console.log('Ignoring non-WTMG subscription'); + return res.sendStatus(200); + } + const uid = await getFirebaseUserId(subscription.customer); // Save updated subscription state in Firebase diff --git a/api/src/subscriptions/stripeEventHandlers/util.js b/api/src/subscriptions/stripeEventHandlers/util.js new file mode 100644 index 00000000..3ce33789 --- /dev/null +++ b/api/src/subscriptions/stripeEventHandlers/util.js @@ -0,0 +1,64 @@ +// @ts-check +const functions = require('firebase-functions'); +const stripe = require('../stripe'); + +const priceIdsObj = functions.config().stripe.price_ids; +const wtmgPriceIds = Object.values(priceIdsObj); + +exports.wtmgPriceIds = wtmgPriceIds; + +function isWTMGPriceId(priceId) { + return wtmgPriceIds.includes(priceId); +} +exports.isWTMGPriceId = isWTMGPriceId; + +/** + * @param {import('stripe').Stripe.Invoice} invoice + */ +exports.isWTMGInvoice = async function (invoice) { + // eslint-disable-next-line camelcase + const { lines, subscription, amount_paid } = invoice; + const lineOne = lines.data[0]; + let price = null; + if (lineOne != null && lineOne.price?.type === 'one_time') { + // In this case, the plan is null, and then this was a modified invoice + // (the customer changed their mind), + // which had a new one-time price & product id generated for it by Stripe + // Get the actual (hopefully!) current product & price from the subscription (?) + // do verify with the amount? + // if price->type is one_time instead of recurring + // TODO: we should consider cancelling & recreating subs on change instead, to avoid confusing issues like this. + // + // The sub will contain the actual price ID of the sub plan. + const sub = await stripe.subscriptions.retrieve(/** @type {string} */ (subscription)); + price = sub.items.data[0].price; + // eslint-disable-next-line camelcase + if (price.unit_amount !== amount_paid) { + // check if this person perhaps updated their sub two times?? :S + // TODO 1: is the price ID info now used in an intergration? ARE THE FAKE ONES + // in our Firebase? That could lead to mega weirdness + // TODO 2: update the product ID too (that one is also unique per invoice) + console.error(`Weirdness occurred in ${sub.id}`); + } + } else { + // Normal case + price = lineOne.price; + } + + const priceId = price?.id; + if (isWTMGPriceId(priceId)) { + return true; + } + return false; +}; + +/** + * @param {import('stripe').Stripe.Subscription} subscription + */ +exports.isWTMGSubscription = function (subscription) { + const priceId = subscription.items.data[0].price.id; + if (isWTMGPriceId(priceId)) { + return true; + } + return false; +}; diff --git a/package.json b/package.json index 877ec5f8..61708d8f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "vite --host", "dev:staging": "vite --mode staging --host", "dev:push": "vite --mode devpush --host", + "dev:prod": "vite --mode production --host", "build": "vite build", "build:prod": "vite build --mode production", "build:staging": "vite build --mode staging", diff --git a/src/lib/api/firebase.ts b/src/lib/api/firebase.ts index 55a17c81..3a2f4a98 100644 --- a/src/lib/api/firebase.ts +++ b/src/lib/api/firebase.ts @@ -1,5 +1,5 @@ import { getApps, initializeApp, type FirebaseApp } from 'firebase/app'; -import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check'; +import { initializeAppCheck, ReCaptchaV3Provider, type AppCheck } from 'firebase/app-check'; import { connectAuthEmulator, getAuth, type Auth } from 'firebase/auth'; import { type Firestore, getFirestore, connectFirestoreEmulator } from 'firebase/firestore'; import { connectStorageEmulator, getStorage, type FirebaseStorage } from 'firebase/storage'; @@ -25,22 +25,19 @@ const FIREBASE_CONFIG = { }; const messageFor = (str: string) => `Trying to use an uninitialized ${str}.`; -type FirestoreWarning = { - app: string; - firestore: string; - auth: string; - storage: string; - functions: string; - messaging: string; -}; -export const FIREBASE_WARNING: FirestoreWarning = [ + +const firestoreWarningKeys = [ 'app', 'firestore', 'auth', 'storage', 'functions', - 'messaging' -].reduce( + 'messaging', + 'appCheck' +] as const; +type FirestoreWarning = { [key in (typeof firestoreWarningKeys)[number]]: string }; + +export const FIREBASE_WARNING: FirestoreWarning = firestoreWarningKeys.reduce( (warningsObj, service) => ({ ...warningsObj, [service]: messageFor(service) }), {} ) as FirestoreWarning; @@ -78,6 +75,9 @@ export const db: () => Firestore = guardNull(() => dbRef, 'firestore' let authRef: Auth | null = null; export const auth: () => Auth = guardNull(() => authRef, 'auth'); +let appCheckRef: AppCheck | null = null; +export const appCheck: () => AppCheck = guardNull(() => appCheckRef, 'appCheck'); + let usCentral1FunctionsRef: Functions | null = null; export const functions: () => Functions = guardNull( () => usCentral1FunctionsRef, @@ -130,7 +130,7 @@ export async function initialize(): Promise { } if (typeof import.meta.env.VITE_FIREBASE_APP_CHECK_PUBLIC_KEY !== 'undefined') { - initializeAppCheck(appRef, { + appCheckRef = initializeAppCheck(appRef, { provider: new ReCaptchaV3Provider(import.meta.env.VITE_FIREBASE_APP_CHECK_PUBLIC_KEY), isTokenAutoRefreshEnabled: true }); diff --git a/src/lib/api/garden.ts b/src/lib/api/garden.ts index 29eb3200..726120c6 100644 --- a/src/lib/api/garden.ts +++ b/src/lib/api/garden.ts @@ -2,9 +2,6 @@ import type { User } from '$lib/models/User'; import { CAMPSITES } from './collections'; import { collection, - query, - where, - getDocs, doc, setDoc, updateDoc, @@ -15,9 +12,11 @@ import { } from 'firebase/firestore'; import { getUser } from '$lib/stores/auth'; import { isUploading, uploadProgress, allGardens, isFetchingGardens } from '$lib/stores/garden'; -import { db, storage } from './firebase'; +import { appCheck, db, storage } from './firebase'; import { getDownloadURL, ref, uploadBytesResumable } from 'firebase/storage'; import type { Garden, GardenFacilities } from '$lib/types/Garden'; +import { get } from 'svelte/store'; +import { getToken } from 'firebase/app-check'; /** * Get a single garden, if it exists and is listed. Returns `null` otherwise. @@ -27,7 +26,7 @@ export const getGarden = async (id: string) => { const gardenDoc = await getDoc( doc(collection(db(), CAMPSITES) as CollectionReference, id) ); - let data = gardenDoc.data()!; + const data = gardenDoc.data()!; if (gardenDoc.exists() && data.listed) { return { id: gardenDoc.id, @@ -38,18 +37,187 @@ export const getGarden = async (id: string) => { } }; +type DoubleValue = { + doubleValue: number; +}; + +type IntegerValue = { + integerValue: number; +}; + +type StringValue = { + stringValue: string; +}; + +type BooleanValue = { + booleanValue: boolean; +}; + +type MapValue = { + mapValue: { + fields: { + [key: string]: StringValue | BooleanValue | DoubleValue | IntegerValue; + }; + }; +}; + +type RESTGardenDoc = { + document: { + /** + * Path + */ + name: string; + fields: { + name: StringValue; + description: StringValue; + location: MapValue; + listed: BooleanValue; + facilities: MapValue; + photo: StringValue; + owner: StringValue; + createTime: string; + updateTime: string; + }; + createTime: string; + updateTime: string; + }; + /** + * ISO string + */ + readTime: string; +}; + +function mapRestToGarden(doc: RESTGardenDoc): Garden { + const { name, fields } = doc.document; + const { description, location, listed, facilities, photo } = fields; + + return { + id: name.split('/').pop() as string, + description: description?.stringValue, + location: { + latitude: location.mapValue.fields.latitude?.doubleValue, + longitude: location.mapValue.fields.longitude?.doubleValue + }, + listed: listed.booleanValue, + facilities: { + // Map facilities fields to boolean values + // Assuming all facilities are stored as boolean values or integer values + ...Object.fromEntries( + Object.entries(facilities.mapValue.fields).map(([key, value]) => [ + key, + typeof value.booleanValue !== 'undefined' ? value.booleanValue : +value.integerValue + ]) + ) + }, + photo: photo?.stringValue + }; +} + export const getAllListedGardens = async () => { + const CHUNK_SIZE = 1500; + // To prevent endless loops in case of unexpected problems or bugs + // Note: this leads to the loop breaking once this number of gardens is reached! + const LOOP_LIMIT_ITEMS = 100000; + + console.log('Starting to fetch all gardens...'); isFetchingGardens.set(true); - const q = query(collection(db(), CAMPSITES), where('listed', '==', true)); - const querySnapshot = await getDocs(q); - const gardens: { [id: string]: Garden } = {}; - querySnapshot.forEach((doc) => { - gardens[doc.id] = { id: doc.id, ...doc.data() }; - }); - allGardens.set(gardens); + let appCheckTokenResponse; + try { + // Use AppCheck if it is initialized (not on localhost development, for example) + if (typeof import.meta.env.VITE_FIREBASE_APP_CHECK_PUBLIC_KEY !== 'undefined') { + appCheckTokenResponse = await getToken(appCheck(), /* forceRefresh= */ false); + } + } catch (err) { + // Handle any errors if the token was not retrieved. + console.error('Error fetching app check token:', err); + return; + } + + let startAfterDocRef = null; + let iteration = 1; + do { + iteration++; + + const url = `${ + // Change the REST API base URL depending on the environment + import.meta.env.VITE_FIREBASE_PROJECT_ID === 'demo-test' + ? 'http://127.0.0.1:8080/v1/projects/' + : 'https://firestore.googleapis.com/v1/projects/' + }${import.meta.env.VITE_FIREBASE_PROJECT_ID}/databases/(default)/documents:runQuery`; + // Query the chunk of gardens using the REST api + const gardensChunkResponse = (await fetch(url, { + ...(appCheckTokenResponse + ? { + headers: { + 'X-Firebase-AppCheck': appCheckTokenResponse.token + } + } + : {}), + method: 'POST', + body: JSON.stringify({ + structuredQuery: { + from: [ + { + collectionId: 'campsites', + allDescendants: false + } + ], + where: { + fieldFilter: { + field: { + fieldPath: 'listed' + }, + op: 'EQUAL', + value: { + booleanValue: true + } + } + }, + limit: CHUNK_SIZE, + // https://stackoverflow.com/a/71812269/4973029 + orderBy: [ + { + direction: 'ASCENDING', + field: { fieldPath: '__name__' } + } + ], + ...(startAfterDocRef + ? { + startAt: { + before: false, + values: [{ referenceValue: startAfterDocRef }] + } + } + : {}) + } + }) + }).then((r) => r.json())) as RESTGardenDoc[]; + + // Query the chunk of gardens + if (gardensChunkResponse.length === CHUNK_SIZE) { + // If a full chunk was fetched, there might be more gardens to fetch + startAfterDocRef = gardensChunkResponse[gardensChunkResponse.length - 1].document.name; + } else { + // If the chunk was not full, there are no more gardens to fetch + startAfterDocRef = null; + } + + // Merge the map with the existing gardens, "in place" + allGardens.update((existingGardens) => { + // Merge the fetched gardens with the existing ones; without creating a new array in memory + // (attempt to reduce memory usage) + gardensChunkResponse.forEach((restDoc) => { + existingGardens.push(mapRestToGarden(restDoc)); + }); + return existingGardens; + }); + } while (startAfterDocRef != null && iteration < LOOP_LIMIT_ITEMS / CHUNK_SIZE); + + console.log('Fetched all gardens'); + isFetchingGardens.set(false); - return gardens; + return get(allGardens); }; const doUploadGardenPhoto = async (photo: File, currentUser: User) => { diff --git a/src/lib/components/Garden/FacilitiesFilter.svelte b/src/lib/components/Garden/FacilitiesFilter.svelte index c60ef645..24375cea 100644 --- a/src/lib/components/Garden/FacilitiesFilter.svelte +++ b/src/lib/components/Garden/FacilitiesFilter.svelte @@ -34,14 +34,9 @@ } const returnFilteredGardens = () => { - const gardensFiltered = Object.values($allGardens) + return $allGardens .filter(gardenFilterFacilities, filter.facilities) .filter(gardenFilterCapacity, filter.capacity); - let gardens = {}; - gardensFiltered.map((garden) => { - gardens[garden.id] = { ...garden }; - }); - return gardens; }; let stickToBottom = false; @@ -117,8 +112,8 @@

{@html $_('garden.filter.available', { values: { - amount: Object.values(filteredGardens).length, - styledAmount: `${Object.values(filteredGardens).length}` + amount: filteredGardens.length, + styledAmount: `${filteredGardens.length}` } })}

diff --git a/src/lib/components/Garden/GardenDrawer.svelte b/src/lib/components/Garden/GardenDrawer.svelte index 3ce7e462..165f11ed 100644 --- a/src/lib/components/Garden/GardenDrawer.svelte +++ b/src/lib/components/Garden/GardenDrawer.svelte @@ -379,7 +379,7 @@ } .garden-title .button-container { - /* Override the 100% width that causes + /* Override the 100% width that causes the paragraph on the left to collapse on desktop drawers. */ width: auto; margin-left: 1rem; diff --git a/src/lib/components/Map/GardenLayer.svelte b/src/lib/components/Map/GardenLayer.svelte index 21622162..31c0c796 100644 --- a/src/lib/components/Map/GardenLayer.svelte +++ b/src/lib/components/Map/GardenLayer.svelte @@ -4,7 +4,7 @@ import type maplibregl from 'maplibre-gl'; import type GeoJSON from 'geojson'; - export let allGardens: { [id: string]: Garden }; + export let allGardens: Garden[]; export let selectedGardenId: string | null = null; export let showGardens: boolean; export let showSavedGardens: boolean; @@ -53,7 +53,7 @@ fcSavedGardens.features = []; fcGardensWithoutSavedGardens.features = []; - Object.values(allGardens).map((garden: Garden) => { + allGardens.forEach((garden: Garden) => { if (!garden.id) return; const gardenId = garden.id; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 63ed7135..70d96e31 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -19,6 +19,8 @@ export const isRegistering = writable(false); export const isUserLoading = writable(false); export const user: Writable = writable(null); +export const appCheckToken = writable(null); + export const resolveOnUserLoaded = async () => { // Guard is necesary, because if !isUserLoading, the reference // to unsubFromLoading won't be created diff --git a/src/lib/stores/garden.ts b/src/lib/stores/garden.ts index 4afdf000..62b3c6e8 100644 --- a/src/lib/stores/garden.ts +++ b/src/lib/stores/garden.ts @@ -4,11 +4,11 @@ import type { Garden } from '../types/Garden'; export const isUploading = writable(false); export const uploadProgress = writable(0); -export const isFetchingGardens = writable(true); -export const allGardens: Writable<{ [id: string]: Garden }> = writable({}); +export const isFetchingGardens = writable(false); +export const allGardens: Writable = writable([]); export const addToAllGardens = async (garden: Garden) => { - if (Object.keys(get(allGardens)).length === 0) { + if (get(allGardens).length === 0 && !get(isFetchingGardens)) { isFetchingGardens.set(true); try { await getAllListedGardens(); @@ -18,7 +18,16 @@ export const addToAllGardens = async (garden: Garden) => { } isFetchingGardens.set(false); } else { - allGardens.update((gardens) => ({ ...gardens, [garden.id]: garden })); + // Update the the specific garden in the local store of gardens + allGardens.update((gardens) => { + const index = gardens.findIndex((g) => g.id === garden.id); + if (index) { + gardens[index] = garden; + } else { + gardens.push(garden); + } + return gardens; + }); } }; export const addGardenLocally = (garden: Garden) => addToAllGardens(garden); diff --git a/src/routes/explore/+layout.svelte b/src/routes/explore/+layout.svelte index afa8feb7..3aa812f3 100644 --- a/src/routes/explore/+layout.svelte +++ b/src/routes/explore/+layout.svelte @@ -33,6 +33,7 @@ import type { Unsubscribe } from 'firebase/firestore'; import { fileDataLayers, removeTrailAnimations } from '$lib/stores/file'; import { isOnIDevicePWA } from '$lib/api/push-registrations'; + import { isEmpty } from 'lodash-es'; let showHiking = false; let showCycling = false; @@ -85,10 +86,27 @@ let zoom = isShowingGarden ? ZOOM_LEVELS.ROAD : ZOOM_LEVELS.WESTERN_EUROPE; $: applyZoom = isShowingGarden ? true : false; + + // Garden to preload when we are loading the app on its permalink URL + let preloadedGarden: Garden | null = null; + /** * @type {Garden | null} */ - $: selectedGarden = $isFetchingGardens ? null : $allGardens[$page.params.gardenId] ?? null; + let selectedGarden = null; + + // Select the preloaded garden if it matches the current URL, but only if it was not selected yet. + $: if ( + preloadedGarden?.id === $page.params.gardenId && + selectedGarden?.id !== preloadedGarden?.id + ) { + selectedGarden = preloadedGarden; + } + + // Select a garden when the URL changes, but only if all gardens are loaded and the garden is not selected yet. + $: if (!isEmpty($allGardens) && $page.params.gardenId !== selectedGarden?.id) { + selectedGarden = $allGardens.find((g) => g.id === $page.params.gardenId) ?? null; + } // This variable controls the location of the map. // Don't make it reactive based on its params, so that it can be imperatively controlled. @@ -108,10 +126,14 @@ const selectGarden = (garden) => { const newSelectedId = garden.id; - const newGarden = $allGardens[newSelectedId]; - setMapToGardenLocation(newGarden); - applyZoom = false; // zoom level is not programatically changed when exploring a garden - goto(`${routes.MAP}/garden/${newSelectedId}`); + const newGarden = $allGardens.find((g) => g.id === newSelectedId); + if (newGarden) { + setMapToGardenLocation(newGarden); + applyZoom = false; // zoom level is not programatically changed when exploring a garden + goto(`${routes.MAP}/garden/${newSelectedId}`); + } else { + console.warn(`Failed garden navigation to ${newSelectedId}`); + } }; const goToPlace = (event) => { @@ -131,27 +153,27 @@ carNoticeShown = false; }; + let fetchError = ''; + // LIFECYCLE HOOKS onMount(async () => { - // If the gardens didn't load yet - if (Object.keys($allGardens).length === 0) { - // If we're loading the page of a garden, load that one immediately before all other gardens - if (isShowingGarden) { - const garden = await getGarden($page.params.gardenId); - if (garden) { - $allGardens = { [garden.id]: garden }; - setMapToGardenLocation(garden); - } + const gardensAreEmpty = $allGardens.length === 0; + if (gardensAreEmpty && !$isFetchingGardens && isShowingGarden) { + // If we're loading the page of a garden, load that one immediately *before* all other gardens + preloadedGarden = await getGarden($page.params.gardenId); + if (preloadedGarden) { + setMapToGardenLocation(preloadedGarden); } + } - // Fetch all gardens - try { - await getAllListedGardens(); - } catch (ex) { + // Fetch all gardens if they are not loaded yet, every time the map opens + if (gardensAreEmpty && !$isFetchingGardens) { + await getAllListedGardens().catch((ex) => { console.error(ex); + fetchError = 'Error' + ex; isFetchingGardens.set(false); - } + }); } }); @@ -178,7 +200,7 @@ {applyZoom} > - {#if !$isFetchingGardens} + {#if !isEmpty($allGardens)}