Skip to content

Commit 3e5fffa

Browse files
committed
feat(extension): attractive add button + closable panel/popup
- On-page button: gradient pill with the shoplit bag icon and a hover lift. - Header bar with the shoplit mark + a ✕ close on every view; panel closes (removes itself), popup closes (window.close). - Popup/panel share one injected stylesheet so they look identical.
1 parent 83ee698 commit 3e5fffa

4 files changed

Lines changed: 72 additions & 50 deletions

File tree

extension/src/add-ui.ts

Lines changed: 47 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
// extension/src/add-ui.ts
22
// Framework-free add UI shared by the toolbar popup and the injected in-page
33
// panel. Renders into the given root; owns its own (injected) styles so popup
4-
// and panel look identical.
4+
// and panel look identical. Every view has a header with a close (✕) button.
55
import type { Cart, ExtractedProduct } from "./types";
66
import { listCarts, addProduct } from "./api";
77

88
interface RenderOpts {
99
root: HTMLElement;
1010
product: ExtractedProduct | null;
1111
onConnectNeeded: () => void;
12+
/** Close the surface (popup → window.close; panel → remove). */
13+
onClose?: () => void;
1214
/** Called after a successful add (e.g. to close the injected panel). */
1315
onAdded?: () => void;
1416
}
@@ -20,33 +22,39 @@ const CSS = `
2022
@keyframes sl-pop { 0%{transform:scale(0);} 55%{transform:scale(1.18);} 100%{transform:scale(1);} }
2123
@keyframes sl-fade { from { opacity:0; } to { opacity:1; } }
2224
.sl-root { animation: sl-in .24s cubic-bezier(.2,.7,.3,1); font: 14px/1.45 system-ui, -apple-system, sans-serif; color:#1a1a1a; }
23-
.sl-pad { padding:16px; display:flex; flex-direction:column; gap:10px; }
25+
.sl-bar { display:flex; align-items:center; justify-content:space-between; padding:11px 14px; border-bottom:1px solid #ece5d8; background:#fbf7f0; border-radius:12px 12px 0 0; }
26+
.sl-logo { display:inline-flex; align-items:center; gap:7px; font-weight:700; font-size:14px; color:#1a1a1a; letter-spacing:-.01em; }
27+
.sl-logo svg { width:18px; height:18px; }
28+
.sl-x { background:none; border:0; font-size:17px; line-height:1; color:#8a8a8a; cursor:pointer; padding:3px 7px; border-radius:7px; transition:background .15s, color .15s; }
29+
.sl-x:hover { background:rgba(0,0,0,.06); color:#1a1a1a; }
30+
.sl-pad { padding:14px; display:flex; flex-direction:column; gap:10px; }
2431
.sl-head { display:flex; gap:11px; align-items:flex-start; }
2532
.sl-thumb { width:56px; height:56px; object-fit:cover; border-radius:10px; background:#ece7de; flex:none; }
2633
.sl-input { width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #d9d2c5; border-radius:9px; font:inherit; background:#fff; transition:border-color .15s, box-shadow .15s; }
2734
.sl-input:focus { outline:none; border-color:#B5532A; box-shadow:0 0 0 3px rgba(181,83,42,.16); }
2835
.sl-title-in { font-weight:600; }
29-
.sl-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; padding:10px; border:0; border-radius:999px; background:#1a1a1a; color:#fbf7f0; font:inherit; font-weight:600; cursor:pointer; transition:transform .12s ease, opacity .15s, background .2s; }
36+
.sl-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; padding:10px; border:0; border-radius:999px; background:#1a1a1a; color:#fbf7f0; font:inherit; font-weight:600; cursor:pointer; transition:transform .12s ease, opacity .15s, box-shadow .2s; }
3037
.sl-btn:hover { transform:translateY(-1px); }
3138
.sl-btn:active { transform:translateY(0); }
3239
.sl-btn[disabled] { opacity:.75; cursor:default; transform:none; }
33-
.sl-btn-accent { background:#B5532A; }
34-
.sl-link { background:none; border:0; color:#B5532A; font:inherit; font-weight:600; cursor:pointer; padding:4px; }
40+
.sl-btn-accent { background:linear-gradient(135deg,#C2410C,#9A3412); box-shadow:0 3px 12px rgba(154,52,18,.3); }
3541
.sl-spinner { width:15px; height:15px; border:2px solid rgba(255,255,255,.45); border-top-color:#fff; border-radius:50%; animation:sl-spin .7s linear infinite; }
3642
.sl-msg { margin:0; font-size:12px; color:#6b6b6b; min-height:1em; }
37-
.sl-success { display:flex; flex-direction:column; align-items:center; gap:10px; padding:28px 18px; text-align:center; }
43+
.sl-success { display:flex; flex-direction:column; align-items:center; gap:10px; padding:30px 18px; text-align:center; }
3844
.sl-check { width:56px; height:56px; border-radius:50%; background:#1f9d55; color:#fff; display:grid; place-items:center; font-size:30px; line-height:1; animation:sl-pop .42s cubic-bezier(.2,.8,.3,1.25); }
3945
.sl-success strong { font-size:16px; }
4046
.sl-sub { color:#6b6b6b; font-size:13px; animation:sl-fade .3s .15s both; }
4147
`;
4248

