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> </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">×</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">↑</button>
331 <button class="ap-reorder-btn ap-down" title="move down">↓</button>
332 </td>
333 <td class="ap-del-col" rowspan="2">
334 <button class="ap-del-btn" title="${t('adminDeleteConfirm')}">×</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