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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a cart to see details
+
+
+
+
+
+
+
+
+
+
+
+
+
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; }