feat(rcart): add cart tab to browser extension + install banner in web UI

Extension gets Clipper/Cart mode tabs, space/cart picker with persistence,
JSON-LD product detection, "Add to rCart" context menu, and badge count.
Web UI shows a dismissible indigo banner prompting extension install when
not detected. Content script sets detection marker on rspace.online pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-10 12:07:13 -07:00
parent 932151aa88
commit c8e95ed506
6 changed files with 422 additions and 3 deletions

View File

@ -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);
});

View File

@ -0,0 +1,2 @@
// Extension detection marker — allows rSpace web UI to detect the extension is installed
window.__rspaceExtensionInstalled = true;

View File

@ -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": {

View File

@ -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 @@
</head>
<body>
<div class="header">
<span class="brand">rSpace Clipper</span>
<span class="brand">rSpace</span>
<span class="user" id="userStatus">...</span>
</div>
<div class="mode-tabs">
<button class="mode-tab active" data-mode="clipper">Clipper</button>
<button class="mode-tab" data-mode="cart">Cart</button>
</div>
<div id="clipperPanel">
<div id="authWarning" class="auth-warning" style="display: none;">
Sign in to clip pages. <a id="openSettings">Open Settings</a>
</div>
@ -92,6 +119,35 @@
<div id="status" class="status"></div>
</div><!-- end clipperPanel -->
<div id="cartPanel" style="display:none">
<div class="controls">
<div>
<label for="spaceSelect">Space</label>
<select id="spaceSelect"><option value="demo">demo</option></select>
</div>
<div>
<label for="cartSelect">Cart</label>
<select id="cartSelect"><option value="">Loading...</option></select>
</div>
</div>
<div class="cart-preview" id="cartPreview">
<div class="empty-msg">Select a cart to see details</div>
</div>
<div class="actions">
<button class="btn-primary" id="addToCartBtn">+ Add to Cart</button>
</div>
<div class="actions">
<button class="btn-link" id="viewCartBtn">View Cart in rSpace</button>
</div>
<div id="cartStatus" class="cart-status"></div>
</div><!-- end cartPanel -->
<div class="footer">
<a href="#" id="optionsLink">Settings</a>
</div>

View File

@ -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 = '<div class="empty-msg">Cart is empty</div>';
return;
}
const vendors = summary.vendorGroups || [];
const vendorHtml = vendors.map((v) =>
`<div class="vendor-bullet">${v.domain || v.vendor || 'unknown'} (${v.count || v.items || 0})</div>`
).join('');
el.innerHTML = `
<div class="preview-card">
<div class="preview-row"><span>Items</span><span class="value">${summary.totalItems}</span></div>
<div class="preview-row"><span>Total</span><span class="value">$${(summary.totalAmount || 0).toFixed(2)}</span></div>
${vendors.length > 0 ? `<div class="vendor-list">${vendorHtml}</div>` : ''}
</div>`;
}
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 = '<option value="">Loading...</option>';
const carts = await fetchCarts(space);
const { selectedCartId } = await chrome.storage.sync.get(['selectedCartId']);
cartSelect.innerHTML = '';
if (carts.length === 0) {
cartSelect.innerHTML = '<option value="">No carts</option>';
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();
}
});

View File

@ -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 `
<div class="ext-banner">
<button class="ext-banner-dismiss" data-action="dismiss-banner" title="Dismiss">&times;</button>
<div class="ext-banner-title">Add products from any store</div>
<div class="ext-banner-text">Install the rSpace browser extension to add items from Amazon, Etsy, Shopify, and more directly to your carts.</div>
<a class="ext-banner-cta" href="https://rspace.online/extension" target="_blank" rel="noopener">Get Extension</a>
</div>`;
}
// ── Cart list view ──
private renderCarts(): string {
@ -423,6 +448,7 @@ class FolkCartShop extends HTMLElement {
if (this.carts.length === 0) {
return `
${this.renderExtensionBanner()}
<div class="empty">
<p>No shopping carts yet. Create one to start group shopping.</p>
<button data-action="new-cart" class="btn btn-primary">+ New Cart</button>
@ -431,6 +457,7 @@ class FolkCartShop extends HTMLElement {
}
return `
${this.renderExtensionBanner()}
<div style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<button data-action="new-cart" class="btn btn-primary btn-sm">+ New Cart</button>
${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; }