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.
55import type { Cart , ExtractedProduct } from "./types" ;
66import { listCarts , addProduct } from "./api" ;
77
88interface 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+
4352function 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
5260async 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-
150162function escapeHtml ( s : string ) {
151163 return s . replace ( / [ & < > ] / g, ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" } [ c ] ! ) ) ;
152164}
0 commit comments