From 272445f8359266c2554c0c938946c24f541a585c Mon Sep 17 00:00:00 2001 From: maheshrijal <62394512+maheshrijal@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:58:01 +0530 Subject: [PATCH 01/17] Refactor: Convert to ES modules and add Playwright tests --- .github/workflows/playwright.yml | 36 + .gitignore | 7 +- index.html | 4 +- package-lock.json | 79 ++ package.json | 24 + playwright.config.js | 63 ++ script.js | 944 ------------------ src/js/main.js | 286 ++++++ src/js/modules/calculations.js | 25 + src/js/modules/dom.js | 97 ++ src/js/modules/form.js | 151 +++ src/js/modules/formatters.js | 52 + src/js/modules/history.js | 156 +++ src/js/modules/invoice.js | 112 +++ src/js/modules/items.js | 114 +++ src/js/modules/json-io.js | 72 ++ src/js/modules/pdf.js | 329 ++++++ src/js/modules/storage.js | 82 ++ styles.css | 14 +- tests/rebill.spec.js | 271 +++++ .../invoice-preview-chromium-darwin.png | Bin 0 -> 41717 bytes .../invoice-preview-firefox-darwin.png | Bin 0 -> 54253 bytes .../invoice-preview-webkit-darwin.png | Bin 0 -> 51597 bytes 23 files changed, 1970 insertions(+), 948 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js delete mode 100644 script.js create mode 100644 src/js/main.js create mode 100644 src/js/modules/calculations.js create mode 100644 src/js/modules/dom.js create mode 100644 src/js/modules/form.js create mode 100644 src/js/modules/formatters.js create mode 100644 src/js/modules/history.js create mode 100644 src/js/modules/invoice.js create mode 100644 src/js/modules/items.js create mode 100644 src/js/modules/json-io.js create mode 100644 src/js/modules/pdf.js create mode 100644 src/js/modules/storage.js create mode 100644 tests/rebill.spec.js create mode 100644 tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png create mode 100644 tests/rebill.spec.js-snapshots/invoice-preview-firefox-darwin.png create mode 100644 tests/rebill.spec.js-snapshots/invoice-preview-webkit-darwin.png diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..0a1c8be --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,36 @@ +name: Playwright Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npx playwright test + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index d5acdb6..0b8a7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,8 @@ node_modules/ *~ # Temporary files -*.tmp -*.temp \ No newline at end of file +*.temp + +# Playwright +playwright-report/ +test-results/ diff --git a/index.html b/index.html index e3e03f8..b7616e6 100644 --- a/index.html +++ b/index.html @@ -35,7 +35,7 @@ - +
@@ -204,6 +204,8 @@No invoices saved yet. Generate a bill to save it here.
'; - return; - } - - list.innerHTML = ''; - history.forEach(entry => { - const item = document.createElement('div'); - item.className = 'history-item'; - item.dataset.id = entry.id; - - const info = document.createElement('div'); - info.className = 'history-item-info'; - - const title = document.createElement('div'); - title.className = 'history-item-title'; - title.textContent = `#${entry.invoiceNumber} - ${entry.customerName || 'No customer'}`; - - const meta = document.createElement('div'); - meta.className = 'history-item-meta'; - meta.textContent = `${formatDate(entry.date)} · ${formatCurrency(entry.total, entry.currency)}`; - - info.appendChild(title); - info.appendChild(meta); - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.className = 'history-item-delete'; - deleteBtn.dataset.id = entry.id; - deleteBtn.title = 'Delete'; - deleteBtn.textContent = '×'; - - item.appendChild(info); - item.appendChild(deleteBtn); - list.appendChild(item); - }); -} - -function toggleHistoryPanel(show) { - const panel = document.getElementById('historyPanel'); - if (!panel) return; - - if (typeof show === 'boolean') { - panel.style.display = show ? 'block' : 'none'; - } else { - panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; - } - - if (panel.style.display === 'block') { - renderHistoryList(); - } -} - -function getValue(id, fallback = '') { - const el = document.getElementById(id); - if (!el) return fallback; - return el.value ?? fallback; -} - -function setValue(id, value) { - const el = document.getElementById(id); - if (!el) return; - el.value = value ?? ''; -} - -function setText(id, value) { - const el = document.getElementById(id); - if (!el) return; - el.textContent = value ?? ''; -} - -function escapeHtml(str) { - if (str == null) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function parseNumber(value, fallback = 0) { - const num = parseFloat(value); - return Number.isFinite(num) ? num : fallback; -} - -function formatCurrency(amount, currency, locale) { - const safeAmount = Number.isFinite(amount) ? amount : 0; - try { - return new Intl.NumberFormat(locale || 'en-IN', { - style: 'currency', - currency: currency || 'INR', - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }).format(safeAmount); - } catch (error) { - const suffix = currency ? ` ${currency}` : ''; - return `${safeAmount.toFixed(2)}${suffix}`; - } -} - -function formatDate(dateValue) { - if (!dateValue) return ''; - const date = new Date(dateValue); - if (Number.isNaN(date.getTime())) return dateValue; - const day = String(date.getDate()).padStart(2, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const year = String(date.getFullYear()); - return `${day}/${month}/${year}`; -} - -function calculateTotals(items, taxRate, discount) { - const subtotal = items.reduce((sum, item) => sum + item.total, 0); - const taxAmount = subtotal * (taxRate / 100); - const total = subtotal + taxAmount - discount; - return { - subtotal, - taxRate, - taxAmount, - discount, - total, - balanceDue: total - }; -} - -function ensureAtLeastOneItemRow() { - const container = document.getElementById('itemsContainer'); - if (!container || container.querySelectorAll('.item-row').length > 0) return; - addItemRow({ - description: '', - quantity: 1, - unitPrice: 0 - }); -} - -function addItemRow(item = {}) { - const container = document.getElementById('itemsContainer'); - if (!container) return; - - const row = document.createElement('div'); - row.className = 'item-row'; - - const descInput = document.createElement('input'); - descInput.type = 'text'; - descInput.className = 'item-description'; - descInput.placeholder = 'Description'; - descInput.value = item.description ?? ''; - - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.className = 'item-quantity'; - qtyInput.min = '0'; - qtyInput.step = '1'; - qtyInput.value = item.quantity ?? 1; - - const priceInput = document.createElement('input'); - priceInput.type = 'number'; - priceInput.className = 'item-unit-price'; - priceInput.min = '0'; - priceInput.step = '0.01'; - priceInput.value = item.unitPrice ?? 0; - - const totalSpan = document.createElement('span'); - totalSpan.className = 'item-total'; - totalSpan.textContent = '0.00'; - - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'remove-item'; - removeBtn.title = 'Remove item'; - removeBtn.textContent = '×'; - - row.appendChild(descInput); - row.appendChild(qtyInput); - row.appendChild(priceInput); - row.appendChild(totalSpan); - row.appendChild(removeBtn); - - container.appendChild(row); -} - -function renderItemsForm(items) { - const container = document.getElementById('itemsContainer'); - if (!container) return; - container.innerHTML = ''; - - if (!Array.isArray(items) || items.length === 0) { - addItemRow(); - return; - } - - items.forEach((item) => addItemRow(item)); -} - -function collectItemsFromForm(settings) { - const rows = Array.from(document.querySelectorAll('.item-row')); - const items = rows.map((row) => { - const description = row.querySelector('.item-description')?.value?.trim() ?? ''; - const quantity = parseNumber(row.querySelector('.item-quantity')?.value, 0); - const unitPrice = parseNumber(row.querySelector('.item-unit-price')?.value, 0); - const total = quantity * unitPrice; - const totalEl = row.querySelector('.item-total'); - if (totalEl) { - totalEl.textContent = formatCurrency(total, settings.currency, settings.locale); - } - return { - description, - quantity, - unitPrice, - total - }; - }); - - return items; -} - -function getDataFromForm() { - const settings = { - currency: getValue('currencyCode', 'INR').trim() || 'INR', - locale: 'en-IN' - }; - - const items = collectItemsFromForm(settings); - const taxRate = parseNumber(getValue('taxRate', 0), 0); - const discount = parseNumber(getValue('discountAmount', 0), 0); - const totals = calculateTotals(items, taxRate, discount); - - return { - schemaVersion: 1, - settings, - seller: { - name: getValue('sellerName'), - address: getValue('sellerAddress'), - email: getValue('sellerEmail'), - phone: getValue('sellerPhone') - }, - billTo: { - name: getValue('billToName'), - address: getValue('billToAddress'), - email: getValue('billToEmail'), - phone: getValue('billToPhone') - }, - invoice: { - title: getValue('invoiceTitle', 'INVOICE'), - date: getValue('invoiceDate'), - dueDate: getValue('invoiceDueDate'), - number: getValue('invoiceNumber'), - notes: getValue('invoiceNotes'), - instructions: getValue('invoiceInstructions') - }, - items, - totals, - meta: { - updatedAt: new Date().toISOString(), - showInvoice: isInvoiceVisible() - } - }; -} - -function updateFormTotalsDisplay(data) { - const { totals, settings } = data; - setText('formSubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); - setText('formTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); - setText('formTotal', formatCurrency(totals.total, settings.currency, settings.locale)); -} - -function renderInvoice(data) { - const { settings, seller, billTo, invoice, items, totals } = data; - - setText('displaySellerName', seller.name); - setText('displaySellerAddress', seller.address || ''); - setText('displaySellerEmail', seller.email); - setText('displaySellerPhone', seller.phone); - - setText('displayInvoiceTitle', invoice.title || 'INVOICE'); - - setText('displayBillToName', billTo.name); - setText('displayBillToAddress', billTo.address || ''); - setText('displayBillToEmail', billTo.email); - setText('displayBillToPhone', billTo.phone); - - setText('displayInvoiceDate', formatDate(invoice.date)); - - // Conditionally show/hide due date row - const dueDateRow = document.getElementById('dueDateRow'); - if (dueDateRow) { - if (invoice.dueDate) { - dueDateRow.style.display = ''; - setText('displayInvoiceDueDate', formatDate(invoice.dueDate)); - } else { - dueDateRow.style.display = 'none'; - } - } - - setText('displayInvoiceNumber', invoice.number); - - const itemsBody = document.getElementById('displayItemsBody'); - if (itemsBody) { - itemsBody.innerHTML = ''; - items.forEach((item) => { - const row = document.createElement('tr'); - - const descTd = document.createElement('td'); - descTd.textContent = item.description || ''; - - const qtyTd = document.createElement('td'); - qtyTd.textContent = item.quantity || 0; - - const priceTd = document.createElement('td'); - priceTd.textContent = formatCurrency(item.unitPrice || 0, settings.currency, settings.locale); - - const totalTd = document.createElement('td'); - totalTd.textContent = formatCurrency(item.total || 0, settings.currency, settings.locale); - - row.appendChild(descTd); - row.appendChild(qtyTd); - row.appendChild(priceTd); - row.appendChild(totalTd); - - itemsBody.appendChild(row); - }); - } - - setText('displaySubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); - - // Conditionally show/hide tax row - const taxRow = document.getElementById('taxRow'); - if (taxRow) { - if (totals.taxAmount > 0) { - taxRow.style.display = ''; - setText('displayTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); - } else { - taxRow.style.display = 'none'; - } - } - - // Conditionally show/hide discount row - const discountRow = document.getElementById('discountRow'); - if (discountRow) { - if (totals.discount > 0) { - discountRow.style.display = ''; - setText('displayDiscount', formatCurrency(totals.discount, settings.currency, settings.locale)); - } else { - discountRow.style.display = 'none'; - } - } - - setText('displayBalanceDue', formatCurrency(totals.balanceDue, settings.currency, settings.locale)); - - setText('displayNotesHeading', invoice.notes || 'Thank you for your business'); - setText('displayInstructions', invoice.instructions || ''); - - document.getElementById('invoicePlaceholder').style.display = 'none'; - document.getElementById('invoice').style.display = 'block'; - document.getElementById('downloadBtn').style.display = 'inline-block'; -} - -function isInvoiceVisible() { - const invoice = document.getElementById('invoice'); - return invoice && invoice.style.display === 'block'; -} - -function saveDraft(data) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); -} - -function loadDraft() { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - try { - return JSON.parse(raw); - } catch (error) { - return null; - } -} - -function deepMerge(target, source) { - if (!source || typeof source !== 'object') return target; - - // Dangerous keys that could lead to prototype pollution - const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; - - function hasUnsafeKeys(obj, visited = new Set()) { - if (!obj || typeof obj !== 'object' || visited.has(obj)) return false; - visited.add(obj); - return UNSAFE_KEYS.some(k => k in obj) || - Object.values(obj).some(v => v && typeof v === 'object' && hasUnsafeKeys(v, visited)); - } - - Object.keys(source).forEach((key) => { - // Skip prototype pollution vectors - if (UNSAFE_KEYS.includes(key)) return; - if (!Object.prototype.hasOwnProperty.call(source, key)) return; - - const value = source[key]; - if (Array.isArray(value)) { - target[key] = value.slice(); - } else if (value && typeof value === 'object') { - if (hasUnsafeKeys(value)) return; - if (!target[key] || typeof target[key] !== 'object') { - target[key] = {}; - } - deepMerge(target[key], value); - } else { - target[key] = value; - } - }); - return target; -} - -function applyDataToForm(data) { - isApplyingData = true; - - setValue('sellerName', data.seller?.name ?? ''); - setValue('sellerAddress', data.seller?.address ?? ''); - setValue('sellerEmail', data.seller?.email ?? ''); - setValue('sellerPhone', data.seller?.phone ?? ''); - setValue('billToName', data.billTo?.name ?? ''); - setValue('billToAddress', data.billTo?.address ?? ''); - setValue('billToEmail', data.billTo?.email ?? ''); - setValue('billToPhone', data.billTo?.phone ?? ''); - - setValue('invoiceTitle', data.invoice?.title ?? 'INVOICE'); - setValue('invoiceDate', data.invoice?.date ?? ''); - setValue('invoiceDueDate', data.invoice?.dueDate ?? ''); - setValue('invoiceNumber', data.invoice?.number ?? ''); - - setValue('currencyCode', data.settings?.currency ?? 'INR'); - - renderItemsForm(data.items || []); - - setValue('taxRate', data.totals?.taxRate ?? 0); - setValue('discountAmount', data.totals?.discount ?? 0); - - setValue('invoiceNotes', data.invoice?.notes ?? ''); - - const legacyBank = data.seller?.bank; - const legacyInstructions = data.invoice?.terms ?? data.invoice?.instructions ?? ''; - let mergedInstructions = legacyInstructions; - if (!mergedInstructions && legacyBank) { - const lines = []; - if (legacyBank.accountNo) lines.push(`A/c no: ${legacyBank.accountNo}`); - if (legacyBank.ifsc) lines.push(`IFSC: ${legacyBank.ifsc}`); - if (legacyBank.name) lines.push(`Name: ${legacyBank.name}`); - if (lines.length > 0) { - mergedInstructions = `Please send payment to below bank account.\n${lines.join('\n')}`; - } - } - setValue('invoiceInstructions', mergedInstructions); - - isApplyingData = false; - - syncFromForm({ render: data.meta?.showInvoice, showInvoice: data.meta?.showInvoice }); -} - -function syncFromForm(options = {}) { - const data = getDataFromForm(); - if (typeof options.showInvoice === 'boolean') { - data.meta.showInvoice = options.showInvoice; - } - updateFormTotalsDisplay(data); - saveDraft(data); - if (options.render) { - renderInvoice(data); - } - return data; -} - -function validateData(data) { - if (!data.seller.name || !data.seller.address) return false; - if (!data.billTo.name || !data.billTo.address) return false; - if (!data.invoice.date || !data.invoice.number) return false; - if (!Array.isArray(data.items) || data.items.length === 0) return false; - const validItems = data.items.some((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); - return validItems; -} - -function generateBill() { - const data = syncFromForm({ showInvoice: true }); - if (!validateData(data)) { - alert('Please fill in all required fields and add at least one line item.'); - return; - } - data.meta.showInvoice = true; - saveDraft(data); - renderInvoice(data); - localStorage.setItem(LAST_NUMBER_KEY, data.invoice.number); - saveToHistory(data); - document.getElementById('invoice').scrollIntoView({ behavior: 'smooth' }); -} - -async function downloadPDF() { - const { jsPDF } = window.jspdf; - const invoiceElement = document.getElementById('invoice'); - const downloadBtn = document.getElementById('downloadBtn'); - - // Show loading state - const originalBtnText = downloadBtn.textContent; - downloadBtn.textContent = 'Generating...'; - downloadBtn.disabled = true; - - // Store original styles to restore later - const originalStyles = { - formDisplay: document.querySelector('.form-section').style.display, - invoiceHeight: invoiceElement.style.height, - invoiceOverflow: invoiceElement.style.overflow - }; - - try { - // Prepare invoice for capture - document.querySelector('.form-section').style.display = 'none'; - invoiceElement.style.height = 'auto'; - invoiceElement.style.overflow = 'visible'; - document.body.classList.add('pdf-generation'); - - // Add print-optimized styles - const printStyles = document.createElement('style'); - printStyles.id = 'pdf-print-styles'; - printStyles.textContent = ` - .pdf-generation .invoice-section { - background: white !important; - box-shadow: none !important; - padding: 40px !important; - margin: 0 !important; - width: 794px !important; - min-height: auto !important; - height: auto !important; - overflow: visible !important; - font-family: 'Inter', Arial, sans-serif !important; - } - .pdf-generation .invoice-header { margin-bottom: 30px !important; } - .pdf-generation .business-info h1 { font-size: 18px !important; } - .pdf-generation .business-info p { font-size: 12px !important; } - .pdf-generation .invoice-title h1 { font-size: 28px !important; } - .pdf-generation .invoice-table { font-size: 12px !important; } - .pdf-generation .invoice-table th, - .pdf-generation .invoice-table td { padding: 10px !important; } - `; - document.head.appendChild(printStyles); - - // Wait for styles to apply - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); - - // Capture with high DPI for crisp output - const canvas = await html2canvas(invoiceElement, { - scale: 3, // Higher scale for better quality - useCORS: true, - backgroundColor: '#ffffff', - logging: false, - windowWidth: 794, // A4 width at 96 DPI - onclone: (clonedDoc) => { - // Ensure cloned element is fully visible - const clonedInvoice = clonedDoc.getElementById('invoice'); - if (clonedInvoice) { - clonedInvoice.style.height = 'auto'; - clonedInvoice.style.overflow = 'visible'; - } - } - }); - - // Create PDF with proper A4 dimensions - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - compress: true - }); - - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const margin = 10; - const contentWidth = pageWidth - (margin * 2); - - // Calculate image dimensions maintaining aspect ratio - const imgWidth = contentWidth; - const imgHeight = (canvas.height * imgWidth) / canvas.width; - - // Handle multi-page if content is taller than one page - const maxHeightPerPage = pageHeight - (margin * 2); - - if (imgHeight <= maxHeightPerPage) { - // Single page - center vertically if short - const yOffset = margin; - pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, yOffset, imgWidth, imgHeight); - } else { - // Multi-page handling for long invoices - let remainingHeight = imgHeight; - let sourceY = 0; - let pageNum = 0; - - while (remainingHeight > 0) { - if (pageNum > 0) pdf.addPage(); - - const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); - const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; - - // Create a slice canvas for this page - const sliceCanvas = document.createElement('canvas'); - sliceCanvas.width = canvas.width; - sliceCanvas.height = sourceSliceHeight; - const ctx = sliceCanvas.getContext('2d'); - ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); - - pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); - - sourceY += sourceSliceHeight; - remainingHeight -= sliceHeight; - pageNum++; - } - } - - // Generate filename - const customerName = getValue('billToName').replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-'); - const invoiceNumber = getValue('invoiceNumber'); - const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; - - pdf.save(filename); - - // Remove print styles - document.head.removeChild(printStyles); - - } catch (error) { - console.error('Error generating PDF:', error); - alert('Error generating PDF. Please try again.'); - } finally { - // Restore original styles - document.querySelector('.form-section').style.display = originalStyles.formDisplay; - invoiceElement.style.height = originalStyles.invoiceHeight; - invoiceElement.style.overflow = originalStyles.invoiceOverflow; - document.body.classList.remove('pdf-generation'); - - // Remove print styles if still present - const printStyles = document.getElementById('pdf-print-styles'); - if (printStyles) printStyles.remove(); - - // Restore button - downloadBtn.textContent = originalBtnText; - downloadBtn.disabled = false; - } -} - -function exportJsonToTextarea() { - const data = syncFromForm({ render: false }); - const json = JSON.stringify(data, null, 2); - setValue('jsonData', json); -} - -function downloadJsonFile() { - const data = syncFromForm({ render: false }); - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - const invoiceNumber = data.invoice.number || 'draft'; - anchor.href = url; - anchor.download = `invoice-${invoiceNumber}.json`; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); -} - -function importJsonData(rawJson) { - let parsed; - try { - parsed = JSON.parse(rawJson); - } catch (error) { - alert('Invalid JSON. Please check the format and try again.'); - return; - } - - const base = getDataFromForm(); - const merged = deepMerge(base, parsed); - merged.meta = merged.meta || {}; - merged.meta.showInvoice = true; - - applyDataToForm(merged); -} - -function handleFileImport(event) { - const file = event.target.files[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - importJsonData(reader.result); - }; - reader.readAsText(file); -} - -function resetDraft() { - const shouldReset = confirm('Reset the current draft? Your stored draft will be cleared.'); - if (!shouldReset) return; - - localStorage.removeItem(STORAGE_KEY); - document.getElementById('invoice').style.display = 'none'; - document.getElementById('invoicePlaceholder').style.display = 'flex'; - document.getElementById('downloadBtn').style.display = 'none'; - setValue('jsonData', ''); - - // Reset all form fields - setValue('sellerName', ''); - setValue('sellerAddress', ''); - setValue('sellerEmail', ''); - setValue('sellerPhone', ''); - setValue('billToName', ''); - setValue('billToAddress', ''); - setValue('billToEmail', ''); - setValue('billToPhone', ''); - setValue('invoiceTitle', 'INVOICE'); - setValue('invoiceDueDate', ''); - setValue('currencyCode', 'INR'); - setValue('taxRate', '0'); - setValue('discountAmount', '0'); - setValue('invoiceNotes', ''); - setValue('invoiceInstructions', ''); - - const today = new Date().toISOString().split('T')[0]; - setValue('invoiceDate', today); - - const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); - if (lastNumber) { - setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); - } else { - setValue('invoiceNumber', '1'); - } - - renderItemsForm([]); - ensureAtLeastOneItemRow(); - syncFromForm({ render: false, showInvoice: false }); -} - -function attachEventHandlers() { - const form = document.getElementById('billForm'); - form.addEventListener('input', () => { - if (isApplyingData) return; - const shouldRender = isInvoiceVisible(); - syncFromForm({ render: shouldRender }); - }); - - document.getElementById('addItemBtn').addEventListener('click', () => { - addItemRow({ description: '', quantity: 1, unitPrice: 0 }); - syncFromForm({ render: isInvoiceVisible() }); - }); - - const itemsContainer = document.getElementById('itemsContainer'); - itemsContainer.addEventListener('click', (event) => { - if (!event.target.classList.contains('remove-item')) return; - const row = event.target.closest('.item-row'); - if (row) row.remove(); - ensureAtLeastOneItemRow(); - syncFromForm({ render: isInvoiceVisible() }); - }); - - document.getElementById('exportJsonBtn').addEventListener('click', exportJsonToTextarea); - document.getElementById('downloadJsonBtn').addEventListener('click', downloadJsonFile); - document.getElementById('importJsonBtn').addEventListener('click', () => { - const raw = getValue('jsonData'); - if (!raw.trim()) { - alert('Paste invoice JSON first.'); - return; - } - importJsonData(raw); - }); - - document.getElementById('importFile').addEventListener('change', handleFileImport); - document.getElementById('resetDraftBtn').addEventListener('click', resetDraft); - - // Invoice number increment/decrement buttons - document.getElementById('incrementInvoice').addEventListener('click', () => { - const input = document.getElementById('invoiceNumber'); - const current = parseNumber(input.value, 0); - input.value = current + 1; - syncFromForm({ render: isInvoiceVisible() }); - }); - - document.getElementById('decrementInvoice').addEventListener('click', () => { - const input = document.getElementById('invoiceNumber'); - const current = parseNumber(input.value, 0); - if (current > 1) { - input.value = current - 1; - syncFromForm({ render: isInvoiceVisible() }); - } - }); - - // Drag and drop support for JSON textarea - const jsonTextarea = document.getElementById('jsonData'); - - jsonTextarea.addEventListener('dragover', (e) => { - e.preventDefault(); - jsonTextarea.classList.add('drag-over'); - }); - - jsonTextarea.addEventListener('dragleave', (e) => { - e.preventDefault(); - jsonTextarea.classList.remove('drag-over'); - }); - - jsonTextarea.addEventListener('drop', (e) => { - e.preventDefault(); - jsonTextarea.classList.remove('drag-over'); - - const file = e.dataTransfer.files[0]; - if (!file) return; - - if (!file.name.endsWith('.json') && file.type !== 'application/json') { - alert('Please drop a .json file.'); - return; - } - - const reader = new FileReader(); - reader.onload = () => { - importJsonData(reader.result); - }; - reader.readAsText(file); - }); - - // History panel handlers - document.getElementById('historyToggle').addEventListener('click', () => toggleHistoryPanel()); - document.getElementById('clearHistoryBtn').addEventListener('click', clearHistory); - - document.getElementById('historyList').addEventListener('click', (e) => { - const deleteBtn = e.target.closest('.history-item-delete'); - if (deleteBtn) { - e.stopPropagation(); - const id = parseInt(deleteBtn.dataset.id, 10); - deleteFromHistory(id); - return; - } - - const item = e.target.closest('.history-item'); - if (item) { - const id = parseInt(item.dataset.id, 10); - loadFromHistory(id); - } - }); -} - -document.addEventListener('DOMContentLoaded', () => { - const today = new Date().toISOString().split('T')[0]; - if (!getValue('invoiceDate')) { - setValue('invoiceDate', today); - } - - attachEventHandlers(); - - const storedDraft = loadDraft(); - if (storedDraft) { - applyDataToForm(storedDraft); - if (storedDraft.meta?.showInvoice) { - renderInvoice(storedDraft); - } - return; - } - - const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); - if (lastNumber && !getValue('invoiceNumber')) { - setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); - } - - ensureAtLeastOneItemRow(); - syncFromForm({ render: false, showInvoice: false }); -}); diff --git a/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..1503142 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,286 @@ +/** + * Rebill - Invoice Generator + * Main Application Entry Point + */ + +// Import modules +import { getValue, setValue } from './modules/dom.js'; +import { parseNumber } from './modules/formatters.js'; +import { saveDraft, loadDraft, clearDraft, STORAGE_KEY } from './modules/storage.js'; +import { + getHistory, + saveToHistory, + deleteFromHistory, + clearHistory, + getHistoryEntry, + renderHistoryList, + toggleHistoryPanel +} from './modules/history.js'; +import { addItemRow, renderItemsForm, ensureAtLeastOneItemRow } from './modules/items.js'; +import { renderInvoice, isInvoiceVisible } from './modules/invoice.js'; +import { + getDataFromForm, + updateFormTotalsDisplay, + validateData, + syncFromForm, + applyDataToForm +} from './modules/form.js'; +import { downloadPDF, shareInvoice } from './modules/pdf.js'; +import { + exportJsonToTextarea, + downloadJsonFile, + importJsonData, + handleFileImport +} from './modules/json-io.js'; + +// Constants +const LAST_NUMBER_KEY = 'invoice.lastNumber'; + +// State +let isApplyingData = false; + +/** + * Generate bill from form data + */ +function generateBill() { + const data = syncFromForm({ showInvoice: true }); + if (!validateData(data)) { + alert('Please fill in all required fields and add at least one line item.'); + return; + } + data.meta.showInvoice = true; + saveDraft(data); + renderInvoice(data); + localStorage.setItem(LAST_NUMBER_KEY, data.invoice.number); + saveToHistory(data); + document.getElementById('invoice').scrollIntoView({ behavior: 'smooth' }); +} + +/** + * Load invoice from history by ID + * @param {number} id - History entry ID + */ +function loadFromHistory(id) { + const entry = getHistoryEntry(id); + if (!entry || !entry.data) return; + applyDataToForm(entry.data, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + toggleHistoryPanel(false); +} + +/** + * Reset the current draft + */ +function resetDraft() { + const shouldReset = confirm('Reset the current draft? Your stored draft will be cleared.'); + if (!shouldReset) return; + + clearDraft(); + document.getElementById('invoice').style.display = 'none'; + document.getElementById('invoicePlaceholder').style.display = 'flex'; + document.getElementById('downloadBtn').style.display = 'none'; + document.getElementById('shareBtn').style.display = 'none'; + setValue('jsonData', ''); + + // Reset all form fields + setValue('sellerName', ''); + setValue('sellerAddress', ''); + setValue('sellerEmail', ''); + setValue('sellerPhone', ''); + setValue('billToName', ''); + setValue('billToAddress', ''); + setValue('billToEmail', ''); + setValue('billToPhone', ''); + setValue('invoiceTitle', 'INVOICE'); + setValue('invoiceDueDate', ''); + setValue('currencyCode', 'INR'); + setValue('taxRate', '0'); + setValue('discountAmount', '0'); + setValue('invoiceNotes', ''); + setValue('invoiceInstructions', ''); + + const today = new Date().toISOString().split('T')[0]; + setValue('invoiceDate', today); + + const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); + if (lastNumber) { + setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); + } else { + setValue('invoiceNumber', '1'); + } + + renderItemsForm([]); + ensureAtLeastOneItemRow(addItemRow); + syncFromForm({ render: false, showInvoice: false }); +} + +/** + * Attach all event handlers + */ +function attachEventHandlers() { + const form = document.getElementById('billForm'); + form.addEventListener('input', () => { + if (isApplyingData) return; + const shouldRender = isInvoiceVisible(); + syncFromForm({ render: shouldRender }); + }); + + document.getElementById('addItemBtn').addEventListener('click', () => { + addItemRow({ description: '', quantity: 1, unitPrice: 0 }); + syncFromForm({ render: isInvoiceVisible() }); + }); + + const itemsContainer = document.getElementById('itemsContainer'); + itemsContainer.addEventListener('click', (event) => { + if (!event.target.classList.contains('remove-item')) return; + const row = event.target.closest('.item-row'); + if (row) row.remove(); + ensureAtLeastOneItemRow(addItemRow); + syncFromForm({ render: isInvoiceVisible() }); + }); + + document.getElementById('exportJsonBtn').addEventListener('click', exportJsonToTextarea); + document.getElementById('downloadJsonBtn').addEventListener('click', downloadJsonFile); + document.getElementById('importJsonBtn').addEventListener('click', () => { + const raw = getValue('jsonData'); + if (!raw.trim()) { + alert('Paste invoice JSON first.'); + return; + } + importJsonData(raw, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + }); + + document.getElementById('importFile').addEventListener('change', (event) => { + handleFileImport(event, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + }); + document.getElementById('resetDraftBtn').addEventListener('click', resetDraft); + + // Invoice number increment/decrement buttons + document.getElementById('incrementInvoice').addEventListener('click', () => { + const input = document.getElementById('invoiceNumber'); + const current = parseNumber(input.value, 0); + input.value = current + 1; + syncFromForm({ render: isInvoiceVisible() }); + }); + + document.getElementById('decrementInvoice').addEventListener('click', () => { + const input = document.getElementById('invoiceNumber'); + const current = parseNumber(input.value, 0); + if (current > 1) { + input.value = current - 1; + syncFromForm({ render: isInvoiceVisible() }); + } + }); + + // Drag and drop support for JSON textarea + const jsonTextarea = document.getElementById('jsonData'); + + jsonTextarea.addEventListener('dragover', (e) => { + e.preventDefault(); + jsonTextarea.classList.add('drag-over'); + }); + + jsonTextarea.addEventListener('dragleave', (e) => { + e.preventDefault(); + jsonTextarea.classList.remove('drag-over'); + }); + + jsonTextarea.addEventListener('drop', (e) => { + e.preventDefault(); + jsonTextarea.classList.remove('drag-over'); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + if (!file.name.endsWith('.json') && file.type !== 'application/json') { + alert('Please drop a .json file.'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + importJsonData(reader.result, (options) => { + isApplyingData = true; + syncFromForm(options); + isApplyingData = false; + }); + }; + reader.readAsText(file); + }); + + // History panel handlers + document.getElementById('historyToggle').addEventListener('click', () => toggleHistoryPanel()); + document.getElementById('clearHistoryBtn').addEventListener('click', () => { + clearHistory(); + renderHistoryList(); + }); + + document.getElementById('historyList').addEventListener('click', (e) => { + const deleteBtn = e.target.closest('.history-item-delete'); + if (deleteBtn) { + e.stopPropagation(); + const id = parseInt(deleteBtn.dataset.id, 10); + deleteFromHistory(id); + renderHistoryList(); + return; + } + + const item = e.target.closest('.history-item'); + if (item) { + const id = parseInt(item.dataset.id, 10); + loadFromHistory(id); + } + }); +} + +/** + * Initialize the application + */ +function init() { + const today = new Date().toISOString().split('T')[0]; + if (!getValue('invoiceDate')) { + setValue('invoiceDate', today); + } + + attachEventHandlers(); + + const storedDraft = loadDraft(); + if (storedDraft) { + isApplyingData = true; + applyDataToForm(storedDraft, (options) => { + syncFromForm(options); + }); + isApplyingData = false; + if (storedDraft.meta?.showInvoice) { + renderInvoice(storedDraft); + } + return; + } + + const lastNumber = localStorage.getItem(LAST_NUMBER_KEY); + if (lastNumber && !getValue('invoiceNumber')) { + setValue('invoiceNumber', String(parseNumber(lastNumber, 0) + 1)); + } + + ensureAtLeastOneItemRow(addItemRow); + syncFromForm({ render: false, showInvoice: false }); +} + +// Expose functions to global scope for onclick handlers in HTML +window.generateBill = generateBill; +window.downloadPDF = downloadPDF; +window.shareInvoice = shareInvoice; + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', init); diff --git a/src/js/modules/calculations.js b/src/js/modules/calculations.js new file mode 100644 index 0000000..4a821ca --- /dev/null +++ b/src/js/modules/calculations.js @@ -0,0 +1,25 @@ +/** + * Calculations Module + * Invoice totals calculation + */ + +/** + * Calculate invoice totals from items + * @param {Array} items - Array of line items with total property + * @param {number} taxRate - Tax rate as percentage + * @param {number} discount - Discount amount + * @returns {Object} Totals object with subtotal, taxRate, taxAmount, discount, total, balanceDue + */ +export function calculateTotals(items, taxRate, discount) { + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const taxAmount = subtotal * (taxRate / 100); + const total = subtotal + taxAmount - discount; + return { + subtotal, + taxRate, + taxAmount, + discount, + total, + balanceDue: total + }; +} diff --git a/src/js/modules/dom.js b/src/js/modules/dom.js new file mode 100644 index 0000000..19f041e --- /dev/null +++ b/src/js/modules/dom.js @@ -0,0 +1,97 @@ +/** + * DOM Helper Functions + * Utilities for getting/setting form values and creating clickable links + */ + +/** + * Get value from a form element by ID + * @param {string} id - Element ID + * @param {string} fallback - Default value if element not found + * @returns {string} Element value or fallback + */ +export function getValue(id, fallback = '') { + const el = document.getElementById(id); + if (!el) return fallback; + return el.value ?? fallback; +} + +/** + * Set value on a form element by ID + * @param {string} id - Element ID + * @param {*} value - Value to set + */ +export function setValue(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.value = value ?? ''; +} + +/** + * Set text content of an element by ID + * @param {string} id - Element ID + * @param {*} value - Text to set + */ +export function setText(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = value ?? ''; +} + +/** + * Escape HTML special characters to prevent XSS + * @param {string} str - String to escape + * @returns {string} Escaped string + */ +export function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Set clickable email link in an element + * @param {string} id - Element ID + * @param {string} email - Email address + */ +export function setEmailLink(id, email) { + const el = document.getElementById(id); + if (!el) return; + if (email && email.trim()) { + el.innerHTML = ''; + const link = document.createElement('a'); + link.href = `mailto:${escapeHtml(email.trim())}`; + link.textContent = email.trim(); + link.style.color = 'inherit'; + link.style.textDecoration = 'none'; + el.appendChild(link); + } else { + el.textContent = ''; + } +} + +/** + * Set clickable phone link in an element + * @param {string} id - Element ID + * @param {string} phone - Phone number + */ +export function setPhoneLink(id, phone) { + const el = document.getElementById(id); + if (!el) return; + if (phone && phone.trim()) { + el.innerHTML = ''; + const link = document.createElement('a'); + // Remove spaces and special chars for tel: link + const cleanPhone = phone.trim().replace(/[\s\-\(\)]/g, ''); + link.href = `tel:${escapeHtml(cleanPhone)}`; + link.textContent = phone.trim(); + link.style.color = 'inherit'; + link.style.textDecoration = 'none'; + el.appendChild(link); + } else { + el.textContent = ''; + } +} diff --git a/src/js/modules/form.js b/src/js/modules/form.js new file mode 100644 index 0000000..26a5240 --- /dev/null +++ b/src/js/modules/form.js @@ -0,0 +1,151 @@ +/** + * Form Module + * Form data collection and synchronization + */ + +import { getValue, setValue, setText } from './dom.js'; +import { parseNumber, formatCurrency } from './formatters.js'; +import { calculateTotals } from './calculations.js'; +import { collectItemsFromForm, renderItemsForm, addItemRow } from './items.js'; +import { saveDraft, deepMerge } from './storage.js'; +import { renderInvoice, isInvoiceVisible } from './invoice.js'; + +/** + * Get all invoice data from the form + * @returns {Object} Complete invoice data object + */ +export function getDataFromForm() { + const settings = { + currency: getValue('currencyCode', 'INR').trim() || 'INR', + locale: 'en-IN' + }; + + const items = collectItemsFromForm(settings); + const taxRate = parseNumber(getValue('taxRate', 0), 0); + const discount = parseNumber(getValue('discountAmount', 0), 0); + const totals = calculateTotals(items, taxRate, discount); + + return { + schemaVersion: 1, + settings, + seller: { + name: getValue('sellerName'), + address: getValue('sellerAddress'), + email: getValue('sellerEmail'), + phone: getValue('sellerPhone') + }, + billTo: { + name: getValue('billToName'), + address: getValue('billToAddress'), + email: getValue('billToEmail'), + phone: getValue('billToPhone') + }, + invoice: { + title: getValue('invoiceTitle', 'INVOICE'), + date: getValue('invoiceDate'), + dueDate: getValue('invoiceDueDate'), + number: getValue('invoiceNumber'), + notes: getValue('invoiceNotes'), + instructions: getValue('invoiceInstructions') + }, + items, + totals, + meta: { + updatedAt: new Date().toISOString(), + showInvoice: isInvoiceVisible() + } + }; +} + +/** + * Update form totals display + * @param {Object} data - Invoice data with totals + */ +export function updateFormTotalsDisplay(data) { + const { totals, settings } = data; + setText('formSubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); + setText('formTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); + setText('formTotal', formatCurrency(totals.total, settings.currency, settings.locale)); +} + +/** + * Validate invoice data + * @param {Object} data - Invoice data to validate + * @returns {boolean} True if valid + */ +export function validateData(data) { + if (!data.seller.name || !data.seller.address) return false; + if (!data.billTo.name || !data.billTo.address) return false; + if (!data.invoice.date || !data.invoice.number) return false; + if (!Array.isArray(data.items) || data.items.length === 0) return false; + const validItems = data.items.some((item) => item.description && item.quantity > 0 && item.unitPrice >= 0); + return validItems; +} + +/** + * Sync data from form, save draft, and optionally render + * @param {Object} options - { render: boolean, showInvoice: boolean } + * @returns {Object} Current form data + */ +export function syncFromForm(options = {}) { + const data = getDataFromForm(); + if (typeof options.showInvoice === 'boolean') { + data.meta.showInvoice = options.showInvoice; + } + updateFormTotalsDisplay(data); + saveDraft(data); + if (options.render) { + renderInvoice(data); + } + return data; +} + +/** + * Apply data to form fields + * @param {Object} data - Invoice data to apply + * @param {Function} syncCallback - Callback to sync after applying + */ +export function applyDataToForm(data, syncCallback) { + setValue('sellerName', data.seller?.name ?? ''); + setValue('sellerAddress', data.seller?.address ?? ''); + setValue('sellerEmail', data.seller?.email ?? ''); + setValue('sellerPhone', data.seller?.phone ?? ''); + setValue('billToName', data.billTo?.name ?? ''); + setValue('billToAddress', data.billTo?.address ?? ''); + setValue('billToEmail', data.billTo?.email ?? ''); + setValue('billToPhone', data.billTo?.phone ?? ''); + + setValue('invoiceTitle', data.invoice?.title ?? 'INVOICE'); + setValue('invoiceDate', data.invoice?.date ?? ''); + setValue('invoiceDueDate', data.invoice?.dueDate ?? ''); + setValue('invoiceNumber', data.invoice?.number ?? ''); + + setValue('currencyCode', data.settings?.currency ?? 'INR'); + + renderItemsForm(data.items || []); + + setValue('taxRate', data.totals?.taxRate ?? 0); + setValue('discountAmount', data.totals?.discount ?? 0); + + setValue('invoiceNotes', data.invoice?.notes ?? ''); + + const legacyBank = data.seller?.bank; + const legacyInstructions = data.invoice?.terms ?? data.invoice?.instructions ?? ''; + let mergedInstructions = legacyInstructions; + if (!mergedInstructions && legacyBank) { + const lines = []; + if (legacyBank.accountNo) lines.push(`A/c no: ${legacyBank.accountNo}`); + if (legacyBank.ifsc) lines.push(`IFSC: ${legacyBank.ifsc}`); + if (legacyBank.name) lines.push(`Name: ${legacyBank.name}`); + if (lines.length > 0) { + mergedInstructions = `Please send payment to below bank account.\n${lines.join('\n')}`; + } + } + setValue('invoiceInstructions', mergedInstructions); + + if (syncCallback) { + syncCallback({ render: data.meta?.showInvoice, showInvoice: data.meta?.showInvoice }); + } +} + +export { deepMerge }; diff --git a/src/js/modules/formatters.js b/src/js/modules/formatters.js new file mode 100644 index 0000000..d9a77bc --- /dev/null +++ b/src/js/modules/formatters.js @@ -0,0 +1,52 @@ +/** + * Formatting Functions + * Number parsing and currency/date formatting utilities + */ + +/** + * Parse a value as a number with fallback + * @param {*} value - Value to parse + * @param {number} fallback - Default if parsing fails + * @returns {number} Parsed number or fallback + */ +export function parseNumber(value, fallback = 0) { + const num = parseFloat(value); + return Number.isFinite(num) ? num : fallback; +} + +/** + * Format amount as currency string + * @param {number} amount - Amount to format + * @param {string} currency - Currency code (e.g., 'INR', 'USD') + * @param {string} locale - Locale string (e.g., 'en-IN') + * @returns {string} Formatted currency string + */ +export function formatCurrency(amount, currency, locale) { + const safeAmount = Number.isFinite(amount) ? amount : 0; + try { + return new Intl.NumberFormat(locale || 'en-IN', { + style: 'currency', + currency: currency || 'INR', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(safeAmount); + } catch (error) { + const suffix = currency ? ` ${currency}` : ''; + return `${safeAmount.toFixed(2)}${suffix}`; + } +} + +/** + * Format date value as DD/MM/YYYY + * @param {string|Date} dateValue - Date to format + * @returns {string} Formatted date string + */ +export function formatDate(dateValue) { + if (!dateValue) return ''; + const date = new Date(dateValue); + if (Number.isNaN(date.getTime())) return dateValue; + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()); + return `${day}/${month}/${year}`; +} diff --git a/src/js/modules/history.js b/src/js/modules/history.js new file mode 100644 index 0000000..77b8d24 --- /dev/null +++ b/src/js/modules/history.js @@ -0,0 +1,156 @@ +/** + * History Module + * Invoice history management in localStorage + */ + +import { formatDate, formatCurrency } from './formatters.js'; + +const HISTORY_KEY = 'invoice.history'; + +/** + * Get all history entries from localStorage + * @returns {Array} Array of history entries + */ +export function getHistory() { + const raw = localStorage.getItem(HISTORY_KEY); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch (e) { + return []; + } +} + +/** + * Save invoice data to history + * @param {Object} data - Invoice data to save + */ +export function saveToHistory(data) { + const history = getHistory(); + const entry = { + id: Date.now(), + date: data.invoice?.date || new Date().toISOString().split('T')[0], + number: data.invoice?.number || '', + customerName: data.billTo?.name || '', + total: data.totals?.total || 0, + currency: data.settings?.currency || 'INR', + data: data + }; + + // Check for duplicate invoice number + const existingIndex = history.findIndex(h => h.number === entry.number && entry.number); + if (existingIndex >= 0) { + history[existingIndex] = entry; + } else { + history.unshift(entry); + } + + // Keep only last 50 entries + const trimmed = history.slice(0, 50); + + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(trimmed)); + } catch (e) { + console.warn('Failed to save history:', e); + } +} + +/** + * Delete a history entry by ID + * @param {number} id - Entry ID to delete + */ +export function deleteFromHistory(id) { + const history = getHistory().filter(h => h.id !== id); + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); +} + +/** + * Clear all history entries + */ +export function clearHistory() { + if (!confirm('Clear all invoice history? This cannot be undone.')) return; + localStorage.removeItem(HISTORY_KEY); +} + +/** + * Get a history entry by ID + * @param {number} id - Entry ID + * @returns {Object|null} History entry or null + */ +export function getHistoryEntry(id) { + const history = getHistory(); + return history.find(h => h.id === id) || null; +} + +/** + * Render the history list in the UI + * @param {Function} onLoad - Callback when an entry is loaded + * @param {Function} onDelete - Callback when an entry is deleted + */ +export function renderHistoryList() { + const list = document.getElementById('historyList'); + if (!list) return; + + const history = getHistory(); + list.innerHTML = ''; + + if (history.length === 0) { + const empty = document.createElement('p'); + empty.className = 'history-empty'; + empty.textContent = 'No saved invoices yet'; + list.appendChild(empty); + return; + } + + history.forEach((entry) => { + const item = document.createElement('div'); + item.className = 'history-item'; + item.dataset.id = entry.id; + + const info = document.createElement('div'); + info.className = 'history-item-info'; + + const title = document.createElement('div'); + title.className = 'history-item-title'; + title.textContent = `#${entry.number} - ${entry.customerName || 'Unknown'}`; + + const meta = document.createElement('div'); + meta.className = 'history-item-meta'; + meta.textContent = `${formatDate(entry.date)} · ${formatCurrency(entry.total, entry.currency)}`; + + info.appendChild(title); + info.appendChild(meta); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'history-item-delete'; + deleteBtn.dataset.id = entry.id; + deleteBtn.title = 'Delete'; + deleteBtn.textContent = '×'; + + item.appendChild(info); + item.appendChild(deleteBtn); + list.appendChild(item); + }); +} + +/** + * Toggle history panel visibility + * @param {boolean} show - Optional explicit show/hide + */ +export function toggleHistoryPanel(show) { + const panel = document.getElementById('historyPanel'); + if (!panel) return; + + if (typeof show === 'boolean') { + panel.style.display = show ? 'block' : 'none'; + } else { + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + } + + if (panel.style.display === 'block') { + renderHistoryList(); + } +} + +export { HISTORY_KEY }; diff --git a/src/js/modules/invoice.js b/src/js/modules/invoice.js new file mode 100644 index 0000000..0fee987 --- /dev/null +++ b/src/js/modules/invoice.js @@ -0,0 +1,112 @@ +/** + * Invoice Rendering Module + * Display invoice preview + */ + +import { setText, setEmailLink, setPhoneLink } from './dom.js'; +import { formatCurrency, formatDate } from './formatters.js'; + +/** + * Check if invoice preview is currently visible + * @returns {boolean} True if invoice is visible + */ +export function isInvoiceVisible() { + const invoice = document.getElementById('invoice'); + return invoice && invoice.style.display === 'block'; +} + +/** + * Render invoice preview from data + * @param {Object} data - Complete invoice data object + */ +export function renderInvoice(data) { + const { settings, seller, billTo, invoice, items, totals } = data; + + setText('displaySellerName', seller.name); + setText('displaySellerAddress', seller.address || ''); + setEmailLink('displaySellerEmail', seller.email); + setPhoneLink('displaySellerPhone', seller.phone); + + setText('displayInvoiceTitle', invoice.title || 'INVOICE'); + + setText('displayBillToName', billTo.name); + setText('displayBillToAddress', billTo.address || ''); + setEmailLink('displayBillToEmail', billTo.email); + setPhoneLink('displayBillToPhone', billTo.phone); + + setText('displayInvoiceDate', formatDate(invoice.date)); + + // Conditionally show/hide due date row + const dueDateRow = document.getElementById('dueDateRow'); + if (dueDateRow) { + if (invoice.dueDate) { + dueDateRow.style.display = ''; + setText('displayInvoiceDueDate', formatDate(invoice.dueDate)); + } else { + dueDateRow.style.display = 'none'; + } + } + + setText('displayInvoiceNumber', invoice.number); + + const itemsBody = document.getElementById('displayItemsBody'); + if (itemsBody) { + itemsBody.innerHTML = ''; + items.forEach((item) => { + const row = document.createElement('tr'); + + const descTd = document.createElement('td'); + descTd.textContent = item.description || ''; + + const qtyTd = document.createElement('td'); + qtyTd.textContent = item.quantity || 0; + + const priceTd = document.createElement('td'); + priceTd.textContent = formatCurrency(item.unitPrice || 0, settings.currency, settings.locale); + + const totalTd = document.createElement('td'); + totalTd.textContent = formatCurrency(item.total || 0, settings.currency, settings.locale); + + row.appendChild(descTd); + row.appendChild(qtyTd); + row.appendChild(priceTd); + row.appendChild(totalTd); + + itemsBody.appendChild(row); + }); + } + + setText('displaySubtotal', formatCurrency(totals.subtotal, settings.currency, settings.locale)); + + // Conditionally show/hide tax row + const taxRow = document.getElementById('taxRow'); + if (taxRow) { + if (totals.taxAmount > 0) { + taxRow.style.display = ''; + setText('displayTaxAmount', formatCurrency(totals.taxAmount, settings.currency, settings.locale)); + } else { + taxRow.style.display = 'none'; + } + } + + // Conditionally show/hide discount row + const discountRow = document.getElementById('discountRow'); + if (discountRow) { + if (totals.discount > 0) { + discountRow.style.display = ''; + setText('displayDiscount', formatCurrency(totals.discount, settings.currency, settings.locale)); + } else { + discountRow.style.display = 'none'; + } + } + + setText('displayBalanceDue', formatCurrency(totals.balanceDue, settings.currency, settings.locale)); + + setText('displayNotesHeading', invoice.notes || 'Thank you for your business'); + setText('displayInstructions', invoice.instructions || ''); + + document.getElementById('invoicePlaceholder').style.display = 'none'; + document.getElementById('invoice').style.display = 'block'; + document.getElementById('downloadBtn').style.display = 'inline-block'; + document.getElementById('shareBtn').style.display = 'inline-block'; +} diff --git a/src/js/modules/items.js b/src/js/modules/items.js new file mode 100644 index 0000000..7673eb8 --- /dev/null +++ b/src/js/modules/items.js @@ -0,0 +1,114 @@ +/** + * Items Module + * Line items form management + */ + +import { parseNumber, formatCurrency } from './formatters.js'; + +/** + * Ensure at least one item row exists + * @param {Function} addItemRowFn - Function to add an item row + */ +export function ensureAtLeastOneItemRow(addItemRowFn) { + const container = document.getElementById('itemsContainer'); + if (!container || container.querySelectorAll('.item-row').length > 0) return; + addItemRowFn({ + description: '', + quantity: 1, + unitPrice: 0 + }); +} + +/** + * Add an item row to the form + * @param {Object} item - Item data (description, quantity, unitPrice) + */ +export function addItemRow(item = {}) { + const container = document.getElementById('itemsContainer'); + if (!container) return; + + const row = document.createElement('div'); + row.className = 'item-row'; + + const descInput = document.createElement('input'); + descInput.type = 'text'; + descInput.className = 'item-description'; + descInput.placeholder = 'Description'; + descInput.value = item.description ?? ''; + + const qtyInput = document.createElement('input'); + qtyInput.type = 'number'; + qtyInput.className = 'item-quantity'; + qtyInput.min = '0'; + qtyInput.step = '1'; + qtyInput.value = item.quantity ?? 1; + + const priceInput = document.createElement('input'); + priceInput.type = 'number'; + priceInput.className = 'item-unit-price'; + priceInput.min = '0'; + priceInput.step = '0.01'; + priceInput.value = item.unitPrice ?? 0; + + const totalSpan = document.createElement('span'); + totalSpan.className = 'item-total'; + totalSpan.textContent = '0.00'; + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove-item'; + removeBtn.title = 'Remove item'; + removeBtn.textContent = '×'; + + row.appendChild(descInput); + row.appendChild(qtyInput); + row.appendChild(priceInput); + row.appendChild(totalSpan); + row.appendChild(removeBtn); + + container.appendChild(row); +} + +/** + * Render items form from data + * @param {Array} items - Array of item objects + */ +export function renderItemsForm(items) { + const container = document.getElementById('itemsContainer'); + if (!container) return; + container.innerHTML = ''; + + if (!Array.isArray(items) || items.length === 0) { + addItemRow(); + return; + } + + items.forEach((item) => addItemRow(item)); +} + +/** + * Collect items data from the form + * @param {Object} settings - Settings with currency and locale + * @returns {Array} Array of item objects with totals + */ +export function collectItemsFromForm(settings) { + const rows = Array.from(document.querySelectorAll('.item-row')); + const items = rows.map((row) => { + const description = row.querySelector('.item-description')?.value?.trim() ?? ''; + const quantity = parseNumber(row.querySelector('.item-quantity')?.value, 0); + const unitPrice = parseNumber(row.querySelector('.item-unit-price')?.value, 0); + const total = quantity * unitPrice; + const totalEl = row.querySelector('.item-total'); + if (totalEl) { + totalEl.textContent = formatCurrency(total, settings.currency, settings.locale); + } + return { + description, + quantity, + unitPrice, + total + }; + }); + + return items; +} diff --git a/src/js/modules/json-io.js b/src/js/modules/json-io.js new file mode 100644 index 0000000..809ecfe --- /dev/null +++ b/src/js/modules/json-io.js @@ -0,0 +1,72 @@ +/** + * JSON Import/Export Module + * Export and import invoice data as JSON + */ + +import { getValue, setValue } from './dom.js'; +import { getDataFromForm, applyDataToForm, syncFromForm } from './form.js'; +import { deepMerge } from './storage.js'; + +/** + * Export current form data to JSON textarea + */ +export function exportJsonToTextarea() { + const data = syncFromForm({ render: false }); + const json = JSON.stringify(data, null, 2); + setValue('jsonData', json); +} + +/** + * Download form data as JSON file + */ +export function downloadJsonFile() { + const data = syncFromForm({ render: false }); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + const invoiceNumber = data.invoice.number || 'draft'; + anchor.href = url; + anchor.download = `invoice-${invoiceNumber}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +/** + * Import JSON data and apply to form + * @param {string} rawJson - JSON string to import + * @param {Function} syncCallback - Callback to sync after import + */ +export function importJsonData(rawJson, syncCallback) { + let parsed; + try { + parsed = JSON.parse(rawJson); + } catch (error) { + alert('Invalid JSON. Please check the format and try again.'); + return; + } + + const base = getDataFromForm(); + const merged = deepMerge(base, parsed); + merged.meta = merged.meta || {}; + merged.meta.showInvoice = true; + + applyDataToForm(merged, syncCallback); +} + +/** + * Handle file import from file input + * @param {Event} event - File input change event + * @param {Function} syncCallback - Callback to sync after import + */ +export function handleFileImport(event, syncCallback) { + const file = event.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + importJsonData(reader.result, syncCallback); + }; + reader.readAsText(file); +} diff --git a/src/js/modules/pdf.js b/src/js/modules/pdf.js new file mode 100644 index 0000000..db080b3 --- /dev/null +++ b/src/js/modules/pdf.js @@ -0,0 +1,329 @@ +/** + * PDF Module + * PDF generation and sharing functionality + */ + +import { getValue } from './dom.js'; +import { getDataFromForm } from './form.js'; + +/** + * Generate PDF and download it + */ +export async function downloadPDF() { + const { jsPDF } = window.jspdf; + const invoiceElement = document.getElementById('invoice'); + const downloadBtn = document.getElementById('downloadBtn'); + + // Show loading state + const originalBtnText = downloadBtn.textContent; + downloadBtn.textContent = 'Generating...'; + downloadBtn.disabled = true; + + // Get invoice data for metadata + const data = getDataFromForm(); + const customerName = data.billTo?.name?.replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-') || 'Customer'; + const invoiceNumber = data.invoice?.number || 'draft'; + const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; + + // Store original styles to restore later + const originalStyles = { + formDisplay: document.querySelector('.form-section').style.display, + invoiceHeight: invoiceElement.style.height, + invoiceOverflow: invoiceElement.style.overflow + }; + + try { + // Prepare invoice for capture + document.querySelector('.form-section').style.display = 'none'; + invoiceElement.style.height = 'auto'; + invoiceElement.style.overflow = 'visible'; + document.body.classList.add('pdf-generation'); + + // Add print-optimized styles + const printStyles = document.createElement('style'); + printStyles.id = 'pdf-print-styles'; + printStyles.textContent = ` + .pdf-generation .invoice-section { + background: white !important; + box-shadow: none !important; + padding: 40px !important; + margin: 0 !important; + width: 794px !important; + min-height: auto !important; + height: auto !important; + overflow: visible !important; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important; + } + .pdf-generation .invoice-header { margin-bottom: 30px !important; } + .pdf-generation .business-info h1 { font-size: 18px !important; font-weight: 600 !important; } + .pdf-generation .business-info p { font-size: 11px !important; line-height: 1.5 !important; } + .pdf-generation .invoice-title h1 { font-size: 28px !important; font-weight: 700 !important; } + .pdf-generation .invoice-table { font-size: 11px !important; } + .pdf-generation .invoice-table th, + .pdf-generation .invoice-table td { padding: 10px !important; } + .pdf-generation .invoice-table th { font-size: 10px !important; font-weight: 600 !important; } + /* Tabular figures for number alignment */ + .pdf-generation .invoice-table td:nth-child(2), + .pdf-generation .invoice-table td:nth-child(3), + .pdf-generation .invoice-table td:nth-child(4), + .pdf-generation .invoice-totals span, + .pdf-generation .balance-due span { + font-variant-numeric: tabular-nums !important; + } + `; + document.head.appendChild(printStyles); + + // Wait for styles to apply + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + // Capture with high DPI for crisp output + const canvas = await html2canvas(invoiceElement, { + scale: 3, // Higher scale for better quality + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + windowWidth: 794, // A4 width at 96 DPI + onclone: (clonedDoc) => { + // Ensure cloned element is fully visible + const clonedInvoice = clonedDoc.getElementById('invoice'); + if (clonedInvoice) { + clonedInvoice.style.height = 'auto'; + clonedInvoice.style.overflow = 'visible'; + } + } + }); + + // Create PDF with proper A4 dimensions + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + compress: true + }); + + // Set PDF metadata + pdf.setProperties({ + title: `Invoice ${invoiceNumber}`, + subject: `Invoice for ${data.billTo?.name || 'Customer'}`, + author: data.seller?.name || 'Rebill', + creator: 'Rebill - Invoice Generator (rebill.mrjl.dev)', + keywords: 'invoice, receipt, billing' + }); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const contentWidth = pageWidth - (margin * 2); + + // Calculate image dimensions maintaining aspect ratio + const imgWidth = contentWidth; + const imgHeight = (canvas.height * imgWidth) / canvas.width; + + // Handle multi-page if content is taller than one page + const maxHeightPerPage = pageHeight - (margin * 2); + + if (imgHeight <= maxHeightPerPage) { + // Single page - center vertically if short + const yOffset = margin; + pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, yOffset, imgWidth, imgHeight); + } else { + // Multi-page handling for long invoices + let remainingHeight = imgHeight; + let sourceY = 0; + let pageNum = 0; + + while (remainingHeight > 0) { + if (pageNum > 0) pdf.addPage(); + + const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); + const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; + + // Create a slice canvas for this page + const sliceCanvas = document.createElement('canvas'); + sliceCanvas.width = canvas.width; + sliceCanvas.height = sourceSliceHeight; + const ctx = sliceCanvas.getContext('2d'); + ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); + + pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); + + sourceY += sourceSliceHeight; + remainingHeight -= sliceHeight; + pageNum++; + } + } + + pdf.save(filename); + + // Remove print styles + document.head.removeChild(printStyles); + + } catch (error) { + console.error('Error generating PDF:', error); + alert('Error generating PDF. Please try again.'); + } finally { + // Restore original styles + document.querySelector('.form-section').style.display = originalStyles.formDisplay; + invoiceElement.style.height = originalStyles.invoiceHeight; + invoiceElement.style.overflow = originalStyles.invoiceOverflow; + document.body.classList.remove('pdf-generation'); + + // Remove print styles if still present + const printStyles = document.getElementById('pdf-print-styles'); + if (printStyles) printStyles.remove(); + + // Restore button + downloadBtn.textContent = originalBtnText; + downloadBtn.disabled = false; + } +} + +/** + * Generate PDF as Blob for sharing + * @returns {Promise<{blob: Blob, filename: string}>} PDF blob and filename + */ +export async function generatePDFBlob() { + const { jsPDF } = window.jspdf; + const invoiceElement = document.getElementById('invoice'); + + const data = getDataFromForm(); + const customerName = data.billTo?.name?.replace(/[^a-zA-Z0-9\s]/g, '').trim().replace(/\s+/g, '-') || 'Customer'; + const invoiceNumber = data.invoice?.number || 'draft'; + const filename = `Invoice-${invoiceNumber}-${customerName}.pdf`; + + // Store original styles + const originalStyles = { + formDisplay: document.querySelector('.form-section').style.display, + invoiceHeight: invoiceElement.style.height, + invoiceOverflow: invoiceElement.style.overflow + }; + + try { + // Prepare invoice for capture + document.querySelector('.form-section').style.display = 'none'; + invoiceElement.style.height = 'auto'; + invoiceElement.style.overflow = 'visible'; + document.body.classList.add('pdf-generation'); + + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + // Capture with html2canvas + const canvas = await html2canvas(invoiceElement, { + scale: 3, + useCORS: true, + backgroundColor: '#ffffff', + logging: false, + windowWidth: 794, + onclone: (clonedDoc) => { + const clonedInvoice = clonedDoc.getElementById('invoice'); + if (clonedInvoice) { + clonedInvoice.style.height = 'auto'; + clonedInvoice.style.overflow = 'visible'; + } + } + }); + + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + compress: true + }); + + pdf.setProperties({ + title: `Invoice ${invoiceNumber}`, + subject: `Invoice for ${data.billTo?.name || 'Customer'}`, + author: data.seller?.name || 'Rebill', + creator: 'Rebill - Invoice Generator', + keywords: 'invoice, receipt, billing' + }); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 10; + const contentWidth = pageWidth - (margin * 2); + const imgWidth = contentWidth; + const imgHeight = (canvas.height * imgWidth) / canvas.width; + const maxHeightPerPage = pageHeight - (margin * 2); + + if (imgHeight <= maxHeightPerPage) { + pdf.addImage(canvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, imgHeight); + } else { + let remainingHeight = imgHeight; + let sourceY = 0; + let pageNum = 0; + + while (remainingHeight > 0) { + if (pageNum > 0) pdf.addPage(); + const sliceHeight = Math.min(remainingHeight, maxHeightPerPage); + const sourceSliceHeight = (sliceHeight / imgHeight) * canvas.height; + const sliceCanvas = document.createElement('canvas'); + sliceCanvas.width = canvas.width; + sliceCanvas.height = sourceSliceHeight; + const ctx = sliceCanvas.getContext('2d'); + ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceSliceHeight, 0, 0, canvas.width, sourceSliceHeight); + pdf.addImage(sliceCanvas.toDataURL('image/png', 1.0), 'PNG', margin, margin, imgWidth, sliceHeight); + sourceY += sourceSliceHeight; + remainingHeight -= sliceHeight; + pageNum++; + } + } + + return { blob: pdf.output('blob'), filename }; + } finally { + // Restore original styles + document.querySelector('.form-section').style.display = originalStyles.formDisplay; + invoiceElement.style.height = originalStyles.invoiceHeight; + invoiceElement.style.overflow = originalStyles.invoiceOverflow; + document.body.classList.remove('pdf-generation'); + } +} + +/** + * Share invoice using Web Share API + */ +export async function shareInvoice() { + const shareBtn = document.getElementById('shareBtn'); + if (shareBtn) { + shareBtn.textContent = 'Preparing...'; + shareBtn.disabled = true; + } + + try { + const { blob, filename } = await generatePDFBlob(); + const file = new File([blob], filename, { type: 'application/pdf' }); + + if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { + await navigator.share({ + files: [file], + title: filename.replace('.pdf', ''), + text: 'Invoice generated with Rebill' + }); + } else { + // Fallback: download the file + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + + // Show a message that sharing isn't available + if (!navigator.share) { + console.log('Web Share API not available, downloaded file instead'); + } + } + } catch (error) { + if (error.name !== 'AbortError') { + console.error('Error sharing invoice:', error); + alert('Error sharing invoice. Please try downloading instead.'); + } + } finally { + if (shareBtn) { + shareBtn.textContent = '📤 Share'; + shareBtn.disabled = false; + } + } +} diff --git a/src/js/modules/storage.js b/src/js/modules/storage.js new file mode 100644 index 0000000..74e3120 --- /dev/null +++ b/src/js/modules/storage.js @@ -0,0 +1,82 @@ +/** + * Storage Module + * Draft save/load and deep merge utilities + */ + +const STORAGE_KEY = 'invoice.draft.v1'; + +/** + * Save draft data to localStorage + * @param {Object} data - Invoice data to save + */ +export function saveDraft(data) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.warn('Failed to save draft:', e); + } +} + +/** + * Load draft data from localStorage + * @returns {Object|null} Saved draft or null + */ +export function loadDraft() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (error) { + return null; + } +} + +/** + * Clear the saved draft + */ +export function clearDraft() { + localStorage.removeItem(STORAGE_KEY); +} + +/** + * Deep merge source object into target + * Includes protection against prototype pollution + * @param {Object} target - Target object + * @param {Object} source - Source object to merge + * @returns {Object} Merged target object + */ +export function deepMerge(target, source) { + if (!source || typeof source !== 'object') return target; + + // Dangerous keys that could lead to prototype pollution + const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype']; + + function hasUnsafeKeys(obj, visited = new Set()) { + if (!obj || typeof obj !== 'object' || visited.has(obj)) return false; + visited.add(obj); + return UNSAFE_KEYS.some(k => k in obj) || + Object.values(obj).some(v => v && typeof v === 'object' && hasUnsafeKeys(v, visited)); + } + + Object.keys(source).forEach((key) => { + // Skip prototype pollution vectors + if (UNSAFE_KEYS.includes(key)) return; + if (!Object.prototype.hasOwnProperty.call(source, key)) return; + + const value = source[key]; + if (Array.isArray(value)) { + target[key] = value.slice(); + } else if (value && typeof value === 'object') { + if (hasUnsafeKeys(value)) return; + if (!target[key] || typeof target[key] !== 'object') { + target[key] = {}; + } + deepMerge(target[key], value); + } else { + target[key] = value; + } + }); + return target; +} + +export { STORAGE_KEY }; diff --git a/styles.css b/styles.css index c247254..3a68851 100644 --- a/styles.css +++ b/styles.css @@ -731,7 +731,19 @@ button:focus-visible { padding: var(--space-10); height: 100vh; overflow-y: auto; - font-family: 'Inter', Arial, sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Tabular figures for proper number alignment in invoice */ +.invoice-table td:nth-child(2), +.invoice-table td:nth-child(3), +.invoice-table td:nth-child(4), +.invoice-totals span, +.balance-due span { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; } .invoice-header { diff --git a/tests/rebill.spec.js b/tests/rebill.spec.js new file mode 100644 index 0000000..44b93af --- /dev/null +++ b/tests/rebill.spec.js @@ -0,0 +1,271 @@ +// @ts-check +import { test, expect } from '@playwright/test'; + +/** + * Rebill Invoice Generator - E2E Tests + * + * These tests verify the core functionality of the invoice generator, + * with special focus on PDF generation and layout correctness. + */ + +// Test data fixtures +const testData = { + seller: { + name: 'Test Company Ltd', + address: '123 Business Park, Tech City 12345', + email: 'billing@testcompany.com', + phone: '+91 9876543210' + }, + customer: { + name: 'Customer Corp', + address: '456 Client Street, Commerce Town 67890', + email: 'accounts@customercorp.com', + phone: '+91 9876543211' + }, + invoice: { + number: '001', + title: 'INVOICE' + }, + items: [ + { description: 'Web Development Services', quantity: 1, price: 50000 }, + { description: 'UI/UX Design', quantity: 2, price: 15000 } + ] +}; + +test.describe('Invoice Form', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Clear any stored draft + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should load the homepage', async ({ page }) => { + await expect(page).toHaveTitle(/Rebill/); + await expect(page.locator('.form-section')).toBeVisible(); + }); + + test('should fill seller information', async ({ page }) => { + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#sellerEmail', testData.seller.email); + await page.fill('#sellerPhone', testData.seller.phone); + + await expect(page.locator('#sellerName')).toHaveValue(testData.seller.name); + await expect(page.locator('#sellerAddress')).toHaveValue(testData.seller.address); + }); + + test('should add line items', async ({ page }) => { + // Fill first item + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Add second item + await page.click('#addItemBtn'); + const itemRows = page.locator('.item-row'); + await expect(itemRows).toHaveCount(2); + }); + + test('should calculate totals correctly', async ({ page }) => { + // Fill one item + await page.fill('.item-description', 'Test Service'); + await page.fill('.item-quantity', '2'); + await page.fill('.item-unit-price', '1000'); + + // Wait for calculation + await page.waitForTimeout(100); + + // Check item total + const itemTotal = page.locator('.item-total').first(); + await expect(itemTotal).toContainText('2,000'); + }); +}); + +test.describe('Invoice Generation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should generate invoice preview', async ({ page }) => { + // Fill required fields + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', testData.invoice.number); + + // Fill item + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Generate invoice + await page.click('button:has-text("Generate Bill")'); + + // Verify preview is visible + await expect(page.locator('#invoice')).toBeVisible(); + await expect(page.locator('#downloadBtn')).toBeVisible(); + }); + + test('should show validation alert for incomplete form', async ({ page }) => { + // Try to generate without filling required fields + page.on('dialog', async dialog => { + expect(dialog.message()).toContain('required fields'); + await dialog.accept(); + }); + + await page.click('button:has-text("Generate Bill")'); + }); +}); + +test.describe('PDF Generation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + }); + + test('should download PDF without errors', async ({ page }) => { + // Fill complete invoice + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#sellerEmail', testData.seller.email); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', testData.invoice.number); + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Generate invoice + await page.click('button:has-text("Generate Bill")'); + await expect(page.locator('#invoice')).toBeVisible(); + + // Start waiting for download before clicking + const downloadPromise = page.waitForEvent('download'); + await page.click('#downloadBtn'); + const download = await downloadPromise; + + // Verify download + expect(download.suggestedFilename()).toMatch(/Invoice-.*\.pdf/); + + // Verify file is not empty + const path = await download.path(); + const fs = await import('fs'); + const stats = fs.statSync(path); + expect(stats.size).toBeGreaterThan(10000); // PDF should be > 10KB + }); + + test('invoice preview should not have horizontal cutoff', async ({ page }) => { + // Fill complete invoice + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', testData.invoice.number); + await page.fill('.item-description', testData.items[0].description); + await page.fill('.item-quantity', String(testData.items[0].quantity)); + await page.fill('.item-unit-price', String(testData.items[0].price)); + + // Generate invoice + await page.click('button:has-text("Generate Bill")'); + await expect(page.locator('#invoice')).toBeVisible(); + + // Verify key elements are fully visible (this catches horizontal cutoff) + const invoiceTitle = page.locator('.invoice-title h1'); + await expect(invoiceTitle).toHaveText('INVOICE'); + await expect(invoiceTitle).toBeVisible(); + + // Verify seller name is visible + const sellerName = page.locator('#displaySellerName'); + await expect(sellerName).toHaveText(testData.seller.name); + + // Verify customer name is visible + const customerName = page.locator('#displayBillToName'); + await expect(customerName).toHaveText(testData.customer.name); + + // Verify invoice number is visible + const invoiceNumber = page.locator('#displayInvoiceNumber'); + await expect(invoiceNumber).toHaveText(testData.invoice.number); + + // Verify balance due is visible (right-side element - catches horizontal cutoff) + const balanceDue = page.locator('#displayBalanceDue'); + await expect(balanceDue).toBeVisible(); + await expect(balanceDue).toContainText('₹'); + }); +}); + +test.describe('History Management', () => { + test('should save invoice to history', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Fill and generate invoice + await page.fill('#sellerName', testData.seller.name); + await page.fill('#sellerAddress', testData.seller.address); + await page.fill('#billToName', testData.customer.name); + await page.fill('#billToAddress', testData.customer.address); + await page.fill('#invoiceNumber', '100'); + await page.fill('.item-description', 'Test Item'); + await page.fill('.item-quantity', '1'); + await page.fill('.item-unit-price', '1000'); + + await page.click('button:has-text("Generate Bill")'); + + // Open history panel + await page.click('#historyToggle'); + await expect(page.locator('#historyPanel')).toBeVisible(); + + // Verify entry exists + await expect(page.locator('.history-item')).toHaveCount(1); + await expect(page.locator('.history-item-title')).toContainText('#100'); + }); +}); + +test.describe('JSON Import/Export', () => { + test('should export and import JSON', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + // Fill invoice data + await page.fill('#sellerName', testData.seller.name); + await page.fill('#invoiceNumber', '200'); + + // Export JSON + await page.click('#exportJsonBtn'); + const jsonData = await page.locator('#jsonData').inputValue(); + expect(jsonData).toContain(testData.seller.name); + expect(jsonData).toContain('200'); + + // Parse and verify JSON structure + const parsed = JSON.parse(jsonData); + expect(parsed.seller.name).toBe(testData.seller.name); + expect(parsed.invoice.number).toBe('200'); + }); +}); + +test.describe('Reset Draft', () => { + test('should clear form on reset', async ({ page }) => { + await page.goto('/'); + + // Fill some data + await page.fill('#sellerName', testData.seller.name); + await page.fill('#billToName', testData.customer.name); + + // Click reset and accept dialog + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + await page.click('#resetDraftBtn'); + + // Verify fields are cleared + await expect(page.locator('#sellerName')).toHaveValue(''); + await expect(page.locator('#billToName')).toHaveValue(''); + }); +}); diff --git a/tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png b/tests/rebill.spec.js-snapshots/invoice-preview-chromium-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e26b095b3d856c5cfa0f5ef84110e2988f0da7 GIT binary patch literal 41717 zcmeEuf!*Y}C~M|H41S??p3&=`qv7{5QvhAC%y&XL!MnGw$v648A@ zY07|wYgbG+*QlG1Oy$ZSRzKvrZ+CgQ$YgG;X4EY@oq_eq+m@i8o-ccF5C58?$1iE| zMqq(6nJxGDMq=lt$;%i|$fMl;gUnPjrB0w({sn#68|fVwK#Pe+1nrxteIR&LN-M z=5Y(;+F((Q)v!B)Y)nHsnZdrkLf=az)aPKJ#`4W?OIJemw z0W?y9sP%1U!b1L@pz}@PEE(Im%HAgep5M@3jVT5VRi?+Mb6nK5$BUgS1xl2Tq9)8* z-dp=MHN>@eeX&ap>YXN?MYta|6lD&RSZ6r`u-K5p2e4^EWetXVO4D=)Ib1#36SapO ziCIP6961Bstj(tOE|klp6V_U2oi*6q=zi9W6}YJOj0wI! o5cOh?VTy;4in^6w7XZm#Z zRAe9oONHk>TJT7H&p;CsikgpkGjZ2B#u|$8Vx@c5(b>x| ~9f%SqeK?VH56B{D1#EXXLXv3fX! z-5e!s_nT<@#^{lQZAyQaYh*A`Ocubjm(Of+>nlJ`_wW{S?f+Oca4q`+G1Kof;7Vnm z&gU75H62M#8`%1o6gc;Ico%+9cUYm7AaJG09GVV!wsx7r3OZDBZ1^m}K=Hf~k#TyH z0UJ_kn!TQ5(_u2Qj;px%O|VPbE6Z* _@@`n55Hd)BA0dRJtMCgF>)DKmmbjO%ufcb_#*jgEpCU~ zGr6qh!f1Mt`cV8?UbP)w3(_XyB7K4{k>meml7HuDTmv)y-pc;wu?u %7h-c!DN>U;hwh)r%w4f)Z+*5 MPcAa0h!pXS~U4vzsGitr=ecW#y5|e^W`%humT~&d%ww+>6cdkN_ zgqOy`qvhzYwuYmy!xctH)a$KKYyD0}afdXU2dF|m#XV38v{$gkB0Qak?7i|7tACYa zb}-TscugtTUq^y*o7{B$%=-QJw~ZF<8D@x+MdX<^ybn0ebn+xC)OM3~)*3U0*0U@x z3dkdIOt=}5@(Zq`y3Gy?WnRR!$D6fpobIoDGyCgcq%sbZBX8n;3qqg_aX6oTHqDLC zcIeVTE>%m|q7|uly{2t3Yh-WhS%ir5$p{O5g_m9Bjlbd3;q8alHE)Gv>Na}@rM jV>ZYI8U`kZ!=F-V^|8~+xt 394ACobdnh2Ae0WDNr-UESaCJ%=ox2FaC& z==Nbo7rW_gR~q c)!I-WuF?aI+5bDmr!UOQ|IWZ_9y^9XslqP lv@!qDN$un3E^Z i5vDkLfW2#)M!GcysM2}Vk4ndSd z+vBGA%U `zm{vic(gOG zxSZTp6Hicd0R*#z8$vY`g72|BF-69+wbXhQo_wZM-g->&{%sQ716QW=DM1WZxvtk~ z-`VzsKj#WMnZ%k=m_XAPa+Wu@`nzV)L-!}mcitEG(Oy{vp8Y0QD|d&*(6V1j>n=`P zUe0!e(&yP<6`Y$XWv`M}`*VssapbmU`YaYK(IG~YyxiRg-I?z6hLY73(JLf#nc_W{ z&Q-))-nryl(|)lSV0X*^UHN7*?_<<^twztQ+5XAMc~|-rp_!lMP3|Yf@*DFrWh*3n z=0uX5{#`;4)sm!fpWUlU0rcmG4D!~6YivpXB&Wx!@OVb0K5xD|o{lE}uXA1rhJK54 z`wfC^X-v)z7k^oi-aeXm@hdHcqr3xySziF#HlCj8;D_0Nw1EDOlsV16T5nvh>1_Ti zKcJHz#Hi;u5W*!5>MxJ4-)*kcXRT14F7Z|idoU)xzlau(dE^<^>=S+D5-~hpyt@&g zT(m{;%-nC{@+I{zA6a?`N+{QAOJ*8bIlmyk5i(rEae06lotPqyBH`Q9ffDJiJbbdi z<~d54ra_V}m>p^M+8N0;cjT+}n0dvHoj1Z7;GLT1TwG(TGulMWdFCp>mj%DzdQ!)> z!>qj}<{JPn@|5G7cC+RMX;sflB30|w|Fa$!BQziIn_|T?_^>+^Z_4BHSLfe%Kb~is zWUKC{ @6gq+2X@lPM6m3VM_)&D^B^^1RGT-%5e%4z;?U6{#i1o#iN9_hT)e zTUC}% o-ltg>nq4 {a{jkmWM|AzWOL8uW4;%?gF3Z7p>fLS5cr_q)Z02(wZkvV(Ie? zdZ3TZZUOT1ypLdCsLzT0$;=<);*+HdDefJ}UFMQ5d@h@M^?D`CbgFSU-N#i+!FShC zM2lPD-Y!vtjCE5Rd>zo04okGrTu~`AKD{>~;uU>`i(W~;Z&vkB6zSlp{3lNix*-$A zEtgj=t|!b$RVSJQ2$9ljCWsHd(0*4F!mijo=%)fV;4^r%wcn_a@Y(O5$6L}*+kDs$ zM?5SP=Ga|+ddTz<3WS r<4ycAI+!X4}Rr{<+!5Hw}-h?na);wW#zZO1O!Wzp3fA=-5 #NdDgH6Pu>2DI?8(6~ab0qG z6EQzbyGjP#gCRm$hyW3lhYG!2SH!iNjIAv18~%?7-qGdQ_vV*BB?3>^48+3+gjU*O z&a?HH0UC_Ge|5HB=`&cM+Nr^sBUN>Qyng+(x1uYBgk7&oE#@nb>ruHtUsZRqC=$Ii zlfa}iwqYFdDf*>)d{WND`> g|+$vregh}D6>yT<=-Q=g#& HN?4j0o$2C78cjGBYbHz-2T___ZyXz}kq&6h0R{h ;S>cc(|vRLC@!DG&oKD!{*5KtjfL@Vl=ziBrfjFTle$Jxf`J; zi=O6Q?~fEFmu6GT4Augu97S;#x8I(HE)r@F=6AXZtH@|(tml`T4bh_$ +l`JaRucbG&$`EI7W|peFr7&!I$!LXAKF{k& z2yNDZagFA>ra~;G`Q@&XVj6#y hZ{9ea*S)5qV#nfKwnU`E-9X#A*Z!G%H#kk#E$%=oZ*sL*s(h#; zyCdjWcB$ZVE>>kA3MIEa&2y@RvHj}V`6yXG9Ds h_=3D!Xl&!~AK w{+u#1M(vlzG&7eSMqW(f*xHHu%=wApQKlASin8>oT H6QfDtx9k_k-F84Qe*svJ#H>iSKmY+O7&cAEthmxY+2*XmgqZ#zgr-^U_mY>YrfL z*dM#j1DmLpE%CHllTT_67<7TXwkpWL>)|x(x(iSICv9CX!ov(9uXl&xb4`a&X!|s{ zo-BhtppYTxdejq%BZEyRL$I=uPo+cbp9Zzk=uGeFHcGy;=Al-08hn+_ d9lXSU>H!aUzlbT%q477;OoBDE|)%uSWCaQzfIxNEtP% zkC&RN+|D=q^*e*LTRd=IBx@|D&bKZgSx5I*2UJo~{C2BQdl7_etq;ddWCnAsUJwt9 z#yuoV%3S%>TJNX(7)p4Td#KB!g*um`c^-$&3LT;V1k`!(9ub4msAUrl(_NQk(zxyQ zI7zr{Na*rC@6KDp?I$>GE+3{3K;?+mucCO~EV+JJMH r{) zx3ooR5qUGw zeoY7VVSlAf$W^q%A6|ap`E~ xfm5P?1`9bbhi8(WRtjDt5W!xOm#e-PWn5PrPV~P z3^uKt+24W~T(jA6dKMqcsUp>8w=+K1;}Xrf;}CN1+wD|a(H3q*g^TTBd6=WU*-FDV zQv#=UBc{VC-8w($6uq|w;wue%pPwI2P>I;ze$JKlgMokR` Wo?r`aunf !Srzyh`Wq1zHUbF;UobXf|t;`HEESwk=n9d5ok?-z1pj zbjd#j5vcMwZl`gLo_Kv=7>TF 40ET8p&o9Y`$u_ z4yk#E5yO{~*~hz!(R4m3+4rhNMh)JUL=vX-;*Abl19ulYby^9$zwIAy?N!=9m7Q($ z_QHuLxE#*gm+4$dKhbnf+5!e z@q`}Q+ELxgNZh+vTr;qF9M<#f*YSd0_l?d6l95F5LrYDrvF@9FF*xr&+aVZqe=}}K zYXJqe+~mp}kU=Y-5{P^;tN2|ZQ?Nxol{1af@yEebamb>B3Ac{Ihw0P&Oz(jrbfL!z zuzJ!Qrv0I;A=lv7UGI)lY1P|3>o`%qCiq>zY_5X0^h*ORQy(sI`H)F6f?&6LDaq&g zjt &Un81kl)k-*~g(%zqKCF*7eY1Ud% z#2Pr=)A}7|%|#M(Wq`dJ&+%Rn&g6IhumIL1UQT7_bgk1UyJqgUv6u!p)>UTXln&b? z>9RBglYRY%b5(PO!cvic*Homw-iBAKvGl%Ch yM{66v>Khl zpcp&h`9gXe6Y4*2Q*UGnEAwPlC-a(b?oYiu=OP~)8!dm)9X!a+`CWVA1CC3d(3At+ zcg3yUY97W~+2n1dyHwlOy=76jB Ovc} z3-;CE{%7xUDh7;rE+#Cm4b{T4e~kQ26pAuiLa{3}NfE0yhODG7YyI9aYdR*2g(M6i z^c#shEEJ9fLIpH`KLje=K3~`2K*qvAJWAYTHdD^+bk>2h!k$RtL{DWbR@y!b+rDLu zbIku}0a2fH(VC6=e*nAb6vo<6H#dxTBRwxjZ5Aolo=tCk!P%t$M>j!zge}2S{Y>X7 z0`cEoxwl_}T=fI-781j|B{r!I*NLV6VC(w^X-BPD?hxp5bs%j{yUc9dZ}*%gl?BnI z6-URn`Mf-1Qc3dlG|`Q0NZ%H6m#M-Qv1T5HWl zZ7gv-L==M4sjs?snmn)kf tO%X=)&P*spJaA!lmw}RpJFSvgiF?5;>h^Zj$6)f}ChVf3 zxkFLma5}17Sx6xWn@%AK+xV_!f^0xgXCs{xF$%A7CLn+mb-NMngPA-k!S7T~>vd|q zRD>RjeEr*ngTIBbEc&8+vHBRTmVqSb(D-P@=^YpbC#2?__R#H&9q^Po|56Wdul*CC za?0!9Dsl6L2vZeF;>+#G-h50N>)pQW4rM3hweYJ*mgBW=5@y0DaDV$2OWJo%${Nn; ztYp2^xvm>}uwFS|;nw6#Wn)s(RVf|x1W;nH^XU-|xK=zjD#MZkw=x2Xlm8IpiR-%_ zFNS$PEFj2zCgOM4?6X;F%#!)?Gm8bmBa(=N{{s)tb+$e7Z5d)GX(rp6k> ?@xNb0VU|FZ(>Z;j_Pq_ zz@`7Pd1xPE_p8rTyt}U-6!WSuhDVcoPXu9aG`a0`N%GUjuzv9!hD6j50jW6FpTm`3 zc7{`>q#{JZ(p`7QC<5^!MNs6RDMN*xZ>ch7yTY(H3VZs^?*dJX`o2TU&K#z9K=TGN zhRRE-*2kz@(gsmv&YBj$w`yJQGTlLou$kqLt1P@6q9Xe1_qM~-HHWD>eq+*YeeAk7 zCnkzU{YV7Owx;s|`34KQR;wHq^K~7J$kqN7G99+T4)v$C0;L?g@g{ouFMP+)7iG0u zzr}g()Us=>i7-eoHKIB+%Cva+Vke`hH>&Hj{M`3h@zYs6L(oVynnFqWu8D1%^*yfw zCw4|NZ5#YMv7{&;V$h1nE5<%STQCTTnhkh{guEYvjohruAGt)3Ql?!+CBsF!C={ z>xcVfFQtrZqhH&NANgm_Hl`;WA6=Kehn}X=Mzh-Ntm_Nrdr1mj*W-w544PliNa&n| z9EMkv)$Gv*x7kYES_3FBcKC=rDl{ccM*Uca8W!H!nY9`<7AxKlM{)0z$3*7?1R_=U zA=m~}V9uG)^S@F{=5~`e0Yy`}#{(Nm{=) f zKv7niTk`awMByu#u2+PGeK2M?f4W(LG{4(~;3MTe$gF-de44z?f9i|tO|0NwEphN0 zng^&sFIP}(m-n>k!dE0XOc|Kqm~u#D F0kR2JVSK%+8;iJ~*!zrTk5Ie=J>{^+K>>##>(iLMZkAPta-it1rkj{ZG>A z|A#b}? t%kIq21?c zpNs8M*pTSCtEQK5Oh${o?-X*C#9X%4^R@I&yPsmHB&tE4NS_Hna}cBoK_?GJCtvok zKU`|M0T|^Yz`(iEF(o^ze+!iX^=WfHm {{v0J3dXT93SNzLx6 qmtTGw4z?(bq zgGD@EX*+4TN#(L7<8=z<6$?h$%TQd)k&6E88246*|I1PfBoV=p7VxIS@gH9!4Dn9m zEoX|j?%D (h` zac#Lhi2*VipU2~0I^FA)--cWo3MRk1kXLM#PCyQg0pz=WfOpHJQC$F#F6dviIwY1_ zfRb@4q;XF>vKaMY81?637J+H8(;u4;I7Dlm=qaboA_4uElEnr`&C!9i&X5pP;^5O2 zfRDW&Z^5S`^Oq6~hzDcFaBt$*U!Uj4J4%Uff6KHN64 j3C66EcU()^2h^@f|86<#Vy4HHS^5mP`6RpDh(_ z!xMPHv|Dz<1-TuYZ!{deYzSZQIiG5lp#pZCxd0c?&>!lpv9Z3E={%Tk-U5E_J=++g zj>{? J(We>B z6!N(}U0Z5&4#%YGsWzYdQm(U9Z*S*Ms-%8X_Er -u9dWoo}8ch)6W^U&wj=N}rESFVy4@Lo#7#zj(AS9Ofzr{LXaL$zNhkqD|B%?FG z-G5=y%yymeQS|EIpX0l_J?>pIq-hro1$=8Y1{@}Vr5m6e#L~!4SLlmHuyi5(0&9i* zL$VT;m@|TyE2_qNzPf`X4NM}yaz6EfGnDvVbs( K|o>0ZKq60E;NxPZwha$527$+Zg^JC>9pggbD&88G0r zwqNdM=s`Sz(Sr8{Vj*a=< o>K<$azVCb6d*8ZfS!V&m5SAt`R$7u0wt1kvNdFQd>#{FWO zqD69E-D C^C!<0=K!2u^`4>(;rX?uxc!`6MP;g4$Cfb!`alT#VR*m%4TWkiklzRGkI@D= JhdKl;gEWU$X=GAGP?$AuCrTxTH zsV+wshCc$&+K!&}Z|(F;6NiRV>(IyqB`J-6cOBoE*gqV5f&61VTyO>v->gF+%zNV@ zKBIPruxX%mAiEnV=`%6wG$)n4RE-K!CEQJG+Gf?iJYGag1rZcJcAxI=!mxHqST!^k zBB_C6SeYwyw~1}_(SoYi^
!nkWPM2Bk!V-4jCRM6nXpi;u$6ufeZ(#Pq?2(C$FJ|C8hz z3ay~g2$9ntV&Jt=!KTr}OzwC`#9^T_#PBgsI_8fw3@^L!08Z|B3#SuWknsM{CXhIP zc)mQ{m;g?>a2p izQt+qTNrJ+BgEVOqG(Dsw!p z_V1eTt)v#0j(Kj~tRE)YJ1CvOfUZ=<%db0AN+m+7Z%i~D&p~iyNw?2}7#Y-HKUpu- zmTKo0_DU6pl7rD(59BB 5n?9FTSxS3%aTMf)r=s+1Tj zM*;`F-e?GPG;svT!Q6cyxQaR5G4p zd`P|hMZ(p2f4acrKB{Ib>80iM(B4LGB=G*1Y`pF#gXQ)n#F5BH0CR+D?+EV*Y1cEr zYGe0PQyFu3NBJQ@JLsvG(oe^=js8CHN|HaxAdZd|jDMVL&>{<9Xy4fw@s-HRkgv($ zxB_O10yCyykPVn8-TL5h1nM^|%`*D{?PW5=+gZ$z035=H2~q}c(fpl}bhc&l$Ib%o zWs@qGjh;yH7$x0k^JN?{W_ex!F(RZ-7g7mzq^USPfXMnJz^=f6xK7s66Cf{~USShM zDZx(^ywQOsI*HYUN`gdTt#u6Mf|C$B!Pa%LRs#Lyh{PG1XxoQ0{8bZD$f@h-vse$T zR#E87vac<|vqe+*#nynvMX3YrQvFl@^_^FpN&I@yxtbqvrs{wx)nauO{DC7SUnco* z_bisnb}0&*z6T^$07RtJ`%ce3TJNreb8&@i#he?rN~vQ^GG6?(oOUuJ)Ka-bt+G%# zKf5U~GUjdI &?gx zm<;QxI#ni-SJF;}nKjbS|5~#y*S}5xpl }WA>F&SL_f4kS{w|CJ$_Q zHJJkH0W2bc&!0d1 sySJ~b^Ec{S@~gcE(fG92 zBD24&t_I&`&GZVP5Y`wR(ON_S_#N}uputrX|5ii`I0l~n3v+mSu>oXbONJhl#ev@A z8x~VV$+n;B1Nd+@oV&l_0$kFK`3>b$gr|R;d dkWuyH91Fphk^>rnX)uEr}37jx8xe38dPf$Dxk69j5Q5fEmWH;Lt?Uq)bVLO ztO;23pyJuaI{N-eq he2Dh1*{I)f0*gsm@_6P`u*jY>DPL>Zl z0ubGjmfALUTc|XmRFBG`h))kvzWSBYv3lN~0`zT22-&Q9{r=3}={7Lt4qzXFsSwU# zzSikUU*fc?HMctEf6x1zQ35cJ#%gl&PRS3H09D|xCEZ7_mYDx~B Lxm0;K9knZBCrqY z%&~w6If$Ay+!Tf5Gm{B=&ORXC1D@arr2YeEaXj#_(=98&r=U_*!MOuwz;w7_WH_jd zo-piQ4>aIiH~@*7fYsm fb&%f>AYX;Kv=7Rn9IiKvR&9{rPT{0Da63RWiuTa zD$@17qfsgNwBmK64V sXNDcDr1GROs9@>nbubW8H7Z9rfnFHYR1R`OosWv*9 zqnr(=aydH8D&@%~0|;o;lJ^z?JnEM5^A%hGxSMO-A@CjfTn^hmQjv>Lk}q*|e<#NS zKwbDA4ALY{Yof|!uy)yR>*dB(2Dfy7q5P5b0Qjvd0NFo#?$7}2+iXw*eQS749nb)P zV;6)%h&7eTZJ*tS3fYL{y5%z&B8X;re?F>Hrqy78ce2tpO4(FF?8}_kve@cHObW;+ zLojU*&2Ra60YQ1l^~nnPyU)}_caw@j$*OsCbP8#!(u9e6St)FK7Crpk*%A-^9oj>o zaKuYFbL`PYSTrAvcHW%kD`wVeQRrx3G3)R{a1$!kgYGrTIYdN#8_et5GJSrCe)4^Q z0Iq_9K+f-`hfLwm2E53}^>F5j0GS-t0;1Y?=7(&9G!8(^K*ywn+kW#qQ2!g^6@an_ ziwzf=wk<*H2F4yLp&UaifN8rriLm3SR%!hXISSSPM)TB9mnj}imOkHiW`l+BcANS0 z$F0NpSL`5ratpQ_xJc#=5lSdqpv;IMIOgoI@URJ%pk8_Q+07@$pd< ^kP{*EWdB&t+QD&z|jJV%z__P zAd(C4R=OR+UM=fg!YUIqp+2BY0c9R3Jjqwh{Ox!9SV{*;Lx=4l9spcPYL9EiyR+!k zAZ}*razCh%1vRT)B{KT}**v7wykXn$;%2iymY|DFUi1p6z1A)vZ7FAdxMp&yYRiwm zqJPy<#{deL5;&)aFUH5(A+uW8gVY19mm-grSE?ORJ2x3m=)-c(Vm<-|i*(XLeDi@c zC%scap4MmSLY}p1zou8_brPmAm~8dkVJ+C-O}X)CKH^Az2Ggo0gRI9XLbBBK4`lse z(jRkF>C}w1Z1bo0 f+O-24kskdW3rN`AZUEak&bXw|}w(u#Cixx3hs zWm~%bg|3+-g~N3ukFHA>Bccz28!-+3a2x>jr2&$wYnWE;`_FP#?fPnncP82ljQoO` zEU}PNZ;Va72$ME+7QJ>~Fn)0vHU2sT5!PRvnzMLzns@#ZZD!5G)H5Q$$M{M_KJE|y zn&8)mV|vY623(CY;ny%LfcgI+L!mBKDf|J1oER#p&k=t5;dqQ@0ZX+tmNO3IEVrdf zIl=w;GPkC7NqW{kw`(CL4CZPz7PvQD&xDRMAIOaUAk>q` zCP1f2Fm0fWAOKx}*1TCQm(ClH=g@c3d=b^=$Qp vg-~wKq)K=g0+jP{XK~Cm* z!j*!W|CxU;VQ|qYQJ@j?p4~qWCx<_bLM%8}F*747)Ei-;4os6gPBw|b5atTdA1-l^ zt77Gl@GRqXn1Bp+88Xc#!af$5%p*LATPShDdUkd?jz3;Qc?1}9hv&}hQ>h;r)@W_r z@&?b(j-Em&CnpmV?x4N(-AbNH2Aw7s)P)kDBu)E3(J|^Bi$)XhB|_`78T6-b9w)9A zB~f7A^adhh6F`BA=v^EGZI=qWmt*7iP%>NF%`ya~<}~g5X!@{$KOAy}xctwcdIl~5 z0~wS&K$HFk +~Co7rul&^+YW1c1ul!F)U!E7Ijr9+pm(K0m3Ai1muLl8*_@!{d5 zO%p`2U>l_iUdObz1?do-(38TDqD8amYynlU(}}i6&n_Q4HkEJ?xeT;2*41r&t`-uH z0ChQ6cCUM Q)R}N$mk43o?QEmElAN^>d(PHS!f(pQe>NiO>z{8^p#j>n=MuKcI*ZOzM@b z9`x|83peNVmD5Bee=!Npc*w06{&S1Q7IzCr#o#nk@ByD;kBVXD^_5!k_Pzl$8)d4H z4**?P@~FlGar$b#NO6$3z=nwk0n?!*{RS;A>iHT=(;jG?-M@v{=&51bSZf73Esd_l z6 !>|<2p5g^mCzyr8L=P5VPtg->&kmqZER2j22$ag>@XjLu#!r?8* zAH%)LLR^9O$vCPQ*#6HpQ4}Ngz&h)P;HH}b2D+PO&->XLKwj(Ue_Ad;EkXueND(AG ztN zYvO3;va-ueaNHV#R)73d^OoD$>VdzP?9k3Cu7gojdUba^hwQFPVG;I<^IF(XL^)H? z%Pws7{T-Nw_A_pYm-VpHO;WdBhWw(KOTu5QlhKi?S02=m4pQW lb7J70AVl*H$VQ;syfN~NAs(P7Sa z?DwCQ_bqvdVSDboRvmq2B9mT?!(VXN1&3^FTXh2HCkNxz&D6E>FQkMKu3e$$*E`eM z@J;3iHATDC4hg>-%jt?==T5}7zUEhNxbb!0Atz#nzhITtLTeqzz8Z{PYoXj+fA6WY zYSQIz$2H8v9)Y?VqXyiq>mVo=M$Xso3~k(76iuxK^iuK4&_1F{YRM+R+O7^}bQ>Iu z_x~2UNb3PC9w^Z-p8V;9q~> 1Fd7|7wL?daBmrupq1Fa%CdgAUitlYJN zZ<^yj8HwU(J5u1^S0Z35w66Hyw}Re>v=5q_f|*}8X#FjB+o>kjAPr^r0XtDbmtLj7 zw{3ni@trb4n@zq#I!4i3rEGQ({Bm;$9sitEe-6Ss-ww0JIz!NVxoIsln_Q0a?{f)7 z=0J)I+q~ySwipQt;xqF%mDhRy5hR!ZC(+luDDwjTMFbrk&^tj6E{WTI{q}5wCk;UA zWVM>MF4r@VKcRXl{QS#6?B7CKh8V_H@VBp0*{whA6VNgkY^N&{kXueHuwNpb rc5>yw2Ry>iUd?^)*&*ll(Eq^gS+gQzPC0+l%8e?AK3t*ih34oLcaG z5~~uQ?EeLrF<#yoLI3yfMfkt`r4b?$zCfZQVA84wBP*pFlnzKjb+iZrtLNwvq^;1& z1xWx0fkVau;Qo(P4G`XWTn^Pp@Lje+>Psx>6)q4)fL~0;<3J6L^NCrGG9K4WvzSv2oYTvZn>XoVrL>y;b=a*-%49uv1LAnHt^_n-*NG$0iUv<53 zK`ar}*77C;co(3^fx68aj->YT{3P!II4>~qvhtgp5Agh-mLVq;zm}cJxb0Q}+srY! z=icN8{|wA~7NHtG;BWx03Uoq4KsaO*82VGWPS}t(0Xh3xs-X%Z3@6reOF-r^qk}ZK zol*MF6sgi1C8>h6Vl*D31~_A-_#Gf)2=XSF^j{cOy2k?SH+o9dtAZ8M*aaQ{s0S>W z^}tylP-rcwLFZhO9Iu7T{i$$Sy%srwA#hZqi-%A_lvSF^DTUp<9=i4U-txwv&+*sC z_+`%v`7fws{7Gw6bseCe6ai!d3>0M(@F7dGpK`&;SONOTgW#I|YTi~ofvH`0D!cig zRt5@jmR@&glfOcM7Fm6`p%8ikk&cEG4$EyNf+l?c-od~yV1xmUQYe~Cpb K0AY;?oeL5U5 zdjX7t7w~5=In>HB*a1Ce{^ o6{eojiBe6j zz~oC{{mkt*owRNQn>x(`N(OAuUvkOkfH46$LuH^ OiM~38VK(9Z~)1DKcKgh zEFrh)y5fZ|3r}9MhlmQy)^;ZG+@sRzGHqGcum(9ug#PgA8<5I%gMTNNuMXr4hHW4b zppf#$)ui(}i}n 6Z@5AGqX{I%>rNCGR`hsa2 z5>F31F?hn<5P)D5q5-ft6?!7d6fJB9ZPp8%LsTT&;8Lsi6R#In*)52p`cd5jS+f+1 z+vD CsYCUFjs? RDp)r87 (BzyY94JV7*dKvsN!(EFW@CcJ6jC`$0Tv`<;yB-%K9dHa%^jyPryN@H z9Oyc>ydGeh`o)N#n>7!Ry{S(AVC*p3Ar|_vp}2g@0cD~th5dc#tRBXO1iQCrFnq#b zX2kzu?=7RMe7kmU5d;Ad0R@p*N{V!YloEncD$*U&-5r92G$>ty0t(Vyi$F(}k zPyWw+?=kimd%vHa_a1w^ H^ZEDJD|L^L&UY55p0>m Ni#k-D$0(6_~8Q^UQ%NU_j?{*fV-EVYY-}>bD1dU7Kehf-tzv_Mx67R0Fw-} zxoeJ~`^^_D8;sh&g$sEHP{?ZT@mzoU)Ew2E>czFFHf3S L+Q-=O9Z6)!7*BP3~Q;JXDtTNqRtlL;LHEp%lSq-yB|;E=QT1 z+@g4XorvlBv&~r8Wcs9chxi^Srix-ZYbQ*Qk~MQ+1uv=O@IHTu(B3QsRd2I?>S^ X?(qO35_T9{5)UpllH#%z-Ownc3 zsN$KB$W}EK*TXz4@QWYu^!+Pb|Ik|(6#WUxqL}UcmOI2ByE)@sPu|O1Keu=odYktf z2CgCF7r9UYxqxtt^d~5~l(i)ek6>q>i?=N@>|uyD8DbbIDYhyv 3!P;f z7N72kC}T?6-Et#Uk77^`+S?)0JQO5qW;bPe+89XG#brQjkWx&(--rLg$G?L24d^0h zp9R_sC!lE+c~LfoSY0y?zA*;JDs<|YPSHE}l^ 3 zsKLbVk~ 8ROvmWYBVAI1vVs=-@=WqoUWSR%1zc#eE$UaBAen2EJy1@U1d`(ZAn zR*vn8(zoCLtaF5$S7?MTzrXgk@0Ry{j51th37hN5Jp1Z5@%F5wJgp|b+OMi9F(LEk z 1150|z4BtChKt0O{*hv}kOhU2e1!szh`%~p-vv1kr@zWxMhev7=W2ys8v z7%X1Ku_r;pVNx%w- d8+f^S^E5gbOxn7 zPxgg3j5%8DeU1z5u|dL18^mK%MztYDYEK()`u^cfUU;YL5}V%{NRcwTQ(H}~R87_H zLx=FY6m=yf)A6mcY7Y7x-lY}7!x5E#){l^Ad#n^9s+|wryB+Sdwho-e06!X-Btk zN=IIt?aCfB33v_}z?q+hG{gjBG+LyY-HScpDe>(%7jz+BT0J0Mf|SeI^S@Rv>S90o zK>2)6?laH4`|&)HFw(Zzwk`&*+5c;Dy=Z!J9NT-Wabj{)kI9y2Zj&yX0#8kzYGwm} za@j8W(^CtL#d**0se|o@9ca<-ug~jl4ln(F38E{*h|?z@oc8u%j`BR7Sn5jOjLDG& zx|%;$&`<2FLY=)Gt{>tI=MRswZNFGjlLu0|kGm8QZhLsgdz=0GRcZc@=L0_Ij!{-; zW^bE0*A8D_R)N_C7a^yX9R3&4Ck)Y&QGc#B{;OHS)9nu>=Hn+#HOnO3R!&nB_@8?C z)rxCyF9VJQpB6=BBJs4C2r_PTRUr{}m^XLladPaSV^7>L{pO1LQT}K^vO9FTAJ+Bh ze=-6r8#v~LfQITuvvWOx@l@=tSl0dBN7Kn}2gLGUYsSL#34Coqqc`sho@wkCBupoA z!T2Dbjk@E-K|_kd_^oSy$R?2S-W4Qj+o&Yu|Ndd6slCJepL@>D&!WVK+e^P!DaH~% zg+tfY0ih1XX^g0d_mA$=%>3A;;&HN=jbiP2T@_Z(yz9+Ay60w$U%ERDb}KL t3(h`h`dm@;fQ3YguE!O#B{ -xnNxd=@`390myS+xEHlerFvevT;zd&C2 z;wBN-%NaEL3osgVSero5>7ZeiMGLtdoXyh@!Q3&D!Akg$YqHuUnMU0I&IEogkzWXD zrR%;k(96Pp7l(iM-1y*CXTUq7C-ucajhBG=cyw^NqHeSQ13vv*kqy7x*BX~$5J4*& zv9wZ5GIpSsE)!0r_uB)ZZT&m+9;FI6gRIli`@BEq(Ky{cbUYwv3?!ho7WGhU3MJw* zo|8C8J*6}?ND}a{x~!x8Jog+MkHY5^CBnm%7%LTi*y#IG^6)#<}g#2F!O*{SbnC z1|p~U2qCAFlVDYaYlk8r%F3P+c}+A$Ka|61KG~r$H`o>NNIKE@9`<5~(cqxb-&Wno zF @fzKowy4MbzeL(@PB+9h;UT4yc {^Q RNfUQ^@tvF$*&h~7xIe5IO-g~t^;*hEA*L5b4z)Rp~ zF 2uCfbPgy@%A3uBv3`%=$O`6uHu;}6o$!16}=%!P|=@b^VE zd%EO%;Kw%HR@RQcPVWEy!k9*_LF9gX-nH$~#l}tLU<3ve6(d$2Exy3@W^)?V=~S zFOhrfhX+aLeyb%XOCTl|L2hn8xiLJJ#ebcVWRQb*)M4Fh{|&Qq>vjWDvs# _AH&Ru{*RhJ!6;g(2)V5U>|3jMf?X|SHACa`nC(p4@FgDFml-Ez( zZFZ~5gz6cX)Qf8-yIK806IrPHGGuzn-3r^6 hJ}(-TJKYLdk>% zW-IGd9X6ixv^Ewv=Nd6S)$X-Zbvv7?L*2vy^DOseog2ynN9VE~x;wK(>R9t_+JhrR zZXIh@2RE6Q2u#(w`M<+yO~`exafg#+5b*ADv%XULYVF+fl=rBo-&z7^4FYnM3iA~w zAKf$#^yO^uz0Gb?*ZUVcTKv=9uO_Dp+2;k9{;horDcK;n4J4pl*cl)pWKgx+`rVT+ z BbtcQUJ-xndItR~I2 zWUAbH0|Qw@%cR6<)sv}Sbfm1pXosMuF1`HL6-DL(CV~na9vfhS%BWOfd^G$rqciCg z+)-SpIt5ztgNV=M!6%Pl`{~YrCthTF=d}H=eT8FSvvZUxtu|)9#Dt>OoKd~`P$p=e z;b0;k%W8qiRrg!y!g%1f?D5HMvX3WG^vaJF2UB )?bn ziS+Oonm{ciWUx5zS)BDd48HRy;q=L^H1NtRT|R5_!JF*qskAulI{PkwB)dx?`uo%O zz;vl7J?Lt)6EUPkXr3OHxV<^HHlR=7_N#^!W1C@{%2l(m8Y8so&? r1%1muKayFqT&2>u$p>>LF9z&=E^;QT$o7Nr7+0Fw z6v0MMAKQ|JdiW*_r3qzz){vR`X>0W4?Th)2f~NK2m<;l?qydtWol?hX{{1ZE%T&E^ z*S-aYPY+tV2tF#M)=W2iBs-d?eT?AU&r-}8TA?%#1e2~Wk3SFfzfOHk&i*0 )qTy9ZSxg!lHTtukIO8J~+@w4`9`KT5ul3#T z$okA|WKAI+;4{ECpL~p{*h4M&Cb?dTQcp5Tv|akL?~P_fA336!l^6WyR!^F6z1`1I zQF8V(=MZui&pjoHn8P@ARa{1S0~L2l!Lmsc_snVfQ!x+Ea_cWG$voBpv-@G`5^+l# z<1U5~iRFv6+WZy^h@qeaisLiae#fsEAxDW@PpoMAvz+JI^vp-ALy>>kGPqZ;Z*{w0 zYzI0;x-*6GRUVQ<)3|POOS|F7T iLS?wd%9~0yfEcmv6nq+T6DiFE7`q(SRh#b|LBmfkHS+VDOIh1Ipt4GM2 z* bQAvenV0* z@^OTaVrMon<|w0b@dQ>UTAOlrOi {!Hxx4)}(M3uU--&d5=8K8)zJ9C+&W6h&f+I_j?9zU-Mg((BIaIB0 zW`AmugspY9)NA#E!{Il gp(Jr+` z8-XykQRoOLR@>!IzvLRm??U $5yB51Okz@t!(i0QcnWlO`FW&iF!=re1}V0AhiPEb7pXqAJ^g zTy0ie(Lx*OHMWH5^cL}g^&l08VOQwAT#So}uxK@h8@iRX{hlVJZxVlsya=Hk|6$kg z(8X`8(j&C?58s!z;P{=c?l%M)5_~3{n}!(yA2OuB1TCc~lX5wyR{z{wMEqqIzklI& zlA}@R8o_GzCn$$k`3|G?T}gff?5=@#S?l*nxO3HNRs{LZd*2V?AjXGqgdDc9+AJvC zdgTndG9 mkQZ` zhc_ab@n&T qp zazj-jhd&AlW10+m&U|xV)!~h1`zC|;$dza8VMB`)+wM3f{^9q-6BmU!|I1B=o%ug+ z3oI|}J60md_jVB%ok4y~>+!ZmLq$&K4ogQ#_M*uR`+^OhJr;6RN@LN^H#4_AHb)#- z)Q0m8QbuVceL2PqI9xXCD5D&MQ~Cn#JgO>xWc4svz+u~4775mca^f5eiktepKIO4Q z%$hD|-zKqBz-1|x-|Tikf-*m?E@kVwYCmmKspVhe)$-}()v*#f4F}x);xqE;Z2p*! zI}2rYr=4BM%jNv23{EICs46_ze^+HowCwEHCNE={k5#-6i1sbdI>jM#TWRNQ`<^X; zB$nFrtJXu(taN;`i_cL%j1OrD%ToCcPJ$o93>a0+Z>W9 {q%u!RJ16cNy$d z_D@p-Pe-B<2St@h9CVRx!|88g@oy+*kAu0X<^r=S8J)0?U3^JC)@ju1*Go6Q)~sG= zpUNW1x%6kiWCzOjYLm%Fd;9CJEMj6eHu^K)UX-@7cULK6M=L&-Z*=~uWj4zJrOEcD z-EtxnGBB-7Ksv?}x{p_TSK2hEn?*jwj{a4CMzvUeQ=@y?buvxv)Lb);WasPtroqjh zicE43W$o!>!YJvzoA2I2WN{xqH2X6r9-7>k9YDzW>X;h~9G^4`wlvunb0>?&osYGU zOqzw;UBtU93Ry8r+tL2c?mo4|O8?dc^rtUlB;&ne*tgdOXc%EWR`v1t)V}EwIm4qn z5fQBFFcL_n!nwCW-6+?SIjA~}-0QL!G-)srvY+YiP%WKp{M1t^>4H%0uu`tR|0ua? za%}D$c+JbS$&85^bgI|JYO{?Or xsc(h0KI{AZv=`RzFp@5I)bz33GkRc#yjc3PRF%e{+!5LSs zOt)j)9;vev_ujP;nJBlJU$KjHTG>1HyKpCXEKwiHws%T?Z}PG5Gjo7;?w2`P>m8R* zF49F(Z F)X+w`mjA?kY#0@J@(nD8M5&F~$+!mxrgDXfh0wLi zHNNGa5?;h&4$L^!9&Sk=hH#$Oq=b2H#C0AArygUtjd!$+PCwpzJLIznz`$^9<0%1k zP#3$7P6N6FBag#H#JwWS>3#xt7{7=HL@F^Oa>n3hui-8^*6Oy#a6fL`GI|ly{dL_c z!4ZeYeQl|4Vs2@0ebk}bTL`SojaCv~*!?^?JvgSHCB9v^6L_H!LeBY$<>X=bys^!* z>PGs7znxAjA2W5Xto(US^f74p|LF@ 8TGfr8tRw+ V?nU!1BpRr!nD!+qZv##-i0^L zfM0;#iK#dKu228ROP0SR(Z`f{c zhywYJ_m=yhxd(n!-&%ey3Jr8AICWEHVsC+k4U9W>z-J|<35IuvAcu#)g|=aAYq|5j zaaWQ6pY2i@lloH+lNb)8w?X$=jr%jFb(ulgw>etM9KfPoKL&aX-i(r~5p5u0hx6B# z0+@EgjI|$&O%0R);A#Nh#BIVMSQp{XbwKx!Adn>A=>&l8n7D9PP H0^-}Onzc9={3I~?b1|3ysD3Lt@T0cwqGkS5`?VNw;#ov0jbXx+coeLa0NWlG z1HGG5?|N~U)6eAcorjXpOaVkuCW)Vzeo*N<%!&g@xS43~R or!!wDCJ>SR}2EiOJS A$K8quZtg_@~yBQ0}KtNVQBa<-fjF)lp_X+MFG&_{$tr;~qcoYIx$`Anz zd;>g<5;6I>nA!5P64TeUUOasAxo)m_ymJp*tsKVt5pqDdM^H B}4EWuJob%(wWTSX9RS>-=_b7w@`u7;X7$AVSjSy@ `W^jdkZ${Fxn3xlO%yDnQ-Em2fy%`CFmvc za0e&V YN54>`qYv`Qt1-cwWg(ILYwQ@G{cG s7e8d8~b{QorK?I`_t+*DnAfI>H9+drm8{&j PkXCZjdKZA`N>|mzLlG zOfJ(je}3Yi*KTdc#H0Jh9|R#+=4hF1U_0`=MePMmxQGk0nxlCLv|KO1wG31k;=14m z)@g}Plp}s#MCb;9^gEzbULV58pBfU?wq0pCExb8s&CskKbSH{l@%HZB;wvF`rB(y1 zMe)X{Bb)=h?_~7*(xt=o+Le7PZmGdEtYHL%lr#C;hL2{0Kq8ZOj|ru1KA}`(7A4WR z7sP+m5seK&6BxGcnRhUJTbxpmFBXXPV#aS21%kc={MHg92jKzry$M)G>Ap&>m{n-e z4L-*gWr2cLUP}=ToT&swYLP7W`>j=bmu<*E9%O9sk+b3Es0LMs+=6=fKa1fpJ?`Kg zozpoye |p$is)VF z9{9=|4C;JE?^pRLra-*Z6hPo-g}|B8Qs#HuN}-$59pbk;a{m!^kd|&LOnRfC%{o>S zh`~J`*Pl(@Etj&cZXlCVy0o8lOE88ofwt$5Xi*Xo9$ly+illj@%<)IVH@qFpud!i1 zb=|7NfTyZZ^v*R5n!1NcuQs@I$f5;hllaH9qT0SUGD`dXuEj&%=n?N@xBlYfIBp;w z(R|q8*k7&xukWt;4Vy65oV&O*ZrB@-pJF**n-z8K473AK>&5YgoIzejYmr9d5BHKk zzfz=G9C0w+nucIphUu&O7)$uWjUeroFbr?p`Pv-enEZsi{H6I0g)sr!!M*+{$$O?5 zSmWGeXw_9nb3%u_-r?x^xWd|xXRxwK38E9n+f4tp0Abp;mlnN^NZ&PfRTP4C*gg$^ z(uF!YN@b;M^P}FPY^QMGxBgIUMuUDjs#0h;)DTvGq|Gq&OF!FSva-a_FO!+k;gIkI z+w3t&3L;v+;+a494!0lc=a^{uk*;5f9?~|sOVI7a;!>p6_YHOA#(JGuXKPm!BS}VE zQUe{SL2CrH?I4=8Kar)8sird>5smbe_Y`T;7my^s@!axqTUBAwe)fCZ;F>ZMLh0Fm zJ??PJ9OEp>tRAXmgUXZ{w4Vn*7ps(RRrUyzYw)qyt`0KJjyPS&otu79eIBo+g~C4f zoF9w`8t#zv{jpZZyj3T_6tw6=uU)R!>dRsP%6uuD4He1gU`~C>I9wJFY0 ABs784}Togo}QKE?hE3gBPvr^tDW~}RtR>MM~dkOR45F)3*JlSFcSqn zfBP&=b%)r3wU#Zgk?!Wx5tEnf8haCi;`s)u`)6M;OtzAz(Hp|FL|1z_%Xe=P1;%we zIn*&dYJ8w!JpVj)O8Z$#!Id)i1;;e?vw-cF5k~2^r$gUYdAIZ)z4P*l7#}B*E%16( z7A8wdtj#wg#ZE4x7i0eY?e|yusVUpx4@Lv|?hBFFFM_SZ)@KzavxW}Iy9dGC6I L)qnY4G3kZ}@A4;w5h z_OSIwujRPyomtls8-MLET~nRx$D}l%K&Qq?thIaT8CKbHSO1RR{tH?eO(9Sjc?zOf zjoeV+)R??DKkW;+d&S5c`)rF{f@%QmUfIgt>3!|yG11-3;HTxhLAZm~<754`N3~T0 zos1#e18(B}+`q`c_tIQGVbnCFa rB$Y1a$?H0-RWwniz4sYyHGfmRsfRL!C4#Fub3Z#H z?jkB-@dvEg{0v&VzC72py6!f~P<(1t8kO4f1QiGD0uPKDB`8jmO3l^z&XhsXy65ue zGhMntOY-ujM!VV!;H|o0c$-DiZ;hcACrfJr`wst$a1>prEzFpi(wYE;tw0Wc!VW3X z#)>JgStHZ!)YbHnU&?Sk+z75>O)=?94;JwD#6s;;@xW})8K2f_i(+gu^wzSus$~^> zs8m4fi6|oeuGiS`CSG1prkHzTv1M|%R>PmO8=b%b*N~{J)BWPuU@r#S%MB_Zxz%E= zMt{6pi_*?e#zw7<#PY7xVe=EfSpd|wH@$^+i+aU&wyb4)Gj!{;sN?g(=i~ai;jrNw zWLw~mqWCxIN1pM(6OLiq+^d@R$&1XJ=%t#;jAH-p1>6xhDje8)P%9$cKNZMaGlf!A zE!SnAe_tCkXdSKsz7hm9XlceA5h0(uzpRtHl*n~_0Y{?%r>{#_WuXsC)L52Z&nKis ziy$rkyT^Cb%N-m-J4911oYL1>-Ziq|R1MgHNhsE-zevi{`%gCzJ)}= (>OdFc!tP0G16v+6|O1mr>FgMnzYE2%ZX0OidIc(=9N2329^(R z>{TlzT@TF)VD`~=0Mj2=B9{gEi9VL(UpvuWv!xw!>I_)A$-vv9pti!YqcOkf^Owja zpOk+(ca-X_ZxM>y0dLAF-Ik9H-qDWi*)>jXg=fEk>7;BCNaV`&!W4Xp=lLQxve*B&D@jMW@5%E`?*0R(Lt|CEJJkH9z5bull1& zKT}+; pz5@Ry_&-5~q;$X8$0ta3Iq%rZSK#K3%>IwyV00FH86&$pfoIpnfKW_YV zj~)*EK$28AY$ml{fd;Xl<9;YD07AEe2}wZA!P3jT7mn9}d$XtlN8Zh3+4S+y7Y5aE z&J)D6mqz9P`2i3q5X3Wr^-@o&SmAY^S|F@7s~p4G`t{j ^0gS$gCv*rQ|EY>Eu#&8M_A?1~FzOn=|=z0+fmR~ZblTM3dn}3L< zZo%#Y92}g9b%=mEZ;$8BtsWG|VJ_ZL485`-PrCl@08Z>JSPY6eq^Xr4j|Pz!@YYwp z%eZ 19+Jc|C>H=3|4{$& zHUQ $b@t0pq12_f}gQkzRCgwtQbr?&n0isNsW8)S=t zJ#HMfGuGAM(b}swW-WeYPu?i!$s0&QOi&1Ca0WmaVkQzuKeL5#>cKVz$~LMTAPnJ? z?Y~YroDB_;`_~fyOM?0;HUSRlsWodGfL^mP$@N#rCJV-tfN)uQy8f<+Cs2|`7KY_# z?L~w=9YAl!1>e`*K`#hjk^`l}xXnnJ)*a8~(4lcvuyw3cht8ACO{1b;Kr;ceb*R9@ zW`+4M>>Hf=I$oxjy`XgK-+D{$;1dQ8O8(Cxxy)3mavdYYgYE;FbNl+Ivhd|U - zzG}~_m6EbaUJ;AyL9anM>}iD~__kK1>_vSN;6#=ChQ{$oPRkKZjd?Ewf3?y-?h&?k z8I 5Mcm~fgdLZ#7?Zx6pY@*x5Y;)l4_PyQ{OE&;?=hYjrzkREp zJiJ3fInfxOkKH3V)^oqk>#kP93LL1pRaBvK^FI$HZ<(Z?f_zH!?P7bZ93b(Zabs Sdt#5q=B*Cvq?dR59tiTvYRFN M$g^%c}x>W*pM-L+;s7wzG%p{3Ydx}H$g>tJe-KReP!4o(8mxbkD`6nwFRvq zu+1ReFIMAXJ<+@I0lK09H7r7dudH%>UZn*BC-+WEW#hNE9 }bwdSPD#=q@L$qP3t$faVi;N$niIShlKiT{#Wr|PG z(Xt^`27S2iF(XlJq2PjVdTRqZnN+e#V=cbTm$$^!?}*X3ZO@3MHF}|pM&ktRJEOfK zVo>tjw_wJ{Z~o0? fx%}$2P 0h`i zaI1F~{;Bj11DCw9IhdF|J$wQ>XQ@hMdaTcS^mo;7uc;qe2uj6qV5Sj|Q)sp7z;V}Y zjR{sf=*HBMoXv&`74R`w*!!iHf-+jC(I3T3Y?S5C%PB-jT9Tt$IB8XPzmau!58$vd z9IR__KyvOES&DW>$0ZN)9v4JA5QM^fCOhaFS6QvNnF9RNA0Y526?8_zGY-;XXb}x$ z>)_E^LdR8tfDHk*i4_uRS6fz)R{N-2jmcKNF?75WNZFH*|2}c>dA;!74E9wol&|#= zakup5EN6+HhE7n)wWqmNDw7lJJ7R+T>BAM@d0Em$XqBh~s?(!J+K*-!F7j#yG|20@ z_F5Hh*zcvwB#ul_ItL8S;OK|0g*08!I_**=&uQ?We*qvLH=nRFF~J>+k~ZjvTx-=H zvZ&fdw|^vPGj~=`DN#wpm7*cB0QT=jUj$U+jWG*SoxoXs@z%rM14SM~+G(?cPL 6w(AT7QOqsS*|L2XG!KacbA2J>d|EHOLyn6Gmer2kx;x z@$_(0H%RB^$j)-7I+0*dzrnSR=940!TgwcFEui5>jXPauEsm)Nqx>t1S$u4y<lOH?Ri1f`CR<+l#h32lv7$9JTjWRS(vhkKFv-t+52hxg>vWe&jk;&&!W$MKYRnl z<}qLR%HJm*U;e2KtJEyt&te^1sQX;<{1X)evE10OWBuP+uYnNNfKFlHA0ev#>I_{J z!M~Kcwc02e+zubEQpkCpZ}f69EQa0qzb#kXpQsfSTHvc{!R(7o!t`p+f3VMTA5xAT z%p?8D*nRIcmf0+Bd~8!>Qm?_Me%lDG@fSA{f^Qpd4oWT@B=SMD9nyZ?844AC1xPq! ze3uz^Wh`g8Tma>o@W)Shj6cN}Am;c0AxOw9lMRz-WiQ>yx-_WzBqAO^2W!D|MoU;X z)lYAO>Xp^%!_ie;R@(lfaIq^98N>1B4063xy{6vx6Zc C|-4lr<5e3H>@UY zmWey)%bl&>ZScXYw*2$^_1AiGUi(z%$lDV)3`dWqFD=#~p{My$z-4z({ZNhao7!JC zyURQ~Naejg!u km0L7 z%H0;v9_SLQ>3(Umk$P_-N2}~LSbo7cncsHl+Q1^bDGp2(?ou$if;2L0NSd+eE?NCb z*^)gJ0%3#~ylxuN%qKXc+m-v)!+Cbm4*J_4oK~yUdt$yVGHW=!B^%6f!w_2T^Egjm z&&(wr%$}^OHrYh5aG;LN1=fh9x5xOM#O)|eLwaDj ((9LeU&%K{{?PuOC zYPiWJ37SO59v!aLAw$?-5raqY=^;WYzS{6v?cxeto?bY7V;#4t{D} Q}7J%)EHK{o8syV#O^x-a}- zTm6vi>8I=E1^Q=2B5jt_4d+{Z66W5MG_qaM?2>g=yj6#%Y%8}4u2w1Lq&ts9L<1ju zMDqYwU<3+{+sqts;Q@@cK*Dp{RYeUki!DAc6Zu~Tm;UJ)MSn*kurn1t8(z_8(A5-h zuTY{U0moQ@^}(~dd4DmI!;ezlpZiZ!LT<0ac@&vr)#R@?ydE2-_BeYZU`MKie194~ zn}OdHDp&Ka2XfIJLJf7&;JxFcZf~5Q8p%-`q=Zq6JV0Q?FiG$1{g4a$9ZA`yIxoq! z+D&G4G9M~L(w{B$F6O5@DOXgnrma~rqpk`*xB0}+zuA-lSl>U5P>}sjyg2TvvXaw} z+XEf+kz{x2ktz-^YNY!z5v%YKc8(Nj9;8yeNUbs%?$|~My-Wq6)nB&nl-uSVPe3I! zR(@EUKuv#kZwmB=Tijr`Y!*K-D{4hH%Ju8CE<=+&Or_AuzlS=|Kf0|% z-9?&Wl2UB#;Qb{Cen)R`nU1$2aaA>c$`@B1E>s?p8IRSBuJbuCL?gG4TKi@%;>umu z)5Fh^&+d_rRazuOx*U+D$EOp~Km_0*BdF5OYs44!ekk)>9vKGWx~SDy54`iguYLCG zZ_#>-1|mLl8q(J*-Hy|$TfPu+X`39ZjOD1c3M)KJA(md1-yEq#pazmv9zJS?@E@*U z6*i|ObPZ1;ZH`Bf9XHQp4mX5IJCT27gV =NNhpx?DKN2>&w6dKloDL+-j9G;4Pi5cOJChYE9y;7LfXShRg~%`t{UlB?zy+ zWwpIto+X|#DxFH-p`yQ4+DnNw4Lli5A==kIa?I)Kj@?BC?8=czqoVgb-s{W+^x$(` z;Bgz{1_4tgYW7<>)fiT(TtDUQmWx;mn{lPXIiR;@JZB+qL-gyJ`%Fvlc}us2V&~cU z>|tD3fIz8Z)6+v8%9eBa43+PjLYwc3o|(6C+Bhy$QAf398jpU*T_JhoS_V<)$vdl| z;HoQmv0@hw>6m8aAIb!3h$T`%cq!L%>%>M6srHxsYxE?}#SN1#EK;MlG@JpTCA*P+ zCbwP$ao)0I0%0CkdDbyp-&+#|+=p?i1-X`On27eEQd=!v=AS8Ty=CqjoTQXBFK%CT za3=|!C||d^C@Yw(%_4%AuZeytUwqAuefv+fE7*;<>&=R^$dp|O{$xM6q|Jh~B8y`Y zCPseSy|QY`((~pbY{xBlu>V}`+f|O{6IGnL8~84)8VNDON<1<> C z1-~zSwr6Joo9i6+M{qVBqAbI9aQg;hbbiRuuphhN@^cMUyq-w-MKVxuChQ&i3fXs8 z1Pf9HUMBWLG}}#bHNVdo49?N44Cm24qhdnE^IHKpL&OhjUpa$Ip9p~*DuXa@(7TOS zxjBFjWcIT=LNcb2EJ+izJMZ|;t(Icmn{bUmw%RJ9nkoZ=HDlcDz5J&e K4tB-uH zu?zCJbZ)vP@w)VMo_TnQGrx*`(3w!VRFft?fBxrnXzBd<)$RFb-6PlWgIFHXnUmsG zY=!JZelr>5SKV$UT_gp&N0*9c?-V<~(*lEzX^FLm*D#sdpF*NvJf7<|D?ykaPVTvH zDNVnli--}Olfc9LxbfFxX=I;-!F;^DWzL`@rjx4sYZqT>G51(WUzbP-slt!R=DD%T z%UY`2AIe=K?F66LY}7Xe5+Pi~k9(<+Z23B(y9$4z-x$2U>J>R|`(TnZH)=8} zE*(NTZkg-liNOU%$GPo?n2lH)_d%%-M^?Lwf9$~@u!HXa?cD$%7gkv+?vr~r12ak; z>-YiA*=Hi`HQNbyIa&CnN`v=$$o0G#c_V^GW-9-Q&QYzqp6+3dH%&NQ^`%^C(EN!h zpBb#QUre~uo&q9snQ0aY9@dUSU77XgmDC4n-x`=4tO~wicFg?6CNe`2gd8t?AFO+9 zR>EC}g}jQGGcDUBq=`sF1D&_=tkMNk0cswI0jG`2_nk>X7Wj~B$nhS+JZCim)SK^a zT@_oOuRIks@X9{QCi8N;Y-9Bkp_({OCjDj4puJr662hRWg}w)gfYp-#of|uQ%Y)?F zWsb+6kk?7?#3JZsCeHoVSnD7I9ltR!l2Wch6m!;~GtBw#%0BGx ZsUfsjo0rn9~AL_Qz>@YmB)UG#(P zqpiM0(BaSj*)RGL!D+Fzsj6|HTKGdf1VuG=fl $G$DxA`NVF57aEqHu zA8!0%!-FcChD7l9y6t9+(~0 )&%lUYYTiQL(YrC-ta9tEma*)S%b?J1bc7s_W)4rz%eU`85ppoodtCNO zY@jd IJR$ax83FDt7q;_)x~AkToW50R=YX^Ic`rqG950-a=9(oJBXtm`K`d< z!QqfpcEW=WA*m#)>4QX c`j@`vO+K}62GA>+{bZ2H`&$HAP1hOT#-B`dTv ze6HHQu@WqlvUxES8por}t4MVZyV$(=74Tew#%GfroiQ!sq2-_HR6a`t;5TDus-T5> zZ1Ad&aQ;%u@p(!6GR6}RwZd-is1;Z6JdRWWuc5|zy@GD*79aIzm#r<<&W4qJ)e~IA z>GL-9gP#=xPjb{M!Q+ bQDgE{-V1ihPz{ zuh4C>-ImWD_n);VvG5BYSwik>mckE?PV{fl9_c}vM^%!1qZj5wMH}d7mY0uI-q)i= ztzu7%^A)o#!-iw;hIAU5I0q D@k-=R do3 z>JH}udG<2lagD$<^yHSl2i~LVc3=J@54$9V*jj$-swyG+XC4#ZB8QEM_olX|or!ZV z2^62GubnUKcf>ccXs@gRc*)L|sa8_f`be515$iA7OQslF@vIk|*z?Di(M wHhYXImqcGI4>JaOSq9Y@kACHr7#}FwaG+g&;XqjM8 zt*<&gJ+l+M*U&6Amm4c|xq>;($~pBV{JWC;MNT|l9lr3{VXcAV%-r&Q*3`d~a(g)8 z5)6vhOS^V=Z{6$ycw(j{K$o;>LhGvr(!HS_r9=bmsO!fchtG#i8+-k+HcR=T0@e%6 z8Z~R@l5zv_wmi0T!!4VfteY<@MaBfI8~tlSuVbJx=^*?(V_gm>sxvi&mh{T6S6o~q z^z8N%OISze)J^^h%utef2)JxLivKY6cHFf!)LT5W(r)CI;<_QHkDn=C@K={}qtKnV zjnWBkR9-WfP<0CsK?p4`qB74FbP8-;NQ+oNILLD4H7;8;`UGUw^%K^*#UJcSS3zU` zmrpagl4!G&o{HfkkTQ=oc<`b3e3a|0i d=mCAo` za(Hz$|0n71RNl2kCiRA^R?mgYzq&jR8v)(8k_!u0sr X8 zwrCFP;oA%eV@&Qr^RW_>$-0# KGiczo$Rzw6s<3%xlBM97=C
Y8FiN6+VMGz7bG0aN2y}4wj`fs9d1l| do24%_%AJGRhH@s0G&s^1V6|3#?)^25 zHnj$#Y#(bDSOQ=7YpNCg-B*#1KaUlTgH`S4!1olu`G2l50KYMwLTY=mR0UwQojv zls=a(d)Fet@fvMYnw;P94w#^Mm^4VF#+O`gZYg2*xe9~=4?1TW!_kd%KZz>UG;5%1 ztA wTP}Xb*Y2S_uwM+xI7tmk-}%ws1kI?zO+?vYr3Og~)5{wSP1OBUe#$ zw;fsO#@l}Wjdv^=ZLsL*>1RK8gmhT@n+)KCEMHNPKoKnO>)fX8odOsjCHG2I^eD7V zQ?ZE6DKwmScry>dLS=44HAn--D$8YSC|az7U+l<^RJ!qM)fEhcQ}`fyHh1*`2;!u; zeyAZC-K<8_?Ti(FX|bK&CKfOnNr>r5V^v7O0A#xD2&w;TwCKF9!LPjVefEWZZ2hry zTtqqwnQFD_ueU~h{e*}EDpva~MIb3k9L@nO{}EOlwlUyfh}71_@EB)wS=eilNg@~2 z?;VjI?M$c+ 0Z85kR_}Jn6yq zz-D{r%PYH47SU(ThSSi`a6hp-`w4;Tn?eas#UfmJ3rmdNwKXb8cL16nd)y+MUUFFJ zR;)i;0_erw^2mgs&(}%2%|44G=$I@@-H&>xLAtryiiu#EDQ#2CS!fMEh8?dj{nbU| zA7y^`o%SJAcYS7PE55obv;m-HsnJ_n$ZRL4?dy*el*YPF;O%~)+cXmV62Hk}BIQLn z{zs`|*nWx6R?EJua;^XM;CWs({G~0*P^DDm>c~AZNz-*?b28>F9_oif;Opzwo$4OK zK-~Tl$k*FP$A{Gzw 6+faWq47c>bKh|wVoClb!1RSqE#&O( zBtcqhV=kVL*?O-c5&Ca_UFWt1Lq6^i&MaWmzS{on(`SwYD)fiJLH#_8(bgw30>#fo zUPwcQdc9~RzU%4m3U;Px)Gec^UlS}Qb!?Q7>$%4pn0fqbhng;Q14=jS6{zDG2EmFZ zizru3Xsay;(5yU?XNu;=l^&Nh+Z{SC<(p*0Q%W~2 ~#5f0_zLdWQaU-#^2or@YxB@*s} z d#z-Ew|pAKE~aqpA*T+Q02~+If9nUwwi$ z+r3|yH22s$hFzv4pzTXQ>klN+0~=3Cg`8PO;w4gk>$QyVS-QbZY$PT|LyOFYkSl`$ z@MuK|kpN58j2=X`BKIUC+31$xC}I;JM ``9YRQ!D!JA`1 -VQjC4ck}O#VO<7Ww?8}TX^ISeW|H5;; z?{U1}?&JP+9rtowzu$SDzw;b>Z$2y?_*Jd80rtyN;Wi0c!4MwCSK}k-PS_f`w&YW- zywf%khXiy_@Sn)#9ra7sv@!b(qVA{;;uhOr)+~>^T^^_G`jytJ)!C+SpOR?~naOsl zE|7cogm#vDo7^rp8o1YeVZrn!*uihDIA+N|QwJs;n?0MK WLgLkjtjGb{fv$&CH # A;C74YWqNEV|2TjH+1`I`7!l^t