Spaces:
Sleeping
Phase 2 — Community Engagement
Browse files4 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 +221 -0
- Eesha buying folder/product.html +83 -0
- Eesha buying folder/wishlist.html +28 -3
- group-buy.html +199 -0
- index.html +61 -0
- js/config/edge-functions.js +44 -0
- live-watch.html +269 -0
- live.html +141 -0
- seller-profile.html +44 -1
- seller/academy.html +0 -0
- seller/css/dashboard.css +0 -0
- seller/go-live.html +207 -0
- seller/index.html +0 -0
- seller/js/shared.js +1 -0
- seller/orders.html +0 -0
- seller/products.html +0 -0
- seller/profile.html +0 -0
- seller/settings.html +0 -0
- shared-wishlist.html +101 -0
- supabase/functions/group-buy/index.ts +235 -0
- supabase/functions/live/index.ts +371 -0
- supabase/functions/reviews/index.ts +213 -0
- supabase/functions/seller-dashboard/index.ts +0 -0
- supabase/functions/seller-orders/index.ts +0 -0
- supabase/functions/seller-products/index.ts +0 -0
- supabase/functions/seller-profile/index.ts +0 -0
- supabase/functions/social/index.ts +0 -0
- supabase/functions/wishlist-share/index.ts +161 -0
- web-server/server.py +17 -0
|
@@ -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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 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 |
+
}
|
|
@@ -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">
|
|
@@ -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 |
-
<
|
| 112 |
-
<
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
|
@@ -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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 195 |
+
}[c]));
|
| 196 |
+
}
|
| 197 |
+
</script>
|
| 198 |
+
</body>
|
| 199 |
+
</html>
|
|
@@ -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>
|
|
@@ -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 |
|
|
@@ -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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 265 |
+
}[c]));
|
| 266 |
+
}
|
| 267 |
+
</script>
|
| 268 |
+
</body>
|
| 269 |
+
</html>
|
|
@@ -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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 137 |
+
}[c]));
|
| 138 |
+
}
|
| 139 |
+
</script>
|
| 140 |
+
</body>
|
| 141 |
+
</html>
|
|
@@ -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>
|
|
File without changes
|
|
File without changes
|
|
@@ -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>
|
|
File without changes
|
|
@@ -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' },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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 |
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
| 97 |
+
}[c]));
|
| 98 |
+
}
|
| 99 |
+
</script>
|
| 100 |
+
</body>
|
| 101 |
+
</html>
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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 |
+
}
|
|
@@ -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 {
|