diff --git a/browser-extension/background.js b/browser-extension/background.js index f2b4d43..23809b3 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -8,6 +8,8 @@ chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'save-image', title: 'Save image to rSpace', contexts: ['image'] }); chrome.contextMenus.create({ id: 'clip-selection', title: 'Clip selection to rSpace', contexts: ['selection'] }); chrome.contextMenus.create({ id: 'unlock-article', title: 'Unlock & Clip article to rSpace', contexts: ['page', 'link'] }); + chrome.contextMenus.create({ id: 'separator-cart', type: 'separator', contexts: ['page', 'link'] }); + chrome.contextMenus.create({ id: 'add-to-cart', title: 'Add to rCart', contexts: ['page', 'link'] }); }); // --- Helpers --- @@ -140,6 +142,31 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { } break; } + case 'add-to-cart': { + const targetUrl = info.linkUrl || tab.url; + const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']); + const space = selectedSpace || 'demo'; + const settings = await getSettings(); + const token = await getToken(); + + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${settings.host}/${space}/rcart/api/cart/quick-add`, { + method: 'POST', + headers, + body: JSON.stringify({ url: targetUrl }), + }); + + if (res.ok) { + const added = await res.json(); + showNotification('Added to rCart', added.name || `Item from ${new URL(targetUrl).hostname}`); + updateCartBadge(space); + } else { + showNotification('rCart Error', `Failed to add item (${res.status})`); + } + break; + } case 'clip-selection': { let content = ''; try { @@ -168,8 +195,38 @@ chrome.commands.onCommand.addListener(async (command) => { } }); +// --- Cart Badge --- + +async function updateCartBadge(space) { + if (!space) { + const result = await chrome.storage.sync.get(['selectedSpace']); + space = result.selectedSpace || 'demo'; + } + try { + const settings = await getSettings(); + const token = await getToken(); + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${settings.host}/${space}/rcart/api/cart/summary`, { headers }); + if (res.ok) { + const summary = await res.json(); + const count = summary.totalItems || 0; + chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' }); + chrome.action.setBadgeBackgroundColor({ color: '#6366f1' }); + } + } catch { + // Badge update is best-effort + } +} + +// Update badge on startup +chrome.runtime.onStartup?.addListener(() => updateCartBadge()); + // --- Message Handler --- chrome.runtime.onMessage.addListener((message) => { if (message.type === 'notify') showNotification(message.title, message.message); + if (message.type === 'cart-settings-changed') updateCartBadge(message.space); + if (message.type === 'cart-item-added') updateCartBadge(message.space); }); diff --git a/browser-extension/content.js b/browser-extension/content.js new file mode 100644 index 0000000..312a2e0 --- /dev/null +++ b/browser-extension/content.js @@ -0,0 +1,2 @@ +// Extension detection marker — allows rSpace web UI to detect the extension is installed +window.__rspaceExtensionInstalled = true; diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 808b7c9..3764bb8 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -8,7 +8,8 @@ "contextMenus", "storage", "notifications", - "offscreen" + "offscreen", + "scripting" ], "host_permissions": [ "https://rspace.online/*", @@ -38,6 +39,13 @@ "content_security_policy": { "extension_pages": "script-src 'self' https://esm.sh; object-src 'self'" }, + "content_scripts": [ + { + "matches": ["https://rspace.online/*", "https://*.rspace.online/*"], + "js": ["content.js"], + "run_at": "document_start" + } + ], "commands": { "open-voice-recorder": { "suggested_key": { diff --git a/browser-extension/popup.html b/browser-extension/popup.html index 7b9aa00..058e418 100644 --- a/browser-extension/popup.html +++ b/browser-extension/popup.html @@ -32,6 +32,26 @@ .status.success { background: #052e16; border: 1px solid #166534; color: #4ade80; display: block; } .status.error { background: #450a0a; border: 1px solid #991b1b; color: #fca5a5; display: block; } .status.loading { background: #172554; border: 1px solid #1e40af; color: #93c5fd; display: block; } + .mode-tabs { display: flex; border-bottom: 1px solid #262626; } + .mode-tab { flex: 1; padding: 8px 0; text-align: center; font-size: 12px; font-weight: 600; color: #737373; background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; transition: color 0.15s, border-color 0.15s; } + .mode-tab:hover { color: #a3a3a3; } + .mode-tab.active { color: #6366f1; border-bottom-color: #6366f1; } + + .cart-preview { padding: 10px 14px; } + .cart-preview .preview-card { background: #171717; border: 1px solid #262626; border-radius: 6px; padding: 10px; margin-bottom: 8px; } + .cart-preview .preview-row { display: flex; justify-content: space-between; font-size: 12px; color: #a3a3a3; margin-bottom: 4px; } + .cart-preview .preview-row .value { color: #e5e5e5; font-weight: 600; } + .cart-preview .vendor-list { margin-top: 6px; } + .cart-preview .vendor-bullet { font-size: 11px; color: #737373; padding: 1px 0; } + .cart-preview .vendor-bullet::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-right: 6px; vertical-align: middle; } + .cart-preview .empty-msg { color: #525252; font-size: 12px; text-align: center; padding: 12px 0; } + .btn-link { display: block; text-align: center; padding: 8px 12px; border: 1px solid #404040; border-radius: 6px; color: #a3a3a3; background: none; font-size: 12px; cursor: pointer; text-decoration: none; margin-top: 4px; transition: border-color 0.15s, color 0.15s; } + .btn-link:hover { border-color: #6366f1; color: #6366f1; } + .cart-status { margin: 0 14px 10px; padding: 8px 10px; border-radius: 4px; font-size: 12px; display: none; } + .cart-status.success { background: #052e16; border: 1px solid #166534; color: #4ade80; display: block; } + .cart-status.error { background: #450a0a; border: 1px solid #991b1b; color: #fca5a5; display: block; } + .cart-status.loading { background: #172554; border: 1px solid #1e40af; color: #93c5fd; display: block; } + .footer { padding: 8px 14px; border-top: 1px solid #262626; text-align: center; } .footer a { color: #737373; text-decoration: none; font-size: 11px; } .footer a:hover { color: #6366f1; } @@ -39,10 +59,17 @@
- rSpace Clipper + rSpace ...
+
+ + +
+ +
+ @@ -92,6 +119,35 @@
+
+ + + diff --git a/browser-extension/popup.js b/browser-extension/popup.js index 9b565cc..ea0535e 100644 --- a/browser-extension/popup.js +++ b/browser-extension/popup.js @@ -296,4 +296,265 @@ document.getElementById('openSettings')?.addEventListener('click', (e) => { chrome.runtime.openOptionsPage(); }); -document.addEventListener('DOMContentLoaded', init); +// --- Mode switching --- + +document.querySelectorAll('.mode-tab').forEach((tab) => { + tab.addEventListener('click', () => { + const mode = tab.dataset.mode; + document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active')); + tab.classList.add('active'); + + document.getElementById('clipperPanel').style.display = mode === 'clipper' ? '' : 'none'; + document.getElementById('cartPanel').style.display = mode === 'cart' ? '' : 'none'; + + chrome.storage.local.set({ lastPopupMode: mode }); + + if (mode === 'cart') initCartMode(); + }); +}); + +// --- Cart helpers --- + +function showCartStatus(message, type) { + const el = document.getElementById('cartStatus'); + el.textContent = message; + el.className = `cart-status ${type}`; + if (type === 'success') { + setTimeout(() => { el.className = 'cart-status'; }, 3000); + } +} + +async function fetchSpaces() { + const settings = await getSettings(); + const token = await getToken(); + try { + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${settings.host}/api/spaces`, { headers }); + if (res.ok) { + const data = await res.json(); + return data.spaces || data || []; + } + } catch {} + return [{ slug: 'demo', name: 'demo' }]; +} + +async function fetchCarts(space) { + const settings = await getSettings(); + const token = await getToken(); + try { + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${settings.host}/${space}/rcart/api/shopping-carts`, { headers }); + if (res.ok) { + const data = await res.json(); + return data.carts || []; + } + } catch {} + return []; +} + +async function fetchCartSummary(space) { + const settings = await getSettings(); + const token = await getToken(); + try { + const headers = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`${settings.host}/${space}/rcart/api/cart/summary`, { headers }); + if (res.ok) return res.json(); + } catch {} + return null; +} + +function renderCartPreview(summary) { + const el = document.getElementById('cartPreview'); + if (!summary || summary.totalItems === 0) { + el.innerHTML = '
Cart is empty
'; + return; + } + + const vendors = summary.vendorGroups || []; + const vendorHtml = vendors.map((v) => + `
${v.domain || v.vendor || 'unknown'} (${v.count || v.items || 0})
` + ).join(''); + + el.innerHTML = ` +
+
Items${summary.totalItems}
+
Total$${(summary.totalAmount || 0).toFixed(2)}
+ ${vendors.length > 0 ? `
${vendorHtml}
` : ''} +
`; +} + +async function initCartMode() { + const spaceSelect = document.getElementById('spaceSelect'); + const cartSelect = document.getElementById('cartSelect'); + + // Populate spaces + const spaces = await fetchSpaces(); + const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']); + const currentSpace = selectedSpace || 'demo'; + + spaceSelect.innerHTML = ''; + for (const s of spaces) { + const opt = document.createElement('option'); + opt.value = s.slug || s.name || s; + opt.textContent = s.name || s.slug || s; + spaceSelect.appendChild(opt); + } + // Ensure current space option exists + if (!spaceSelect.querySelector(`option[value="${currentSpace}"]`)) { + const opt = document.createElement('option'); + opt.value = currentSpace; + opt.textContent = currentSpace; + spaceSelect.prepend(opt); + } + spaceSelect.value = currentSpace; + + // Load carts for this space + await loadCartsForSpace(currentSpace); +} + +async function loadCartsForSpace(space) { + const cartSelect = document.getElementById('cartSelect'); + cartSelect.innerHTML = ''; + + const carts = await fetchCarts(space); + const { selectedCartId } = await chrome.storage.sync.get(['selectedCartId']); + + cartSelect.innerHTML = ''; + if (carts.length === 0) { + cartSelect.innerHTML = ''; + renderCartPreview(null); + return; + } + + for (const c of carts) { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name || c.id; + cartSelect.appendChild(opt); + } + + if (selectedCartId && cartSelect.querySelector(`option[value="${selectedCartId}"]`)) { + cartSelect.value = selectedCartId; + } + + // Load preview + const summary = await fetchCartSummary(space); + renderCartPreview(summary); +} + +// Space change handler +document.getElementById('spaceSelect').addEventListener('change', async (e) => { + const space = e.target.value; + await chrome.storage.sync.set({ selectedSpace: space }); + await loadCartsForSpace(space); + chrome.runtime.sendMessage({ type: 'cart-settings-changed', space }); +}); + +// Cart change handler +document.getElementById('cartSelect').addEventListener('change', async (e) => { + await chrome.storage.sync.set({ selectedCartId: e.target.value }); +}); + +// Add to Cart +document.getElementById('addToCartBtn').addEventListener('click', async () => { + const btn = document.getElementById('addToCartBtn'); + btn.disabled = true; + showCartStatus('Detecting product...', 'loading'); + + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const settings = await getSettings(); + const token = await getToken(); + const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']); + const space = selectedSpace || 'demo'; + + // Try to detect product info from the page + let product = null; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + // Try JSON-LD + const ldScripts = document.querySelectorAll('script[type="application/ld+json"]'); + for (const s of ldScripts) { + try { + const data = JSON.parse(s.textContent); + const item = data['@type'] === 'Product' ? data : (Array.isArray(data['@graph']) ? data['@graph'].find(i => i['@type'] === 'Product') : null); + if (item) { + return { + name: item.name, + image: item.image?.url || item.image?.[0] || item.image, + price: item.offers?.price || item.offers?.[0]?.price, + currency: item.offers?.priceCurrency || item.offers?.[0]?.priceCurrency, + }; + } + } catch {} + } + // Fallback to meta tags + const title = document.querySelector('meta[property="og:title"]')?.content || document.title; + const image = document.querySelector('meta[property="og:image"]')?.content; + return { name: title, image }; + }, + }); + if (result?.result?.name) product = result.result; + } catch {} + + showCartStatus('Adding to cart...', 'loading'); + + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const body = { url: tab.url }; + if (product) body.product = product; + + const res = await fetch(`${settings.host}/${space}/rcart/api/cart/quick-add`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); + + const added = await res.json(); + showCartStatus(`Added: ${added.name || product?.name || 'item'}`, 'success'); + + // Update preview + const summary = await fetchCartSummary(space); + renderCartPreview(summary); + + // Notify background to update badge + chrome.runtime.sendMessage({ type: 'cart-item-added', space }); + } catch (err) { + showCartStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +// View Cart +document.getElementById('viewCartBtn').addEventListener('click', async () => { + const settings = await getSettings(); + const { selectedSpace } = await chrome.storage.sync.get(['selectedSpace']); + const space = selectedSpace || 'demo'; + chrome.tabs.create({ url: `${settings.host}/${space}/rcart` }); + window.close(); +}); + +// --- Init --- + +document.addEventListener('DOMContentLoaded', async () => { + await init(); + + // Restore last popup mode + const { lastPopupMode } = await chrome.storage.local.get(['lastPopupMode']); + if (lastPopupMode === 'cart') { + document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active')); + document.querySelector('.mode-tab[data-mode="cart"]').classList.add('active'); + document.getElementById('clipperPanel').style.display = 'none'; + document.getElementById('cartPanel').style.display = ''; + initCartMode(); + } +}); diff --git a/modules/rcart/components/folk-cart-shop.ts b/modules/rcart/components/folk-cart-shop.ts index bf33a26..42afe11 100644 --- a/modules/rcart/components/folk-cart-shop.ts +++ b/modules/rcart/components/folk-cart-shop.ts @@ -23,6 +23,8 @@ class FolkCartShop extends HTMLElement { private loading = true; private addingUrl = false; private contributingAmount = false; + private extensionInstalled = false; + private bannerDismissed = false; private _offlineUnsubs: (() => void)[] = []; constructor() { @@ -31,6 +33,9 @@ class FolkCartShop extends HTMLElement { } connectedCallback() { + this.extensionInstalled = !!(window as any).__rspaceExtensionInstalled; + this.bannerDismissed = localStorage.getItem('rcart-ext-banner-dismissed') === 'true'; + const attr = this.getAttribute("space"); if (attr) { this.space = attr; @@ -355,6 +360,13 @@ class FolkCartShop extends HTMLElement { }); }); + // Extension banner dismiss + this.shadow.querySelector("[data-action='dismiss-banner']")?.addEventListener("click", () => { + this.bannerDismissed = true; + localStorage.setItem('rcart-ext-banner-dismissed', 'true'); + this.render(); + }); + // Cart card clicks this.shadow.querySelectorAll("[data-cart-id]").forEach((el) => { el.addEventListener("click", () => { @@ -411,6 +423,19 @@ class FolkCartShop extends HTMLElement { }); } + // ── Extension install banner ── + + private renderExtensionBanner(): string { + if (this.extensionInstalled || this.bannerDismissed) return ''; + return ` +
+ +
Add products from any store
+
Install the rSpace browser extension to add items from Amazon, Etsy, Shopify, and more directly to your carts.
+ Get Extension +
`; + } + // ── Cart list view ── private renderCarts(): string { @@ -423,6 +448,7 @@ class FolkCartShop extends HTMLElement { if (this.carts.length === 0) { return ` + ${this.renderExtensionBanner()}

