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.