Z User commited on
Commit
b0f33c8
·
1 Parent(s): f2a57e4

Phase 2 — Community Engagement

Browse files

4 new feature areas, all using existing HTTP-only cookie auth + EdgeFunctions client.

EDGE FUNCTIONS (4 new, all deployed):
- reviews: by_product, by_seller, create (verifies purchase), helpful, delete
- group-buy: create, join, status, active, mine, cancel (Pinduoduo model)
- wishlist-share: share (snapshot), get_shared, my_shares, revoke
- live: list, get, messages, start, end, join, leave, message, mynext, whatsapp_buy

DATABASE (7 new tables + triggers):
- product_ratings (avg + count cache, trigger-maintained from buyers_reviews)
- group_buys + group_buy_members (status auto-flips to 'completed' at target)
- shared_wishlists (token-based, snapshot JSON, view_count)
- shop_live_sessions + shop_live_messages + shop_live_viewers
(renamed from live_sessions to avoid clash with academy table)

FRONTEND (all using EdgeFunctions, zero Supabase SDK):
- Product page: WhatsApp/Telegram/copy share buttons, 'Start group buy' button,
active group buys list, full reviews section + write-review modal with star picker
- /group-buy.html: shareable group buy page with progress bar, member avatars,
WhatsApp share, join button, discount unlock when target reached
- /shared-wishlist.html: public wishlist view by token (works post-revoke)
- Wishlist page: 'Share wishlist' button → creates share link, opens WhatsApp
- /live.html: live shopping browse page (live now + recent)
- /live-watch.html: stream + chat sidebar with 2.5s polling, auto-join, featured
product card with buy button, message types (chat/join/leave/purchase/system)
- /seller/go-live.html: seller dashboard to start/end live sessions with optional
YouTube/HLS stream URL, featured product picker, live viewer counter
- Seller sidebar: new 'Go Live' nav item
- Public seller-profile.html: new 'Reviews' tab with seller aggregate rating
- Home page: new 'Live Now' carousel section (auto-shown when sessions active)

WHATSAPP INTEGRATION:
- The /api/live edge function exposes op=whatsapp_buy which the WhatsApp bot
can call when a user texts 'buy' during a live session. Returns the checkout
URL for the bot to send back. (1-line change in the Render bot needed — the
endpoint is ready.)

Phase 1 leftover also addressed: product sharing buttons on every product page.

17 edge functions now deployed (8 buyer + 5 seller/social + 4 phase 2).

Eesha buying folder/js/product.js CHANGED
@@ -34,6 +34,7 @@ async function loadProductDetails() {
34
  }
35
 
36
  const product = result.product;
 
37
 
38
  // Hide loading states
39
  document.getElementById('loadingSkeleton').classList.add('hidden');
@@ -286,6 +287,10 @@ document.addEventListener('DOMContentLoaded', async () => {
286
  // Finally load product details and update cart count
287
  await loadProductDetails();
288
  await updateCartCount();
 
 
 
 
289
  });
290
 
291
  // Search bar behavior (navigate to search-result.html with query)
@@ -313,3 +318,219 @@ document.addEventListener('DOMContentLoaded', async () => {
313
  console.error('Search setup failed:', err);
314
  }
315
  })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
  const product = result.product;
37
+ window._currentProduct = product; // cache for share functions
38
 
39
  // Hide loading states
40
  document.getElementById('loadingSkeleton').classList.add('hidden');
 
287
  // Finally load product details and update cart count
288
  await loadProductDetails();
289
  await updateCartCount();
290
+
291
+ // Phase 2: load reviews + active group buys for this product
292
+ loadProductReviews();
293
+ loadActiveGroupBuys();
294
  });
295
 
296
  // Search bar behavior (navigate to search-result.html with query)
 
318
  console.error('Search setup failed:', err);
319
  }
320
  })();
321
+
322
+ // ====================================================================
323
+ // Phase 2: Product sharing, group buy, reviews
324
+ // ====================================================================
325
+ function getCurrentProductId() {
326
+ return getProductIdFromUrl();
327
+ }
328
+
329
+ function getCurrentProduct() {
330
+ // Stored when loadProductDetails runs
331
+ return window._currentProduct || null;
332
+ }
333
+
334
+ // ---- Sharing ----
335
+ function shareProduct(platform) {
336
+ const id = getCurrentProductId();
337
+ if (!id) return;
338
+ const url = `${window.location.origin}/Eesha%20buying%20folder/product.html?id=${id}`;
339
+ const product = getCurrentProduct();
340
+ const text = product
341
+ ? `Check out "${product.name}" on Cellex — $${Number(product.price).toFixed(2)}`
342
+ : 'Check out this product on Cellex';
343
+
344
+ if (platform === 'whatsapp') {
345
+ window.open(`https://wa.me/?text=${encodeURIComponent(text + ' ' + url)}`, '_blank');
346
+ } else if (platform === 'telegram') {
347
+ window.open(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`, '_blank');
348
+ }
349
+ }
350
+
351
+ async function copyShareLink() {
352
+ const id = getCurrentProductId();
353
+ if (!id) return;
354
+ const url = `${window.location.origin}/Eesha%20buying%20folder/product.html?id=${id}`;
355
+ try {
356
+ await navigator.clipboard.writeText(url);
357
+ const btn = event?.target?.closest('button');
358
+ if (btn) {
359
+ const orig = btn.innerHTML;
360
+ btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
361
+ setTimeout(() => btn.innerHTML = orig, 1500);
362
+ }
363
+ } catch (e) {
364
+ window.prompt('Copy this link:', url);
365
+ }
366
+ }
367
+
368
+ // ---- Group buy ----
369
+ async function startGroupBuy() {
370
+ const productId = getCurrentProductId();
371
+ if (!productId) return;
372
+ if (!currentUser) {
373
+ if (confirm('Please login to start a group buy. Go to login page?')) {
374
+ window.location.href = `login.html?next=${encodeURIComponent('/Eesha buying folder/product.html?id=' + productId)}`;
375
+ }
376
+ return;
377
+ }
378
+ if (!confirm('Start a group buy?\n\nGet 3 friends to join and everyone gets 20% off.\n\nWe will generate a share link for you.')) return;
379
+ const r = await window.EdgeFunctions.groupBuy.create(Number(productId), 3, 20);
380
+ if (r.success) {
381
+ const url = `${window.location.origin}/group-buy.html?id=${r.groupBuy.id}`;
382
+ const shareText = `🛍️ Join my group buy on Cellex! Get 20% off when 3 of us join. ${url}`;
383
+ window.open(`https://wa.me/?text=${encodeURIComponent(shareText)}`, '_blank');
384
+ // Also navigate to the group buy page
385
+ window.location.href = `/group-buy.html?id=${r.groupBuy.id}`;
386
+ } else {
387
+ alert('Failed to start group buy: ' + (r.error || 'Unknown error'));
388
+ }
389
+ }
390
+
391
+ async function loadActiveGroupBuys() {
392
+ const productId = getCurrentProductId();
393
+ if (!productId) return;
394
+ const container = document.getElementById('activeGroupBuys');
395
+ if (!container) return;
396
+ try {
397
+ const r = await window.EdgeFunctions.groupBuy.active(Number(productId));
398
+ if (!r.success || !r.groupBuys || r.groupBuys.length === 0) return;
399
+ container.classList.remove('hidden');
400
+ container.innerHTML = `
401
+ <div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
402
+ <div class="text-xs font-bold text-amber-700 uppercase tracking-wide mb-2">
403
+ <i class="fas fa-fire"></i> ${r.groupBuys.length} active group buy${r.groupBuys.length > 1 ? 's' : ''} — join & get 20% off!
404
+ </div>
405
+ ${r.groupBuys.map(gb => `
406
+ <a href="/group-buy.html?id=${gb.id}" class="block text-sm text-amber-900 hover:underline">
407
+ 👥 ${gb.current_count}/${gb.target_count} joined · expires ${new Date(gb.expires_at).toLocaleString()}
408
+ </a>
409
+ `).join('')}
410
+ </div>`;
411
+ } catch (e) { /* silent fail */ }
412
+ }
413
+
414
+ // ---- Reviews ----
415
+ async function loadProductReviews() {
416
+ const productId = getCurrentProductId();
417
+ if (!productId) return;
418
+ try {
419
+ const r = await window.EdgeFunctions.reviews.byProduct(Number(productId));
420
+ if (!r.success) return;
421
+
422
+ // Summary
423
+ const summary = document.getElementById('reviewsSummary');
424
+ if (r.summary.count > 0) {
425
+ summary.classList.remove('hidden');
426
+ document.getElementById('reviewsAvg').textContent = Number(r.summary.avg).toFixed(1);
427
+ document.getElementById('reviewsStars').textContent =
428
+ '★'.repeat(Math.round(r.summary.avg)) + '☆'.repeat(5 - Math.round(r.summary.avg));
429
+ document.getElementById('reviewsCount').textContent = `${r.summary.count} review${r.summary.count > 1 ? 's' : ''}`;
430
+ document.getElementById('reviewsBlurb').textContent =
431
+ r.summary.avg >= 4 ? 'Buyers love this product!' :
432
+ r.summary.avg >= 3 ? 'Most buyers are satisfied.' :
433
+ 'Mixed reviews — read below.';
434
+ }
435
+
436
+ // List
437
+ const list = document.getElementById('reviewsList');
438
+ if (!r.reviews || r.reviews.length === 0) {
439
+ list.innerHTML = '<div class="text-gray-500 text-sm">No reviews yet. Be the first to write one!</div>';
440
+ return;
441
+ }
442
+ list.innerHTML = r.reviews.map(rev => `
443
+ <div class="border-b pb-3">
444
+ <div class="flex items-center justify-between">
445
+ <div class="flex items-center gap-2">
446
+ <div class="w-8 h-8 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center font-bold text-sm">
447
+ ${(rev.reviewer_name || 'B').charAt(0).toUpperCase()}
448
+ </div>
449
+ <div>
450
+ <div class="font-medium text-sm">${escapeHtml(rev.reviewer_name || 'Buyer')}</div>
451
+ <div class="text-xs text-gray-500">${new Date(rev.created_at).toLocaleDateString()}</div>
452
+ </div>
453
+ </div>
454
+ <div class="text-yellow-400 text-sm">${'★'.repeat(rev.rating)}${'☆'.repeat(5 - rev.rating)}</div>
455
+ </div>
456
+ ${rev.title ? `<div class="font-semibold mt-2">${escapeHtml(rev.title)}</div>` : ''}
457
+ ${rev.comment ? `<p class="text-gray-700 text-sm mt-1">${escapeHtml(rev.comment)}</p>` : ''}
458
+ ${rev.verified_purchase ? '<div class="text-xs text-green-600 mt-1"><i class="fas fa-check-circle"></i> Verified purchase</div>' : ''}
459
+ ${rev.images && rev.images.length ? `<div class="flex gap-2 mt-2">${rev.images.map(img => `<img src="${escapeHtml(img)}" class="w-16 h-16 object-cover rounded">`).join('')}</div>` : ''}
460
+ <button onclick="markHelpful('${rev.id}')" class="text-xs text-gray-500 hover:text-amber-600 mt-2">
461
+ <i class="fas fa-thumbs-up"></i> Helpful (${rev.helpful_count || 0})
462
+ </button>
463
+ </div>`).join('');
464
+ } catch (e) {
465
+ console.error('reviews load error', e);
466
+ }
467
+ }
468
+
469
+ function escapeHtml(s) {
470
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
471
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
472
+ }[c]));
473
+ }
474
+
475
+ // ---- Review modal ----
476
+ function openReviewModal() {
477
+ if (!currentUser) {
478
+ if (confirm('Please login to write a review. Go to login page?')) {
479
+ const id = getCurrentProductId();
480
+ window.location.href = `login.html?next=${encodeURIComponent('/Eesha buying folder/product.html?id=' + id)}`;
481
+ }
482
+ return;
483
+ }
484
+ document.getElementById('reviewModal').classList.remove('hidden');
485
+ // Star picker
486
+ document.querySelectorAll('#starPicker button').forEach(btn => {
487
+ btn.onclick = () => {
488
+ const star = parseInt(btn.dataset.star);
489
+ document.getElementById('reviewRating').value = star;
490
+ document.querySelectorAll('#starPicker button').forEach(b => {
491
+ b.classList.remove('text-amber-400');
492
+ b.classList.add('text-gray-300');
493
+ });
494
+ for (let i = 1; i <= star; i++) {
495
+ const b = document.querySelector(`#starPicker button[data-star="${i}"]`);
496
+ if (b) { b.classList.add('text-amber-400'); b.classList.remove('text-gray-300'); }
497
+ }
498
+ };
499
+ });
500
+ document.getElementById('reviewForm').onsubmit = submitReview;
501
+ }
502
+
503
+ function closeReviewModal() {
504
+ document.getElementById('reviewModal').classList.add('hidden');
505
+ }
506
+
507
+ async function submitReview(e) {
508
+ e.preventDefault();
509
+ const errEl = document.getElementById('reviewError');
510
+ errEl.classList.add('hidden');
511
+ const rating = parseInt(document.getElementById('reviewRating').value);
512
+ if (!rating || rating < 1 || rating > 5) {
513
+ errEl.textContent = 'Please select a rating (1-5 stars)';
514
+ errEl.classList.remove('hidden');
515
+ return;
516
+ }
517
+ const r = await window.EdgeFunctions.reviews.create({
518
+ productId: Number(getCurrentProductId()),
519
+ rating,
520
+ title: document.getElementById('reviewTitle').value.trim(),
521
+ comment: document.getElementById('reviewComment').value.trim(),
522
+ });
523
+ if (r.success) {
524
+ closeReviewModal();
525
+ await loadProductReviews();
526
+ alert('Thanks for your review!');
527
+ } else {
528
+ errEl.textContent = r.error || 'Failed to submit review';
529
+ errEl.classList.remove('hidden');
530
+ }
531
+ }
532
+
533
+ async function markHelpful(reviewId) {
534
+ const r = await window.EdgeFunctions.reviews.helpful(reviewId);
535
+ if (r.success) await loadProductReviews();
536
+ }
Eesha buying folder/product.html CHANGED
@@ -222,6 +222,25 @@
222
  </button>
223
  </div>
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  <!-- Shipping & Policy -->
226
  <div class="border-t pt-6 space-y-4">
227
  <div class="flex items-start gap-3">
@@ -241,8 +260,72 @@
241
  </div>
242
  </div>
243
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  </main>
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  <!-- AI Assistant -->
247
  <div class="fixed bottom-6 right-6 z-50" id="aiAssistant">
248
  <button class="w-14 h-14 bg-brand-500 text-white rounded-full shadow-lg hover:bg-opacity-90 transition" id="aiToggle" aria-label="Open AI assistant">
 
222
  </button>
223
  </div>
224
 
225
+ <!-- Share & Group Buy buttons (Phase 2) -->
226
+ <div class="flex gap-2 flex-wrap mt-3" id="phase2-actions">
227
+ <button onclick="shareProduct('whatsapp')" class="px-3 py-2 bg-green-500 text-white rounded-lg text-sm font-medium hover:bg-green-600 transition flex items-center gap-1.5">
228
+ <i class="fab fa-whatsapp"></i> Share on WhatsApp
229
+ </button>
230
+ <button onclick="shareProduct('telegram')" class="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm font-medium hover:bg-blue-600 transition flex items-center gap-1.5">
231
+ <i class="fab fa-telegram"></i> Telegram
232
+ </button>
233
+ <button onclick="copyShareLink()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200 transition flex items-center gap-1.5">
234
+ <i class="fas fa-link"></i> Copy link
235
+ </button>
236
+ <button onclick="startGroupBuy()" class="px-3 py-2 bg-gradient-to-r from-amber-400 to-amber-600 text-ink-900 rounded-lg text-sm font-bold hover:opacity-90 transition flex items-center gap-1.5">
237
+ <i class="fas fa-users"></i> Start group buy · 20% off
238
+ </button>
239
+ </div>
240
+
241
+ <!-- Active group buys for this product -->
242
+ <div id="activeGroupBuys" class="mt-3 hidden"></div>
243
+
244
  <!-- Shipping & Policy -->
