PageSourceSearch

https://fascatcoaching.com/js/reviews-backend.js

js fascatcoaching.com collected 2026-10-02 06:00:41 UTC 17,508 bytes, 410 lines download raw bytes

1/* FasCat Customer Reviews — shared backend connector.
2 *
3 * Fill in the two values below ONCE the Supabase project exists
4 * (Project Settings → API). The anon/publishable key is SAFE to ship in
5 * public website code — it can only do what the database security rules allow.
6 * NEVER put the service_role / secret key in here.
7 *
8 * Until these are filled in, the pages run in "demo mode" with sample data.
9 */
10window.REVIEWS_CONFIG = {
11  SUPABASE_URL: "https://vdqnqojbkdtvuexzxkyc.supabase.co",
12  SUPABASE_ANON_KEY: "sb_publishable_NbCvmzk8JJfAQhotWdAR6A_tsml-U3i",
13  PHOTO_BUCKET: "review-photos"
14};
15
16window.reviewsBackend = (function () {
17  var cfg = window.REVIEWS_CONFIG;
18  var client = null;
19
20  function isConfigured() {
21    return cfg.SUPABASE_URL.indexOf("PASTE_") !== 0 &&
22           cfg.SUPABASE_ANON_KEY.indexOf("PASTE_") !== 0 &&
23           typeof window.supabase !== "undefined";
24  }
25
26  function getClient() {
27    if (!client && isConfigured()) {
28      client = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY);
29    }
30    return client;
31  }
32
33  // ---- Client-side image optimization ----------------------------------
34  // Before anything reaches Supabase Storage we resize the photo so its long
35  // edge is <= MAX_EDGE and re-encode it as WebP at ~QUALITY. A 12MB HEIC or
36  // 5MB JPEG lands in the bucket as a ~30-80KB WebP. If anything in the
37  // pipeline fails we fall back to uploading the original file untouched, so a
38  // submission is never blocked by optimization.
39  var IMG = { MAX_EDGE: 800, QUALITY: 0.82 };
40  var heicLoader = null;
41
42  function loadScript(src) {
43    return new Promise(function (resolve, reject) {
44      var s = document.createElement("script");
45      s.src = src; s.onload = resolve; s.onerror = reject;
46      document.head.appendChild(s);
47    });
48  }
49
50  function isHeic(file) {
51    var t = (file.type || "").toLowerCase();
52    return t === "image/heic" || t === "image/heif" || /\.hei[cf]$/i.test(file.name || "");
53  }
54
55  // HEIC/HEIF can't be drawn to a <canvas> in most browsers, so decode it to a
56  // JPEG blob first via heic2any (loaded on demand, once, only when needed —
57  // it never touches the read-only display pages).
58  function decodeHeic(file) {
59    if (!heicLoader) {
60      heicLoader = loadScript("https://cdn.jsdelivr.net/npm/[email protected]/dist/heic2any.min.js");
61    }
62    return heicLoader.then(function () {
63      return window.heic2any({ blob: file, toType: "image/jpeg", quality: 0.92 });
64    }).then(function (out) { return Array.isArray(out) ? out[0] : out; });
65  }
66
67  function loadBitmap(blob) {
68    if (window.createImageBitmap) {
69      // imageOrientation:"from-image" bakes in EXIF rotation (iPhone photos);
70      // fall back if this browser doesn't support the option.
71      return createImageBitmap(blob, { imageOrientation: "from-image" })
72        .catch(function () { return createImageBitmap(blob); });
73    }
74    return new Promise(function (resolve, reject) {
75      var img = new Image();
76      img.onload = function () { resolve(img); };
77      img.onerror = reject;
78      img.src = URL.createObjectURL(blob);
79    });
80  }
81
82  function encode(canvas, type, quality) {
83    return new Promise(function (resolve) { canvas.toBlob(resolve, type, quality); });
84  }
85
86  // Returns { blob, ext } — the optimized WebP, or the original file on failure.
87  function optimizeImage(file) {
88    var pre = isHeic(file) ? decodeHeic(file) : Promise.resolve(file);
89    return pre.then(loadBitmap).then(function (bmp) {
90      var w = bmp.width, h = bmp.height;
91      var scale = Math.min(1, IMG.MAX_EDGE / Math.max(w, h)); // never upscale
92      var canvas = document.createElement("canvas");
93      canvas.width = Math.max(1, Math.round(w * scale));
94      canvas.height = Math.max(1, Math.round(h * scale));
95      canvas.getContext("2d").drawImage(bmp, 0, 0, canvas.width, canvas.height);
96      if (bmp.close) bmp.close();
97      return encode(canvas, "image/webp", IMG.QUALITY).then(function (blob) {
98        if (blob && blob.type === "image/webp") return { blob: blob, ext: "webp" };
99        // Browser can't encode WebP — fall back to JPEG so we still resize.
100        return encode(canvas, "image/jpeg", 0.85).then(function (jpg) {
101          return jpg ? { blob: jpg, ext: "jpg" } : rawFile(file);
102        });
103      });
104    }).catch(function (e) {
105      console.warn("image optimize failed, uploading original:", e);
106      return rawFile(file);
107    });
108  }
109
110  function rawFile(file) {
111    return { blob: file, ext: (file.name.split(".").pop() || "jpg").toLowerCase() };
112  }
113
114  function uploadPhoto(file) {
115    if (!file) return Promise.resolve(null);
116    var sb = getClient();
117    return optimizeImage(file).then(function (out) {
118      var path = "uploads/" + Date.now() + "-" + Math.random().toString(36).slice(2) + "." + out.ext;
119      return sb.storage.from(cfg.PHOTO_BUCKET).upload(path, out.blob, {
120        contentType: out.blob.type || undefined
121      }).then(function (res) {
122        if (res.error) throw res.error;
123        return sb.storage.from(cfg.PHOTO_BUCKET).getPublicUrl(path).data.publicUrl;
124      });
125    });
126  }
127
128  // ---- Public: customer submits a review ----
129  function submitReview(data, profileFile, actionFile) {
130    var sb = getClient();
131    return Promise.all([uploadPhoto(profileFile), uploadPhoto(actionFile)])
132      .then(function (urls) {
133        var row = {
134          name: data.name,
135          email: data.email || null,        // INTERNAL ONLY: never in public_reviews
136          age: data.age ? parseInt(data.age, 10) : null,
137          city: data.city || null,
138          state: data.state || null,
139          country: data.country || null,
140          occupation: data.occupation || null,
141          claim_to_fame: data.claim_to_fame || null,
142          coaching_type: data.coaching,
143          coach: data.coach || null,
144          story: data.story,
145          profile_photo_url: urls[0],
146          action_photo_url: urls[1],
147          status: "pending"
148        };
149        return sb.from("reviews").insert(row).then(function (res) {
150          // Safety net: if this ships before the add-email-field.sql migration
151          // is run, the table has no `email` column and the insert errors. Retry
152          // once without email so a submission is never lost. Email capture
153          // starts working automatically the moment the migration is applied.
154          if (res.error && /email/i.test(res.error.message || "")) {
155            delete row.email;
156            return sb.from("reviews").insert(row);
157          }
158          return res;
159        });
160      })
161      .then(function (res) { if (res.error) throw res.error; return true; });
162  }
163
164  // ---- Public: live pages read approved reviews ----
165  function fetchApproved() {
166    var sb = getClient();
167    return sb.from("public_reviews").select("*").order("created_at", { ascending: false })
168      .then(function (res) { if (res.error) throw res.error; return res.data; });
169  }
170
171  // ---- Admin (inbox): login + moderate ----
172  function signIn(email, password) {
173    var sb = getClient();
174    return sb.auth.signInWithPassword({ email: email, password: password })
175      .then(function (res) { if (res.error) throw res.error; return res.data; });
176  }
177
178  function listByStatus(status) {
179    var sb = getClient();
180    return sb.from("reviews").select("*").eq("status", status).order("created_at", { ascending: false })
181      .then(function (res) { if (res.error) throw res.error; return res.data; });
182  }
183
184  function listAll() {
185    var sb = getClient();
186    return sb.from("reviews").select("*").order("created_at", { ascending: false })
187      .then(function (res) { if (res.error) throw res.error; return res.data; });
188  }
189
190  function setStatus(id, status, placements) {
191    var sb = getClient();
192    var patch = { status: status, reviewed_at: new Date().toISOString() };
193    if (placements) patch.placements = placements;
194    return sb.from("reviews").update(patch).eq("id", id)
195      .then(function (res) { if (res.error) throw res.error; return true; });
196  }
197
198  function deleteReview(id) {
199    var sb = getClient();
200    return sb.from("reviews").delete().eq("id", id)
201      .then(function (res) { if (res.error) throw res.error; return true; });
202  }
203
204  // ---- Admin (inbox): edit a review's content (name, story, photos, ...) ----
205  // `fields` is a partial patch of columns to overwrite. Gated to staff by the
206  // same "staff can update reviews" RLS policy used by setStatus.
207  function updateReview(id, fields) {
208    var sb = getClient();
209    return sb.from("reviews").update(fields).eq("id", id)
210      .then(function (res) { if (res.error) throw res.error; return true; });
211  }
212
213
214  // ---- Admin (inbox): social / newsletter assets -----------------------------
215  // On publish the inbox renders the share cards in the browser and hands them
216  // here. They go into the same public bucket under cards/<review id>-<fmt>.jpg
217  // (a stable path, so re-publishing overwrites rather than piling up copies)
218  // and the public URLs get written back onto the review row. Those URLs are
219  // what Slack, the newsletter block and anything else downstream point at.
220  function uploadCard(reviewId, format, blob) {
221    var sb = getClient();
222    var path = "cards/" + reviewId + "-" + format + ".jpg";
223    return sb.storage.from(cfg.PHOTO_BUCKET).upload(path, blob, {
224      contentType: "image/jpeg",
225      upsert: true
226    }).then(function (res) {
227      if (res.error) throw res.error;
228      // Cache-bust so a re-published card isn't served from the old CDN copy.
229      return sb.storage.from(cfg.PHOTO_BUCKET).getPublicUrl(path).data.publicUrl +
230        "?v=" + Date.now();
231    });
232  }
233
234  // Render-and-store all three formats for one review. Resolves to
235  // { square, story, wide } of public URLs. Never rejects the caller's publish:
236  // a failure here resolves to {} so the review still goes live.
237  function buildAndStoreCards(review) {
238    if (typeof window.FasCatShareCards === "undefined") return Promise.resolve({});
239    var photo = review.actionUrl || review.profileUrl || null;
240    return window.FasCatShareCards.renderAll({
241      photoUrl: photo, coachingType: review.coaching, coach: review.coach, name: review.name,
242      focus: review.photoFocus
243    }).then(function (canvases) {
244      var formats = Object.keys(canvases);
245      return Promise.all(formats.map(function (f) {
246        return window.FasCatShareCards.toBlob(canvases[f]).then(function (blob) {
247          return uploadCard(review.id, f, blob);
248        });
249      })).then(function (urls) {
250        var out = {};
251        formats.forEach(function (f, i) { out[f] = urls[i]; });
252        return out;
253      });
254    }).then(function (urls) {
255      var patch = {
256        card_square_url: urls.square || null,
257        card_story_url: urls.story || null,
258        card_wide_url: urls.wide || null
259      };
260      return getClient().from("reviews").update(patch).eq("id", review.id)
261        .then(function (res) {
262          // If the add-card-columns.sql migration hasn't been run yet the
263          // columns don't exist. The images are already uploaded and usable, so
264          // hand back the URLs anyway instead of failing the publish.
265          if (res.error) console.warn("card URLs not saved to the row:", res.error.message);
266          return urls;
267        });
268    }).catch(function (err) {
269      console.warn("share-card build failed:", err);
270      return {};
271    });
272  }
273
274
275  /* Is the review-notify function actually deployed?
276   *
277   * Everything the publish flow does after the review goes live (rendering the
278   * share cards, uploading them, posting to Slack) exists to feed that
279   * notifier. Until it is set up there is nothing downstream to consume any of
280   * it, so the work is skipped rather than done and thrown away.
281   *
282   * Probed with an unauthenticated OPTIONS: the Supabase gateway answers 200
283   * for a deployed function and 404 for one that isn't there, without running
284   * the function or needing a session. Cached for the life of the page, so it
285   * costs one request per visit to the inbox, and it starts working on its own
286   * the first time the inbox is loaded after the function is deployed.
287   */
288  var notifierAvailable = null;
289
290  function hasNotifier() {
291    if (notifierAvailable !== null) return Promise.resolve(notifierAvailable);
292    return fetch(cfg.SUPABASE_URL + "/functions/v1/review-notify", { method: "OPTIONS" })
293      .then(function (r) { notifierAvailable = r.ok; return notifierAvailable; })
294      .catch(function () { notifierAvailable = false; return false; });
295  }
296
297  // ---- Admin (inbox): tell Slack -------------------------------------------
298  // Posts through the `review-notify` Supabase Edge Function, which holds the
299  // Slack webhook URL as a server-side secret — the webhook never ships in this
300  // public file. Requires a signed-in staff session. Resolves to false (never
301  // rejects) when Slack isn't set up yet or the call fails, so a publish is
302  // never blocked by a notification.
303  function notifySlack(event, reviewId, cards) {
304    var sb = getClient();
305    return sb.auth.getSession().then(function (res) {
306      var token = res.data && res.data.session && res.data.session.access_token;
307      if (!token) return false;
308      return fetch(cfg.SUPABASE_URL + "/functions/v1/review-notify", {
309        method: "POST",
310        headers: {
311          "Content-Type": "application/json",
312          "Authorization": "Bearer " + token,
313          "apikey": cfg.SUPABASE_ANON_KEY
314        },
315        body: JSON.stringify({ event: event, id: reviewId, cards: cards || null })
316      }).then(function (r) { return r.ok; });
317    }).catch(function (err) {
318      console.warn("Slack notify failed:", err);
319      return false;
320    });
321  }
322
323
324  // ---- Admin (inbox): Claude-picked soundbites -------------------------------
325  // Asks the `pick-quote` Edge Function for up to three pull quotes from a
326  // review. The Anthropic key lives there as a server-side secret, never here.
327  // Resolves to an array of quotes on success (possibly empty, when the review
328  // had nothing quotable in it), or { failed: true, status, detail } when the
329  // call did not succeed. Never rejects: the inbox falls back to its own local
330  // suggestion either way, but it can say exactly what went wrong.
331  function pickQuotes(review) {
332    var sb = getClient();
333    return sb.auth.getSession().then(function (res) {
334      var token = res.data && res.data.session && res.data.session.access_token;
335      if (!token) return [];
336      return fetch(cfg.SUPABASE_URL + "/functions/v1/pick-quote", {
337        method: "POST",
338        headers: {
339          "Content-Type": "application/json",
340          "Authorization": "Bearer " + token,
341          "apikey": cfg.SUPABASE_ANON_KEY
342        },
343        body: JSON.stringify({
344          id: review.id,
345          coachingType: review.coaching
346        })
347      }).then(function (r) {
348        if (!r.ok) {
349          // Distinguish "couldn't ask" from "asked, nothing usable c
349ame back".
350          // The inbox says something different for each; collapsing them into
351          // an empty list is what made a failure here look like a success.
352          // Carry the status and the function's own message back with it, so
353          // the panel can say what actually went wrong instead of sending
354          // someone off to read logs in another tab.
355          return r.json().catch(function () { return {}; }).then(function (d) {
356            console.warn("quote pick unavailable:", r.status, d);
357            return { failed: true, status: r.status, detail: (d && (d.detail || d.error)) || "" };
358          });
359        }
360        return r.json().then(function (d) { return (d && d.quotes) || []; });
361      });
362    }).catch(function (err) {
363      console.warn("quote pick failed:", err);
364      return { failed: true, status: 0, detail: String(err && err.message || err) };
365    });
366  }
367
368  /* Where the rider is in this review's photo, so the cards can crop around
369   * them (see supabase/functions/_shared/photo-focus.ts). Measured once by the
370   * pick-quote function and saved on the review; resolves to null when it
371   * can't be had, and the cards then use their old fixed crop. */
372  function getPhotoFocus(review) {
373    var sb = getClient();
374    return sb.auth.getSession().then(function (res) {
375      var token = res.data && res.data.session && res.data.session.access_token;
376      if (!token) return null;
377      return fetch(cfg.SUPABASE_URL + "/functions/v1/pick-quote", {
378        method: "POST",
379        headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token, "apikey": cfg.SUPABASE_ANON_KEY },
380        body: JSON.stringify({ id: review.id, focus: true })
381      }).then(function (r) {
382        if (!r.ok) return null;
383        return r.json().then(function (d) { return (d && d.focus) || null; });
384      });
385    }).catch(function (err) {
386      console.warn("photo focus unavailable:", err);
387      return null;
388    });
389  }
390
391  return {
392    isConfigured: isConfigured,
393    getPhotoFocus: getPhotoFocus,
394    submitReview: submitReview,
395    fetchApproved: fetchApproved,
396    signIn: signIn,
397    listByStatus: listByStatus,
398    listAll: listAll,
399    setStatus: setStatus,
400    deleteReview: deleteReview,
401    updateReview: updateReview,
402    uploadPhoto: uploadPhoto,
403    optimizeImage: optimizeImage,
404    uploadCard: uploadCard,
405    buildAndStoreCards: buildAndStoreCards,
406    notifySlack: notifySlack,
407    hasNotifier: hasNotifier,
408    pickQuotes: pickQuotes
409  };
410})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.