checkout.astro raw
1 ---
2 import Base from '../layouts/Base.astro';
3 ---
4 <Base title="Плащане">
5 <section class="section">
6 <div class="container">
7 <h1 data-i18n="checkoutTitle">Плащане</h1>
8 <div class="checkout-layout">
9 <form id="checkout-form" class="checkout-form" novalidate>
10 <h2 data-i18n="checkoutShipping">Данни за доставка</h2>
11
12 <div id="saved-address" style="display:none;margin-bottom:1.5rem">
13 <label class="payment-option" style="display:flex;align-items:flex-start;gap:8px;padding:12px;border:2px solid var(--primary);border-radius:8px;background:var(--primary-pale);margin-bottom:0.5rem">
14 <input type="radio" name="use-saved" value="saved" checked />
15 <div><strong data-i18n="useSavedAddress">Използвай запазен адрес</strong></div>
16 </label>
17 <div id="saved-details" style="padding:0.5rem 0;font-size:0.9rem;line-height:1.6"></div>
18 <label class="payment-option" style="display:flex;align-items:center;gap:8px;padding:12px;border:2px solid #ddd;border-radius:8px;margin-top:0.5rem">
19 <input type="radio" name="use-saved" value="custom" />
20 <span data-i18n="useCustomAddress">Въведи друг адрес</span>
21 </label>
22 </div>
23
24 <div id="custom-address">
25 <div class="form-group">
26 <label data-i18n="name" for="name">Име</label>
27 <input type="text" id="name" required />
28 </div>
29 <div class="form-group">
30 <label data-i18n="email" for="email">Имейл</label>
31 <input type="email" id="email" required />
32 </div>
33 <div class="form-group">
34 <label data-i18n="phone" for="phone">Телефон</label>
35 <input type="tel" id="phone" />
36 </div>
37 <div class="form-group" id="address-group">
38 <label data-i18n="address" for="address">Адрес</label>
39 <input type="text" id="address" required />
40 </div>
41 <div class="form-group" id="address2-group">
42 <label data-i18n="address2" for="address2">Адрес 2</label>
43 <input type="text" id="address2" />
44 </div>
45 <div class="form-group" id="suburb-group">
46 <label data-i18n="suburb" for="suburb">Квартал</label>
47 <input type="text" id="suburb" />
48 </div>
49 <div class="form-group" id="postcode-group">
50 <label data-i18n="postCode" for="postcode">ПК</label>
51 <input type="text" id="postcode" required />
52 </div>
53 <div class="form-group">
54 <label data-i18n="city" for="city">Град</label>
55 <input type="text" id="city" list="city-list" required />
56 <datalist id="city-list"></datalist>
57 </div>
58 <div class="form-group">
59 <label data-i18n="courier" for="courier">Куриер</label>
60 <select id="courier">
61 <option value="econt">Econt</option>
62 </select>
63 </div>
64 <div class="form-group">
65 <label data-i18n="checkoutDeliveryType">Тип доставка</label>
66 <div class="delivery-type-options">
67 <a href="#" class="delivery-type-link active" data-type="address" data-i18n="checkoutToAddress">До адрес</a>
68 <a href="#" class="delivery-type-link" data-type="office" data-i18n="checkoutToOffice">До офис</a>
69 </div>
70 </div>
71 <div class="form-group" id="office-group" style="display:none">
72 <label data-i18n="checkoutSelectOffice">Изберете офис</label>
73 <input type="hidden" id="office-id" />
74 <div class="office-scroll" id="office-scroll"></div>
75 <div id="office-selected" style="font-size:0.85rem;margin-top:8px;color:var(--primary);font-weight:500"></div>
76 </div>
77 </div><!-- /custom-address -->
78
79 <h2 data-i18n="checkoutPayment">Начин на плащане</h2>
80
81 <div id="pw-buttons" style="display:none;margin-bottom:0.75rem">
82 <div id="wallet-apple-pay" style="display:none"></div>
83 <div id="wallet-google-pay" style="display:none"></div>
84 </div>
85
86 <div class="pm-select-row">
87 <select id="payment-select" class="payment-select">
88 <option value="stripe" data-i18n="payStripe">Кредитна / Дебитна карта</option>
89 <option value="btcpay" data-i18n="payBtcpay">BTCPay (Bitcoin + Lightning)</option>
90 <option value="cod" data-i18n="payCod">Наложен платеж</option>
91 </select>
92 <input type="hidden" name="payment" value="stripe" />
93 </div>
94
95 <div id="payment-box" class="payment-box"></div>
96 <p id="checkout-error" style="display:none;color:red"></p>
97
98 <button type="submit" class="btn btn-primary btn-lg" id="place-order" data-i18n="checkoutPlaceOrder">Поръчай</button>
99 </form>
100
101 <div class="checkout-summary">
102 <h2 data-i18n="checkoutYourOrder">Вашата поръчка</h2>
103 <div id="checkout-items"></div>
104 <div class="checkout-line">
105 <span data-i18n="checkoutSubtotal">Междинна сума</span>
106 <span id="checkout-subtotal">-</span>
107 </div>
108 <div class="checkout-line">
109 <span data-i18n="checkoutShippingCost">Доставка</span>
110 <span id="checkout-shipping">-</span>
111 </div>
112 <div class="checkout-total">
113 <strong data-i18n="checkoutTotal">Общо: </strong>
114 <strong id="checkout-total-amount">-</strong>
115 </div>
116 </div>
117 </div>
118 </div>
119 </section>
120 </Base>
121
122 <script>
123 import { getCart, clearCart, formatLv, escapeHtml } from '../lib/cart-client';
124 import { t } from '../lib/i18n';
125
126 const cart = getCart();
127
128 let savedEmail = '';
129
130 // check for saved address
131 (async () => {
132 const token = localStorage.getItem('token');
133 if (!token) return;
134 try {
135 const r = await fetch('/api/auth/session', { headers: { 'Authorization': 'Bearer ' + token } });
136 const d = await r.json();
137 if (!d.user?.delivery_prefs) return;
138 const prefs = JSON.parse(d.user.delivery_prefs);
139 if (!prefs.name && !prefs.city) return;
140 savedEmail = d.user.email || '';
141 const savedDiv = document.getElementById('saved-address')!;
142 savedDiv.style.display = 'block';
143 document.getElementById('saved-details')!.innerHTML = `
144 <p><strong>${escapeHtml(prefs.name || '')}</strong>${prefs.phone ? ' · ' + escapeHtml(prefs.phone) : ''}</p>
145 <p>${escapeHtml(savedEmail)}</p>
146 <p>${escapeHtml(prefs.city || '')}${prefs.postCode ? ', ' + escapeHtml(prefs.postCode) : ''}</p>
147 <p>${escapeHtml(prefs.address || '')}</p>
148 <p>${prefs.courier ? prefs.courier.toUpperCase() : ''}${prefs.deliveryType === 'office' ? ' - Офис' : ''}</p>
149 `;
150 document.getElementById('custom-address')!.style.display = 'none';
151 document.querySelectorAll('input[name="use-saved"]').forEach(r => {
152 r.addEventListener('change', () => {
153 const useSaved = (document.querySelector('input[name="use-saved"]:checked') as HTMLInputElement).value === 'saved';
154 document.getElementById('custom-address')!.style.display = useSaved ? 'none' : 'block';
155 refreshQuote();
156 });
157 });
158 // pre-fill the custom form from saved prefs so it's ready if user switches
159 (document.getElementById('name') as HTMLInputElement).value = prefs.name || '';
160 (document.getElementById('email') as HTMLInputElement).value = savedEmail;
161 (document.getElementById('phone') as HTMLInputElement).value = prefs.phone || '';
162 (document.getElementById('city') as HTMLInputElement).value = prefs.city || '';
163 (document.getElementById('postcode') as HTMLInputElement).value = prefs.postCode || '';
164 (document.getElementById('address') as HTMLInputElement).value = prefs.address || '';
165 (document.getElementById('courier') as HTMLSelectElement).value = prefs.courier || 'econt';
166 if (prefs.deliveryType === 'office') {
167 document.querySelectorAll('.delivery-type-link').forEach(l => l.classList.remove('active'));
168 const link = document.querySelector('.delivery-type-link[data-type="office"]');
169 if (link) { link.classList.add('active'); toggleOffice(); }
170 }
171 // pre-fill office if saved
172 if (prefs.officeId) {
173 (document.getElementById('office-id') as HTMLInputElement).value = prefs.officeId || '';
174 }
175 // re-quote now that pre-filled values are set
176 refreshQuote();
177 } catch (e: any) { console.error('express checkout init error:', e); }
178 })();
179
180 const itemsContainer = document.getElementById('checkout-items')!;
181 const errEl = document.getElementById('checkout-error')!;
182
183 if (cart.length === 0) {
184 window.location.href = '/cart';
185 }
186
187 itemsContainer.innerHTML = cart.map(item =>
188 `<div class="checkout-item"><span>${escapeHtml(item.name)} × ${item.qty} бр.</span><span>${formatLv(item.price_cents * item.qty)}</span></div>`
189 ).join('');
190
191 // server-side priced quote (subtotal + shipping)
192 async function refreshQuote() {
193 try {
194 const courier = (document.getElementById('courier') as HTMLSelectElement).value;
195 const city = (document.getElementById('city') as HTMLInputElement).value.trim();
196 const isOffice = document.querySelector('.delivery-type-link.active')?.getAttribute('data-type') === 'office';
197 const isCod = (document.querySelector('input[name="payment"]') as HTMLInputElement).value === 'cod';
198 const r = await fetch('/api/shipping-quote', {
199 method: 'POST',
200 headers: { 'Content-Type': 'application/json' },
201 body: JSON.stringify({
202 items: cart.map(it => ({ slug: it.slug, qty: it.qty })),
203 city: city || undefined,
204 courier,
205 cod: isCod,
206 officeDelivery: isOffice,
207 }),
208 });
209 const q = await r.json();
210 document.getElementById('checkout-subtotal')!.textContent = formatLv(q.subtotal_cents);
211 document.getElementById('checkout-shipping')!.textContent = q.shipping_cents === 0 ? t('checkoutFreeShipping') : formatLv(q.shipping_cents);
212 document.getElementById('checkout-total-amount')!.textContent = formatLv(q.total_cents);
213 } catch (e: any) { console.error('refreshQuote error:', e); }
214 }
215 refreshQuote();
216
217 // re-quote on courier change, city change, delivery type change, payment change
218 document.getElementById('courier')!.addEventListener('change', refreshQuote);
219 document.getElementById('city')!.addEventListener('input', () => setTimeout(refreshQuote, 500));
220 document.querySelectorAll('.delivery-type-link').forEach(link => link.addEventListener('click', () => setTimeout(refreshQuote, 100)));
221 document.getElementById('payment-select')!.addEventListener('change', () => setTimeout(refreshQuote, 100));
222
223 // cities autocomplete
224 fetch('/api/cities').then(r => r.json()).then((cities: string[]) => {
225 document.getElementById('city-list')!.innerHTML = cities.map(c => `<option value="${escapeHtml(c)}">`).join('');
226 }).catch(() => {});
227
228 // offices
229 async function loadOffices() {
230 try {
231 const resp = await fetch('/api/offices?courier=econt');
232 const offices = await resp.json();
233 const city = (document.getElementById('city') as HTMLInputElement).value.toLowerCase();
234 renderOfficeList(offices, city);
235 } catch (e) { console.error('office load failed', e); }
236 }
237
238 function renderOfficeList(offices: any[], city: string) {
239 const scroll = document.getElementById('office-scroll')!;
240 const sel = document.getElementById('office-id') as HTMLInputElement;
241 scroll.innerHTML = offices.map((o: any) =>
242 `<div class="office-entry" data-id="${escapeHtml(o.id)}" data-name="${escapeHtml(o.name)} - ${escapeHtml(o.address)}">
243 <strong>${escapeHtml(o.name)}</strong><br><small>${escapeHtml(o.address)}, ${escapeHtml(o.city)}</small>
244 </div>`
245 ).join('') || `<div class="office-entry" style="color:#888">${t('noOfficesFound')}</div>`;
246 scroll.querySelectorAll('.office-entry').forEach(el => {
247 el.addEventListener('click', () => {
248 scroll.querySelectorAll('.office-entry').forEach(e => e.classList.remove('selected'));
249 el.classList.add('selected');
250 sel.value = (el as HTMLElement).dataset.id!;
251 const selDiv = document.getElementById('office-selected');
252 if (selDiv) selDiv.textContent = (el as HTMLElement).dataset.name || '';
253 });
254 });
255 // scroll to first match if city provided
256 if (city) {
257 const lower = city.toLowerCase();
258 const match = scroll.querySelector(`.office-entry`) as HTMLElement;
259 let found: HTMLElement | null = null;
260 scroll.querySelectorAll('.office-entry').forEach(el => {
261 if (!found && (el as HTMLElement).textContent?.toLowerCase().includes(lower)) found = el as HTMLElement;
262 });
263 if (found) found.scrollIntoView({ block: 'center' });
264 }
265 // highlight previously saved office
266 const savedId = sel.value;
267 if (savedId) {
268 const savedEl = scroll.querySelector(`.office-entry[data-id="${CSS.escape(savedId)}"]`) as HTMLElement;
269 if (savedEl) { savedEl.classList.add('selected'); }
270 const selDiv = document.getElementById('office-selected');
271 if (selDiv && savedEl) selDiv.textContent = (savedEl as HTMLElement).dataset.name || '';
272 }
273 }
274
275 loadOffices();
276 document.getElementById('city')!.addEventListener('input', loadOffices);
277
278 // office/address toggle
279 document.querySelectorAll('.delivery-type-link').forEach(link => {
280 link.addEventListener('click', (e) => {
281 e.preventDefault();
282 document.querySelectorAll('.delivery-type-link').forEach(l => l.classList.remove('active'));
283 link.classList.add('active');
284 toggleOffice();
285 });
286 });
287
288 function toggleOffice() {
289 const isOffice = document.querySelector('.delivery-type-link.active')?.getAttribute('data-type') === 'office';
290 document.getElementById('office-group')!.style.display = isOffice ? 'block' : 'none';
291 document.getElementById('address-group')!.style.display = isOffice ? 'none' : 'block';
292 document.getElementById('address2-group')!.style.display = isOffice ? 'none' : 'block';
293 document.getElementById('suburb-group')!.style.display = isOffice ? 'none' : 'block';
294 document.getElementById('postcode-group')!.style.display = isOffice ? 'none' : 'block';
295 (document.getElementById('address') as HTMLInputElement).required = !isOffice;
296 if (isOffice) loadOffices();
297 }
298
299 // payment method select
300 const infoKeys: Record<string, string> = {
301 stripe: 'payStripeInfo', btcpay: 'payBtcpayInfo', cod: 'payCodInfo',
302 };
303 const paymentInput = document.querySelector('input[name="payment"]') as HTMLInputElement;
304 const paymentSelect = document.getElementById('payment-select') as HTMLSelectElement;
305 const paymentBox = document.getElementById('payment-box')!;
306
307 function renderPaymentBox(method: string) {
308 paymentInput.value = method;
309 switch (method) {
310 case 'stripe':
311 paymentBox.innerHTML = `<div class="payment-info"><p data-i18n="payStripeInfo">${escapeHtml(t('payStripeInfo'))}</p></div>
312 <div class="card-fields">
313 <div class="form-group card-field">
314 <label data-i18n="cardName">Име на картодържателя</label>
315 <input type="text" id="card-name" autocomplete="cc-name" data-i18n="cardNamePlaceholder" placeholder="Име както е изписано на картата" />
316 </div>
317 <div class="form-group card-field">
318 <label data-i18n="cardNumber">Номер на карта</label>
319 <input type="text" id="card-number" inputmode="numeric" autocomplete="cc-number" placeholder="4242 4242 4242 4242" />
320 </div>
321 <div class="card-row">
322 <div class="form-group card-field card-half">
323 <label data-i18n="cardExpiry">Валидна до</label>
324 <input type="text" id="card-expiry" inputmode="numeric" autocomplete="cc-exp" placeholder="MM/YY" />
325 </div>
326 <div class="form-group card-field card-half">
327 <label data-i18n="cardCvc">CVC</label>
328 <input type="text" id="card-cvc" inputmode="numeric" autocomplete="cc-csc" placeholder="CVC" />
329 </div>
330 </div>
331 </div>`;
332 break;
333 case 'btcpay':
334 paymentBox.innerHTML = `<div class="payment-info"><p data-i18n="payBtcpayInfo">${escapeHtml(t('payBtcpayInfo'))}</p></div>`;
335 break;
336 case 'cod':
337 paymentBox.innerHTML = `<div class="payment-info"><p data-i18n="payCodInfo">${escapeHtml(t('payCodInfo'))}</p></div>`;
338 break;
339 }
340 }
341
342 paymentSelect.addEventListener('change', () => {
343 renderPaymentBox(paymentSelect.value);
344 });
345 renderPaymentBox('stripe');
346
347 function showError(msg: string) {
348 errEl.textContent = msg;
349 errEl.style.display = 'block';
350 }
351
352 function getCheckoutFields() {
353 const isOffice = document.querySelector('.delivery-type-link.active')?.getAttribute('data-type') === 'office';
354 const selectedOffice = document.getElementById('office-scroll')?.querySelector('.office-entry.selected');
355 const officeName = selectedOffice ? (selectedOffice as HTMLElement).dataset.name || '' : '';
356 const emailValue = (document.getElementById('email') as HTMLInputElement).value.trim() || savedEmail;
357 const name = (document.getElementById('name') as HTMLInputElement).value.trim();
358 const city = (document.getElementById('city') as HTMLInputElement).value.trim();
359 const postCode = (document.getElementById('postcode') as HTMLInputElement).value.trim();
360 return { isOffice, officeName, emailValue, name, city, postCode };
361 }
362
363 function buildCheckoutBody(pm: string) {
364 const fields = getCheckoutFields();
365 return {
366 items: cart.map(it => ({ slug: it.slug, qty: it.qty })),
367 email: fields.emailValue,
368 shipping: {
369 name: (document.getElementById('name') as HTMLInputElement).value,
370 phone: (document.getElementById('phone') as HTMLInputElement).value,
371 city: (document.getElementById('city') as HTMLInputElement).value,
372 postCode: (document.getElementById('postcode') as HTMLInputElement).value,
373 address: fields.isOffice ? fields.officeName : (document.getElementById('address') as HTMLInputElement).value,
374 address2: fields.isOffice ? '' : (document.getElementById('address2') as HTMLInputElement).value,
375 suburb: fields.isOffice ? '' : (document.getElementById('suburb') as HTMLInputElement).value,
376 deliveryType: fields.isOffice ? 'office' : 'address',
377 officeId: fields.isOffice ? (document.getElementById('office-id') as HTMLInputElement).value : null,
378 },
379 courier: (document.getElementById('courier') as HTMLSelectElement).value,
380 paymentMethod: pm,
381 };
382 }
383
384 function loadStripeJs(): Promise<any> {
385 return new Promise((resolve, reject) => {
386 if ((window as any).Stripe) return resolve((window as any).Stripe);
387 const s = document.createElement('script');
388 s.src = 'https://js.stripe.com/clover/stripe.js';
389 s.onload = () => resolve((window as any).Stripe);
390 s.onerror = () => reject(new Error('Stripe.js failed to load'));
391 document.head.appendChild(s);
392 });
393 }
394
395 let stripeLoadPromise: Promise<any> | null = null;
396 let stripeInstance: any = null;
397 let stripeElements: any = null;
398 let stripeElsClientSecret: string | null = null;
399
400 async function loadStripe(): Promise<any> {
401 if (stripeInstance) return stripeInstance;
402 if (stripeLoadPromise) return stripeLoadPromise;
403 stripeLoadPromise = (async () => {
404 const Stripe = await loadStripeJs();
405 const [pkResp, piResp] = await Promise.all([
406 fetch('/api/stripe-key'),
407 fetch('/api/stripe/payment-intent', {
408 method: 'POST',
409 headers: { 'Content-Type': 'application/json' },
410 body: JSON.stringify({ items: cart.map(it => ({ slug: it.slug, qty: it.qty })) }),
411 }),
412 ]);
413 const pkData = await pkResp.json();
414 if (!pkData.publishableKey || pkData.publishableKey === 'pk_mock') return null;
415 stripeInstance = Stripe(pkData.publishableKey);
416 const pi = await piResp.json();
417 if (pi.clientSecret) {
418 stripeElsClientSecret = pi.clientSecret;
419 stripeElements = stripeInstance.elements({ clientSecret: pi.clientSecret });
420 } else {
421 const total = cart.reduce((s, it) => s + it.price_cents * it.qty, 0);
422 stripeElements = stripeInstance.elements({ mode: 'payment', amount: Math.max(total, 50), currency: 'eur' });
423 }
424 return stripeInstance;
425 })();
426 return stripeLoadPromise;
427 }
428
429 // Apple Pay / Google Pay / Link express checkout buttons
430 (async () => {
431 try {
432 await loadStripe();
433 if (!stripeInstance || !stripeElsClientSecret) return;
434
435 const appleEls = stripeInstance.elements({ clientSecret: stripeElsClientSecret });
436 const googleEls = stripeInstance.elements({ clientSecret: stripeElsClientSecret });
437
438 const appleBtn = appleEls.create('expressCheckout', {
439 layout: { maxColumns: 0, maxRows: 0, overflow: 'never' },
440 buttonHeight: 48,
441 buttonTheme: { applePay: 'black', googlePay: 'black' },
442 buttonType: { applePay: 'plain', googlePay: 'plain' },
443 paymentMethods: { applePay: 'always', googlePay: 'never', link: 'never', amazonPay: 'never' },
444 });
445 document.getElementById('wallet-apple-pay')!.style.display = '';
446 appleBtn.mount('#wallet-apple-pay');
447
448 const googleBtn = googleEls.create('expressCheckout', {
449 layout: { maxColumns: 0, maxRows: 0, overflow: 'never' },
450 buttonHeight: 48,
451 buttonTheme: { applePay: 'black', googlePay: 'black' },
452 buttonType: { applePay: 'plain', googlePay: 'plain' },
453 paymentMethods: { applePay: 'never', googlePay: 'always', link: 'never', amazonPay: 'never' },
454 });
455 document.getElementById('wallet-google-pay')!.style.display = '';
456 googleBtn.mount('#wallet-google-pay');
457
458 document.getElementById('pw-buttons')!.style.display = 'block';
459
460 // on confirm: create order and redirect (payment already confirmed by express checkout)
461 const handleConfirm = async (pm: string, event: any) => {
462 const fields = getCheckoutFields();
463 if (!fields.name || !fields.emailValue || !fields.city || !fields.postCode) {
464 showError('Попълнете данните за доставка');
465 return;
466 }
467 if (!fields.isOffice && !(document.getElementById('address') as HTMLInputElement).value.trim()) {
468 showError('Попълнете адрес');
469 return;
470 }
471 const token = localStorage.getItem('token');
472 const headers: Record<string, string> = { 'Content-Type': 'application/json' };
473 if (token) headers['Authorization'] = 'Bearer ' + token;
474 try {
475 const body = buildCheckoutBody(pm);
476 if (event?.paymentIntent?.id) {
477 (body as any).paymentIntentId = event.paymentIntent.id;
478 }
479 const resp = await fetch('/api/checkout', {
480 method: 'POST',
481 headers,
482 body: JSON.stringify(body),
483 });
484 const data = await resp.json();
485 if (!resp.ok) { showError(data.error || 'checkout failed'); return; }
486 clearCart();
487 window.location.href = `/order/${data.orderId}?t=${data.viewToken}`;
488 } catch (e: any) { showError(e.message || 'connection error'); }
489 };
490
491 appleBtn.on('confirm', (event: any) => handleConfirm('applepay', event));
492 googleBtn.on('confirm', (event: any) => handleConfirm('googlepay', event));
493 } catch (e: any) { console.error('express checkout init error:', e); }
494
495 // fallback buttons if Stripe didn't render
496 setTimeout(() => {
497 const a = document.getElementById('wallet-apple-pay')!;
498 const g = document.getElementById('wallet-google-pay')!;
499 if (!a.children.length || !a.querySelector('iframe')) {
500 a.style.display = '';
501 a.innerHTML = `<button type="button" class="wallet-fallback apple-pay-fallback">
502 <img src="/img/apple-logo.svg" alt="" width="20" height="24" style="vertical-align:middle" />
503 <span style="font-weight:600;font-size:15px;margin-left:6px">Pay</span>
504 </button>`;
505 a.querySelector('button')!.addEventListener('click', () => {
506 paymentInput.value = 'applepay';
507 (document.getElementById('checkout-form') as HTMLFormElement).requestSubmit();
508 });
509 }
510 if (!g.children.length || !g.querySelector('iframe')) {
511 g.style.display = '';
512 g.innerHTML = `<button type="button" class="wallet-fallback google-pay-fallback">
513 <img src="/img/google-logo.svg" alt="" width="20" height="20" style="vertical-align:middle" />
514 <span style="font-size:15px;font-weight:500;margin-left:6px;color:#3c4043">Pay</span>
515 </button>`;
516 g.querySelector('button')!.addEventListener('click', () => {
517 paymentInput.value = 'googlepay';
518 (document.getElementById('checkout-form') as HTMLFormElement).requestSubmit();
519 });
520 }
521 if (a.children.length || g.children.length) {
522 document.getElementById('pw-buttons')!.style.display = 'block';
523 }
524 }, 1000);
525 })();
526
527 // submit
528 document.getElementById('checkout-form')!.addEventListener('submit', async (e) => {
529 e.preventDefault();
530 const btn = document.getElementById('place-order') as HTMLButtonElement;
531 btn.disabled = true;
532 btn.textContent = t('checkoutProcessing');
533 errEl.style.display = 'none';
534
535 const paymentMethod = paymentInput.value;
536 const fields = getCheckoutFields();
537
538 if (!fields.name) { showError('Име е задължително'); btn.disabled = false; btn.textContent = t('checkoutPlaceOrder'); return; }
539 if (!fields.emailValue) { showError('Имейл е задължителен'); btn.disabled = false; btn.textContent = t('checkoutPlaceOrder'); return; }
540 if (!fields.city) { showError('Град е задължителен'); btn.disabled = false; btn.textContent = t('checkoutPlaceOrder'); return; }
541 if (!fields.postCode) { showError('Пощенски код е задължителен'); btn.disabled = false; btn.textContent = t('checkoutPlaceOrder'); return; }
542 if (!fields.isOffice) {
543 const addr = (document.getElementById('address') as HTMLInputElement).value.trim();
544 if (!addr) { showError('Адрес е задължителен'); btn.disabled = false; btn.textContent = t('checkoutPlaceOrder'); return; }
545 }
546
547 try {
548 const headers: Record<string, string> = { 'Content-Type': 'application/json' };
549 const token = localStorage.getItem('token');
550 if (token) headers['Authorization'] = 'Bearer ' + token;
551
552 const resp = await fetch('/api/checkout', {
553 method: 'POST',
554 headers,
555 body: JSON.stringify(buildCheckoutBody(paymentMethod)),
556 });
557
558 const data = await resp.json();
559 if (!resp.ok) {
560 showError(data.error || 'checkout failed');
561 btn.disabled = false;
562 btn.textContent = t('checkoutPlaceOrder');
563 return;
564 }
565
566 const orderUrl = `/order/${data.orderId}?t=${data.viewToken}`;
567
568 if ((paymentMethod === 'stripe' || paymentMethod === 'applepay' || paymentMethod === 'googlepay') && data.payment?.clientSecret && data.payment.publishableKey !== 'pk_mock') {
569 if (!stripeInstance) {
570 showError('Card input not available');
571 btn.disabled = false;
572 btn.textContent = t('checkoutPlaceOrder');
573 return;
574 }
575 const cardName = (document.getElementById('card-name') as HTMLInputElement).value.trim();
576 const cardNumber = (document.getElementById('card-number') as HTMLInputElement).value.replace(/\s+/g, '');
577 const cardExpiry = (document.getElementById('card-expiry') as HTMLInputElement).value.trim();
578 const cardCvc = (document.getElementById('card-cvc') as HTMLInputElement).value.trim();
579 if (!cardNumber || !cardExpiry || !cardCvc) {
580 showError('Попълнете данните на картата');
581 btn.disabled = false;
582 btn.textContent = t('checkoutPlaceOrder');
583 return;
584 }
585 const expParts = cardExpiry.split('/');
586 const expMonth = parseInt(expParts[0] || '0');
587 const expYear = parseInt('20' + (expParts[1] || '0'));
588 const billName = (document.getElementById('name') as HTMLInputElement).value.trim();
589 const billAddr = (document.getElementById('address') as HTMLInputElement).value.trim();
590 const billCity = (document.getElementById('city') as HTMLInputElement).value.trim();
591 const billPostcode = (document.getElementById('postcode') as HTMLInputElement).value.trim();
592 const tokenResult = await stripeInstance.createToken('card', {
593 number: cardNumber,
594 exp_month: expMonth,
595 exp_year: expYear,
596 cvc: cardCvc,
597 name: cardName || billName || undefined,
598 address_line1: billAddr || undefined,
599 address_city: billCity || undefined,
600 address_zip: billPostcode || undefined,
601 address_country: 'BG',
602 });
603 if (tokenResult.error) {
604 showError(tokenResult.error.message || 'card validation failed');
605 btn.disabled = false;
606 btn.textContent = t('checkoutPlaceOrder');
607 return;
608 }
609 const { error, paymentIntent } = await stripeInstance.confirmCardPayment(data.payment.clientSecret, {
610 payment_method: { card: { token: tokenResult.token.id } },
611 });
612 if (error) {
613 showError(error.message || 'payment failed');
614 btn.disabled = false;
615 btn.textContent = t('checkoutPlaceOrder');
616 return;
617 }
618 if (paymentIntent?.status === 'succeeded') {
619 clearCart();
620 window.location.href = orderUrl;
621 return;
622 }
623 clearCart();
624 window.location.href = orderUrl;
625 return;
626 }
627
628 clearCart();
629 window.location.href = orderUrl;
630 } catch (err: any) {
631 showError(err.message || 'connection error');
632 btn.disabled = false;
633 btn.textContent = t('checkoutPlaceOrder');
634 }
635 });
636 </script>
637
638 <style>
639 .delivery-type-options { display: flex; gap: 24px; margin-bottom: 0.5rem; }
640 .delivery-type-link {
641 font-size: 14px;
642 font-weight: 500;
643 text-transform: uppercase;
644 letter-spacing: 1px;
645 color: var(--text);
646 text-decoration: none;
647 cursor: pointer;
648 transition: color 0.2s;
649 }
650 .delivery-type-link:hover, .delivery-type-link.active { color: var(--primary); }
651 .checkout-layout {
652 display: grid;
653 grid-template-columns: 1fr 380px;
654 gap: 2rem;
655 align-items: start;
656 }
657 .checkout-line {
658 display: flex;
659 justify-content: space-between;
660 padding: 0.4rem 0;
661 }
662 @media (max-width: 768px) {
663 .checkout-layout { grid-template-columns: 1fr; }
664 }
665 .wallet-fallback { width:100%;height:48px;background:#000;color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;padding:0 12px; }
666 .google-pay-fallback { background:#fff; border:1px solid #3c4043; color:#3c4043; }
667 #pw-buttons { margin-bottom: 0.5rem; }
668 #pw-buttons > * { margin-bottom: 4px; }
669 #pw-buttons > *:last-child { margin-bottom: 0; }
670 #wallet-apple-pay, #wallet-google-pay { line-height: 0; overflow: hidden; }
671 #wallet-apple-pay iframe, #wallet-google-pay iframe { display: block; margin: 0; padding: 0; }
672 .pm-select-row { margin-bottom: 0.5rem; }
673 .payment-select { width: 100%; padding: 0.65rem 0.75rem; border: 2px solid #ddd; border-radius: 8px; font-size: 0.95rem; background: #fff; }
674 .payment-box { margin-bottom: 1rem; }
675 .card-fields { margin: 0.75rem 0; }
676 .card-field { margin-bottom: 0.75rem; }
677 .card-field label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; }
678 .card-field input { width: 100%; padding: 0.55rem 0.7rem; border: 2px solid #ddd; border-radius: 6px; font-size: 1rem; }
679 .stripe-field { padding: 0.6rem 0.7rem; border: 2px solid #ddd; border-radius: 6px; background: #fff; min-height: 20px; }
680 .StripeElement--focus { border-color: #666; }
681 .StripeElement--invalid { border-color: #fa755a; }
682 .card-row { display: flex; gap: 1rem; }
683 .card-half { flex: 1; }
684 </style>
685