245
  <div class="border-t pt-6 space-y-4">
246
  <div class="flex items-start gap-3">
 
260
  </div>
261
  </div>
262
  </div>
263
+
264
+ <!-- Reviews & Ratings (Phase 2) -->
265
+ <section class="mt-8 bg-white rounded-xl shadow-lg p-6">
266
+ <div class="flex items-center justify-between mb-4">
267
+ <h2 class="text-xl font-bold">Ratings & Reviews</h2>
268
+ <button onclick="openReviewModal()" class="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-opacity-90 transition">
269
+ <i class="fas fa-pen mr-1"></i> Write a review
270
+ </button>
271
+ </div>
272
+
273
+ <div id="reviewsSummary" class="mb-4 hidden">
274
+ <div class="flex items-center gap-4">
275
+ <div class="text-center">
276
+ <div class="text-4xl font-bold text-amber-500" id="reviewsAvg">0.0</div>
277
+ <div class="text-yellow-400 text-sm" id="reviewsStars"></div>
278
+ <div class="text-gray-500 text-xs mt-1" id="reviewsCount">0 reviews</div>
279
+ </div>
280
+ <div class="text-sm text-gray-600 max-w-md" id="reviewsBlurb">
281
+ Be the first to review this product.
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ <div id="reviewsList" class="space-y-4">
287
+ <div class="text-gray-500 text-sm">Loading reviews…</div>
288
+ </div>
289
+ </section>
290
  </main>
291
 
292
+ <!-- Review Modal -->
293
+ <div id="reviewModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center p-4">
294
+ <div class="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
295
+ <div class="flex justify-between items-center mb-4">
296
+ <h3 class="text-lg font-bold">Write a review</h3>
297
+ <button onclick="closeReviewModal()" class="text-gray-400 hover:text-gray-700">
298
+ <i class="fas fa-times"></i>
299
+ </button>
300
+ </div>
301
+ <form id="reviewForm" class="space-y-3">
302
+ <div>
303
+ <label class="block text-sm font-medium mb-1">Rating</label>
304
+ <div class="flex gap-1" id="starPicker">
305
+ <button type="button" data-star="1" class="text-2xl text-gray-300 hover:text-amber-400">★</button>
306
+ <button type="button" data-star="2" class="text-2xl text-gray-300 hover:text-amber-400">★</button>
307
+ <button type="button" data-star="3" class="text-2xl text-gray-300 hover:text-amber-400">★</button>
308
+ <button type="button" data-star="4" class="text-2xl text-gray-300 hover:text-amber-400">★</button>
309
+ <button type="button" data-star="5" class="text-2xl text-gray-300 hover:text-amber-400">★</button>
310
+ </div>
311
+ <input type="hidden" id="reviewRating" value="0">
312
+ </div>
313
+ <div>
314
+ <label class="block text-sm font-medium mb-1">Title (optional)</label>
315
+ <input type="text" id="reviewTitle" class="w-full px-3 py-2 border rounded-lg" placeholder="Summarize your experience">
316
+ </div>
317
+ <div>
318
+ <label class="block text-sm font-medium mb-1">Comment</label>
319
+ <textarea id="reviewComment" rows="3" class="w-full px-3 py-2 border rounded-lg" placeholder="What did you like or dislike?"></textarea>
320
+ </div>
321
+ <div id="reviewError" class="text-sm text-red-500 hidden"></div>
322
+ <button type="submit" class="w-full bg-brand-500 text-white py-2 rounded-lg font-medium hover:bg-opacity-90">
323
+ Submit review
324
+ </button>
325
+ </form>
326
+ </div>
327
+ </div>
328
+
329
  <!-- AI Assistant -->
330
  <div class="fixed bottom-6 right-6 z-50" id="aiAssistant">
331
  <button class="w-14 h-14 bg-brand-500 text-white rounded-full shadow-lg hover:bg-opacity-90 transition" id="aiToggle" aria-label="Open AI assistant">
Eesha buying folder/wishlist.html CHANGED
@@ -108,9 +108,14 @@
108
  <h1 class="text-3xl font-bold text-gray-900">My Wishlist</h1>
109
  <p class="text-gray-500 mt-1"><span id="wishlistCount">0</span> items saved</p>
110
  </div>
111
- <button id="clearAllBtn" class="px-4 py-2 text-red-500 hover:bg-red-50 rounded-lg hidden">
112
- <i class="fas fa-trash mr-2"></i> Clear All
113
- </button>
 
 
 
 
 
114
  </div>
115
 
116
  <!-- Wishlist Grid -->
@@ -285,6 +290,26 @@
285
  await window.Cart.updateCartCountUI();
286
  }
287
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  </script>
289
  </body>
290
  </html>
 
108
  <h1 class="text-3xl font-bold text-gray-900">My Wishlist</h1>
109
  <p class="text-gray-500 mt-1"><span id="wishlistCount">0</span> items saved</p>
110
  </div>
111
+ <div class="flex gap-2">
112
+ <button id="shareWishlistBtn" onclick="shareMyWishlist()" class="px-4 py-2 bg-gradient-to-r from-amber-400 to-amber-600 text-ink-900 font-bold rounded-lg hover:opacity-90 transition">
113
+ <i class="fas fa-share-alt mr-1"></i> Share wishlist
114
+ </button>
115
+ <button id="clearAllBtn" class="px-4 py-2 text-red-500 hover:bg-red-50 rounded-lg hidden">
116
+ <i class="fas fa-trash mr-2"></i> Clear All
117
+ </button>
118
+ </div>
119
  </div>
120
 
121
  <!-- Wishlist Grid -->
 
290
  await window.Cart.updateCartCountUI();
291
  }
292
  }
293
+
294
+ // Phase 2 — share wishlist
295
+ async function shareMyWishlist() {
296
+ const r = await window.EdgeFunctions.wishlistShare.share('My Cellex Wishlist');
297
+ if (!r.success) {
298
+ alert('Failed to create share link: ' + (r.error || ''));
299
+ return;
300
+ }
301
+ const url = `${window.location.origin}${r.url}`;
302
+ // Open WhatsApp share
303
+ const text = `🛍️ Check out my wishlist on Cellex! ${url}`;
304
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank');
305
+ // Also copy to clipboard
306
+ try {
307
+ await navigator.clipboard.writeText(url);
308
+ alert('Share link copied!\n\nShare it with friends: ' + url);
309
+ } catch {
310
+ window.prompt('Copy your share link:', url);
311
+ }
312
+ }
313
  </script>
314
  </body>
315
  </html>
group-buy.html ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Group Buy · Cellex</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="stylesheet" href="css/common.css">
10
+ <script src="js/config/edge-functions.js?v=2"></script>
11
+ <style>
12
+ body { background: #f8fafc; }
13
+ .gb-hero {
14
+ background: linear-gradient(135deg, #fbbf24 0%, #d97706 100%);
15
+ color: #0f172a; padding: 3rem 1.5rem;
16
+ }
17
+ .gb-card {
18
+ background: #fff; border-radius: 16px;
19
+ box-shadow: 0 8px 24px rgba(0,0,0,0.08);
20
+ padding: 1.5rem; margin: 1rem auto; max-width: 720px;
21
+ }
22
+ .gb-progress-track {
23
+ width: 100%; height: 12px; background: #fef3c7;
24
+ border-radius: 999px; overflow: hidden;
25
+ }
26
+ .gb-progress-fill {
27
+ height: 100%; background: linear-gradient(90deg, #f59e0b, #d97706);
28
+ transition: width 0.5s ease;
29
+ }
30
+ .gb-avatars { display: flex; }
31
+ .gb-avatar {
32
+ width: 36px; height: 36px; border-radius: 50%;
33
+ background: #fef3c7; color: #92400e;
34
+ display: flex; align-items: center; justify-content: center;
35
+ font-weight: 700; font-size: 0.85rem;
36
+ border: 2px solid #fff; margin-left: -8px;
37
+ }
38
+ .gb-avatar:first-child { margin-left: 0; }
39
+ .gb-avatar.empty { background: #e2e8f0; color: #94a3b8; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div id="header-mount"></div>
44
+ <script>fetch('shared/header.html').then(r=>r.text()).then(h=>document.getElementById('header-mount').innerHTML=h);</script>
45
+
46
+ <div class="gb-hero">
47
+ <div class="max-w-3xl mx-auto text-center">
48
+ <div class="inline-flex items-center gap-2 bg-ink-900 text-amber-400 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide">
49
+ <i class="fas fa-users"></i> Group Buy
50
+ </div>
51
+ <h1 class="text-3xl md:text-4xl font-extrabold mt-3" id="gb-headline">Loading…</h1>
52
+ <p class="text-ink-900/80 mt-2">Get 20% off when 3 friends join</p>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="gb-card" id="gb-card">
57
+ <div class="text-center py-8 text-gray-500">
58
+ <i class="fas fa-spinner fa-spin text-2xl"></i>
59
+ <div class="mt-2">Loading group buy…</div>
60
+ </div>
61
+ </div>
62
+
63
+ <script>
64
+ (async () => {
65
+ const params = new URLSearchParams(location.search);
66
+ const gbId = params.get('id');
67
+ if (!gbId) {
68
+ document.getElementById('gb-card').innerHTML = '<div class="text-center py-8 text-red-500">No group buy ID provided.</div>';
69
+ return;
70
+ }
71
+
72
+ // Load group buy status
73
+ const r = await window.EdgeFunctions.groupBuy.status(gbId);
74
+ if (!r.success || !r.groupBuy) {
75
+ document.getElementById('gb-card').innerHTML = '<div class="text-center py-8 text-red-500">Group buy not found.</div>';
76
+ return;
77
+ }
78
+
79
+ const gb = r.groupBuy;
80
+ const product = r.product;
81
+ const pct = Math.min(100, Math.round((gb.current_count / gb.target_count) * 100));
82
+ const discountPrice = product ? (Number(product.price) * (1 - Number(gb.discount_pct) / 100)).toFixed(2) : null;
83
+ const isCompleted = gb.status === 'completed';
84
+ const isExpired = gb.status === 'expired' || (gb.status === 'open' && new Date(gb.expires_at) < new Date());
85
+
86
+ document.getElementById('gb-headline').textContent =
87
+ isCompleted ? '🎉 Group complete!' :
88
+ isExpired ? 'Group buy expired' :
89
+ `${gb.target_count - gb.current_count} more friend${gb.target_count - gb.current_count > 1 ? 's' : ''} needed!`;
90
+
91
+ // Build member avatars
92
+ const memberAvatars = (r.members || []).map(m => `
93
+ <div class="gb-avatar">${(m.user_name || (m.user_id || '').slice(0, 2) || '?').charAt(0).toUpperCase()}</div>
94
+ `).join('');
95
+ const emptySlots = Math.max(0, gb.target_count - (r.members?.length || 0));
96
+ const emptyAvatars = Array.from({length: emptySlots}).map(() => `<div class="gb-avatar empty">?</div>`).join('');
97
+
98
+ document.getElementById('gb-card').innerHTML = `
99
+ ${product ? `
100
+ <div class="flex gap-4 mb-4">
101
+ ${product.image_url
102
+ ? `<img src="${product.image_url}" class="w-24 h-24 rounded-xl object-cover">`
103
+ : `<div class="w-24 h-24 rounded-xl bg-gray-100 flex items-center justify-center text-3xl text-gray-400"><i class="fas fa-box"></i></div>`}
104
+ <div class="flex-1">
105
+ <a href="Eesha buying folder/product.html?id=${product.id}" class="font-bold text-lg hover:text-amber-600">${escapeHtml(product.name)}</a>
106
+ <div class="flex items-baseline gap-2 mt-1">
107
+ <span class="text-2xl font-extrabold text-amber-600">$${discountPrice}</span>
108
+ <span class="text-gray-400 line-through">$${Number(product.price).toFixed(2)}</span>
109
+ <span class="bg-amber-100 text-amber-800 text-xs font-bold px-2 py-0.5 rounded">-${gb.discount_pct}%</span>
110
+ </div>
111
+ <div class="text-xs text-gray-500 mt-1">Category: ${escapeHtml(product.category || 'General')}</div>
112
+ </div>
113
+ </div>
114
+ ` : ''}
115
+
116
+ <div class="my-6">
117
+ <div class="flex justify-between text-sm font-medium text-gray-700 mb-2">
118
+ <span>${gb.current_count} / ${gb.target_count} joined</span>
119
+ <span>${pct}%</span>
120
+ </div>
121
+ <div class="gb-progress-track">
122
+ <div class="gb-progress-fill" style="width: ${pct}%"></div>
123
+ </div>
124
+ <div class="gb-avatars mt-3">${memberAvatars}${emptyAvatars}</div>
125
+ </div>
126
+
127
+ <div class="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-900">
128
+ <div class="font-semibold mb-1">
129
+ ${isCompleted ? '✅ Group complete — discount unlocked!' :
130
+ isExpired ? '⏰ This group buy has expired.' :
131
+ `⏳ Expires ${new Date(gb.expires_at).toLocaleString()}`}
132
+ </div>
133
+ ${isCompleted ? 'You and your friends can now check out at the discounted price.' : ''}
134
+ </div>
135
+
136
+ <div class="flex gap-2 mt-4">
137
+ ${isCompleted ? `
138
+ <a href="Eesha buying folder/product.html?id=${product?.id || ''}&gb=${gb.id}" class="flex-1 bg-gradient-to-r from-amber-400 to-amber-600 text-ink-900 font-bold text-center py-3 rounded-lg hover:opacity-90">
139
+ <i class="fas fa-shopping-cart"></i> Buy at $${discountPrice}
140
+ </a>
141
+ ` : isExpired ? `
142
+ <button disabled class="flex-1 bg-gray-200 text-gray-500 font-bold py-3 rounded-lg cursor-not-allowed">Expired</button>
143
+ ` : `
144
+ <button onclick="joinGroup('${gb.id}')" class="flex-1 bg-gradient-to-r from-amber-400 to-amber-600 text-ink-900 font-bold py-3 rounded-lg hover:opacity-90">
145
+ <i class="fas fa-plus"></i> Join group buy
146
+ </button>
147
+ `}
148
+ <button onclick="shareGroup('${gb.id}', '${escapeHtml(product?.name || 'this product')}')" class="px-4 bg-green-500 text-white font-bold rounded-lg hover:bg-green-600">
149
+ <i class="fab fa-whatsapp text-lg"></i>
150
+ </button>
151
+ <button onclick="copyGroupLink('${gb.id}')" class="px-4 bg-gray-100 text-gray-700 font-bold rounded-lg hover:bg-gray-200">
152
+ <i class="fas fa-link"></i>
153
+ </button>
154
+ </div>
155
+ `;
156
+ })();
157
+
158
+ async function joinGroup(gbId) {
159
+ // Check login
160
+ const sess = await window.EdgeFunctions.auth.checkSession();
161
+ if (!sess.user) {
162
+ if (confirm('Please login to join the group buy. Go to login page?')) {
163
+ window.location.href = 'Eesha buying folder/login.html?next=' + encodeURIComponent('/group-buy.html?id=' + gbId);
164
+ }
165
+ return;
166
+ }
167
+ const r = await window.EdgeFunctions.groupBuy.join(gbId);
168
+ if (r.success) {
169
+ // Reload to show updated state
170
+ window.location.reload();
171
+ } else {
172
+ alert('Failed to join: ' + (r.error || 'Unknown error'));
173
+ }
174
+ }
175
+
176
+ function shareGroup(gbId, productName) {
177
+ const url = `${window.location.origin}/group-buy.html?id=${gbId}`;
178
+ const text = `🛍️ Join my group buy for "${productName}" on Cellex! Get 20% off when 3 of us join. ${url}`;
179
+ window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank');
180
+ }
181
+
182
+ async function copyGroupLink(gbId) {
183
+ const url = `${window.location.origin}/group-buy.html?id=${gbId}`;
184
+ try {
185
+ await navigator.clipboard.writeText(url);
186
+ alert('Link copied! Share it with friends.');
187
+ } catch {
188
+ window.prompt('Copy this link:', url);
189
+ }
190
+ }
191
+
192
+ function escapeHtml(s) {
193
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
194
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
195
+ }[c]));
196
+ }
197
+ </script>
198
+ </body>
199
+ </html>
index.html CHANGED
@@ -685,6 +685,28 @@
685
  </div>
686
  </section>
687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  <!-- Discover Sellers (social commerce — Phase 1) -->
689
  <section id="discoverSellers" class="mt-5">
690
  <div class="flex items-center justify-between mb-3">
@@ -904,6 +926,8 @@
904
  // Phase 1 — social commerce
905
  loadDiscoverSellers();
906
  loadFollowingFeed();
 
 
907
  }