49+
// shoplit bag mark, inline so it works in popup + content script.
50+
const MARK = `<svg viewBox="0 0 64 64" aria-hidden><rect width="64" height="64" rx="14" fill="#B5532A"/><path d="M24 28 a8 8 0 0 1 16 0" fill="none" stroke="#F4EBDD" stroke-width="3.6" stroke-linecap="round"/><path d="M18.5 27 h27 l2 19.2 a4.5 4.5 0 0 1-4.5 5 H21 a4.5 4.5 0 0 1-4.5-5 z" fill="#F4EBDD"/></svg>`;
51+
4352
function ensureStyles() {
44-
const doc = document;
45-
if (doc.getElementById(STYLE_ID)) return;
46-
const s = doc.createElement("style");
53+
if (document.getElementById(STYLE_ID)) return;
54+
const s = document.createElement("style");
4755
s.id = STYLE_ID;
4856
s.textContent = CSS;
49-
(doc.head || doc.documentElement).appendChild(s);
57+
(document.head || document.documentElement).appendChild(s);
5058
}
5159

5260
async function getToken(): Promise<string | null> {
@@ -55,31 +63,41 @@ async function getToken(): Promise<string | null> {
5563
);
5664
}
5765

58-
export async function renderAddUI({ root, product, onConnectNeeded, onAdded }: RenderOpts) {
66+
export async function renderAddUI(opts: RenderOpts) {
67+
const { root, product, onConnectNeeded, onClose, onAdded } = opts;
5968
ensureStyles();
69+
70+
// Wrap inner HTML in the shell (header + close) and wire the ✕.
71+
const paint = (inner: string) => {
72+
root.innerHTML =
73+
`<div class="sl-root"><div class="sl-bar"><span class="sl-logo">${MARK}shoplit</span>` +
74+
(onClose ? `<button class="sl-x" aria-label="Close">✕</button>` : ``) +
75+
`</div>${inner}</div>`;
76+
if (onClose) root.querySelector<HTMLButtonElement>(".sl-x")!.onclick = onClose;
77+
};
78+
const $ = <T extends HTMLElement>(s: string) => root.querySelector<T>(s)!;
79+
6080
const token = await getToken();
6181

6282
if (!token) {
63-
root.innerHTML = `<div class="sl-root"><div class="sl-pad">
83+
paint(`<div class="sl-pad">
6484
<p style="margin:0">Connect the extension to your shoplit account.</p>
6585
<button id="sl-connect" class="sl-btn sl-btn-accent">Connect shoplit account</button>
6686
<p class="sl-msg">Already opened the connect page? Paste the code:</p>
6787
<input id="sl-code" class="sl-input" placeholder="Paste connection code" />
6888
<button id="sl-save-code" class="sl-btn">Connect</button>
69-
</div></div>`;
70-
root.querySelector<HTMLButtonElement>("#sl-connect")!.onclick = onConnectNeeded;
71-
root.querySelector<HTMLButtonElement>("#sl-save-code")!.onclick = () => {
72-
const code = root.querySelector<HTMLInputElement>("#sl-code")!.value.trim();
89+
</div>`);
90+
$("#sl-connect").onclick = onConnectNeeded;
91+
$("#sl-save-code").onclick = () => {
92+
const code = $<HTMLInputElement>("#sl-code").value.trim();
7393
if (!code) return;
74-
chrome.storage.local.set({ token: code }, () => renderAddUI({ root, product, onConnectNeeded, onAdded }));
94+
chrome.storage.local.set({ token: code }, () => renderAddUI(opts));
7595
};
7696
return;
7797
}
7898

7999
if (!product) {
80-
root.innerHTML = `<div class="sl-root"><div class="sl-pad">
81-
<p style="margin:0">Couldn't find a product on this page. Open a product page and try again.</p>
82-
</div></div>`;
100+
paint(`<div class="sl-pad"><p style="margin:0">Couldn't find a product on this page. Open a product page and try again.</p></div>`);
83101
return;
84102
}
85103

@@ -88,13 +106,12 @@ export async function renderAddUI({ root, product, onConnectNeeded, onAdded }: R
88106
carts = await listCarts();
89107
} catch (e) {
90108
if ((e as Error).message === "unauthorized") return onConnectNeeded();
91-
root.innerHTML = `<div class="sl-root"><div class="sl-pad"><p style="margin:0">Couldn't load your carts. Try again.</p></div></div>`;
109+
paint(`<div class="sl-pad"><p style="margin:0">Couldn't load your carts. Try again.</p></div>`);
92110
return;
93111
}
94112

95113
const options = carts.map((c) => `<option value="${escapeAttr(c.id)}">${escapeHtml(c.title)}</option>`).join("");
96-
root.innerHTML = `
97-
<div class="sl-root"><div class="sl-pad">
114+
paint(`<div class="sl-pad">
98115
<div class="sl-head">
99116
<img src="${escapeAttr(product.imageUrl)}" class="sl-thumb" alt=""/>
100117
<input id="sl-title" class="sl-input sl-title-in" value="${escapeAttr(product.title)}"/>
@@ -106,9 +123,8 @@ export async function renderAddUI({ root, product, onConnectNeeded, onAdded }: R
106123
<input id="sl-note" class="sl-input" placeholder="Note (optional)"/>
107124
<button id="sl-add" class="sl-btn sl-btn-accent">+ Add to cart</button>
108125
<p id="sl-msg" class="sl-msg"></p>
109-
</div></div>`;
126+
</div>`);
110127

111-
const $ = <T extends HTMLElement>(s: string) => root.querySelector<T>(s)!;
112128
$("#sl-add").onclick = async () => {
113129
const btn = $<HTMLButtonElement>("#sl-add");
114130
btn.disabled = true;
@@ -128,7 +144,12 @@ export async function renderAddUI({ root, product, onConnectNeeded, onAdded }: R
128144
},
129145
$<HTMLInputElement>("#sl-note").value.trim(),
130146
);
131-
showSuccess(root, cartName, onAdded);
147+
paint(`<div class="sl-success">
148+
<div class="sl-check">✓</div>
149+
<div><strong>Added!</strong></div>
150+
<div class="sl-sub">Saved to ${escapeHtml(cartName)}</div>
151+
</div>`);
152+
if (onAdded) setTimeout(onAdded, 1400);
132153
} catch (e) {
133154
if ((e as Error).message === "unauthorized") return onConnectNeeded();
134155
btn.disabled = false;
@@ -138,15 +159,6 @@ export async function renderAddUI({ root, product, onConnectNeeded, onAdded }: R
138159
};
139160
}
140161