No shopping carts yet. Create one to start group shopping.

@@ -431,6 +457,7 @@ class FolkCartShop extends HTMLElement { } return ` + ${this.renderExtensionBanner()}
${newCartForm} @@ -678,6 +705,14 @@ class FolkCartShop extends HTMLElement { .order-info { flex: 1; } .order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; } + .ext-banner { position: relative; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.25); border-radius: 12px; padding: 1rem 2.5rem 1rem 1.25rem; margin-bottom: 1rem; } + .ext-banner-dismiss { position: absolute; top: 0.5rem; right: 0.75rem; background: none; border: none; color: #94a3b8; font-size: 1.25rem; cursor: pointer; padding: 0 4px; line-height: 1; } + .ext-banner-dismiss:hover { color: #e2e8f0; } + .ext-banner-title { color: #a5b4fc; font-weight: 600; font-size: 0.9375rem; margin-bottom: 0.25rem; } + .ext-banner-text { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 0.75rem; line-height: 1.4; } + .ext-banner-cta { display: inline-block; padding: 0.375rem 1rem; border-radius: 6px; background: #4f46e5; color: #fff; font-size: 0.8125rem; font-weight: 600; text-decoration: none; } + .ext-banner-cta:hover { background: #4338ca; } + .empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; } .loading { text-align: center; padding: 3rem; color: #94a3b8; }