908
 
909
  // ===== Auth Functions (via edge function) =====
@@ -1327,6 +1351,43 @@
1327
  if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
1328
  return d.toLocaleDateString();
1329
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1330
  </script>
1331
  </body>
1332
  </html>
 
685
  </div>
686
  </section>
687
 
688
+ <!-- Live Now (Phase 2 — Live shopping) -->
689
+ <section id="liveNowSection" class="mt-5 hidden">
690
+ <div class="flex items-center justify-between mb-3">
691
+ <div class="flex items-center gap-2">
692
+ <div class="w-10 h-10 bg-red-500 rounded-xl flex items-center justify-center shadow-glow">
693
+ <i class="fas fa-broadcast-tower text-white text-base"></i>
694
+ </div>
695
+ <div>
696
+ <h2 class="font-display font-extrabold text-base flex items-center gap-2">
697
+ Live Now
698
+ <span class="inline-block w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
699
+ </h2>
700
+ <p class="text-ink-500 text-[11px] -mt-0.5">Watch live demos & buy in real-time</p>
701
+ </div>
702
+ </div>
703
+ <a href="live.html" class="text-xs font-semibold text-brand-600">All live →</a>
704
+ </div>
705
+ <div id="liveNowContainer" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 lg:gap-3">
706
+ <!-- Live sessions loaded dynamically -->
707
+ </div>
708
+ </section>
709
+
710
  <!-- Discover Sellers (social commerce — Phase 1) -->
711
  <section id="discoverSellers" class="mt-5">
712
  <div class="flex items-center justify-between mb-3">
 
926
  // Phase 1 — social commerce
927
  loadDiscoverSellers();
928
  loadFollowingFeed();
929
+ // Phase 2 — live shopping
930
+ loadLiveNow();
931
  }
932
 
933
  // ===== Auth Functions (via edge function) =====
 
1351
  if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
1352
  return d.toLocaleDateString();
1353
  }
1354
+
1355
+ // ====================================================================
1356
+ // Phase 2 — Live shopping "Live Now" carousel
1357
+ // ====================================================================
1358
+ async function loadLiveNow() {
1359
+ const section = document.getElementById('liveNowSection');
1360
+ const container = document.getElementById('liveNowContainer');
1361
+ if (!section || !container) return;
1362
+ try {
1363
+ const resp = await window.EdgeFunctions.live.list('live');
1364
+ if (!resp.success || !resp.sessions || resp.sessions.length === 0) return;
1365
+ section.classList.remove('hidden');
1366
+ container.innerHTML = resp.sessions.slice(0, 6).map(s => {
1367
+ const sellerName = s.seller?.business_name || 'Seller';
1368
+ const sellerImg = s.seller?.profile_image;
1369
+ return `
1370
+ <a href="live-watch.html?id=${s.id}" class="bg-white rounded-2xl border border-ink-100 shadow-card overflow-hidden lift block hover:border-red-400 transition">
1371
+ <div class="aspect-video bg-gradient-to-br from-red-500 to-pink-600 flex items-center justify-center text-white relative">
1372
+ <i class="fas fa-broadcast-tower text-3xl"></i>
1373
+ <span class="absolute top-2 left-2 bg-red-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full uppercase">● Live</span>
1374
+ <span class="absolute top-2 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded-full">👁 ${s.viewer_count || 0}</span>
1375
+ </div>
1376
+ <div class="p-3">
1377
+ <div class="font-bold text-sm line-clamp-1">${escapeHtml(s.title)}</div>
1378
+ <div class="flex items-center gap-1.5 mt-1.5 text-xs text-ink-500">
1379
+ ${sellerImg
1380
+ ? `<img src="${sellerImg}" class="w-4 h-4 rounded-full">`
1381
+ : `<div class="w-4 h-4 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center text-[8px] font-bold text-ink-900">${(sellerName||'?').charAt(0)}</div>`}
1382
+ <span class="truncate">${escapeHtml(sellerName)}</span>
1383
+ </div>
1384
+ </div>
1385
+ </a>`;
1386
+ }).join('');
1387
+ } catch (e) {
1388
+ console.error('liveNow error:', e);
1389
+ }
1390
+ }
1391
  </script>
1392
  </body>
1393
  </html>
js/config/edge-functions.js CHANGED
@@ -233,6 +233,50 @@
233
  feed: (limit = 20, offset = 0) => call('social', { op: 'feed', limit, offset }),
234
  },
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  call,
237
  };
238
 
 
233
  feed: (limit = 20, offset = 0) => call('social', { op: 'feed', limit, offset }),
234
  },
235
 
236
+ // ---- Phase 2: Reviews & Ratings ----
237
+ reviews: {
238
+ byProduct: (productId) => call('reviews', { op: 'by_product', productId }),
239
+ bySeller: (sellerId) => call('reviews', { op: 'by_seller', sellerId }),
240
+ create: (data) => call('reviews', { op: 'create', ...data }),
241
+ helpful: (reviewId) => call('reviews', { op: 'helpful', reviewId }),
242
+ delete: (reviewId) => call('reviews', { op: 'delete', reviewId }),
243
+ },
244
+
245
+ // ---- Phase 2: Group Buying (Pinduoduo) ----
246
+ groupBuy: {
247
+ create: (productId, targetCount = 3, discountPct = 20) =>
248
+ call('group-buy', { op: 'create', productId, targetCount, discountPct }),
249
+ join: (groupBuyId) => call('group-buy', { op: 'join', groupBuyId }),
250
+ status: (groupBuyId) => call('group-buy', { op: 'status', groupBuyId }),
251
+ active: (productId) => call('group-buy', { op: 'active', productId }),
252
+ mine: () => call('group-buy', { op: 'mine' }),
253
+ cancel: (groupBuyId) => call('group-buy', { op: 'cancel', groupBuyId }),
254
+ },
255
+
256
+ // ---- Phase 2: Wishlist Sharing ----
257
+ wishlistShare: {
258
+ share: (title = null) => call('wishlist-share', { op: 'share', title }),
259
+ getShared: (token) => call('wishlist-share', { op: 'get_shared', token }),
260
+ myShares: () => call('wishlist-share', { op: 'my_shares' }),
261
+ revoke: (token) => call('wishlist-share', { op: 'revoke', token }),
262
+ },
263
+
264
+ // ---- Phase 2: Live Shopping (Whatnot) ----
265
+ live: {
266
+ // Public
267
+ list: (status = 'live') => call('live', { op: 'list', status }),
268
+ get: (sessionId) => call('live', { op: 'get', sessionId }),
269
+ messages: (sessionId, afterId = 0) => call('live', { op: 'messages', sessionId, afterId }),
270
+
271
+ // Auth
272
+ start: (data) => call('live', { op: 'start', ...data }),
273
+ end: (sessionId) => call('live', { op: 'end', sessionId }),
274
+ join: (sessionId, name = null) => call('live', { op: 'join', sessionId, name }),
275
+ leave: (sessionId) => call('live', { op: 'leave', sessionId }),
276
+ message: (sessionId, message) => call('live', { op: 'message', sessionId, message }),
277
+ myNext: () => call('live', { op: 'mynext' }),
278
+ },
279
+
280
  call,
281
  };
282
 