141-
function showSuccess(root: HTMLElement, cartName: string, onAdded?: () => void) {
142-
root.innerHTML = `<div class="sl-root"><div class="sl-success">
143-
<div class="sl-check">✓</div>
144-
<div><strong>Added!</strong></div>
145-
<div class="sl-sub">Saved to ${escapeHtml(cartName)}</div>
146-
</div></div>`;
147-
if (onAdded) setTimeout(onAdded, 1400);
148-
}
149-
150162
function escapeHtml(s: string) {
151163
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]!));
152164
}

extension/src/content.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ chrome.runtime.onMessage.addListener((msg: Msg, _sender, sendResponse) => {
1212
});
1313

1414
const BTN_ID = "sl-add-btn";
15+
const BTN_STYLE_ID = "sl-btn-styles";
16+
17+
// White bag mark for the on-page button (button background is terracotta).
18+
const BTN_MARK = `<svg viewBox="0 0 64 64" width="17" height="17" aria-hidden style="flex:none"><path d="M24 28 a8 8 0 0 1 16 0" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/><path d="M18.5 27 h27 l2 19.2 a4.5 4.5 0 0 1-4.5 5 H21 a4.5 4.5 0 0 1-4.5-5 z" fill="#fff"/></svg>`;
19+
20+
function ensureButtonStyles() {
21+
if (document.getElementById(BTN_STYLE_ID)) return;
22+
const s = document.createElement("style");
23+
s.id = BTN_STYLE_ID;
24+
s.textContent = `
25+
@keyframes sl-btn-in { from{opacity:0;transform:translateY(6px) scale(.96)} to{opacity:1;transform:none} }
26+
.sl-fab-btn { display:inline-flex; align-items:center; gap:8px; background:linear-gradient(135deg,#C2410C,#9A3412); color:#fff; border:0; border-radius:999px; padding:10px 18px; font:600 14px/1 system-ui,-apple-system,sans-serif; cursor:pointer; box-shadow:0 4px 14px rgba(154,52,18,.35); transition:transform .16s ease, box-shadow .16s ease; animation:sl-btn-in .26s ease both; }
27+
.sl-fab-btn:hover { transform:translateY(-2px); box-shadow:0 9px 24px rgba(154,52,18,.45); }
28+
.sl-fab-btn:active { transform:translateY(0); }`;
29+
(document.head || document.documentElement).appendChild(s);
30+
}
1531

