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 ? ' &middot; ' + 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