live-watch.html ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Live · Cellex</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="stylesheet" href="css/common.css">
10
+ <script src="js/config/edge-functions.js?v=2"></script>
11
+ <style>
12
+ body { background: #0f172a; color: #e2e8f0; margin: 0; }
13
+ .lw-layout {
14
+ display: grid;
15
+ grid-template-columns: 1fr 320px;
16
+ gap: 1rem;
17
+ max-width: 1400px;
18
+ margin: 0 auto;
19
+ padding: 1rem;
20
+ min-height: calc(100vh - 60px);
21
+ }
22
+ @media (max-width: 900px) {
23
+ .lw-layout { grid-template-columns: 1fr; }
24
+ }
25
+ .lw-stream {
26
+ background: #000; border-radius: 12px; aspect-ratio: 16/9;
27
+ display: flex; align-items: center; justify-content: center;
28
+ color: #94a3b8; position: relative; overflow: hidden;
29
+ }
30
+ .lw-stream iframe { width: 100%; height: 100%; border: 0; }
31
+ .lw-chat {
32
+ background: #1e293b; border-radius: 12px;
33
+ display: flex; flex-direction: column;
34
+ height: calc(100vh - 120px); min-height: 400px;
35
+ }
36
+ .lw-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
37
+ .lw-msg { padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.85rem; }
38
+ .lw-msg.system { color: #94a3b8; font-style: italic; font-size: 0.75rem; }
39
+ .lw-msg.purchase { background: rgba(16, 185, 129, 0.1); color: #6ee7b7; font-weight: 600; }
40
+ .lw-msg.join { color: #94a3b8; font-size: 0.75rem; }
41
+ .lw-msg .name { font-weight: 600; color: #fbbf24; }
42
+ .lw-input { padding: 0.75rem; border-top: 1px solid rgba(255,255,255,0.08); display: flex; gap: 0.5rem; }
43
+ .lw-input input {
44
+ flex: 1; padding: 0.5rem 0.75rem;
45
+ background: #0f172a; border: 1px solid rgba(255,255,255,0.1);
46
+ color: #e2e8f0; border-radius: 8px; font-size: 0.85rem;
47
+ }
48
+ .lw-input button { padding: 0 1rem; background: #f59e0b; color: #0f172a; border: none; border-radius: 8px; font-weight: 700; }
49
+ .live-badge {
50
+ display: inline-flex; align-items: center; gap: 4px;
51
+ background: #ef4444; color: #fff;
52
+ padding: 2px 8px; border-radius: 4px;
53
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
54
+ }
55
+ .live-red-dot {
56
+ display: inline-block; width: 8px; height: 8px;
57
+ background: #fff; border-radius: 50%; animation: pulse 1.5s infinite;
58
+ }
59
+ @keyframes pulse {
60
+ 0%, 100% { opacity: 1; }
61
+ 50% { opacity: 0.4; }
62
+ }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div class="lw-layout">
67
+ <!-- Stream + product -->
68
+ <div>
69
+ <div class="lw-stream" id="streamBox">
70
+ <div class="text-center">
71
+ <i class="fas fa-video text-4xl"></i>
72
+ <div class="mt-2 text-sm">Loading live session…</div>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="mt-4 bg-ink-900 rounded-xl p-4" id="streamInfo">
77
+ <div class="flex items-center gap-2 mb-2">
78
+ <span class="live-badge"><span class="live-red-dot"></span> LIVE</span>
79
+ <span class="text-gray-400 text-sm" id="viewerCount">👁 0 viewers</span>
80
+ </div>
81
+ <h1 class="text-xl font-bold text-white" id="streamTitle">Loading…</h1>
82
+ <div class="text-sm text-gray-400 mt-1" id="streamSeller"></div>
83
+ <p class="text-gray-400 text-sm mt-2" id="streamDesc"></p>
84
+ </div>
85
+
86
+ <!-- Featured product -->
87
+ <div class="mt-3 bg-gradient-to-br from-amber-900/40 to-ink-900 border border-amber-600/30 rounded-xl p-4 hidden" id="featuredCard">
88
+ <div class="text-xs font-bold text-amber-400 uppercase tracking-wide mb-2">
89
+ <i class="fas fa-star"></i> Featured product
90
+ </div>
91
+ <div class="flex gap-3">
92
+ <img id="featuredImg" src="" class="w-20 h-20 rounded-lg object-cover bg-gray-700" alt="">
93
+ <div class="flex-1">
94
+ <div class="font-bold text-white" id="featuredName"></div>
95
+ <div class="text-amber-400 text-xl font-bold mt-1" id="featuredPrice"></div>
96
+ <a href="#" id="featuredBuyLink" class="inline-block mt-2 bg-gradient-to-r from-amber-400 to-amber-600 text-ink-900 font-bold text-sm px-4 py-1.5 rounded-lg hover:opacity-90">
97
+ <i class="fas fa-shopping-cart"></i> Buy now
98
+ </a>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- Chat sidebar -->
105
+ <div class="lw-chat">
106
+ <div class="px-3 py-2 border-b border-white/5 text-sm font-bold text-white flex items-center gap-2">
107
+ <i class="fas fa-comments text-amber-400"></i> Live chat
108
+ <span class="ml-auto text-xs text-gray-400" id="chatCount">0</span>
109
+ </div>
110
+ <div class="lw-messages" id="chatMessages">
111
+ <div class="lw-msg system">Connecting to chat…</div>
112
+ </div>
113
+ <div class="lw-input">
114
+ <input type="text" id="chatInput" placeholder="Type a message…" maxlength="280">
115
+ <button onclick="sendMessage()" id="sendBtn">Send</button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ <script>
121
+ let sessionId = null;
122
+ let lastMsgId = 0;
123
+ let pollTimer = null;
124
+ let currentUser = null;
125
+ let hasJoined = false;
126
+
127
+ (async () => {
128
+ const params = new URLSearchParams(location.search);
129
+ sessionId = params.get('id');
130
+ if (!sessionId) {
131
+ alert('No session ID');
132
+ return;
133
+ }
134
+
135
+ // Check auth (optional — can watch anonymously)
136
+ const sess = await window.EdgeFunctions.auth.checkSession();
137
+ currentUser = sess.user || null;
138
+
139
+ // Load session details
140
+ const r = await window.EdgeFunctions.live.get(sessionId);
141
+ if (!r.success || !r.session) {
142
+ document.getElementById('streamBox').innerHTML = '<div class="text-red-400">Live session not found.</div>';
143
+ return;
144
+ }
145
+ const s = r.session;
146
+ document.title = s.title + ' · Cellex Live';
147
+ document.getElementById('streamTitle').textContent = s.title;
148
+ document.getElementById('streamDesc').textContent = s.description || '';
149
+ document.getElementById('streamSeller').textContent =
150
+ (s.seller?.business_name || 'Seller') + ' · started ' + (s.started_at ? new Date(s.started_at).toLocaleString() : '');
151
+ document.getElementById('viewerCount').textContent = '👁 ' + (s.viewer_count || 0) + ' viewers';
152
+
153
+ // Stream embed
154
+ const streamBox = document.getElementById('streamBox');
155
+ if (s.stream_url && s.stream_platform === 'youtube') {
156
+ const embed = s.stream_url.replace('watch?v=', 'embed/').split('&')[0];
157
+ streamBox.innerHTML = `<iframe src="${embed}" allowfullscreen></iframe>`;
158
+ } else if (s.stream_url && s.stream_platform === 'hls') {
159
+ streamBox.innerHTML = `<video src="${s.stream_url}" controls autoplay muted style="width:100%;height:100%;"></video>`;
160
+ } else {
161
+ streamBox.innerHTML = `
162
+ <div class="text-center">
163
+ <i class="fas fa-broadcast-tower text-5xl text-amber-400"></i>
164
+ <div class="mt-3 text-white font-bold">Audio/Text-only live</div>
165
+ <div class="text-gray-400 text-sm mt-1">The seller is broadcasting in text chat mode.</div>
166
+ </div>`;
167
+ }
168
+
169
+ // Featured product
170
+ if (s.featured) {
171
+ const card = document.getElementById('featuredCard');
172
+ card.classList.remove('hidden');
173
+ document.getElementById('featuredImg').src = s.featured.image_url || '';
174
+ document.getElementById('featuredName').textContent = s.featured.name;
175
+ document.getElementById('featuredPrice').textContent = '$' + Number(s.featured.price).toFixed(2);
176
+ document.getElementById('featuredBuyLink').href = 'Eesha buying folder/product.html?id=' + s.featured.id + '&live=' + sessionId;
177
+ }
178
+
179
+ // Auto-join if logged in
180
+ if (currentUser && !hasJoined) {
181
+ const name = currentUser.email ? currentUser.email.split('@')[0] : 'Guest';
182
+ await window.EdgeFunctions.live.join(sessionId, name);
183
+ hasJoined = true;
184
+ }
185
+
186
+ // Start polling messages
187
+ await pollMessages();
188
+ pollTimer = setInterval(pollMessages, 2500);
189
+
190
+ // Mark join in chat
191
+ document.getElementById('chatMessages').innerHTML = '<div class="lw-msg system">You joined the live session.</div>';
192
+
193
+ // Enter to send
194
+ document.getElementById('chatInput').addEventListener('keypress', (e) => {
195
+ if (e.key === 'Enter') sendMessage();
196
+ });
197
+
198
+ // Cleanup on leave
199
+ window.addEventListener('beforeunload', async () => {
200
+ if (currentUser && hasJoined) {
201
+ // Fire-and-forget leave call
202
+ fetch('/api/live', {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({ op: 'leave', sessionId }),
206
+ credentials: 'same-origin',
207
+ });
208
+ }
209
+ });
210
+ })();
211
+
212
+ async function pollMessages() {
213
+ if (!sessionId) return;
214
+ const r = await window.EdgeFunctions.live.messages(sessionId, lastMsgId);
215
+ if (!r.success || !r.messages || r.messages.length === 0) return;
216
+
217
+ const container = document.getElementById('chatMessages');
218
+ r.messages.forEach(msg => {
219
+ if (msg.id <= lastMsgId) return;
220
+ lastMsgId = Math.max(lastMsgId, msg.id);
221
+ const div = document.createElement('div');
222
+ div.className = 'lw-msg ' + (msg.msg_type || 'chat');
223
+ if (msg.msg_type === 'system') {
224
+ div.textContent = msg.message;
225
+ } else if (msg.msg_type === 'join') {
226
+ div.textContent = '👥 ' + msg.message;
227
+ } else if (msg.msg_type === 'purchase') {
228
+ div.innerHTML = '🛍️ ' + escapeHtml(msg.message);
229
+ } else {
230
+ div.innerHTML = '<span class="name">' + escapeHtml(msg.user_name) + ':</span> ' + escapeHtml(msg.message);
231
+ }
232
+ container.appendChild(div);
233
+ });
234
+ // Auto-scroll
235
+ container.scrollTop = container.scrollHeight;
236
+ // Update count
237
+ document.getElementById('chatCount').textContent = lastMsgId;
238
+ }
239
+
240
+ async function sendMessage() {
241
+ const input = document.getElementById('chatInput');
242
+ const msg = input.value.trim();
243
+ if (!msg) return;
244
+ if (!currentUser) {
245
+ if (confirm('Please login to chat. Go to login page?')) {
246
+ window.location.href = 'Eesha buying folder/login.html?next=' + encodeURIComponent('/live-watch.html?id=' + sessionId);
247
+ }
248
+ return;
249
+ }
250
+ input.value = '';
251
+ // Optimistic append
252
+ const container = document.getElementById('chatMessages');
253
+ const div = document.createElement('div');
254
+ div.className = 'lw-msg';
255
+ div.innerHTML = '<span class="name">' + escapeHtml(currentUser.email?.split('@')[0] || 'You') + ':</span> ' + escapeHtml(msg);
256
+ container.appendChild(div);
257
+ container.scrollTop = container.scrollHeight;
258
+
259
+ await window.EdgeFunctions.live.message(sessionId, msg);
260
+ }
261
+
262
+ function escapeHtml(s) {
263
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
264
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
265
+ }[c]));
266
+ }
267
+ </script>
268
+ </body>
269
+ </html>
live.html ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Live Shopping · Cellex</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="stylesheet" href="css/common.css">
10
+ <script src="js/config/edge-functions.js?v=2"></script>
11
+ <style>
12
+ body { background: #0f172a; color: #e2e8f0; }
13
+ .live-hero {
14
+ background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
15
+ padding: 2.5rem 1.5rem;
16
+ border-bottom: 1px solid rgba(255,255,255,0.08);
17
+ }
18
+ .live-red-dot {
19
+ display: inline-block; width: 10px; height: 10px;
20
+ background: #ef4444; border-radius: 50%;
21
+ animation: pulse 1.5s infinite;
22
+ }
23
+ @keyframes pulse {
24
+ 0%, 100% { opacity: 1; }
25
+ 50% { opacity: 0.4; }
26
+ }
27
+ .live-card {
28
+ background: #1e293b; border-radius: 12px; overflow: hidden;
29
+ border: 1px solid rgba(255,255,255,0.06);
30
+ transition: transform 0.15s, border-color 0.15s;
31
+ display: block; color: inherit; text-decoration: none;
32
+ }
33
+ .live-card:hover { transform: translateY(-2px); border-color: #f59e0b; }
34
+ .live-thumb {
35
+ width: 100%; aspect-ratio: 16/9; object-fit: cover;
36
+ background: #0f172a; display: flex; align-items: center; justify-content: center;
37
+ color: #94a3b8; font-size: 2.5rem;
38
+ position: relative;
39
+ }
40
+ .live-thumb .live-badge {
41
+ position: absolute; top: 8px; left: 8px;
42
+ background: #ef4444; color: #fff;
43
+ padding: 2px 8px; border-radius: 4px;
44
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
45
+ }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="header-mount"></div>
50
+ <script>
51
+ // Dark-theme header injection
52
+ fetch('shared/header.html').then(r => r.text()).then(h => {
53
+ document.getElementById('header-mount').innerHTML = h;
54
+ });
55
+ </script>
56
+
57
+ <div class="live-hero">
58
+ <div class="max-w-6xl mx-auto">
59
+ <div class="inline-flex items-center gap-2 bg-red-500/20 text-red-300 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide">
60
+ <span class="live-red-dot"></span> Live Shopping
61
+ </div>
62
+ <h1 class="text-3xl md:text-4xl font-extrabold mt-3 text-white">Shop in real-time with sellers</h1>
63
+ <p class="text-gray-400 mt-2 max-w-2xl">Watch live demos, ask questions in chat, and buy the featured product with one click.</p>
64
+ <a href="seller/go-live.html" class="inline-flex items-center gap-2 mt-4 bg-gradient-to-r from-amber-400 to-amber-600 text-ink-900 font-bold px-4 py-2 rounded-lg text-sm hover:opacity-90">
65
+ <i class="fas fa-video"></i> Go live as a seller
66
+ </a>
67
+ </div>
68
+ </div>
69
+
70
+ <main class="max-w-6xl mx-auto p-4">
71
+ <h2 class="text-xl font-bold text-white mt-4 mb-3">
72
+ <span class="live-red-dot"></span> Live now
73
+ </h2>
74
+ <div id="liveNowGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
75
+ <div class="col-span-full text-center py-8 text-gray-500">
76
+ <i class="fas fa-spinner fa-spin text-2xl"></i>
77
+ <div class="mt-2">Loading live sessions…</div>
78
+ </div>
79
+ </div>
80
+
81
+ <h2 class="text-xl font-bold text-white mt-8 mb-3">Recent sessions</h2>
82
+ <div id="recentGrid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
83
+ </main>
84
+
85
+ <script>
86
+ (async () => {
87
+ // Live now
88
+ const liveResp = await window.EdgeFunctions.live.list('live');
89
+ const liveNow = document.getElementById('liveNowGrid');
90
+ if (liveResp.success && liveResp.sessions.length > 0) {
91
+ liveNow.innerHTML = liveResp.sessions.map(s => liveCard(s, true)).join('');
92
+ } else {
93
+ liveNow.innerHTML = `
94
+ <div class="col-span-full text-center py-12 text-gray-400">
95
+ <i class="fas fa-video-slash text-4xl text-gray-600"></i>
96
+ <div class="mt-2">No live sessions right now.</div>
97
+ <div class="text-xs mt-1">Check back later or follow your favorite sellers for notifications.</div>
98
+ </div>`;
99
+ }
100
+
101
+ // Recent (ended)
102
+ const recentResp = await window.EdgeFunctions.live.list('ended');
103
+ const recent = document.getElementById('recentGrid');
104
+ if (recentResp.success && recentResp.sessions.length > 0) {
105
+ recent.innerHTML = recentResp.sessions.map(s => liveCard(s, false)).join('');
106
+ } else {
107
+ recent.innerHTML = '<div class="col-span-full text-gray-500 text-sm py-4">No recent sessions.</div>';
108
+ }
109
+ })();
110
+
111
+ function liveCard(s, isLive) {
112
+ const sellerName = s.seller?.business_name || 'Seller';
113
+ const sellerImg = s.seller?.profile_image;
114
+ const thumb = s.stream_url && s.stream_platform === 'youtube'
115
+ ? `<div class="live-thumb"><iframe width="100%" height="100%" src="${s.stream_url.replace('watch?v=', 'embed/')}" frameborder="0" allowfullscreen></iframe></div>`
116
+ : `<div class="live-thumb">${s.featured_product_id ? '<i class="fas fa-box"></i>' : '<i class="fas fa-video"></i>'}${isLive ? '<span class="live-badge">LIVE</span>' : ''}</div>`;
117
+ return `
118
+ <a href="live-watch.html?id=${s.id}" class="live-card">
119
+ ${thumb}
120
+ <div class="p-3">
121
+ <div class="font-bold text-white text-sm line-clamp-1">${escapeHtml(s.title)}</div>
122
+ <div class="text-xs text-gray-400 mt-1 flex items-center gap-2">
123
+ ${sellerImg
124
+ ? `<img src="${sellerImg}" class="w-5 h-5 rounded-full">`
125
+ : `<div class="w-5 h-5 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center text-[10px] font-bold text-ink-900">${(sellerName||'?').charAt(0)}</div>`}
126
+ <span>${escapeHtml(sellerName)}</span>
127
+ ${isLive ? `<span class="ml-auto text-red-400">👁 ${s.viewer_count || 0}</span>` : ''}
128
+ </div>
129
+ ${s.description ? `<div class="text-xs text-gray-500 mt-1 line-clamp-2">${escapeHtml(s.description)}</div>` : ''}
130
+ </div>
131
+ </a>`;
132
+ }
133
+
134
+ function escapeHtml(s) {
135
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
136
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
137
+ }[c]));
138
+ }
139
+ </script>
140
+ </body>
141
+ </html>
seller-profile.html CHANGED
@@ -131,6 +131,7 @@
131
  <div class="sp-section">
132
  <div class="sp-tabs">
133
  <a class="sp-tab active" data-tab="products">Products</a>
 
134
  <a class="sp-tab" data-tab="about">About</a>
135
  <a class="sp-tab" data-tab="activity">Activity</a>
136
  </div>
@@ -142,6 +143,11 @@
142
  </div>
143
  </div>
144
 
 
 
 
 
 
145
  <div class="sp-card" id="tab-about" style="display:none;">
146
  <h2>About</h2>
147
  <p id="about-text" style="color:#475569;line-height:1.7;"></p>
@@ -317,12 +323,49 @@
317
  e.preventDefault();
318
  document.querySelectorAll('.sp-tab').forEach(x => x.classList.remove('active'));
319
  t.classList.add('active');
320
- ['products','about','activity'].forEach(name => {
321
  document.getElementById('tab-' + name).style.display = name === t.dataset.tab ? 'block' : 'none';
322
  });
 
 
 
 
 
323
  });
324
  });
325
  })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  </script>
327
  </body>
328
  </html>
 
131
  <div class="sp-section">
132
  <div class="sp-tabs">
133
  <a class="sp-tab active" data-tab="products">Products</a>
134
+ <a class="sp-tab" data-tab="reviews">Reviews</a>
135
  <a class="sp-tab" data-tab="about">About</a>
136
  <a class="sp-tab" data-tab="activity">Activity</a>
137
  </div>
 
143
  </div>
144
  </div>
145
 
146
+ <div class="sp-card" id="tab-reviews" style="display:none;">
147
+ <h2>Customer reviews <span id="reviewsAvgBadge" class="text-sm font-normal text-gray-500"></span></h2>
148
+ <div id="reviews-list" class="sp-loading">Loading reviews…</div>
149
+ </div>
150
+
151
  <div class="sp-card" id="tab-about" style="display:none;">
152
  <h2>About</h2>
153
  <p id="about-text" style="color:#475569;line-height:1.7;"></p>
 
323
  e.preventDefault();
324
  document.querySelectorAll('.sp-tab').forEach(x => x.classList.remove('active'));
325
  t.classList.add('active');
326
+ ['products','reviews','about','activity'].forEach(name => {
327
  document.getElementById('tab-' + name).style.display = name === t.dataset.tab ? 'block' : 'none';
328
  });
329
+ // Lazy-load reviews
330
+ if (t.dataset.tab === 'reviews' && !window._reviewsLoaded) {
331
+ loadSellerReviews(sellerId);
332
+ window._reviewsLoaded = true;
333
+ }
334
  });
335
  });
336
  })();
337
+
338
+ async function loadSellerReviews(sellerId) {
339
+ const r = await window.EdgeFunctions.reviews.bySeller(sellerId);
340
+ const list = document.getElementById('reviews-list');
341
+ list.classList.remove('sp-loading');
342
+ if (!r.success) {
343
+ list.innerHTML = '<div class="sp-loading">Failed to load reviews.</div>';
344
+ return;
345
+ }
346
+ const summary = r.summary;
347
+ document.getElementById('reviewsAvgBadge').textContent =
348
+ summary.count > 0 ? `· ${summary.avg.toFixed(1)} ★ (${summary.count})` : '';
349
+ if (!r.reviews || r.reviews.length === 0) {
350
+ list.innerHTML = '<div class="sp-loading">No reviews yet.</div>';
351
+ return;
352
+ }
353
+ list.innerHTML = r.reviews.map(rev => `
354
+ <div class="activity-item">
355
+ <div class="activity-icon ${rev.rating >= 4 ? 'green' : rev.rating >= 3 ? '' : 'pink'}">
356
+ <i class="fas fa-star"></i>
357
+ </div>
358
+ <div class="activity-body">
359
+ <div class="activity-title">
360
+ ${rev.product ? `<a href="Eesha buying folder/product.html?id=${rev.product.id}" style="color:#d97706;text-decoration:none;">${escapeHtml(rev.product.name)}</a>` : ''}
361
+ — ${'★'.repeat(rev.rating)}${'☆'.repeat(5 - rev.rating)}
362
+ </div>
363
+ ${rev.title ? `<div style="font-weight:500;margin-top:0.15rem;">${escapeHtml(rev.title)}</div>` : ''}
364
+ ${rev.comment ? `<div style="color:#475569;font-size:0.9rem;margin-top:0.15rem;">${escapeHtml(rev.comment)}</div>` : ''}
365
+ <div class="activity-meta">${new Date(rev.created_at).toLocaleDateString()}${rev.verified_purchase ? ' · ✓ Verified purchase' : ''}</div>
366
+ </div>
367
+ </div>`).join('');
368
+ }
369
  </script>