1632
// Inject an "Add to shoplit" button ONLY on single-product pages — placed on
1733
// its own line right under the product title. A full-width wrapper guarantees
@@ -22,15 +38,11 @@ function injectButton() {
2238
const product = extractProduct(document);
2339
if (!product) return;
2440

41+
ensureButtonStyles();
2542
const btn = document.createElement("button");
2643
btn.type = "button";
27-
btn.textContent = "+ Add to shoplit";
28-
Object.assign(btn.style, {
29-
display: "inline-flex", alignItems: "center", gap: "6px",
30-
background: "#B5532A", color: "#fff", border: "0", borderRadius: "999px",
31-
padding: "9px 16px", font: "600 14px system-ui, sans-serif", cursor: "pointer",
32-
boxShadow: "0 2px 8px rgba(0,0,0,.18)",
33-
});
44+
btn.className = "sl-fab-btn";
45+
btn.innerHTML = `${BTN_MARK}<span>Add to shoplit</span>`;
3446
btn.onclick = (e) => {
3547
e.preventDefault();
3648
e.stopPropagation();
@@ -78,6 +90,7 @@ function togglePanel(product: ReturnType<typeof extractProduct>) {
7890
root: panel,
7991
product,
8092
onConnectNeeded: () => window.open("https://shoplit.in/connect-extension", "_blank"),
93+
onClose: () => panel.remove(),
8194
onAdded: () => panel.remove(),
8295
});
8396
}

extension/src/popup.html

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@
33
<head>
44
<meta charset="utf-8" />
55
<style>
6-
body { width: 320px; margin: 0; font: 14px/1.4 system-ui, sans-serif; color: #1a1a1a; background: #fbf7f0; }
7-
.sl-pad { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
8-
.sl-row { display: flex; gap: 8px; align-items: center; }
9-
.sl-thumb { width: 44px; height: 44px; object-fit: cover; border-radius: 8px; background: #eee; }
10-
.sl-input { width: 100%; box-sizing: border-box; padding: 7px 9px; border: 1px solid #d9d2c5; border-radius: 8px; font: inherit; background: #fff; }
11-
.sl-btn { padding: 9px; border: 0; border-radius: 999px; background: #1a1a1a; color: #fbf7f0; font-weight: 600; cursor: pointer; }
12-
.sl-msg { margin: 0; font-size: 12px; color: #6b6b6b; }
6+
/* Base shell only — component styles are injected by add-ui (shared with the
7+
in-page panel so the two look identical). */
8+
html, body { margin: 0; }
9+
body { width: 320px; background: #fbf7f0; }
1310
</style>
1411
</head>
1512
<body><div id="root"></div><script type="module" src="popup.js"></script></body>

extension/src/popup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async function main() {
2323
if (!product && tab?.url) {
2424
product = { title: "", imageUrl: "", priceText: "", url: tab.url, retailer: retailerFromUrl(tab.url) };
2525
}
26-
await renderAddUI({ root, product, onConnectNeeded: openConnect });
26+
await renderAddUI({ root, product, onConnectNeeded: openConnect, onClose: () => window.close() });
2727
}
2828

2929
main();

0 commit comments

Comments
 (0)