user.astro raw

   1  ---
   2  import Base from '../layouts/Base.astro';
   3  ---
   4  <Base title="Профил">
   5    <section class="section">
   6      <div class="container">
   7        <div id="user-content">Зареждане...</div>
   8      </div>
   9    </section>
  10  </Base>
  11  
  12  <script>
  13  import { escapeHtml } from '../lib/cart-client';
  14  import { t } from '../lib/i18n';
  15  
  16  let toastTimer: any = null;
  17  function showToast(msg: string) {
  18    const el = document.getElementById('admin-toast');
  19    if (!el) return;
  20    el.textContent = msg;
  21    el.classList.add('visible');
  22    clearTimeout(toastTimer);
  23    toastTimer = setTimeout(() => el.classList.remove('visible'), 2500);
  24  }
  25  
  26  async function apiFetch(path: string, opts?: RequestInit) {
  27    const headers: Record<string, string> = { ...(opts?.headers as any || {}) };
  28    const token = localStorage.getItem('token');
  29    if (token) headers['Authorization'] = 'Bearer ' + token;
  30    if (!headers['Content-Type'] && opts?.method !== 'GET' && opts?.body) headers['Content-Type'] = 'application/json';
  31    const r = await fetch(path, { ...opts, headers });
  32    return { resp: r, data: await r.json().catch(() => null) };
  33  }
  34  
  35  function show(msg: string) { document.getElementById('user-content')!.innerHTML = msg; }
  36  
  37  function formatLv(c: number) { return (c / 100).toFixed(2) + ' EUR'; }
  38  function fmtDate(s: string) { return s?.slice(0, 10) || ''; }
  39  function fmtOrderId(id: string) { return id?.slice(0, 8) || id; }
  40  
  41  function statusLabel(s: string): string {
  42    const keys: Record<string, string> = {
  43      pending: 'orderStatusPending', paid: 'orderStatusPaid', failed: 'orderStatusFailed', expired: 'orderStatusExpired',
  44      shipped: 'orderStatusShipped', delivered: 'orderStatusDelivered', cancelled: 'orderStatusCancelled',
  45    };
  46    return keys[s] ? t(keys[s]) : s;
  47  }
  48  function methodLabel(m: string): string {
  49    const keys: Record<string, string> = {
  50      stripe: 'orderPaymentStripe', lightning: 'orderPaymentLightning', onchain: 'orderPaymentOnchain', bank: 'orderPaymentBank', cod: 'orderPaymentCod',
  51    };
  52    return keys[m] ? t(keys[m]) : m;
  53  }
  54  
  55  async function main() {
  56    const { resp, data } = await apiFetch('/api/auth/session');
  57    if (!resp.ok || !data.user) {
  58      show(`<p>${t('userLoginPrompt')}</p><p><a href="/login" onclick="document.getElementById('login-btn')?.click();event.preventDefault()">${t('userToLogin')}</a></p>`);
  59      return;
  60    }
  61    const user = data.user;
  62  
  63    // new nostr user or user without password - show setup
  64    if (!user.delivery_prefs && !user.email || !user.has_password) {
  65      renderSetup(user);
  66      return;
  67    }
  68  
  69    if (user.is_admin) {
  70      renderAdmin(user);
  71    } else {
  72      const { data: orders } = await apiFetch('/api/user/orders');
  73      renderUser(user, orders);
  74    }
  75  }
  76  main();
  77  
  78  function renderSetupHTML(user: any, isEdit = false): string {
  79    let prefs: any = {};
  80    if (isEdit && user.delivery_prefs) {
  81      try { prefs = JSON.parse(user.delivery_prefs); } catch {}
  82    }
  83    return `
  84      <h2 style="margin-top:2em">${isEdit ? t('userProfileEdit') : t('userWelcome')}</h2>
  85      ${!isEdit ? `<p>${t('userSetupIntro')}</p>` : ''}
  86      <form id="setup-form" class="user-form">
  87        <table class="user-form-table">
  88          <tr><td><label>${t('userPrefsName')}</label></td><td><input type="text" id="s-name" value="${escapeHtml(prefs.name || user.name || '')}" /></td></tr>
  89          <tr><td><label>${t('userPrefsPhone')}</label></td><td><input type="tel" id="s-phone" value="${escapeHtml(prefs.phone || user.phone || '')}" /></td></tr>
  90          <tr><td><label>${t('email')}</label></td><td><input type="email" id="s-email" value="${escapeHtml(user.email || '')}" /></td></tr>
  91          <tr><td><label>${t('userPassword')}</label></td><td><input type="password" id="s-password" placeholder="${escapeHtml(t('userPasswordPlaceholder'))}" autocomplete="new-password" ${!user.has_password ? 'required' : ''} />${!user.has_password ? ` <small style="color:var(--primary);font-weight:600">(${escapeHtml(t('userPasswordRequired'))})</small>` : ''}</td></tr>
  92          <tr><td><label>${t('userPrefsCity')}</label></td><td><input type="text" id="s-city" list="city-list" value="${escapeHtml(prefs.city || '')}" /><datalist id="city-list"></datalist></td></tr>
  93          <tr><td><label>${t('postCode')}</label></td><td><input type="text" id="s-postcode" value="${escapeHtml(prefs.postCode || '')}" /></td></tr>
  94          <tr><td><label>${t('userPrefsAddress')}</label></td><td><input type="text" id="s-address" value="${escapeHtml(prefs.deliveryType === 'office' ? '' : (prefs.address || ''))}" /></td></tr>
  95          <tr><td><label>${t('suburb')}</label></td><td><input type="text" id="s-suburb" value="${escapeHtml(prefs.suburb || '')}" /></td></tr>
  96          <tr><td><label>${t('userPrefsCourier')}</label></td><td><select id="s-courier"><option value="econt" ${prefs.courier==='econt'?'selected':''}>Econt</option></select></td></tr>
  97          <tr><td><label>${t('userPrefsDeliveryType')}</label></td><td>
  98            <div class="delivery-type-options">
  99              <a href="#" class="delivery-type-link${prefs.deliveryType!=='office'?' active':''}" data-type="address">${t('userPrefsToAddress')}</a>
 100              <a href="#" class="delivery-type-link${prefs.deliveryType==='office'?' active':''}" data-type="office">${t('userPrefsToOffice')}</a>
 101            </div>
 102          </td></tr>
 103          <tr id="s-office-row" style="display:${prefs.deliveryType==='office'?'table-row':'none'}"><td><label>${t('checkoutSelectOffice')}</label></td><td>
 104            <input type="hidden" id="s-office-id" value="${escapeHtml(prefs.officeId || '')}" />
 105            <div class="office-scroll" id="s-office-scroll"></div>
 106            <div id="s-office-selected" style="font-size:0.85rem;margin-top:8px;color:var(--primary);font-weight:500"></div>
 107          </td></tr>
 108          <tr><td></td><td><button type="submit" class="btn btn-primary">${t('userSave')}</button>${isEdit ? ` <button type="button" class="btn btn-sm" id="cancel-edit">${escapeHtml(t('adminBack'))}</button>` : ''}</td></tr>
 109        </table>
 110      </form>
 111    `;
 112  }
 113  
 114  function renderSetup(user: any) {
 115    show(renderSetupHTML(user, false));
 116    attachSetupHandlers();
 117  }
 118  
 119  function attachSetupHandlers() {
 120    fetch('/api/cities').then(r => r.json()).then(cities => {
 121      document.getElementById('city-list')!.innerHTML = cities.map((c: string) => `<option value="${escapeHtml(c)}">`).join('');
 122    }).catch(() => {});
 123  
 124    document.querySelectorAll('.delivery-type-link').forEach(link => {
 125      link.addEventListener('click', (e) => {
 126        e.preventDefault();
 127        document.querySelectorAll('.delivery-type-link').forEach(l => l.classList.remove('active'));
 128        link.classList.add('active');
 129        const isOffice = link.getAttribute('data-type') === 'office';
 130        document.getElementById('s-office-row')!.style.display = isOffice ? 'table-row' : 'none';
 131        if (isOffice) loadOffices();
 132      });
 133    });
 134  
 135    document.getElementById('s-city')!.addEventListener('input', loadOffices);
 136    document.getElementById('s-courier')!.addEventListener('change', loadOffices);
 137  
 138    async function loadOffices() {
 139      const isOffice = document.querySelector('.delivery-type-link.active')?.getAttribute('data-type') === 'office';
 140      if (!isOffice) return;
 141      try {
 142        const r = await fetch('/api/offices?courier=econt');
 143        const offs = await r.json();
 144        const city = (document.getElementById('s-city') as HTMLInputElement).value.toLowerCase();
 145        const scroll = document.getElementById('s-office-scroll')!;
 146        const sel = document.getElementById('s-office-id') as HTMLInputElement;
 147        scroll.innerHTML = offs.map((o: any) =>
 148          `<div class="office-entry" data-id="${escapeHtml(o.id)}" data-name="${escapeHtml(o.name)} - ${escapeHtml(o.address)}">
 149            <strong>${escapeHtml(o.name)}</strong><br><small>${escapeHtml(o.address)}, ${escapeHtml(o.city)}</small>
 150          </div>`
 151        ).join('') || `<div class="office-entry" style="color:#888">${t('noOfficesFound')}</div>`;
 152        scroll.querySelectorAll('.office-entry').forEach(el => {
 153          el.addEventListener('click', () => {
 154            scroll.querySelectorAll('.office-entry').forEach(e => e.classList.remove('selected'));
 155            el.classList.add('selected');
 156            sel.value = (el as HTMLElement).dataset.id!;
 157            const selDiv = document.getElementById('s-office-selected');
 158            if (selDiv) selDiv.textContent = (el as HTMLElement).dataset.name || '';
 159          });
 160        });
 161        if (city) {
 162          let found: HTMLElement | null = null;
 163          scroll.querySelectorAll('.office-entry').forEach(el => {
 164            if (!found && (el as HTMLElement).textContent?.toLowerCase().includes(city)) found = el as HTMLElement;
 165          });
 166          if (found) found.scrollIntoView({ block: 'center' });
 167        }
 168        const savedId = (document.getElementById('s-office-id') as HTMLInputElement).value;
 169        if (savedId) {
 170          const savedEl = scroll.querySelector(`.office-entry[data-id="${CSS.escape(savedId)}"]`) as HTMLElement;
 171          if (savedEl) { savedEl.classList.add('selected'); }
 172          const selDiv = document.getElementById('s-office-selected');
 173          if (selDiv && savedEl) selDiv.textContent = (savedEl as HTMLElement).dataset.name || '';
 174        }
 175      } catch {}
 176    }
 177    loadOffices();
 178  
 179    document.getElementById('setup-form')!.addEventListener('submit', async (e) => {
 180      e.preventDefault();
 181      const btn = document.querySelector('#setup-form button[type="submit"]') as HTMLButtonElement;
 182      btn.disabled = true;
 183      btn.textContent = '...';
 184      const isOffice = document.querySelector('.delivery-type-link.active')?.getAttribute('data-type') === 'office';
 185      const pref = JSON.stringify({
 186        name: (document.getElementById('s-name') as HTMLInputElement).value,
 187        phone: (document.getElementById('s-phone') as HTMLInputElement).value,
 188        city: (document.getElementById('s-city') as HTMLInputElement).value,
 189        postCode: (document.getElementById('s-postcode') as HTMLInputElement).value,
 190        address: isOffice ? '' : (document.getElementById('s-address') as HTMLInputElement).value,
 191        suburb: isOffice ? '' : (document.getElementById('s-suburb') as HTMLInputElement).value,
 192        courier: (document.getElementById('s-courier') as HTMLSelectElement).value,
 193        deliveryType: isOffice ? 'office' : 'address',
 194        officeId: isOffice ? (document.getElementById('s-office-id') as HTMLInputElement).value : null,
 195      });
 196      const { resp, data } = await apiFetch('/api/user/prefs', {
 197        method: 'PUT',
 198        body: JSON.stringify({ email: (document.getElementById('s-email') as HTMLInputElement).value, name: (document.getElementById('s-name') as HTMLInputElement).value, phone: (document.getElementById('s-phone') as HTMLInputElement).value, delivery_prefs: JSON.parse(pref) }),
 199      });
 200      if (resp.ok) {
 201        const pwEl = document.getElementById('s-password') as HTMLInputElement;
 202        const pw = pwEl?.value;
 203        if (pw && pw.length >= 6) {
 204          await apiFetch('/api/user/password', { method: 'PUT', body: JSON.stringify({ password: pw }) });
 205        } else if (pwEl?.required && (!pw || pw.length < 6)) {
 206          alert(t('userPasswordRequired') + ': ' + t('userPasswordPlaceholder'));
 207          btn.disabled = false;
 208          btn.textContent = t('userSave');
 209          return;
 210        }
 211        location.reload();
 212      }
 213      btn.disabled = false;
 214      btn.textContent = t('userSave');
 215      if (!resp.ok) alert(data?.error || 'Save failed');
 216    });
 217  
 218    document.getElementById('cancel-edit')?.addEventListener('click', () => { location.reload(); });
 219  }
 220  
 221  function renderUser(user: any, orders: any[]) {
 222    const rows = !orders || orders.length === 0
 223      ? `<tr><td colspan="6" data-i18n="userNoOrders">${t('userNoOrders')}</td></tr>`
 224      : orders.map((o: any) => `<tr class="order-row" data-id="${o.id}">
 225          <td>${fmtDate(o.created_at)}</td>
 226          <td>${fmtOrderId(o.id)}</td>
 227          <td>${formatLv(o.total_cents)}</td>
 228          <td>${statusLabel(o.status)}</td>
 229          <td>${methodLabel(o.payment_method)}</td>
 230          <td>${o.status === 'pending' ? `<button class="btn btn-sm cancel-order" data-id="${o.id}">${t('orderCancel')}</button>` : ''}</td>
 231        </tr>`).join('');
 232  
 233    show(`<h1>${t('userProfile')}</h1>
 234      <p>&nbsp;</p>
 235      <h2>${t('userOrders')}</h2>
 236      <table class="user-table"><thead><tr><th>${t('userOrderDate')}</th><th>${t('userOrderId')}</th><th>${t('userOrderTotal')}</th><th>${t('userOrderStatus')}</th><th>${t('userOrderPayment')}</th><th></th></tr></thead><tbody>${rows}</tbody></table>
 237      <div id="profile-editor">${renderSetupHTML(user, true)}</div>`);
 238  
 239    document.querySelectorAll('.order-row').forEach(el => {
 240      el.addEventListener('click', (e) => {
 241        const target = e.target as HTMLElement;
 242        if (target.closest('.cancel-order') || target.closest('button')) return;
 243        window.location.href = `/user/order?id=${(el as HTMLElement).dataset.id!}`;
 244      });
 245    });
 246    document.querySelectorAll('.cancel-order').forEach(el => {
 247      el.addEventListener('click', async (e) => {
 248        e.stopPropagation();
 249        if (!confirm(t('adminDeleteConfirm'))) return;
 250        const btn = el as HTMLButtonElement;
 251        const { resp } = await apiFetch(`/api/user/orders/${btn.dataset.id}/cancel`, { method: 'POST' });
 252        if (resp.ok) location.reload();
 253      });
 254    });
 255    attachSetupHandlers();
 256  }
 257  
 258  function renderAdmin(user: any) {
 259    let html = `<h1>${t('adminTitle')}</h1>`;
 260    html += `<nav class="admin-nav"><a href="#" class="admin-nav-link active" data-tab="a-products">${t('adminProductsTab')}</a><a href="#" class="admin-nav-link" data-tab="a-orders">${t('adminOrdersTab')}</a><a href="#" class="admin-nav-link" data-tab="a-users">${t('adminUsersTab')}</a><a href="#" class="admin-nav-link" data-tab="a-messages">${t('adminMessagesTab')}</a><a href="#" class="admin-nav-link" data-tab="a-options">${t('adminOptionsTab')}</a><a href="#" class="admin-nav-link" data-tab="a-terms">${t('adminTermsTab')}</a><a href="#" class="admin-nav-link" data-tab="a-shipping">${t('adminShippingTab')}</a><a href="#" class="admin-nav-link" data-tab="a-stripe">${t('adminPaymentsTab')}</a></nav>`;
 261    html += `<div id="a-products" class="admin-tab active"></div><div id="a-orders" class="admin-tab" style="display:none"></div><div id="a-users" class="admin-tab" style="display:none"></div><div id="a-messages" class="admin-tab" style="display:none"></div><div id="a-options" class="admin-tab" style="display:none"></div><div id="a-terms" class="admin-tab" style="display:none"></div><div id="a-shipping" class="admin-tab" style="display:none"></div><div id="a-stripe" class="admin-tab" style="display:none"></div><div id="admin-toast" class="admin-toast"></div>`;
 262    show(html);
 263  
 264    document.querySelectorAll('.order-row').forEach(el => el.addEventListener('click', () => { window.location.href = `/user/order?id=${(el as HTMLElement).dataset.id!}`; }));
 265  
 266    loadAdminProducts();
 267    setupTabs();
 268  }
 269  
 270  window.addEventListener('lang-changed', () => { main(); });
 271  
 272  function setupTabs() {
 273    document.querySelectorAll('.admin-nav-link').forEach(link => {
 274      link.addEventListener('click', (e) => {
 275        e.preventDefault();
 276        document.querySelectorAll('.admin-nav-link, .admin-tab').forEach(el => el.classList.remove('active'));
 277        link.classList.add('active');
 278        const t = (link as HTMLElement).dataset.tab!;
 279        const el = document.getElementById(t)!;
 280        el.classList.add('active');
 281        el.style.display = 'block';
 282        document.querySelectorAll('.admin-tab').forEach((o: any) => { if (o.id !== t) o.style.display = 'none'; });
 283        if (t === 'a-orders') loadAdminOrders();
 284        if (t === 'a-users') loadAdminUsers();
 285        if (t === 'a-messages') loadAdminMessages();
 286        if (t === 'a-options') loadAdminOptions();
 287        if (t === 'a-terms') loadAdminTerms();
 288        if (t === 'a-shipping') loadAdminShipping();
 289        if (t === 'a-stripe') loadAdminPayments();
 290      });
 291    });
 292  }
 293  
 294  // ---- admin products (inline editing with image upload) ----
 295  function loadAdminProducts() {
 296    apiFetch('/api/products').then(({ data }) => {
 297      if (!Array.isArray(data)) return;
 298      const el = document.getElementById('a-products')!;
 299      let parsers: Record<string, string[]> = {};
 300      try { data.forEach((p: any) => { parsers[p.id] = JSON.parse(p.images || '[]'); }); } catch {}
 301  
 302      el.innerHTML = data.map((p: any) => `<table class="ap-row" data-id="${p.id}" data-slug="${escapeHtml(p.slug)}"><tr>
 303          <td class="ap-img-col" rowspan="2">
 304            <div class="ap-images">
 305              ${(parsers[p.id] || []).map((img: string) =>
 306                `<div class="ap-img-wrap"><img src="${escapeHtml(img)}" class="ap-thumb" /><button class="ap-img-remove">&times;</button></div>`
 307              ).join('')}
 308              <button class="ap-add-img" title="${t('adminNewProduct')}">
 309                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
 310              </button>
 311              <input type="file" accept="image/*" class="ap-upload" style="display:none" />
 312            </div>
 313          </td>
 314          <td class="ap-field-col">
 315            <div class="ap-fr"><label>BG</label><input type="text" class="ap-name" value="${escapeHtml(p.name)}" /></div>
 316            <div class="ap-fr"><label>EN</label><input type="text" class="ap-name-en" value="${escapeHtml(p.name_en||'')}" /></div>
 317          </td>
 318          <td class="ap-field-col">
 319            <div class="ap-fr"><label>${t('adminProdPrice')} (EUR)</label><input type="number" step="0.01" min="0" class="ap-price" value="${(p.price_cents/100).toFixed(2)}" /></div>
 320            <div class="ap-fr"><label>${t('adminProdWeight')}</label><input type="number" min="1" class="ap-unit-size" value="${p.unit_size||100}" /></div>
 321          </td>
 322          <td class="ap-field-col">
 323            <div class="ap-fr"><label>${t('adminProdStock')}</label><input type="number" min="0" class="ap-stock" value="${p.stock_count||0}" /></div>
 324            <div class="ap-fr"><label>${t('adminProdYear')}</label><input type="number" class="ap-harvest" value="${p.harvest_year||2025}" /></div>
 325          </td>
 326          <td class="ap-field-col ap-cat-col">
 327            <div class="ap-fr"><label>${t('adminProdCat')}</label><select class="ap-cat"><option value="bilki" ${p.category==='bilki'?'selected':''}>bilki</option><option value="plodove" ${p.category==='plodove'?'selected':''}>plodove</option></select></div>
 328          </td>
 329          <td class="ap-reorder-col" rowspan="2">
 330            <button class="ap-reorder-btn ap-up" title="move up">&uarr;</button>
 331            <button class="ap-reorder-btn ap-down" title="move down">&darr;</button>
 332          </td>
 333          <td class="ap-del-col" rowspan="2">
 334            <button class="ap-del-btn" title="${t('adminDeleteConfirm')}">&times;</button>
 335          </td>
 336        </tr><tr>
 337          <td colspan="4" class="ap-field-col" style="padding-top:0">
 338            <div class="ap-fr"><label>${t('adminProdDesc')} BG</label><textarea class="ap-desc" rows="2">${escapeHtml(p.description||'')}</textarea></div>
 339            <div class="ap-fr"><label>${t('adminProdDesc')} EN</label><textarea class="ap-desc-en" rows="2">${escapeHtml(p.description_en||'')}</textarea></div>
 340          </td>
 341          <td colspan="2" class="ap-field-col" style="padding-top:0">
 342            <div class="ap-fr"><label>${t('adminProdUsage')} BG</label><textarea class="ap-usage" rows="2">${escapeHtml(p.usage||'')}</textarea></div>
 343            <div class="ap-fr"><label>${t('adminProdUsage')} EN</label><textarea class="ap-usage-en" rows="2">${escapeHtml(p.usage_en||'')}</textarea></div>
 344          </td>
 345        </tr></table>
 346        <div class="ap-spacer"></div>`).join('') + `<div style="margin-top:0.5em"><button class="btn btn-sm new-prod">${t('adminNewProduct')}</button></div>`;
 347  
 348      el.querySelectorAll('.ap-row').forEach(tbl => {
 349        const row = tbl as HTMLElement;
 350        const id = row.dataset.id!;
 351        const upload = row.querySelector('.ap-upload') as HTMLInputElement;
 352        let debounce: any;
 353  
 354        function autoSave() {
 355          clearTimeout(debounce);
 356          debounce = setTimeout(() => {
 357            saveRow(row, parsers[id] || [], false).then(() => showToast(t('changesSaved')));
 358          }, 5000);
 359        }
 360  
 361        row.querySelector('.ap-add-img')!.addEventListener('click', () => upload.click());
 362  
 363        upload.addEventListener('change', async () => {
 364          const file = upload.files?.[0];
 365          if (!file) return;
 366          const form = new FormData(); form.append('file', file);
 367          try {
 368            const token = localStorage.getItem('token');
 369            const r = await fetch('/api/admin/upload', { method:'POST', headers: token ? {'Authorization':'Bearer '+token} : {}, body:form });
 370            const d = await r.json();
 371            if (!r.ok) { alert(d.error || 'Upload failed'); return; }
 372            if (d.url) {
 373              const imgs = parsers[id] || []; imgs.push(d.url); parsers[id] = imgs;
 374              await saveRow(row, imgs);
 375            }
 376          } catch (e: any) { alert(e.message || 'Upload failed'); }
 377        });
 378  
 379        // auto-save on any input/textarea/dropdown change (exclude file upload)
 380        row.querySelectorAll('input:not([type="file"]), select, textarea').forEach(el => {
 381          el.addEventListener('input', autoSave);
 382          el.addEventListener('change', () => { clearTimeout(debounce); saveRow(row, parsers[id] || [], false).then(() => showToast(t('changesSaved'))); });
 383        });
 384  
 385        // remove image
 386        row.querySelector('.ap-img-col')?.addEventListener('click', (e) => {
 387          const btn = (e.target as HTMLElement).closest('.ap-img-remove');
 388          if (btn) {
 389            e.stopPropagation();
 390            const idx = Array.from((row.querySelector('.ap-images')!).children).indexOf(btn.parentElement!);
 391            const imgs = parsers[id] || []; imgs.splice(idx, 1); parsers[id] = imgs;
 392            saveRow(row, imgs);
 393          }
 394        });
 395  
 396        // reorder
 397        row.querySelector('.ap-up')?.addEventListener('click', () => {
 398          apiFetch(`/api/admin/products/${id}/reorder`, { method:'POST', body:JSON.stringify({ direction:'up' }) }).then(() => loadAdminProducts());
 399        });
 400        row.querySelector('.ap-down')?.addEventListener('click', () => {
 401          apiFetch(`/api/admin/products/${id}/reorder`, { method:'POST', body:JSON.stringify({ direction:'down' }) }).then(() => loadAdminProducts());
 402        });
 403  
 404        // delete product row
 405        row.querySelector('.ap-del-btn')?.addEventListener('click', () => {
 406          if (confirm(t('adminDeleteConfirm'))) {
 407            apiFetch(`/api/admin/products/${id}`, { method:'DELETE' }).then(() => loadAdminProducts());
 408          }
 409        });
 410      });
 411  
 412      const newBtn = el.querySelector('.new-prod')!;
 413      newBtn.addEventListener('click', () => {
 414        apiFetch('/api/admin/products', { method:'POST', body:JSON.stringify({ name:'Нов продукт', slug:'new-product-'+Date.now(), price_cents:100, unit_size:100, unit:'g', category:'bilki', stock_count:0 }) }).then(() => loadAdminProducts());
 415      });
 416    });
 417  }
 418  
 419  function saveRow(row: HTMLElement, imgs: string[], reload = true): Promise<void> {
 420    const rawPrice = parseFloat((row.querySelector('.ap-price') as HTMLInputElement).value);
 421    const rawUnit = parseInt((row.querySelector('.ap-unit-size') as HTMLInputElement).value);
 422    const rawStock = parseInt((row.querySelector('.ap-stock') as HTMLInputElement).value);
 423    const rawHarvest = parseInt((row.querySelector('.ap-harvest') as HTMLInputElement).value);
 424    const data = {
 425      name: (row.querySelector('.ap-name') as HTMLInputElement).value,
 426      name_en: (row.querySelector('.ap-name-en') as HTMLInputElement).value,
 427      slug: row.dataset.slug || '',
 428      description: (row.querySelector('.ap-desc') as HTMLTextAreaElement)?.value || '',
 429      description_en: (row.querySelector('.ap-desc-en') as HTMLTextAreaElement)?.value || '',
 430      price_cents: isFinite(rawPrice) ? Math.round(rawPrice * 100) : 100,
 431      unit_size: isFinite(rawUnit) && rawUnit > 0 ? rawUnit : 100,
 432      unit: 'g',
 433      images: JSON.stringify(imgs),
 434      category: (row.querySelector('.ap-cat') as HTMLSelectElement).value,
 435      stock_count: isFinite(rawStock) ? Math.max(0, rawStock) : 0,
 436      harvest_year: isFinite(rawHarvest) ? rawHarvest : null,
 437      usage: (row.querySelector('.ap-usage') as HTMLTextAreaElement)?.value || '',
 438      usage_en: (row.querySelector('.ap-usage-en') as HTMLTextAreaElement)?.value || '',
 439    };
 440    return apiFetch(`/api/admin/products/${row.dataset.id}`, { method:'PUT', body:JSON.stringify(data) }).then(({ resp }) => {
 441      if (!resp.ok) { alert('Save failed: ' + resp.status); return; }
 442      if (reload) loadAdminProducts();
 443    });
 444  }
 445  
 446  // ---- admin orders (status transitions) ----
 447  function loadAdminOrders() {
 448    apiFetch('/api/admin/orders').then(({ data }) => {
 449      if (!Array.isArray(data)) return;
 450      document.getElementById('a-orders')!.innerHTML = `<table class="admin-table"><tr><th>${t('adminOrderDate')}</th><th>${t('adminOrderCustomer')}</th><th>${t('adminOrderTotal')}</th><th>${t('adminOrderStatus')}</th><th>${t('adminOrderPayment')}</th><th>${t('adminOrderAction')}</th></tr>
 451        ${data.map((o: any) => `<tr><td>${fmtDate(o.created_at)}</td><td>${escapeHtml(o.customer_name)}<br><small>${escapeHtml(o.customer_email)}</small></td><td>${formatLv(o.total_cents)}</td><td>${o.status}</td><td>${o.payment_method}/${o.payment_status}</td><td>
 452          <select class="status-select" data-id="${o.id}"><option value="">--</option><option value="paid">${t('adminStatusPaid')}</option><option value="shipped">${t('adminStatusShipped')}</option><option value="delivered">${t('adminStatusDelivered')}</option><option value="cancelled">${t('adminStatusCancelled')}</option></select>
 453          ${o.status === 'paid' && o.courier === 'econt' ? `<button class="btn btn-sm print-label-btn" data-id="${o.id}" style="margin-left:4px">${t('adminPrintLabel')}</button>` : ''}
 454        </td></tr>`).join('')}
 455      </table>`;
 456      document.getElementById('a-orders')!.querySelectorAll('.status-select').forEach(sel => sel.addEventListener('change', async (e: any) => {
 457        const to = e.target.value;
 458        const id = e.target.dataset.id!;
 459        if (!to) return;
 460        const { resp } = await apiFetch(`/api/admin/orders/${id}/status`, { method:'POST', body:JSON.stringify({to}) });
 461        if (resp.ok) { loadAdminOrders(); }
 462      }));
 463      document.getElementById('a-orders')!.querySelectorAll('.print-label-btn').forEach(btn => {
 464        btn.addEventListener('click', async () => {
 465          const id = (btn as HTMLElement).dataset.id!;
 466          if (!confirm('Създаване на етикет за пратка ' + id.slice(0,8) + '?')) return;
 467          (btn as HTMLButtonElement).disabled = true;
 468          const { resp, data } = await apiFetch(`/api/admin/orders/${id}/label`, { method: 'POST' });
 469          if (resp.ok && data.pdfURL) {
 470            window.open(data.pdfURL, '_blank');
 471            loadAdminOrders();
 472          } else {
 473            alert(data?.error || 'Label creation failed');
 474          }
 475          (btn as HTMLButtonElement).disabled = false;
 476        });
 477      });
 478    });
 479  }
 480  
 481  // ---- admin users ----
 482  function fmtPubkey(hex: string): string {
 483    if (!hex || hex.length < 64) return hex ? hex.slice(0, 8) + '...' : '—';
 484    try {
 485      const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
 486      const bytes = hex.match(/.{2}/g)!.map(b => parseInt(b, 16));
 487      const data: number[] = [];
 488      let acc = 0, bits = 0;
 489      for (const b of bytes) { acc = (acc << 8) | b; bits += 8; while (bits >= 5) { bits -= 5; data.push((acc >> bits) & 0x1f); } }
 490      if (bits > 0) data.push((acc << (5 - bits)) & 0x1f);
 491      const polymod = (pre: number) => { const b = pre >> 25; return ((pre & 0x1ffffff) << 5) ^ (-((b>>0)&1) & 0x3b6a57b2) ^ (-((b>>1)&1) & 0x26508e6d) ^ (-((b>>2)&1) & 0x1ea119fa) ^ (-((b>>3)&1) & 0x3d4233dd) ^ (-((b>>4)&1) & 0x2a1462b3); };
 492      let chk = 1;
 493      const prefix = 'npub';
 494      for (let i = 0; i < prefix.length; i++) { chk = polymod(chk) ^ (prefix.charCodeAt(i) >> 5); }
 495      chk = polymod(chk);
 496      for (let i = 0; i < prefix.length; i++) { chk = polymod(chk) ^ (prefix.charCodeAt(i) & 0x1f); }
 497      for (const w of data) { chk = polymod(chk) ^ w; }
 498      for (let i = 0; i < 6; i++) { chk = polymod(chk); }
 499      chk ^= 1;
 500      for (let i = 0; i < 6; i++) { data.push((chk >> ((5 - i) * 5)) & 0x1f); }
 501      const enc = prefix + '1' + data.map(v => CHARSET[v]).join('');
 502      return enc.slice(0, 10) + '...' + enc.slice(-5);
 503    } catch { return hex.slice(0, 8) + '...'; }
 504  }
 505  
 506  function loadAdminUsers() {
 507    apiFetch('/api/admin/users').then(({ data }) => {
 508      if (!Array.isArray(data)) return;
 509      document.getElementById('a-users')!.innerHTML = `
 510      <h3 style="margin-bottom:0.5rem">${t('adminNewUser')}</h3>
 511      <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:1.25rem">
 512        <input type="email" id="new-user-email" placeholder="${t('adminNewUserEmail')}" style="padding:0.5rem;border:1px solid #ddd;border-radius:6px;font-size:0.9rem" />
 513        <input type="password" id="new-user-password" placeholder="${t('adminNewUserPassword')}" style="padding:0.5rem;border:1px solid #ddd;border-radius:6px;font-size:0.9rem" />
 514        <label style="display:flex;align-items:center;gap:4px;font-size:0.9rem"><input type="checkbox" id="new-user-admin" /> ${t('adminNewUserAdmin')}</label>
 515        <button class="btn btn-sm" id="create-user-btn">${t('adminCreateUser')}</button>
 516      </div>
 517      <table class="admin-table"><tr><th>${t('adminUserEmail')}</th><th>${t('adminUserPubkey')}</th><th>${t('adminUserAdmin')}</th><th>${t('adminUserOrders')}</th><th></th></tr>
 518        ${data.map((u: any) => `<tr data-id="${u.id}">
 519          <td>${escapeHtml(u.email||'—')}</td>
 520          <td style="font-family:monospace;font-size:0.8rem">${escapeHtml(fmtPubkey(u.nostr_pubkey || ''))}</td>
 521          <td><input type="checkbox" class="admin-toggle" ${u.is_admin?'checked':''} /></td>
 522          <td>${u.order_count||0}</td>
 523          <td><button class="btn btn-sm view-user-orders">${t('adminUserViewOrders')}</button></td>
 524        </tr>`).join('')}
 525      </table>
 526      <div id="user-order-detail" style="margin-top:1rem"></div>`;
 527  
 528      document.getElementById('create-user-btn')!.addEventListener('click', async () => {
 529        const email = (document.getElementById('new-user-email') as HTMLInputElement).value.trim();
 530        const password = (document.getElementById('new-user-password') as HTMLInputElement).value;
 531        const isAdmin = (document.getElementById('new-user-admin') as HTMLInputElement).checked;
 532        if (!email || password.length < 6) { alert(t('adminNewUserEmail') + ' / ' + t('adminNewUserPassword')); return; }
 533        const { resp, data: d } = await apiFetch('/api/admin/users', { method:'POST', body:JSON.stringify({ email, password, is_admin: isAdmin ? 1 : 0 }) });
 534        if (resp.ok) { loadAdminUsers(); } else { alert(d?.error || 'Create failed'); }
 535      });
 536  
 537      document.getElementById('a-users')!.querySelectorAll('.admin-toggle').forEach(cb => {
 538        cb.addEventListener('change', (e: any) => {
 539          const id = (e.target.closest('tr') as HTMLElement).dataset.id!;
 540          apiFetch(`/api/admin/users/${id}/admin`, { method:'POST', body:JSON.stringify({is_admin: e.target.checked?1:0}) });
 541        });
 542      });
 543      document.getElementById('a-users')!.querySelectorAll('.view-user-orders').forEach(btn => {
 544        btn.addEventListener('click', () => {
 545          const id = (btn as HTMLElement).closest('tr')!.dataset.id!;
 546          apiFetch(`/api/admin/users/${id}/orders`).then(({ data: uo }) => {
 547            const det = document.getElementById('user-order-detail')!;
 548            if (!Array.isArray(uo) || uo.length === 0) { det.innerHTML = `<p>${t('adminUserNoOrders')}</p>`; return; }
 549              det.innerHTML = `<h3>${t('adminUserOrders')}</h3><table class="admin-table"><tr><th>${t('adminOrderDate')}</th><th>${t('adminOrderCustomer')}</th><th>${t('adminOrderTotal')}</th><th>${t('adminOrderStatus')}</th></tr>
 550              ${uo.map((o: any) => `<tr><td>${fmtDate(o.created_at)}</td><td>${escapeHtml(o.customer_name||'')}</td><td>${formatLv(o.total_cents)}</td><td>${statusLabel(o.status)}</td></tr>`).join('')}</table>`;
 551          });
 552        });
 553      });
 554    });
 555  }
 556  function loadAdminMessages() {
 557    apiFetch('/api/admin/messages').then(({ data }) => {
 558      const rows = data || [];
 559      document.getElementById('a-messages')!.innerHTML = `
 560        <h2>${t('adminMessagesTab')}</h2>
 561        ${rows.length === 0 ? `<p>${t('adminMessagesNone')}</p>` : `<table class="admin-table" id="admin-msgs-table"><tr><th>${t('contactFrom')}</th><th>${t('email')}</th><th>${t('contactSubject')}</th><th>${t('adminMessagesDate')}</th></tr>
 562          ${rows.map((r: any, i: number) => `<tr class="msg-row" data-idx="${i}" style="cursor:pointer"><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.email)}</td><td>${escapeHtml(r.subject === 'degustation' ? t('contactDegustation') : r.subject === 'pickup' ? t('contactPickup') : t('contactGeneral'))}</td><td>${fmtDate(r.created_at)}</td></tr>
 563          <tr class="msg-body" id="msg-body-${i}" style="display:none"><td colspan="4" style="white-space:pre-wrap;word-break:break-word;background:#f9faf7;padding:0.75rem 1rem;font-size:0.9rem">${escapeHtml(r.message)}</td></tr>`).join('')}
 564        </table>`}
 565      `;
 566      document.getElementById('admin-msgs-table')?.querySelectorAll('.msg-row').forEach(row => {
 567        row.addEventListener('click', () => {
 568          const idx = (row as HTMLElement).dataset.idx!;
 569          const body = document.getElementById('msg-body-' + idx)!;
 570          body.style.display = body.style.display === 'none' ? 'table-row' : 'none';
 571        });
 572      });
 573    });
 574  }
 575  function loadAdminOptions() {
 576    apiFetch('/api/options').then(({ data }) => {
 577      const aboutImg = data?.about_image || '';
 578      const aboutBg = data?.about_md_bg || '';
 579      const aboutEn = data?.about_md_en || '';
 580      const delImg = data?.delivery_image || '';
 581      const delBg = data?.delivery_md_bg || '';
 582      const delEn = data?.delivery_md_en || '';
 583      const contactPhone = data?.contact_phone || '+359 896 214 445';
 584      const contactEmail = data?.contact_email || 'shop@zlattea.com';
 585      const contactAddr = data?.contact_address || 'гр. София, бул. „Цар Борис“ III № 382';
 586      const contactSvcsBg = data?.contact_services_md_bg || '';
 587      const contactSvcsEn = data?.contact_services_md_en || '';
 588      const contactPickBg = data?.contact_pickup_md_bg || '';
 589      const contactPickEn = data?.contact_pickup_md_en || '';
 590      const smtpHost = data?.smtp_host || '';
 591      const smtpPort = data?.smtp_port || '';
 592      const smtpUser = data?.smtp_user || '';
 593      const smtpPass = data?.smtp_pass || '';
 594      const smtpFrom = data?.smtp_from || '';
 595  
 596      document.getElementById('a-options')!.innerHTML = `
 597        <h2>${t('adminOptionsAbout')}</h2>
 598        <div class="form-group"><label>${t('adminOptionsAboutImage')}</label><input type="text" class="opt-input" data-key="about_image" value="${escapeHtml(aboutImg)}" placeholder="/images/about-image.jpg" /></div>
 599        <div class="form-group"><label>${t('adminOptionsAboutText')} BG (markdown)</label><textarea class="opt-input-md" data-key="about_md_bg" rows="6">${escapeHtml(aboutBg)}</textarea></div>
 600        <div class="form-group"><label>${t('adminOptionsAboutText')} EN (markdown)</label><textarea class="opt-input-md" data-key="about_md_en" rows="6">${escapeHtml(aboutEn)}</textarea></div>
 601  
 602        <h2 style="margin-top:2rem">${t('deliveryTitle')}</h2>
 603        <div class="form-group"><label>${t('adminOptionsAboutImage')}</label><input type="text" class="opt-input" data-key="delivery_image" value="${escapeHtml(delImg)}" placeholder="/images/delivery-image.jpg" /></div>
 604        <div class="form-group"><label>BG (markdown)</label><textarea class="opt-input-md" data-key="delivery_md_bg" rows="8">${escapeHtml(delBg)}</textarea></div>
 605        <div class="form-group"><label>EN (markdown)</label><textarea class="opt-input-md" data-key="delivery_md_en" rows="8">${escapeHtml(delEn)}</textarea></div>
 606  
 607        <h2 style="margin-top:2rem">${t('contactTitle')}</h2>
 608        <div class="form-group"><label>${t('contactPhoneLabel')}</label><input type="text" class="opt-input" data-key="contact_phone" value="${escapeHtml(contactPhone)}" /></div>
 609        <div class="form-group"><label>${t('contactEmailLabel')}</label><input type="text" class="opt-input" data-key="contact_email" value="${escapeHtml(contactEmail)}" /></div>
 610        <div class="form-group"><label>${t('contactAddressLabel')}</label><input type="text" class="opt-input" data-key="contact_address" value="${escapeHtml(contactAddr)}" /></div>
 611        <div class="form-group"><label>${t('adminOptionsServices')} BG (markdown)</label><textarea class="opt-input-md" data-key="contact_services_md_bg" rows="4">${escapeHtml(contactSvcsBg)}</textarea></div>
 612        <div class="form-group"><label>${t('adminOptionsServices')} EN (markdown)</label><textarea class="opt-input-md" data-key="contact_services_md_en" rows="4">${escapeHtml(contactSvcsEn)}</textarea></div>
 613        <div class="form-group"><label>${t('adminOptionsPickup')} BG (markdown)</label><textarea class="opt-input-md" data-key="contact_pickup_md_bg" rows="3">${escapeHtml(contactPickBg)}</textarea></div>
 614        <div class="form-group"><label>${t('adminOptionsPickup')} EN (markdown)</label><textarea class="opt-input-md" data-key="contact_pickup_md_en" rows="3">${escapeHtml(contactPickEn)}</textarea></div>
 615  
 616        <h2 style="margin-top:2rem">${t('emailTitle')}</h2>
 617        <div class="form-group"><label>${t('smtpHost')}</label><input type="text" class="opt-input" data-key="smtp_host" value="${escapeHtml(smtpHost)}" placeholder="smtp.gmail.com" /></div>
 618        <div class="form-group"><label>${t('smtpPort')}</label><input type="text" class="opt-input" data-key="smtp_port" value="${escapeHtml(smtpPort)}" placeholder="587" /></div>
 619        <div class="form-group"><label>${t('smtpUser')}</label><input type="text" class="opt-input" data-key="smtp_user" value="${escapeHtml(smtpUser)}" placeholder="you@gmail.com" /></div>
 620        <div class="form-group"><label>${t('smtpPass')}</label><input type="password" class="opt-input" data-key="smtp_pass" value="${escapeHtml(smtpPass)}" placeholder="app password" /></div>
 621        <div class="form-group"><label>${t('smtpFrom')}</label><input type="text" class="opt-input" data-key="smtp_from" value="${escapeHtml(smtpFrom)}" placeholder="shop@zlattea.com" /></div>
 622  
 623        <div style="margin-top:1rem"><button class="btn btn-primary" id="save-options">${t('userSave')}</button></div>
 624      `;
 625  
 626      let optDebounce: any;
 627      function saveOptionsNow() {
 628        const data: Record<string, string> = {};
 629        document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('.opt-input, .opt-input-md').forEach(el => {
 630          data[el.dataset.key!] = el.value;
 631        });
 632        apiFetch('/api/admin/options', { method: 'PUT', body: JSON.stringify(data) }).then(({ resp }) => {
 633          if (resp.ok) showToast(t('changesSaved'));
 634        });
 635      }
 636  
 637      document.getElementById('save-options')!.addEventListener('click', saveOptionsNow);
 638  
 639      document.querySelectorAll('.opt-input, .opt-input-md').forEach(el => {
 640        el.addEventListener('input', () => {
 641          clearTimeout(optDebounce);
 642          optDebounce = setTimeout(saveOptionsNow, 5000);
 643        });
 644        el.addEventListener('change', () => {
 645          clearTimeout(optDebounce);
 646          saveOptionsNow();
 647        });
 648      });
 649    });
 650  }
 651  
 652  function loadAdminPayments() {
 653    Promise.all([
 654      apiFetch('/api/admin/stripe-config'),
 655      apiFetch('/api/admin/btcpay-config'),
 656    ]).then(([stripeData, btcpayData]) => {
 657      const sk = stripeData.data?.stripe_secret_key || '';
 658      const pk = stripeData.data?.stripe_publishable_key || '';
 659      const ws = stripeData.data?.stripe_webhook_secret || '';
 660      const bu = btcpayData.data?.btcpay_url || '';
 661      const bk = btcpayData.data?.btcpay_api_key || '';
 662      const bs = btcpayData.data?.btcpay_store_id || '';
 663      document.getElementById('a-stripe')!.innerHTML = `
 664        <h2>Stripe</h2>
 665        <div class="form-group"><label>${t('adminStripeSecretKey')}</label><input type="text" id="stripe-sk" class="stripe-cfg-input" value="${escapeHtml(sk)}" placeholder="sk_live_... or sk_test_..." /></div>
 666        <div class="form-group"><label>${t('adminStripePublishableKey')}</label><input type="text" id="stripe-pk" class="stripe-cfg-input" value="${escapeHtml(pk)}" placeholder="pk_live_... or pk_test_..." /></div>
 667        <div class="form-group"><label>${t('adminStripeWebhookSecret')} <span style="color:#888;font-weight:400">(${t('optional')})</span></label><input type="text" id="stripe-ws" class="stripe-cfg-input" value="${escapeHtml(ws)}" placeholder="whsec_..." /></div>
 668        <h2 style="margin-top:2rem">BTCPay Server</h2>
 669        <div class="form-group"><label>${t('adminBtcpayUrl')}</label><input type="text" id="btcpay-url" class="stripe-cfg-input" value="${escapeHtml(bu)}" placeholder="https://btcpay.example.com" /></div>
 670        <div class="form-group"><label>${t('adminBtcpayApiKey')}</label><input type="text" id="btcpay-apikey" class="stripe-cfg-input" value="${escapeHtml(bk)}" /></div>
 671        <div class="form-group"><label>${t('adminBtcpayStoreId')}</label><input type="text" id="btcpay-storeid" class="stripe-cfg-input" value="${escapeHtml(bs)}" /></div>
 672        <div style="margin-top:1rem"><button class="btn btn-primary" id="save-payments">${t('userSave')}</button></div>
 673      `;
 674      document.getElementById('save-payments')!.addEventListener('click', () => {
 675        Promise.all([
 676          apiFetch('/api/admin/stripe-config', {
 677            method: 'PUT',
 678            body: JSON.stringify({
 679              stripe_secret_key: (document.getElementById('stripe-sk') as HTMLInputElement).value,
 680              stripe_publishable_key: (document.getElementById('stripe-pk') as HTMLInputElement).value,
 681              stripe_webhook_secret: (document.getElementById('stripe-ws') as HTMLInputElement).value,
 682            }),
 683          }),
 684          apiFetch('/api/admin/btcpay-config', {
 685            method: 'PUT',
 686            body: JSON.stringify({
 687              btcpay_url: (document.getElementById('btcpay-url') as HTMLInputElement).value,
 688              btcpay_api_key: (document.getElementById('btcpay-apikey') as HTMLInputElement).value,
 689              btcpay_store_id: (document.getElementById('btcpay-storeid') as HTMLInputElement).value,
 690            }),
 691          }),
 692        ]).then(([sr, br]) => {
 693          if (sr.resp.ok && br.resp.ok) alert('Saved');
 694        });
 695      });
 696    });
 697  }
 698  function loadAdminShipping() {
 699    apiFetch('/api/admin/shipping-config').then(({ data }) => {
 700      const eu = data?.econt_username || '';
 701      const ep = data?.econt_password || '';
 702      const sc = data?.econt_sender_city || 'София';
 703      const sp = data?.econt_sender_postcode || '';
 704      const st = data?.econt_sender_street || '';
 705      const sn = data?.econt_sender_street_num || '';
 706      const sq = data?.econt_sender_quarter || '';
 707      const tm = data?.econt_test_mode === '1';
 708      const rpk = data?.shipping_rate_per_kg ? (parseInt(data.shipping_rate_per_kg) / 100).toFixed(2) : '5.00';
 709      const fst = data?.free_shipping_threshold ? (parseInt(data.free_shipping_threshold) / 100).toFixed(2) : '50.00';
 710  
 711      document.getElementById('a-shipping')!.innerHTML = `
 712        <h2>Econt API</h2>
 713        <div class="form-group"><label>${t('adminEcontUsername')}</label><input type="text" id="s-econt-user" class="stripe-cfg-input" value="${escapeHtml(eu)}" /></div>
 714        <div class="form-group"><label>${t('adminEcontPassword')}</label><input type="password" id="s-econt-pass" class="stripe-cfg-input" value="${escapeHtml(ep)}" /></div>
 715        <div class="form-group"><label>${t('adminEcontSenderCity')}</label><input type="text" id="s-econt-city" class="stripe-cfg-input" value="${escapeHtml(sc)}" /></div>
 716        <div class="form-group"><label>${t('adminEcontTestMode')} <input type="checkbox" id="s-econt-test" ${tm ? 'checked' : ''} /></label></div>
 717        <h3 style="margin-top:1.5rem">${t('adminEcontSenderAddress')}</h3>
 718        <div class="form-group"><label>${t('adminEcontSenderPostcode')}</label><input type="text" id="s-sender-pc" class="stripe-cfg-input" value="${escapeHtml(sp)}" /></div>
 719        <div class="form-group"><label>${t('adminEcontSenderStreet')}</label><input type="text" id="s-sender-street" class="stripe-cfg-input" value="${escapeHtml(st)}" /></div>
 720        <div class="form-group"><label>${t('adminEcontSenderStreetNum')}</label><input type="text" id="s-sender-num" class="stripe-cfg-input" value="${escapeHtml(sn)}" /></div>
 721        <div class="form-group"><label>${t('adminEcontSenderQuarter')}</label><input type="text" id="s-sender-quarter" class="stripe-cfg-input" value="${escapeHtml(sq)}" /></div>
 722        <h2 style="margin-top:2rem">${t('adminShippingRatePerKg')}</h2>
 723        <div class="form-group"><label>${t('adminShippingRatePerKg')} (EUR)</label><input type="number" step="0.01" min="0" id="s-rate-kg" class="stripe-cfg-input" value="${rpk}" /></div>
 724        <p style="font-size:0.8rem;color:#888">${t('adminShippingRatePerKgHint')}</p>
 725        <div class="form-group"><label>${t('adminFreeShippingThreshold')} (EUR)</label><input type="number" step="0.01" min="0" id="s-free-thresh" class="stripe-cfg-input" value="${fst}" /></div>
 726        <p style="font-size:0.8rem;color:#888">${t('adminFreeShippingThresholdHint')}</p>
 727        <div style="margin-top:1rem"><button class="btn btn-primary" id="save-shipping">${t('userSave')}</button></div>
 728      `;
 729  
 730      function saveShippingNow() {
 731        const data: Record<string, string> = {
 732          econt_username: (document.getElementById('s-econt-user') as HTMLInputElement).value,
 733          econt_password: (document.getElementById('s-econt-pass') as HTMLInputElement).value,
 734          econt_sender_city: (document.getElementById('s-econt-city') as HTMLInputElement).value,
 735          econt_sender_postcode: (document.getElementById('s-sender-pc') as HTMLInputElement).value,
 736          econt_sender_street: (document.getElementById('s-sender-street') as HTMLInputElement).value,
 737          econt_sender_street_num: (document.getElementById('s-sender-num') as HTMLInputElement).value,
 738          econt_sender_quarter: (document.getElementById('s-sender-quarter') as HTMLInputElement).value,
 739          econt_test_mode: (document.getElementById('s-econt-test') as HTMLInputElement).checked ? '1' : '0',
 740          shipping_rate_per_kg: String(Math.round(parseFloat((document.getElementById('s-rate-kg') as HTMLInputElement).value) * 100)),
 741          free_shipping_threshold: String(Math.round(parseFloat((document.getElementById('s-free-thresh') as HTMLInputElement).value) * 100)),
 742        };
 743        apiFetch('/api/admin/shipping-config', { method: 'PUT', body: JSON.stringify(data) }).then(({ resp }) => {
 744          if (resp.ok) showToast(t('changesSaved'));
 745        });
 746      }
 747  
 748      document.getElementById('save-shipping')!.addEventListener('click', saveShippingNow);
 749  
 750      let shippingDebounce: any;
 751      function autoShipping() {
 752        clearTimeout(shippingDebounce);
 753        shippingDebounce = setTimeout(saveShippingNow, 5000);
 754      }
 755      document.querySelectorAll('#s-econt-user, #s-econt-pass, #s-econt-city, #s-sender-pc, #s-sender-street, #s-sender-num, #s-sender-quarter, #s-rate-kg, #s-free-thresh').forEach(el => {
 756        el.addEventListener('input', autoShipping);
 757        el.addEventListener('change', () => { clearTimeout(shippingDebounce); saveShippingNow(); });
 758      });
 759      document.getElementById('s-econt-test')!.addEventListener('change', () => {
 760        clearTimeout(shippingDebounce);
 761        saveShippingNow();
 762      });
 763    });
 764  }
 765  function loadAdminTerms() {
 766    apiFetch('/api/terms').then(({ data }) => {
 767      const bg = data?.terms_bg || '';
 768      const en = data?.terms_en || '';
 769      const pbg = data?.privacy_bg || '';
 770      const pen = data?.privacy_en || '';
 771      document.getElementById('a-terms')!.innerHTML = `
 772        <h2>${t('termsLink')}</h2>
 773        <h3 style="font-size:0.9rem;margin-top:0.5rem">${t('adminTermsBg')}</h3>
 774        <textarea id="terms-bg" class="terms-editor" rows="14">${escapeHtml(bg)}</textarea>
 775        <h3 style="font-size:0.9rem;margin-top:1rem">${t('adminTermsEn')}</h3>
 776        <textarea id="terms-en" class="terms-editor" rows="14">${escapeHtml(en)}</textarea>
 777        <h2 style="margin-top:2rem">${t('privacyLink')}</h2>
 778        <h3 style="font-size:0.9rem;margin-top:0.5rem">${t('adminTermsBg')}</h3>
 779        <textarea id="privacy-bg" class="terms-editor" rows="14">${escapeHtml(pbg)}</textarea>
 780        <h3 style="font-size:0.9rem;margin-top:1rem">${t('adminTermsEn')}</h3>
 781        <textarea id="privacy-en" class="terms-editor" rows="14">${escapeHtml(pen)}</textarea>
 782        <div style="margin-top:1rem"><button class="btn btn-primary" id="save-terms">${t('userSave')}</button></div>
 783      `;
 784  
 785      function saveTermsNow() {
 786        apiFetch('/api/admin/terms', {
 787          method: 'PUT',
 788          body: JSON.stringify({
 789            terms_bg: (document.getElementById('terms-bg') as HTMLTextAreaElement).value,
 790            terms_en: (document.getElementById('terms-en') as HTMLTextAreaElement).value,
 791            privacy_bg: (document.getElementById('privacy-bg') as HTMLTextAreaElement).value,
 792            privacy_en: (document.getElementById('privacy-en') as HTMLTextAreaElement).value,
 793          }),
 794        }).then(({ resp }) => {
 795          if (resp.ok) showToast(t('changesSaved'));
 796        });
 797      }
 798  
 799      document.getElementById('save-terms')!.addEventListener('click', saveTermsNow);
 800  
 801      let termsDebounce: any;
 802      function autoTerms() {
 803        clearTimeout(termsDebounce);
 804        termsDebounce = setTimeout(saveTermsNow, 5000);
 805      }
 806      ['terms-bg', 'terms-en', 'privacy-bg', 'privacy-en'].forEach(id => {
 807        document.getElementById(id)!.addEventListener('input', autoTerms);
 808        document.getElementById(id)!.addEventListener('change', () => { clearTimeout(termsDebounce); saveTermsNow(); });
 809      });
 810    });
 811  }
 812  </script>
 813  
 814  <style is:global>
 815    .delivery-type-options { display: flex; gap: 24px; }
 816    .delivery-type-link {
 817      font-size: 14px;
 818      font-weight: 500;
 819      text-transform: uppercase;
 820      letter-spacing: 1px;
 821      color: var(--text);
 822      text-decoration: none;
 823      cursor: pointer;
 824      transition: color 0.2s;
 825    }
 826    .delivery-type-link:hover, .delivery-type-link.active { color: var(--primary); }
 827    .user-form { max-width: 560px; }
 828    .user-form-table td { padding: 0.25rem 0.5rem; vertical-align: middle; }
 829    .user-form-table td:first-child { text-align: right; width: 120px; }
 830    .user-form-table label { font-size: 0.9rem; }
 831    .user-form-table input, .user-form-table select { padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; width: 100%; max-width: 320px; }
 832    .user-table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
 833    .user-table th, .user-table td { padding: 0.6rem 0.5rem; border-bottom: 1px solid #eee; text-align: left; font-size: 0.9rem; }
 834    .user-table th { font-weight: 600; background: #f8f8f8; }
 835    .order-row:hover { background: #f0f7f4; }
 836    .admin-nav { display: flex; flex-wrap: wrap; gap: 16px 32px; margin: 1.5rem 0 1rem; }
 837    .admin-nav-link { font-size: 14px; font-weight: 500; text-transform: uppercase; letter-spacing: 1px; color: var(--text); cursor: pointer; transition: color 0.2s; }
 838    .admin-nav-link:hover, .admin-nav-link.active { color: var(--primary); }
 839    .admin-tab { margin-top: 1rem; }
 840    .admin-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
 841    .admin-table th, .admin-table td { padding: 0.4rem 0.5rem; border-bottom: 1px solid #eee; text-align: left; }
 842    .admin-table th { background: #f8f8f8; font-weight: 600; }
 843    .msg-row:hover { background: #f0f7f4; }
 844    .ap-row { width: 100%; border-bottom: 1px solid #eee; }
 845    .ap-row td { padding: 3px 6px; vertical-align: middle; }
 846    .ap-img-col { text-align: right; width: 100px; }
 847    .ap-images { display: flex; flex-direction: column; gap: 2px; align-items: flex-end; }
 848    .ap-img-wrap { position: relative; width: 80px; height: 80px; }
 849    .ap-img-wrap .ap-img-remove { position: absolute; top: 0; right: 0; z-index: 2; display: flex; }
 850    .ap-img-wrap .ap-thumb { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid #ddd; }
 851    .ap-img-remove { width: 18px; height: 18px; border-radius: 9px; border: none; background: rgba(200,0,0,0.85); color: #fff; font-size: 11px; cursor: pointer; align-items: center; justify-content: center; line-height: 1; padding: 0; }
 852    .ap-add-img { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 4px; border: 1px dashed #ccc; background: none; color: #888; cursor: pointer; margin-top: 2px; }
 853    .ap-add-img:hover { border-color: var(--primary); color: var(--primary); }
 854    .ap-field-col { min-width: 100px; }
 855    .ap-fr { display: flex; flex-direction: column; margin-bottom: 1px; }
 856    .ap-fr label { font-size: 0.7rem; color: var(--text-light); white-space: nowrap; }
 857    .ap-fr input, .ap-fr select { padding: 2px 4px; border: 1px solid #ddd; border-radius: 3px; font-size: 0.85rem; width: 100%; min-width: 50px; }
 858    .ap-cat-col { min-width: 80px; }
 859    .ap-reorder-col { width: 24px; vertical-align: middle; }
 860    .ap-reorder-btn { width: 24px; height: 16px; border: 1px solid #ddd; border-radius: 3px; background: #f5f5f5; cursor: pointer; font-size: 10px; line-height: 1; padding: 0; display: block; margin: 1px 0; color: var(--text); }
 861    .ap-reorder-btn:hover { background: #e0e0e0; }
 862    .ap-del-col { width: 28px; vertical-align: top; }
 863    .ap-del-btn { width: 24px; height: 24px; border-radius: 12px; border: none; background: rgba(200,0,0,0.85); color: #fff; font-size: 14px; cursor: pointer; line-height: 1; padding: 0; display: flex; align-items: center; justify-content: center; }
 864    .ap-del-btn:hover { background: rgba(220,0,0,0.95); }
 865    @media (max-width: 720px) {
 866      .ap-row td { display: block; }
 867      .ap-img-col { width: auto; text-align: left; }
 868      .ap-field-col { min-width: auto; margin-top: 4px; }
 869      .ap-fr { display: flex; }
 870      .ap-img-wrap { width: 60px; height: 60px; }
 871      .ap-img-wrap .ap-thumb { width: 60px; height: 60px; }
 872      .ap-spacer { height: 1em; }
 873    }
 874    .ap-spacer { height: 0.5em; }
 875    .save-prod, .del-prod, .new-prod { padding: 0.25rem 0.75rem; }
 876    .status-select { padding: 0.2rem; }
 877    .btn-sm { font-size: 0.8rem; padding: 0.25rem 0.6rem; border: 1px solid #ccc; border-radius: 4px; background: #f5f5f5; cursor: pointer; }
 878    .btn-sm:hover { background: #e9e9e9; }
 879    .terms-editor { width: 100%; max-width: 800px; font-family: monospace; font-size: 0.85rem; padding: 0.75rem; border: 1px solid #ddd; border-radius: 6px; resize: vertical; }
 880    .stripe-cfg-input { width: 100%; max-width: 600px; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9rem; font-family: monospace; }
 881    .admin-toast {
 882      position: fixed;
 883      bottom: 24px;
 884      left: 50%;
 885      transform: translateX(-50%) translateY(100px);
 886      background: var(--primary, #2d6a4f);
 887      color: #fff;
 888      padding: 10px 24px;
 889      border-radius: 8px;
 890      font-size: 0.9rem;
 891      font-weight: 500;
 892      z-index: 9999;
 893      opacity: 0;
 894      transition: transform 0.3s ease, opacity 0.3s ease;
 895      pointer-events: none;
 896      box-shadow: 0 4px 16px rgba(0,0,0,0.2);
 897    }
 898    .admin-toast.visible {
 899      transform: translateX(-50%) translateY(0);
 900      opacity: 1;
 901    }
 902  </style>
 903