370
  </body>
371
  </html>
seller/academy.html CHANGED
File without changes
seller/css/dashboard.css CHANGED
File without changes
seller/go-live.html ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Go Live · Cellex Seller</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <link rel="stylesheet" href="css/dashboard.css">
9
+ <script src="../js/config/edge-functions.js"></script>
10
+ <script src="js/shared.js"></script>
11
+ </head>
12
+ <body class="seller-page">
13
+ <button class="seller-mobile-toggle" aria-label="Toggle menu"><i class="fas fa-bars"></i></button>
14
+ <aside class="seller-sidebar"></aside>
15
+
16
+ <main class="seller-main">
17
+ <div class="seller-topbar">
18
+ <div>
19
+ <h1>Go Live</h1>
20
+ <div class="subtitle">Start a live shopping session</div>
21
+ </div>
22
+ <a href="../live.html" class="btn-seller secondary">
23
+ <i class="fas fa-video"></i> Watch others
24
+ </a>
25
+ </div>
26
+
27
+ <div class="seller-panel">
28
+ <div class="seller-panel-header"><h2>Start a new session</h2></div>
29
+ <form class="seller-form" id="goLiveForm" style="max-width:560px;">
30
+ <div class="form-group">
31
+ <label for="title">Stream title *</label>
32
+ <input type="text" id="title" required placeholder="e.g. 🔥 Flash sale — Electronics deals!">
33
+ </div>
34
+ <div class="form-group">
35
+ <label for="description">Description</label>
36
+ <textarea id="description" rows="3" placeholder="What will you showcase? What's the special deal?"></textarea>
37
+ </div>
38
+ <div class="form-group">
39
+ <label for="featuredProductId">Featured product (buyers can buy this during the stream)</label>
40
+ <select id="featuredProductId">
41
+ <option value="">Loading your products…</option>
42
+ </select>
43
+ </div>
44
+ <div class="form-group">
45
+ <label for="streamPlatform">Stream source</label>
46
+ <select id="streamPlatform">
47
+ <option value="none">Text-only (chat-only live)</option>
48
+ <option value="youtube">YouTube live</option>
49
+ <option value="hls">HLS video (.m3u8 URL)</option>
50
+ </select>
51
+ </div>
52
+ <div class="form-group" id="streamUrlGroup" style="display:none;">
53
+ <label for="streamUrl">Stream URL</label>
54
+ <input type="url" id="streamUrl" placeholder="https://youtube.com/watch?v=… or https://…/stream.m3u8">
55
+ </div>
56
+ <button type="submit" class="btn-seller" style="margin-top:1rem;">
57
+ <i class="fas fa-broadcast-tower"></i> Go live now
58
+ </button>
59
+ </form>
60
+ </div>
61
+
62
+ <div class="seller-panel hidden" id="liveStatusPanel">
63
+ <div class="seller-panel-header">
64
+ <h2>🔴 You are live now</h2>
65
+ <button class="btn-seller danger small" id="endStreamBtn">
66
+ <i class="fas fa-stop"></i> End stream
67
+ </button>
68
+ </div>
69
+ <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
70
+ <div>
71
+ <div class="stat-label">Title</div>
72
+ <div class="stat-value" style="font-size:1.1rem;" id="liveTitle">—</div>
73
+ </div>
74
+ <div>
75
+ <div class="stat-label">Viewers</div>
76
+ <div class="stat-value" style="font-size:1.1rem;" id="liveViewers">0</div>
77
+ </div>
78
+ <div>
79
+ <div class="stat-label">Duration</div>
80
+ <div class="stat-value" style="font-size:1.1rem;" id="liveDuration">0:00</div>
81
+ </div>
82
+ <div style="margin-left:auto;">
83
+ <a id="watchLink" href="#" target="_blank" class="btn-seller secondary">
84
+ <i class="fas fa-external-link"></i> Open watch page
85
+ </a>
86
+ </div>
87
+ </div>
88
+ <div class="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-900">
89
+ <strong>💡 Tip:</strong> Share this link on WhatsApp so buyers can join:<br>
90
+ <code id="shareLink" style="font-size:0.85rem;"></code>
91
+ </div>
92
+ </div>
93
+ </main>
94
+
95
+ <script>
96
+ let liveSessionId = null;
97
+ let liveStartedAt = null;
98
+ let durationTimer = null;
99
+ let viewerTimer = null;
100
+
101
+ (async () => {
102
+ const user = await window.SellerUI.requireSeller();
103
+ if (!user) return;
104
+ window.SellerUI.mountSidebar('live');
105
+
106
+ // Load seller's products for the featured dropdown
107
+ const prodResp = await window.EdgeFunctions.sellerProducts.list();
108
+ const select = document.getElementById('featuredProductId');
109
+ if (prodResp.success && prodResp.products.length) {
110
+ select.innerHTML = '<option value="">— None —</option>' +
111
+ prodResp.products.map(p => `<option value="${p.id}">${p.name} — $${Number(p.price).toFixed(2)}</option>`).join('');
112
+ } else {
113
+ select.innerHTML = '<option value="">No products yet — add one first</option>';
114
+ }
115
+
116
+ // Stream URL visibility
117
+ document.getElementById('streamPlatform').addEventListener('change', (e) => {
118
+ document.getElementById('streamUrlGroup').style.display = e.target.value === 'none' ? 'none' : 'block';
119
+ });
120
+
121
+ // Check if seller already has an active session
122
+ const nextResp = await window.EdgeFunctions.live.myNext();
123
+ if (nextResp.success && nextResp.session) {
124
+ showLiveStatus(nextResp.session);
125
+ }
126
+
127
+ // Form submit
128
+ document.getElementById('goLiveForm').addEventListener('submit', handleStart);
129
+ document.getElementById('endStreamBtn').addEventListener('click', handleEnd);
130
+ })();
131
+
132
+ async function handleStart(e) {
133
+ e.preventDefault();
134
+ const data = {
135
+ title: document.getElementById('title').value.trim(),
136
+ description: document.getElementById('description').value.trim(),
137
+ streamPlatform: document.getElementById('streamPlatform').value,
138
+ streamUrl: document.getElementById('streamUrl').value.trim() || null,
139
+ };
140
+ const fp = document.getElementById('featuredProductId').value;
141
+ if (fp) data.featuredProductId = Number(fp);
142
+
143
+ if (!data.title) {
144
+ window.SellerUI.toast('Please enter a title', 'error');
145
+ return;
146
+ }
147
+
148
+ const r = await window.EdgeFunctions.live.start(data);
149
+ if (r.success) {
150
+ window.SellerUI.toast('🔴 You are live!');
151
+ showLiveStatus(r.session);
152
+ } else {
153
+ window.SellerUI.toast('Failed to go live: ' + (r.error || ''), 'error');
154
+ }
155
+ }
156
+
157
+ function showLiveStatus(session) {
158
+ liveSessionId = session.id;
159
+ liveStartedAt = session.started_at ? new Date(session.started_at) : new Date();
160
+ document.getElementById('liveStatusPanel').classList.remove('hidden');
161
+ document.getElementById('liveTitle').textContent = session.title;
162
+ const url = `${window.location.origin}/live-watch.html?id=${session.id}`;
163
+ document.getElementById('watchLink').href = url;
164
+ document.getElementById('shareLink').textContent = url;
165
+
166
+ // Duration timer
167
+ if (durationTimer) clearInterval(durationTimer);
168
+ durationTimer = setInterval(() => {
169
+ const secs = Math.floor((Date.now() - liveStartedAt.getTime()) / 1000);
170
+ const mm = Math.floor(secs / 60), ss = secs % 60;
171
+ document.getElementById('liveDuration').textContent = `${mm}:${ss.toString().padStart(2,'0')}`;
172
+ }, 1000);
173
+
174
+ // Viewer poll
175
+ if (viewerTimer) clearInterval(viewerTimer);
176
+ viewerTimer = setInterval(async () => {
177
+ const r = await window.EdgeFunctions.live.get(liveSessionId);
178
+ if (r.success) {
179
+ document.getElementById('liveViewers').textContent = r.session.viewer_count || 0;
180
+ if (r.session.status !== 'live') {
181
+ // Stream ended (e.g. from another tab)
182
+ clearInterval(durationTimer);
183
+ clearInterval(viewerTimer);
184
+ document.getElementById('liveStatusPanel').classList.add('hidden');
185
+ window.SellerUI.toast('Stream ended');
186
+ }
187
+ }
188
+ }, 5000);
189
+ }
190
+
191
+ async function handleEnd() {
192
+ if (!liveSessionId) return;
193
+ if (!confirm('End the live session?')) return;
194
+ const r = await window.EdgeFunctions.live.end(liveSessionId);
195
+ if (r.success) {
196
+ clearInterval(durationTimer);
197
+ clearInterval(viewerTimer);
198
+ document.getElementById('liveStatusPanel').classList.add('hidden');
199
+ liveSessionId = null;
200
+ window.SellerUI.toast('Stream ended');
201
+ } else {
202
+ window.SellerUI.toast('Failed to end: ' + (r.error || ''), 'error');
203
+ }
204
+ }
205
+ </script>
206
+ </body>
207
+ </html>
seller/index.html CHANGED
File without changes
seller/js/shared.js CHANGED
@@ -25,6 +25,7 @@
25
  { key: 'dashboard', href: 'index.html', icon: 'fa-gauge-high', label: 'Dashboard' },
26
  { key: 'products', href: 'products.html', icon: 'fa-box', label: 'Products' },
27
  { key: 'orders', href: 'orders.html', icon: 'fa-receipt', label: 'Orders' },
 
28
  { key: 'profile', href: 'profile.html', icon: 'fa-store', label: 'Seller Profile' },
29
  { key: 'academy', href: 'academy.html', icon: 'fa-graduation-cap', label: 'Academy' },
30
  { key: 'settings', href: 'settings.html', icon: 'fa-gear', label: 'Settings' },
 
25
  { key: 'dashboard', href: 'index.html', icon: 'fa-gauge-high', label: 'Dashboard' },
26
  { key: 'products', href: 'products.html', icon: 'fa-box', label: 'Products' },
27
  { key: 'orders', href: 'orders.html', icon: 'fa-receipt', label: 'Orders' },
28
+ { key: 'live', href: 'go-live.html', icon: 'fa-broadcast-tower',label: 'Go Live' },
29
  { key: 'profile', href: 'profile.html', icon: 'fa-store', label: 'Seller Profile' },
30
  { key: 'academy', href: 'academy.html', icon: 'fa-graduation-cap', label: 'Academy' },
31
  { key: 'settings', href: 'settings.html', icon: 'fa-gear', label: 'Settings' },
seller/orders.html CHANGED
File without changes
seller/products.html CHANGED
File without changes
seller/profile.html CHANGED
File without changes
seller/settings.html CHANGED
File without changes
shared-wishlist.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Shared Wishlist · Cellex</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="stylesheet" href="css/common.css">
10
+ <script src="js/config/edge-functions.js?v=2"></script>
11
+ <style>
12
+ body { background: #f8fafc; }
13
+ .sw-hero {
14
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
15
+ padding: 2.5rem 1.5rem;
16
+ border-bottom: 1px solid #fcd34d;
17
+ }
18
+ .sw-product-card {
19
+ background: #fff; border-radius: 12px;
20
+ border: 1px solid #e2e8f0; overflow: hidden;
21
+ transition: transform 0.15s, box-shadow 0.15s;
22
+ display: block; color: inherit; text-decoration: none;
23
+ }
24
+ .sw-product-card:hover { transform: translateY(-2px); box-shadow: 0 6px 14px rgba(0,0,0,0.08); }
25
+ .sw-product-card img, .sw-product-card .ph {
26
+ width: 100%; aspect-ratio: 1; object-fit: cover;
27
+ background: #f1f5f9; display: flex; align-items: center; justify-content: center;
28
+ color: #94a3b8; font-size: 2rem;
29
+ }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <div id="header-mount"></div>
34
+ <script>fetch('shared/header.html').then(r=>r.text()).then(h=>document.getElementById('header-mount').innerHTML=h);</script>
35
+
36
+ <div class="sw-hero">
37
+ <div class="max-w-5xl mx-auto">
38
+ <div class="inline-flex items-center gap-2 bg-amber-500 text-ink-900 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide">
39
+ <i class="fas fa-gift"></i> Shared Wishlist
40
+ </div>
41
+ <h1 class="text-2xl md:text-3xl font-extrabold mt-3" id="sw-title">Loading…</h1>
42
+ <div class="text-sm text-gray-700 mt-1" id="sw-meta"></div>
43
+ </div>
44
+ </div>
45
+
46
+ <main class="max-w-5xl mx-auto p-4">
47
+ <div id="sw-content" class="py-8 text-center text-gray-500">
48
+ <i class="fas fa-spinner fa-spin text-2xl"></i>
49
+ <div class="mt-2">Loading wishlist…</div>
50
+ </div>
51
+ </main>
52
+
53
+ <script>
54
+ (async () => {
55
+ const params = new URLSearchParams(location.search);
56
+ const token = params.get('token');
57
+ if (!token) {
58
+ document.getElementById('sw-content').innerHTML = '<div class="py-8 text-red-500">No share token provided.</div>';
59
+ return;
60
+ }
61
+ const r = await window.EdgeFunctions.wishlistShare.getShared(token);
62
+ if (!r.success) {
63
+ document.getElementById('sw-content').innerHTML = `<div class="py-8 text-red-500">${escapeHtml(r.error || 'Share link not found.')}</div>`;
64
+ return;
65
+ }
66
+ document.getElementById('sw-title').textContent = r.wishlist.title;
67
+ document.getElementById('sw-meta').textContent =
68
+ `Shared on ${new Date(r.wishlist.created_at).toLocaleDateString()} · ${r.wishlist.view_count} view${r.wishlist.view_count !== 1 ? 's' : ''}`;
69
+
70
+ const items = r.items || [];
71
+ if (items.length === 0) {
72
+ document.getElementById('sw-content').innerHTML = '<div class="py-12 text-center text-gray-500"><i class="fas fa-heart text-3xl"></i><div class="mt-2">This wishlist is empty.</div></div>';
73
+ return;
74
+ }
75
+
76
+ document.getElementById('sw-content').className = 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 py-6';
77
+ document.getElementById('sw-content').innerHTML = items.map(it => {
78
+ const p = it.product;
79
+ if (!p) return '';
80
+ return `
81
+ <a href="Eesha buying folder/product.html?id=${p.id}" class="sw-product-card">
82
+ ${p.image_url
83
+ ? `<img src="${p.image_url}" alt="">`
84
+ : `<div class="ph"><i class="fas fa-box"></i></div>`}
85
+ <div class="p-3">
86
+ <div class="font-medium text-sm line-clamp-2 h-10 overflow-hidden">${escapeHtml(p.name)}</div>
87
+ <div class="text-amber-600 font-bold mt-1">$${Number(p.price).toFixed(2)}</div>
88
+ <div class="text-xs text-gray-500 mt-0.5">${escapeHtml(p.category || '')}</div>
89
+ </div>
90
+ </a>`;
91
+ }).join('');
92
+ })();
93
+
94
+ function escapeHtml(s) {
95
+ return String(s ?? '').replace(/[&<>"']/g, c => ({
96
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
97
+ }[c]));
98
+ }
99
+ </script>
100
+ </body>
101
+ </html>
supabase/functions/group-buy/index.ts ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="deno.ns" />
2
+ // Cellex Group Buy Edge Function (Phase 2 — Pinduoduo model)
3
+ // -----------------------------------------------------------
4
+ // A buyer initiates a group buy for a product. They share a link. Friends join.
5
+ // When target_count is reached, the group is "completed" and the discount is
6
+ // applied automatically at checkout.
7
+ //
8
+ // API:
9
+ // op=create body: { productId, targetCount?, discountPct? } → { groupBuy }
10
+ // op=join body: { groupBuyId } → { groupBuy, joined: true }
11
+ // op=status body: { groupBuyId } → { groupBuy, members: [...] }
12
+ // op=active body: { productId } → { groupBuys: [...] } (open groups for this product)
13
+ // op=mine → { groupBuys: [...] } (groups I started or joined)
14
+ // op=cancel body: { groupBuyId } → { success } (initiator only)
15
+
16
+ import {
17
+ corsHeaders, jsonResponse, errorResponse, getUser,
18
+ } from '../_shared/cors.ts';
19
+
20
+ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
21
+ const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
22
+
23
+ const adminHeaders = {
24
+ 'apikey': SERVICE_KEY,
25
+ 'Authorization': `Bearer ${SERVICE_KEY}`,
26
+ 'Content-Type': 'application/json',
27
+ };
28
+
29
+ Deno.serve(async (req: Request) => {
30
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
31
+ if (req.method !== 'POST') return errorResponse('Method not allowed', 405);
32
+
33
+ try {
34
+ const body = await req.json().catch(() => ({}));
35
+ const op = body.op || 'active';
36
+
37
+ // Public ops
38
+ if (op === 'status') return await handleStatus(body);
39
+
40
+ // Auth-required ops
41
+ const user = await getUser(req);
42
+ if (!user) return errorResponse('Not authenticated', 401);
43
+
44
+ switch (op) {
45
+ case 'create': return await handleCreate(user.id, body);
46
+ case 'join': return await handleJoin(user.id, body);
47
+ case 'active': return await handleActive(body);
48
+ case 'mine': return await handleMine(user.id);
49
+ case 'cancel': return await handleCancel(user.id, body);
50
+ default: return errorResponse(`Unknown op: ${op}`, 400);
51
+ }
52
+ } catch (error) {
53
+ console.error('group-buy error:', error);
54
+ return errorResponse(`Internal error: ${error instanceof Error ? error.message : String(error)}`, 500);
55
+ }
56
+ });
57
+
58
+ async function handleCreate(userId: string, body: Record<string, unknown>): Promise<Response> {
59
+ const productId = Number(body.productId);
60
+ if (!productId) return errorResponse('productId is required', 400);
61
+ const targetCount = Math.min(Math.max(Number(body.targetCount) || 3, 2), 50);
62
+ const discountPct = Math.min(Math.max(Number(body.discountPct) || 20, 1), 90);
63
+
64
+ // Look up the product to get seller_id and check it exists
65
+ const prodResp = await fetch(
66
+ `${SUPABASE_URL}/rest/v1/products?id=eq.${productId}&select=id,seller_id,name,price`,
67
+ { headers: adminHeaders }
68
+ );
69
+ const products = await prodResp.json();
70
+ if (!products || products.length === 0) return errorResponse('Product not found', 404);
71
+ const product = products[0];
72
+
73
+ // Create group buy
74
+ const createResp = await fetch(`${SUPABASE_URL}/rest/v1/group_buys`, {
75
+ method: 'POST',
76
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
77
+ body: JSON.stringify({
78
+ product_id: productId,
79
+ seller_id: product.seller_id,
80
+ initiator_id: userId,
81
+ target_count: targetCount,
82
+ current_count: 1,
83
+ discount_pct: discountPct,
84
+ status: 'open',
85
+ }),
86
+ });
87
+ const created = await createResp.json();
88
+ if (!createResp.ok) return errorResponse('Failed to create group buy', 500);
89
+ const groupBuyId = created[0].id;
90
+
91
+ // Add initiator as first member
92
+ await fetch(`${SUPABASE_URL}/rest/v1/group_buy_members`, {
93
+ method: 'POST',
94
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
95
+ body: JSON.stringify({
96
+ group_buy_id: groupBuyId,
97
+ user_id: userId,
98
+ }),
99
+ });
100
+
101
+ // Re-fetch the group buy (current_count may have been updated by trigger)
102
+ const refreshed = await fetch(
103
+ `${SUPABASE_URL}/rest/v1/group_buys?id=eq.${encodeURIComponent(groupBuyId)}&select=*`,
104
+ { headers: adminHeaders }
105
+ );
106
+ const freshRows = await refreshed.json();
107
+
108
+ return jsonResponse({ success: true, groupBuy: freshRows?.[0] || created[0], product });
109
+ }
110
+
111
+ async function handleJoin(userId: string, body: Record<string, unknown>): Promise<Response> {
112
+ const groupBuyId = body.groupBuyId as string;
113
+ if (!groupBuyId) return errorResponse('groupBuyId is required', 400);
114
+
115
+ // Check the group buy is still open and not expired
116
+ const gbResp = await fetch(
117
+ `${SUPABASE_URL}/rest/v1/group_buys?id=eq.${encodeURIComponent(groupBuyId)}&select=*`,
118
+ { headers: adminHeaders }
119
+ );
120
+ const gbs = await gbResp.json();
121
+ if (!gbs || gbs.length === 0) return errorResponse('Group buy not found', 404);
122
+ const gb = gbs[0];
123
+ if (gb.status !== 'open') return errorResponse(`Group buy is ${gb.status}`, 400);
124
+ if (new Date(gb.expires_at) < new Date()) return errorResponse('Group buy has expired', 400);
125
+
126
+ // Insert member (UNIQUE constraint handles duplicates — return success either way)
127
+ const insResp = await fetch(`${SUPABASE_URL}/rest/v1/group_buy_members`, {
128
+ method: 'POST',
129
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
130
+ body: JSON.stringify({
131
+ group_buy_id: groupBuyId,
132
+ user_id: userId,
133
+ }),
134
+ });
135
+ // 409 = duplicate, that's fine
136
+ const alreadyMember = insResp.status === 409;
137
+
138
+ // Re-fetch the updated group buy
139
+ const refreshed = await fetch(
140
+ `${SUPABASE_URL}/rest/v1/group_buys?id=eq.${encodeURIComponent(groupBuyId)}&select=*`,
141
+ { headers: adminHeaders }
142
+ );
143
+ const fresh = (await refreshed.json())?.[0];
144
+
145
+ return jsonResponse({ success: true, groupBuy: fresh, alreadyMember });
146
+ }
147
+
148
+ async function handleStatus(body: Record<string, unknown>): Promise<Response> {
149
+ const groupBuyId = body.groupBuyId as string;
150
+ if (!groupBuyId) return errorResponse('groupBuyId is required', 400);
151
+
152
+ const [gbResp, membersResp, productResp] = await Promise.all([
153
+ fetch(`${SUPABASE_URL}/rest/v1/group_buys?id=eq.${encodeURIComponent(groupBuyId)}&select=*`, { headers: adminHeaders }),
154
+ fetch(`${SUPABASE_URL}/rest/v1/group_buy_members?group_buy_id=eq.${encodeURIComponent(groupBuyId)}&select=user_id,joined_at&order=joined_at.asc`, { headers: adminHeaders }),
155
+ ]).then(async ([a, b]) => {
156
+ const gb = await a.json();
157
+ const members = await b.json();
158
+ // Need product info
159
+ if (gb?.[0]?.product_id) {
160
+ const pid = gb[0].product_id;
161
+ const pResp = await fetch(`${SUPABASE_URL}/rest/v1/products?id=eq.${pid}&select=id,name,price,image_url,seller_id`, { headers: adminHeaders });
162
+ const p = await pResp.json();
163
+ return [gb, members, p?.[0] || null];
164
+ }
165
+ return [gb, members, null];
166
+ });
167
+
168
+ return jsonResponse({
169
+ success: true,
170
+ groupBuy: gbResp?.[0] || null,
171
+ members: membersResp || [],
172
+ product: productResp,
173
+ });
174
+ }
175
+
176
+ async function handleActive(body: Record<string, unknown>): Promise<Response> {
177
+ const productId = Number(body.productId);
178
+ if (!productId) return errorResponse('productId is required', 400);
179
+
180
+ const resp = await fetch(
181
+ `${SUPABASE_URL}/rest/v1/group_buys?product_id=eq.${productId}&status=eq.open&expires_at=gt.${encodeURIComponent(new Date().toISOString())}&select=*&order=created_at.desc&limit=10`,
182
+ { headers: adminHeaders }
183
+ );
184
+ const gbs = await resp.json();
185
+ return jsonResponse({ success: true, groupBuys: gbs || [] });
186
+ }
187
+
188
+ async function handleMine(userId: string): Promise<Response> {
189
+ // Groups I started OR joined
190
+ const [initResp, memberResp] = await Promise.all([
191
+ fetch(`${SUPABASE_URL}/rest/v1/group_buys?initiator_id=eq.${encodeURIComponent(userId)}&select=*&order=created_at.desc`, { headers: adminHeaders }),
192
+ fetch(`${SUPABASE_URL}/rest/v1/group_buy_members?user_id=eq.${encodeURIComponent(userId)}&select=group_buy_id`, { headers: adminHeaders }),
193
+ ]);
194
+ const initiated = await initResp.json();
195
+ const memberships = await memberResp.json();
196
+
197
+ // Fetch group buy details for memberships
198
+ const gbIds = (memberships || []).map((m: Record<string, unknown>) => m.group_buy_id);
199
+ let joined: Record<string, unknown>[] = [];
200
+ if (gbIds.length > 0) {
201
+ const joinedResp = await fetch(
202
+ `${SUPABASE_URL}/rest/v1/group_buys?id=in.(${gbIds.join(',')})&select=*&order=created_at.desc`,
203
+ { headers: adminHeaders }
204
+ );
205
+ joined = await joinedResp.json();
206
+ }
207
+
208
+ // Merge and dedupe
209
+ const allMap = new Map<string, Record<string, unknown>>();
210
+ [...(initiated || []), ...joined].forEach((gb: Record<string, unknown>) => {
211
+ allMap.set(gb.id as string, gb);
212
+ });
213
+
214
+ return jsonResponse({ success: true, groupBuys: Array.from(allMap.values()) });
215
+ }
216
+
217
+ async function handleCancel(userId: string, body: Record<string, unknown>): Promise<Response> {
218
+ const groupBuyId = body.groupBuyId as string;
219
+ if (!groupBuyId) return errorResponse('groupBuyId is required', 400);
220
+
221
+ // Only initiator can cancel
222
+ const resp = await fetch(
223
+ `${SUPABASE_URL}/rest/v1/group_buys?id=eq.${encodeURIComponent(groupBuyId)}&initiator_id=eq.${encodeURIComponent(userId)}&select=id`,
224
+ { headers: adminHeaders }
225
+ );
226
+ const rows = await resp.json();
227
+ if (!rows || rows.length === 0) return errorResponse('Not authorized to cancel this group buy', 403);
228
+
229
+ await fetch(
230
+ `${SUPABASE_URL}/rest/v1/group_buys?id=eq.${encodeURIComponent(groupBuyId)}`,
231
+ { method: 'PATCH', headers: { ...adminHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'cancelled' }) }
232
+ );
233
+
234
+ return jsonResponse({ success: true });
235
+ }
supabase/functions/live/index.ts ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="deno.ns" />
2
+ // Cellex Live Shopping Edge Function (Phase 2 — Whatnot model)
3
+ // --------------------------------------------------------------
4
+ // Sellers go live with a video stream (YouTube/HLS/Twitch URL) or text-only.
5
+ // Buyers join, chat in real time, and click "Buy" to purchase the featured product.
6
+ //
7
+ // For MVP, real-time is via polling (every 2-3 seconds) instead of WebSocket
8
+ // to keep infra simple. Buyers send chat messages; everyone polls.
9
+ //
10
+ // WhatsApp integration: the WhatsApp bot (Render) calls /live op=whatsapp_buy
11
+ // when a user texts "buy" during an active session.
12
+ //
13
+ // API:
14
+ // op=list body: { status? } → { sessions: [...] } (public)
15
+ // op=get body: { sessionId } → { session, viewers: [...] } (public)
16
+ // op=messages body: { sessionId, afterId? } → { messages: [...] } (public)
17
+ //
18
+ // op=start body: { title, description?, streamUrl?, streamPlatform?, featuredProductId? } (seller)
19
+ // op=end body: { sessionId } (seller)
20
+ // op=join body: { sessionId, name? } (auth)
21
+ // op=leave body: { sessionId } (auth)
22
+ // op=message body: { sessionId, message } (auth)
23
+ // op=mynext (seller — get seller's next session)
24
+ //
25
+ // op=whatsapp_buy body: { sessionId, phone, name } (called by WhatsApp bot)
26
+
27
+ import {
28
+ corsHeaders, jsonResponse, errorResponse, getUser,
29
+ } from '../_shared/cors.ts';
30
+
31
+ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
32
+ const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
33
+
34
+ const adminHeaders = {
35
+ 'apikey': SERVICE_KEY,
36
+ 'Authorization': `Bearer ${SERVICE_KEY}`,
37
+ 'Content-Type': 'application/json',
38
+ };
39
+
40
+ Deno.serve(async (req: Request) => {
41
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
42
+ if (req.method !== 'POST') return errorResponse('Method not allowed', 405);
43
+
44
+ try {
45
+ const body = await req.json().catch(() => ({}));
46
+ const op = body.op || 'list';
47
+
48
+ // Public ops
49
+ if (op === 'list') return await handleList(body);
50
+ if (op === 'get') return await handleGet(body);
51
+ if (op === 'messages') return await handleMessages(body);
52
+ if (op === 'whatsapp_buy') return await handleWhatsappBuy(body);
53
+
54
+ // Auth-required ops
55
+ const user = await getUser(req);
56
+ if (!user) return errorResponse('Not authenticated', 401);
57
+
58
+ switch (op) {
59
+ case 'start': return await handleStart(user.id, body);
60
+ case 'end': return await handleEnd(user.id, body);
61
+ case 'join': return await handleJoin(user.id, body);
62
+ case 'leave': return await handleLeave(user.id, body);
63
+ case 'message': return await handleMessage(user.id, body);
64
+ case 'mynext': return await handleMyNext(user.id);
65
+ default: return errorResponse(`Unknown op: ${op}`, 400);
66
+ }
67
+ } catch (error) {
68
+ console.error('live error:', error);
69
+ return errorResponse(`Internal error: ${error instanceof Error ? error.message : String(error)}`, 500);
70
+ }
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ async function handleList(body: Record<string, unknown>): Promise<Response> {
75
+ const status = (body.status as string) || 'live';
76
+ const resp = await fetch(
77
+ `${SUPABASE_URL}/rest/v1/shop_live_sessions?status=eq.${status}&select=id,seller_id,title,description,stream_url,stream_platform,featured_product_id,viewer_count,started_at,ended_at,created_at&order=started_at.desc&limit=50`,
78
+ { headers: adminHeaders }
79
+ );
80
+ const sessions = await resp.json();
81
+
82
+ // Enrich with seller info + featured product
83
+ const sellerIds = Array.from(new Set((sessions || []).map((s: Record<string, unknown>) => s.seller_id)));
84
+ let sellerMap = new Map<string, Record<string, unknown>>();
85
+ if (sellerIds.length > 0) {
86
+ const sellersResp = await fetch(`${SUPABASE_URL}/rest/v1/sellers?id=in.(${sellerIds.join(',')})&select=id,business_name,profile_image`, { headers: adminHeaders });
87
+ (await sellersResp.json()).forEach((s: Record<string, unknown>) => sellerMap.set(s.id as string, s));
88
+ }
89
+
90
+ const enriched = (sessions || []).map((s: Record<string, unknown>) => ({
91
+ ...s,
92
+ seller: sellerMap.get(s.seller_id as string) || null,
93
+ }));
94
+
95
+ return jsonResponse({ success: true, sessions: enriched });
96
+ }
97
+
98
+ async function handleGet(body: Record<string, unknown>): Promise<Response> {
99
+ const sessionId = body.sessionId as string;
100
+ if (!sessionId) return errorResponse('sessionId is required', 400);
101
+
102
+ const [sessResp, viewersResp, featuredResp] = await Promise.all([
103
+ fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions?id=eq.${encodeURIComponent(sessionId)}&select=*`, { headers: adminHeaders }),
104
+ fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers?live_session_id=eq.${encodeURIComponent(sessionId)}&select=user_id,user_name,joined_at&order=joined_at.desc&limit=100`, { headers: adminHeaders }),
105
+ ]);
106
+
107
+ const sessions = await sessResp.json();
108
+ const viewers = await viewersResp.json();
109
+ if (!sessions || sessions.length === 0) return errorResponse('Live session not found', 404);
110
+ const session = sessions[0];
111
+
112
+ // Featured product
113
+ let featured = null;
114
+ if (session.featured_product_id) {
115
+ const pResp = await fetch(`${SUPABASE_URL}/rest/v1/products?id=eq.${session.featured_product_id}&select=id,name,price,image_url,seller_id`, { headers: adminHeaders });
116
+ featured = (await pResp.json())?.[0] || null;
117
+ }
118
+
119
+ // Seller info
120
+ const sellerResp = await fetch(`${SUPABASE_URL}/rest/v1/sellers?id=eq.${encodeURIComponent(session.seller_id)}&select=id,business_name,profile_image`, { headers: adminHeaders });
121
+ const seller = (await sellerResp.json())?.[0] || null;
122
+
123
+ return jsonResponse({
124
+ success: true,
125
+ session: { ...session, seller, featured },
126
+ viewers: viewers || [],
127
+ });
128
+ }
129
+
130
+ async function handleMessages(body: Record<string, unknown>): Promise<Response> {
131
+ const sessionId = body.sessionId as string;
132
+ if (!sessionId) return errorResponse('sessionId is required', 400);
133
+ const afterId = Number(body.afterId) || 0;
134
+ const limit = Math.min(Number(body.limit) || 50, 200);
135
+
136
+ const resp = await fetch(
137
+ `${SUPABASE_URL}/rest/v1/shop_live_messages?live_session_id=eq.${encodeURIComponent(sessionId)}&id=gt.${afterId}&select=*&order=id.desc&limit=${limit}`,
138
+ { headers: adminHeaders }
139
+ );
140
+ const messages = await resp.json();
141
+ // Reverse so oldest is first
142
+ return jsonResponse({ success: true, messages: (messages || []).reverse() });
143
+ }
144
+
145
+ async function handleStart(userId: string, body: Record<string, unknown>): Promise<Response> {
146
+ const title = (body.title as string)?.trim();
147
+ if (!title) return errorResponse('title is required', 400);
148
+
149
+ // Verify the user has a seller row
150
+ const sellerResp = await fetch(`${SUPABASE_URL}/rest/v1/sellers?id=eq.${encodeURIComponent(userId)}&select=id,business_name`, { headers: adminHeaders });
151
+ const sellers = await sellerResp.json();
152
+ if (!sellers || sellers.length === 0) return errorResponse('Only sellers can go live', 403);
153
+
154
+ const payload: Record<string, unknown> = {
155
+ seller_id: userId,
156
+ title,
157
+ description: (body.description as string) || '',
158
+ stream_url: (body.streamUrl as string) || null,
159
+ stream_platform: (body.streamPlatform as string) || 'none',
160
+ status: 'live',
161
+ started_at: new Date().toISOString(),
162
+ viewer_count: 1,
163
+ };
164
+ if (body.featuredProductId) payload.featured_product_id = Number(body.featuredProductId);
165
+
166
+ const resp = await fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions`, {
167
+ method: 'POST',
168
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
169
+ body: JSON.stringify(payload),
170
+ });
171
+ const created = await resp.json();
172
+ if (!resp.ok) return errorResponse('Failed to start live session', 500);
173
+ const session = created[0];
174
+
175
+ // Auto-join seller as a viewer
176
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers`, {
177
+ method: 'POST',
178
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
179
+ body: JSON.stringify({
180
+ live_session_id: session.id,
181
+ user_id: userId,
182
+ user_name: sellers[0].business_name || 'Host',
183
+ }),
184
+ });
185
+
186
+ // System message
187
+ await postSystemMessage(session.id, `🔴 Live now: ${title}`);
188
+
189
+ return jsonResponse({ success: true, session });
190
+ }
191
+
192
+ async function handleEnd(userId: string, body: Record<string, unknown>): Promise<Response> {
193
+ const sessionId = body.sessionId as string;
194
+ if (!sessionId) return errorResponse('sessionId is required', 400);
195
+
196
+ // Verify ownership
197
+ const check = await fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions?id=eq.${encodeURIComponent(sessionId)}&seller_id=eq.${encodeURIComponent(userId)}&select=id`, { headers: adminHeaders });
198
+ if (!(await check.json())?.length) return errorResponse('Not authorized to end this session', 403);
199
+
200
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions?id=eq.${encodeURIComponent(sessionId)}`, {
201
+ method: 'PATCH',
202
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ status: 'ended', ended_at: new Date().toISOString() }),
204
+ });
205
+
206
+ await postSystemMessage(sessionId, '⚪ Live session ended.');
207
+
208
+ // Clear viewers
209
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers?live_session_id=eq.${encodeURIComponent(sessionId)}`, {
210
+ method: 'DELETE', headers: adminHeaders,
211
+ });
212
+
213
+ return jsonResponse({ success: true });
214
+ }
215
+
216
+ async function handleJoin(userId: string, body: Record<string, unknown>): Promise<Response> {
217
+ const sessionId = body.sessionId as string;
218
+ if (!sessionId) return errorResponse('sessionId is required', 400);
219
+
220
+ const name = (body.name as string) || 'Guest';
221
+
222
+ // Insert viewer (UNIQUE constraint dedupes)
223
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers`, {
224
+ method: 'POST',
225
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
226
+ body: JSON.stringify({
227
+ live_session_id: sessionId,
228
+ user_id: userId,
229
+ user_name: name,
230
+ }),
231
+ }).catch(() => {}); // ignore duplicate errors
232
+
233
+ // Update viewer_count
234
+ const countResp = await fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers?live_session_id=eq.${encodeURIComponent(sessionId)}&select=id`, { headers: adminHeaders });
235
+ const count = (await countResp.json()).length;
236
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions?id=eq.${encodeURIComponent(sessionId)}`, {
237
+ method: 'PATCH',
238
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({ viewer_count: count }),
240
+ });
241
+
242
+ // System message
243
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_messages`, {
244
+ method: 'POST',
245
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({
247
+ live_session_id: sessionId,
248
+ user_id: userId,
249
+ user_name: name,
250
+ msg_type: 'join',
251
+ message: `${name} joined`,
252
+ }),
253
+ });
254
+
255
+ return jsonResponse({ success: true, viewer_count: count });
256
+ }
257
+
258
+ async function handleLeave(userId: string, body: Record<string, unknown>): Promise<Response> {
259
+ const sessionId = body.sessionId as string;
260
+ if (!sessionId) return errorResponse('sessionId is required', 400);
261
+
262
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers?live_session_id=eq.${encodeURIComponent(sessionId)}&user_id=eq.${encodeURIComponent(userId)}`, {
263
+ method: 'DELETE', headers: adminHeaders,
264
+ });
265
+
266
+ // Update count
267
+ const countResp = await fetch(`${SUPABASE_URL}/rest/v1/shop_live_viewers?live_session_id=eq.${encodeURIComponent(sessionId)}&select=id`, { headers: adminHeaders });
268
+ const count = (await countResp.json()).length;
269
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions?id=eq.${encodeURIComponent(sessionId)}`, {
270
+ method: 'PATCH',
271
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
272
+ body: JSON.stringify({ viewer_count: count }),
273
+ });
274
+
275
+ return jsonResponse({ success: true, viewer_count: count });
276
+ }
277
+
278
+ async function handleMessage(userId: string, body: Record<string, unknown>): Promise<Response> {
279
+ const sessionId = body.sessionId as string;
280
+ const message = (body.message as string)?.trim();
281
+ if (!sessionId || !message) return errorResponse('sessionId and message are required', 400);
282
+
283
+ // Get user name from sellers table (works for both buyers and sellers since the
284
+ // seller row is auto-provisioned for any auth user on first seller-dashboard call)
285
+ let userName = 'Guest';
286
+ const sellerResp = await fetch(`${SUPABASE_URL}/rest/v1/sellers?id=eq.${encodeURIComponent(userId)}&select=business_name,email`, { headers: adminHeaders });
287
+ const sellers = await sellerResp.json();
288
+ if (sellers?.[0]) {
289
+ userName = sellers[0].business_name || (sellers[0].email || 'Guest').split('@')[0];
290
+ }
291
+
292
+ const resp = await fetch(`${SUPABASE_URL}/rest/v1/shop_live_messages`, {
293
+ method: 'POST',
294
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
295
+ body: JSON.stringify({
296
+ live_session_id: sessionId,
297
+ user_id: userId,
298
+ user_name: userName,
299
+ msg_type: 'chat',
300
+ message,
301
+ }),
302
+ });
303
+ const created = await resp.json();
304
+ return jsonResponse({ success: true, message: created?.[0] });
305
+ }
306
+
307
+ async function handleMyNext(userId: string): Promise<Response> {
308
+ // Get seller's most recent live session (live or scheduled)
309
+ const resp = await fetch(
310
+ `${SUPABASE_URL}/rest/v1/shop_live_sessions?seller_id=eq.${encodeURIComponent(userId)}&status=in.(live,scheduled)&select=*&order=created_at.desc&limit=1`,
311
+ { headers: adminHeaders }
312
+ );
313
+ const sessions = await resp.json();
314
+ return jsonResponse({ success: true, session: sessions?.[0] || null });
315
+ }
316
+
317
+ // WhatsApp "buy" command — called by the WhatsApp bot when a user texts "buy"
318
+ // during a live session. We record a purchase message in the chat and return
319
+ // the checkout URL so the bot can send it back to the user.
320
+ async function handleWhatsappBuy(body: Record<string, unknown>): Promise<Response> {
321
+ const sessionId = body.sessionId as string;
322
+ const phone = (body.phone as string)?.trim();
323
+ const name = (body.name as string) || 'WhatsApp buyer';
324
+ if (!sessionId || !phone) return errorResponse('sessionId and phone are required', 400);
325
+
326
+ // Verify session is live
327
+ const sessResp = await fetch(`${SUPABASE_URL}/rest/v1/shop_live_sessions?id=eq.${encodeURIComponent(sessionId)}&status=eq.live&select=id,featured_product_id,seller_id,title`, { headers: adminHeaders });
328
+ const sessions = await sessResp.json();
329
+ if (!sessions?.length) return errorResponse('No active live session', 404);
330
+ const session = sessions[0];
331
+
332
+ if (!session.featured_product_id) {
333
+ return errorResponse('No featured product in this live session', 400);
334
+ }
335
+
336
+ // Post a purchase message to the chat
337
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_messages`, {
338
+ method: 'POST',
339
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({
341
+ live_session_id: sessionId,
342
+ user_name: name,
343
+ msg_type: 'purchase',
344
+ message: `${name} just bought the featured product! 🎉`,
345
+ }),
346
+ });
347
+
348
+ // Return the checkout URL — the WhatsApp bot will send this back to the user
349
+ const checkoutUrl = `https://eeshaai-cellex-web.hf.space/Eesha buying folder/product.html?id=${session.featured_product_id}&live=${sessionId}`;
350
+
351
+ return jsonResponse({
352
+ success: true,
353
+ sessionId,
354
+ productId: session.featured_product_id,
355
+ checkoutUrl,
356
+ reply: `🛒 Thanks for buying during our live! Complete your purchase here: ${checkoutUrl}`,
357
+ });
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+ async function postSystemMessage(sessionId: string, message: string): Promise<void> {
362
+ await fetch(`${SUPABASE_URL}/rest/v1/shop_live_messages`, {
363
+ method: 'POST',
364
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
365
+ body: JSON.stringify({
366
+ live_session_id: sessionId,
367
+ msg_type: 'system',
368
+ message,
369
+ }),
370
+ });
371
+ }
supabase/functions/reviews/index.ts ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="deno.ns" />
2
+ // Cellex Reviews Edge Function (Phase 2)
3
+ // ---------------------------------------
4
+ // Buyers leave reviews (with optional photos) for products they purchased.
5
+ // Reviews appear on the product page and on the seller's public profile.
6
+ //
7
+ // API:
8
+ // op=by_product body: { productId } → { reviews, summary: { avg, count } }
9
+ // op=by_seller body: { sellerId } → { reviews, summary }
10
+ // op=create body: { productId, orderId, rating, title, comment, images? }
11
+ // op=helpful body: { reviewId } → { helpful_count } (increments)
12
+ // op=delete body: { reviewId } → { success }
13
+
14
+ import {
15
+ corsHeaders, jsonResponse, errorResponse, getUser,
16
+ } from '../_shared/cors.ts';
17
+
18
+ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
19
+ const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
20
+
21
+ const adminHeaders = {
22
+ 'apikey': SERVICE_KEY,
23
+ 'Authorization': `Bearer ${SERVICE_KEY}`,
24
+ 'Content-Type': 'application/json',
25
+ };
26
+
27
+ Deno.serve(async (req: Request) => {
28
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
29
+ if (req.method !== 'POST') return errorResponse('Method not allowed', 405);
30
+
31
+ try {
32
+ const body = await req.json().catch(() => ({}));
33
+ const op = body.op || 'by_product';
34
+
35
+ // Public ops
36
+ if (op === 'by_product') return await handleByProduct(body);
37
+ if (op === 'by_seller') return await handleBySeller(body);
38
+
39
+ // Auth-required ops
40
+ const user = await getUser(req);
41
+ if (!user) return errorResponse('Not authenticated', 401);
42
+
43
+ switch (op) {
44
+ case 'create': return await handleCreate(user.id, body);
45
+ case 'helpful': return await handleHelpful(body);
46
+ case 'delete': return await handleDelete(user.id, body);
47
+ default: return errorResponse(`Unknown op: ${op}`, 400);
48
+ }
49
+ } catch (error) {
50
+ console.error('reviews error:', error);
51
+ return errorResponse(`Internal error: ${error instanceof Error ? error.message : String(error)}`, 500);
52
+ }
53
+ });
54
+
55
+ async function handleByProduct(body: Record<string, unknown>): Promise<Response> {
56
+ const productId = Number(body.productId);
57
+ if (!productId) return errorResponse('productId is required', 400);
58
+
59
+ const [reviewsResp, summaryResp] = await Promise.all([
60
+ fetch(`${SUPABASE_URL}/rest/v1/buyers_reviews?product_id=eq.${productId}&select=id,user_id,rating,title,comment,images,verified_purchase,helpful_count,created_at&order=helpful_count.desc,created_at.desc`,
61
+ { headers: adminHeaders }),
62
+ fetch(`${SUPABASE_URL}/rest/v1/product_ratings?product_id=eq.${productId}&select=avg_rating,review_count`,
63
+ { headers: adminHeaders }),
64
+ ]);
65
+ const reviews = await reviewsResp.json();
66
+ const summary = (await summaryResp.json())?.[0] || { avg_rating: 0, review_count: 0 };
67
+
68
+ // Enrich with reviewer name
69
+ const userIds = Array.from(new Set((reviews || []).map((r: Record<string, unknown>) => r.user_id)));
70
+ let userMap = new Map<string, string>();
71
+ if (userIds.length > 0) {
72
+ const usersResp = await fetch(
73
+ `${SUPABASE_URL}/rest/v1/sellers?id=in.(${userIds.join(',')})&select=id,business_name,profile_image`,
74
+ { headers: adminHeaders }
75
+ );
76
+ const sellers = await usersResp.json();
77
+ (sellers || []).forEach((s: Record<string, unknown>) => {
78
+ userMap.set(s.id as string, (s.business_name as string) || 'Buyer');
79
+ });
80
+ }
81
+
82
+ const enriched = (reviews || []).map((r: Record<string, unknown>) => ({
83
+ ...r,
84
+ reviewer_name: userMap.get(r.user_id as string) || 'Buyer',
85
+ }));
86
+
87
+ return jsonResponse({
88
+ success: true,
89
+ reviews: enriched,
90
+ summary: { avg: Number(summary.avg_rating) || 0, count: summary.review_count || 0 },
91
+ });
92
+ }
93
+
94
+ async function handleBySeller(body: Record<string, unknown>): Promise<Response> {
95
+ const sellerId = body.sellerId as string;
96
+ if (!sellerId) return errorResponse('sellerId is required', 400);
97
+
98
+ // Get seller's products
99
+ const productsResp = await fetch(
100
+ `${SUPABASE_URL}/rest/v1/products?seller_id=eq.${encodeURIComponent(sellerId)}&select=id,name,image_url,price`,
101
+ { headers: adminHeaders }
102
+ );
103
+ const products = await productsResp.json();
104
+ const productIds = (products || []).map((p: Record<string, unknown>) => p.id);
105
+ if (productIds.length === 0) {
106
+ return jsonResponse({ success: true, reviews: [], summary: { avg: 0, count: 0 } });
107
+ }
108
+
109
+ // Get all reviews for those products
110
+ const reviewsResp = await fetch(
111
+ `${SUPABASE_URL}/rest/v1/buyers_reviews?product_id=in.(${productIds.join(',')})&select=id,product_id,user_id,rating,title,comment,images,verified_purchase,helpful_count,created_at&order=created_at.desc&limit=50`,
112
+ { headers: adminHeaders }
113
+ );
114
+ const reviews = await reviewsResp.json();
115
+
116
+ // Compute aggregate
117
+ const total = (reviews || []).length;
118
+ const avg = total > 0
119
+ ? (reviews as Record<string, unknown>[]).reduce((s, r) => s + Number(r.rating), 0) / total
120
+ : 0;
121
+
122
+ // Attach product info to each review
123
+ const productMap = new Map((products || []).map((p: Record<string, unknown>) => [p.id, p]));
124
+ const enriched = (reviews || []).map((r: Record<string, unknown>) => ({
125
+ ...r,
126
+ product: productMap.get(r.product_id) || null,
127
+ }));
128
+
129
+ return jsonResponse({
130
+ success: true,
131
+ reviews: enriched,
132
+ summary: { avg: Math.round(avg * 100) / 100, count: total },
133
+ });
134
+ }
135
+
136
+ async function handleCreate(userId: string, body: Record<string, unknown>): Promise<Response> {
137
+ const productId = Number(body.productId);
138
+ const orderId = body.orderId as string;
139
+ const rating = Number(body.rating);
140
+
141
+ if (!productId || !rating || rating < 1 || rating > 5) {
142
+ return errorResponse('productId and rating (1-5) are required', 400);
143
+ }
144
+
145
+ // Verify the user purchased this product (must have an order item with this product_id)
146
+ const verifyResp = await fetch(
147
+ `${SUPABASE_URL}/rest/v1/buyers_order_items?select=id&order_id=in.(SELECT id FROM buyers_orders WHERE user_id=eq.${encodeURIComponent(userId)})&product_id=eq.${productId}&limit=1`,
148
+ { headers: adminHeaders }
149
+ );
150
+ const purchased = await verifyResp.json();
151
+ const verifiedPurchase = (purchased?.length || 0) > 0;
152
+ if (!verifiedPurchase) {
153
+ return errorResponse('You can only review products you have purchased', 403);
154
+ }
155
+
156
+ // Insert review
157
+ const payload: Record<string, unknown> = {
158
+ user_id: userId,
159
+ product_id: productId,
160
+ rating,
161
+ title: (body.title as string) || '',
162
+ comment: (body.comment as string) || '',
163
+ images: body.images || [],
164
+ verified_purchase: true,
165
+ helpful_count: 0,
166
+ };
167
+ if (orderId) payload.order_id = orderId;
168
+
169
+ const resp = await fetch(`${SUPABASE_URL}/rest/v1/buyers_reviews`, {
170
+ method: 'POST',
171
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
172
+ body: JSON.stringify(payload),
173
+ });
174
+ const created = await resp.json();
175
+ if (!resp.ok) return errorResponse(created?.message || 'Failed to create review', 500);
176
+
177
+ return jsonResponse({ success: true, review: created?.[0] });
178
+ }
179
+
180
+ async function handleHelpful(body: Record<string, unknown>): Promise<Response> {
181
+ const reviewId = body.reviewId as string;
182
+ if (!reviewId) return errorResponse('reviewId is required', 400);
183
+
184
+ // Atomic increment
185
+ const resp = await fetch(
186
+ `${SUPABASE_URL}/rest/v1/buyers_reviews?id=eq.${encodeURIComponent(reviewId)}&select=helpful_count`,
187
+ { headers: adminHeaders }
188
+ );
189
+ const rows = await resp.json();
190
+ const cur = rows?.[0]?.helpful_count || 0;
191
+ const newCount = cur + 1;
192
+
193
+ await fetch(`${SUPABASE_URL}/rest/v1/buyers_reviews?id=eq.${encodeURIComponent(reviewId)}`, {
194
+ method: 'PATCH',
195
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({ helpful_count: newCount }),
197
+ });
198
+
199
+ return jsonResponse({ success: true, helpful_count: newCount });
200
+ }
201
+
202
+ async function handleDelete(userId: string, body: Record<string, unknown>): Promise<Response> {
203
+ const reviewId = body.reviewId as string;
204
+ if (!reviewId) return errorResponse('reviewId is required', 400);
205
+
206
+ const resp = await fetch(
207
+ `${SUPABASE_URL}/rest/v1/buyers_reviews?id=eq.${encodeURIComponent(reviewId)}&user_id=eq.${encodeURIComponent(userId)}`,
208
+ { method: 'DELETE', headers: adminHeaders }
209
+ );
210
+ if (!resp.ok) return errorResponse('Failed to delete review', 500);
211
+
212
+ return jsonResponse({ success: true });
213
+ }
supabase/functions/seller-dashboard/index.ts CHANGED
File without changes
supabase/functions/seller-orders/index.ts CHANGED
File without changes
supabase/functions/seller-products/index.ts CHANGED
File without changes
supabase/functions/seller-profile/index.ts CHANGED
File without changes
supabase/functions/social/index.ts CHANGED
File without changes
supabase/functions/wishlist-share/index.ts ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="deno.ns" />
2
+ // Cellex Wishlist Share Edge Function (Phase 2)
3
+ // ------------------------------------------------
4
+ // Generate a shareable link for the user's current wishlist. The link
5
+ // returns a snapshot of items at share time so it keeps working even after
6
+ // the wishlist is edited.
7
+ //
8
+ // API:
9
+ // op=share body: { title? } → { token, url }
10
+ // op=get_shared body: { token } → { wishlist, items: [...] }
11
+ // op=my_shares → { shares: [...] }
12
+ // op=revoke body: { token } → { success }
13
+
14
+ import {
15
+ corsHeaders, jsonResponse, errorResponse, getUser,
16
+ } from '../_shared/cors.ts';
17
+
18
+ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!;
19
+ const SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
20
+
21
+ const adminHeaders = {
22
+ 'apikey': SERVICE_KEY,
23
+ 'Authorization': `Bearer ${SERVICE_KEY}`,
24
+ 'Content-Type': 'application/json',
25
+ };
26
+
27
+ // Generate a short URL-safe token (16 chars)
28
+ function generateToken(): string {
29
+ const bytes = new Uint8Array(12);
30
+ crypto.getRandomValues(bytes);
31
+ return Array.from(bytes).map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 16);
32
+ }
33
+
34
+ Deno.serve(async (req: Request) => {
35
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
36
+ if (req.method !== 'POST') return errorResponse('Method not allowed', 405);
37
+
38
+ try {
39
+ const body = await req.json().catch(() => ({}));
40
+ const op = body.op || 'get_shared';
41
+
42
+ // Public op
43
+ if (op === 'get_shared') return await handleGetShared(body);
44
+
45
+ // Auth-required ops
46
+ const user = await getUser(req);
47
+ if (!user) return errorResponse('Not authenticated', 401);
48
+
49
+ switch (op) {
50
+ case 'share': return await handleShare(user.id, body);
51
+ case 'my_shares': return await handleMyShares(user.id);
52
+ case 'revoke': return await handleRevoke(user.id, body);
53
+ default: return errorResponse(`Unknown op: ${op}`, 400);
54
+ }
55
+ } catch (error) {
56
+ console.error('wishlist-share error:', error);
57
+ return errorResponse(`Internal error: ${error instanceof Error ? error.message : String(error)}`, 500);
58
+ }
59
+ });
60
+
61
+ async function handleShare(userId: string, body: Record<string, unknown>): Promise<Response> {
62
+ // Fetch user's wishlist items
63
+ const wlResp = await fetch(
64
+ `${SUPABASE_URL}/rest/v1/buyers_wishlist?user_id=eq.${encodeURIComponent(userId)}&select=id,product_id,created_at&order=created_at.desc`,
65
+ { headers: adminHeaders }
66
+ );
67
+ const wishlist = await wlResp.json();
68
+ const productIds = (wishlist || []).map((w: Record<string, unknown>) => w.product_id);
69
+ if (productIds.length === 0) {
70
+ return errorResponse('Your wishlist is empty — add products first', 400);
71
+ }
72
+
73
+ // Fetch product details
74
+ const productsResp = await fetch(
75
+ `${SUPABASE_URL}/rest/v1/products?id=in.(${productIds.join(',')})&select=id,name,price,image_url,category,seller_id`,
76
+ { headers: adminHeaders }
77
+ );
78
+ const products = await productsResp.json();
79
+ const productMap = new Map((products || []).map((p: Record<string, unknown>) => [p.id, p]));
80
+
81
+ // Build items snapshot
82
+ const itemsJson = (wishlist || []).map((w: Record<string, unknown>) => ({
83
+ wishlist_item_id: w.id,
84
+ product: productMap.get(w.product_id) || null,
85
+ })).filter((it: Record<string, unknown>) => it.product !== null);
86
+
87
+ // Insert share token
88
+ const token = generateToken();
89
+ const title = (body.title as string) || 'My Cellex Wishlist';
90
+ const insertResp = await fetch(`${SUPABASE_URL}/rest/v1/shared_wishlists`, {
91
+ method: 'POST',
92
+ headers: { ...adminHeaders, 'Prefer': 'return=representation' },
93
+ body: JSON.stringify({
94
+ token,
95
+ user_id: userId,
96
+ title,
97
+ items_json: itemsJson,
98
+ }),
99
+ });
100
+ if (!insertResp.ok) {
101
+ const err = await insertResp.text();
102
+ return errorResponse(`Failed to create share link: ${err}`, 500);
103
+ }
104
+
105
+ return jsonResponse({ success: true, token, url: `/shared-wishlist.html?token=${token}` });
106
+ }
107
+
108
+ async function handleGetShared(body: Record<string, unknown>): Promise<Response> {
109
+ const token = body.token as string;
110
+ if (!token) return errorResponse('token is required', 400);
111
+
112
+ const resp = await fetch(
113
+ `${SUPABASE_URL}/rest/v1/shared_wishlists?token=eq.${encodeURIComponent(token)}&select=*`,
114
+ { headers: adminHeaders }
115
+ );
116
+ const rows = await resp.json();
117
+ if (!rows || rows.length === 0) return errorResponse('Share link not found or revoked', 404);
118
+ const share = rows[0];
119
+
120
+ // Check expiry
121
+ if (share.expires_at && new Date(share.expires_at) < new Date()) {
122
+ return errorResponse('This share link has expired', 410);
123
+ }
124
+
125
+ // Increment view count (fire-and-forget)
126
+ fetch(`${SUPABASE_URL}/rest/v1/shared_wishlists?token=eq.${encodeURIComponent(token)}`, {
127
+ method: 'PATCH',
128
+ headers: { ...adminHeaders, 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({ view_count: (share.view_count || 0) + 1 }),
130
+ }).catch(() => {});
131
+
132
+ return jsonResponse({
133
+ success: true,
134
+ wishlist: {
135
+ title: share.title,
136
+ created_at: share.created_at,
137
+ view_count: (share.view_count || 0) + 1,
138
+ },
139
+ items: share.items_json || [],
140
+ });
141
+ }
142
+
143
+ async function handleMyShares(userId: string): Promise<Response> {
144
+ const resp = await fetch(
145
+ `${SUPABASE_URL}/rest/v1/shared_wishlists?user_id=eq.${encodeURIComponent(userId)}&select=token,title,view_count,created_at,expires_at&order=created_at.desc`,
146
+ { headers: adminHeaders }
147
+ );
148
+ const shares = await resp.json();
149
+ return jsonResponse({ success: true, shares: shares || [] });
150
+ }
151
+
152
+ async function handleRevoke(userId: string, body: Record<string, unknown>): Promise<Response> {
153
+ const token = body.token as string;
154
+ if (!token) return errorResponse('token is required', 400);
155
+
156
+ await fetch(
157
+ `${SUPABASE_URL}/rest/v1/shared_wishlists?token=eq.${encodeURIComponent(token)}&user_id=eq.${encodeURIComponent(userId)}`,
158
+ { method: 'DELETE', headers: adminHeaders }
159
+ );
160
+ return jsonResponse({ success: true });
161
+ }
web-server/server.py CHANGED
@@ -268,6 +268,23 @@ async def proxy_seller_profile(request: Request):
268
  async def proxy_social(request: Request):
269
  return await _proxy_to_edge_function("social", request)
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  @app.get("/api/health")
272
  async def proxy_health():
273
  return {
 
268
  async def proxy_social(request: Request):
269
  return await _proxy_to_edge_function("social", request)
270
 
271
+ # ---- Phase 2: Community Engagement ----
272
+ @app.post("/api/reviews")
273
+ async def proxy_reviews(request: Request):
274
+ return await _proxy_to_edge_function("reviews", request)
275
+
276
+ @app.post("/api/group-buy")
277
+ async def proxy_group_buy(request: Request):
278
+ return await _proxy_to_edge_function("group-buy", request)
279
+
280
+ @app.post("/api/wishlist-share")
281
+ async def proxy_wishlist_share(request: Request):
282
+ return await _proxy_to_edge_function("wishlist-share", request)
283
+
284
+ @app.post("/api/live")
285
+ async def proxy_live(request: Request):
286
+ return await _proxy_to_edge_function("live", request)
287
+
288
  @app.get("/api/health")
289
  async def proxy_health():
290
  return {