Deploy Ladill Woo Manager / deploy (push) Successful in 38s
Standardize customer-facing Ladill Pay checkout shells across products, open them on all viewports, and escape iframe callbacks back to the product flow.
359 lines
12 KiB
JavaScript
359 lines
12 KiB
JavaScript
import './bootstrap';
|
|
|
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
|
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
|
import { registerWooRichText } from './woo-rich-text';
|
|
|
|
registerLadillModalHelpers();
|
|
registerLadillSearchShortcut();
|
|
|
|
|
|
import Alpine from 'alpinejs';
|
|
import { registerLadillClipboard } from './ladill-clipboard';
|
|
import collapse from '@alpinejs/collapse';
|
|
import QRCodeStyling from 'qr-code-styling';
|
|
window.QRCodeStyling = QRCodeStyling;
|
|
import qrcode from 'qrcode-generator';
|
|
window.qrcode = qrcode;
|
|
|
|
Alpine.plugin(collapse);
|
|
registerLadillClipboard(Alpine);
|
|
registerWooRichText(Alpine);
|
|
|
|
Alpine.data('notificationDropdown', (config = {}) => ({
|
|
open: false,
|
|
loading: false,
|
|
notifications: [],
|
|
unreadCount: 0,
|
|
unreadUrl: config.unreadUrl || '/notifications/unread',
|
|
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
|
|
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
|
|
indexUrl: config.indexUrl || '/notifications',
|
|
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
|
|
init() {
|
|
this.fetchUnread();
|
|
setInterval(() => this.fetchUnread(), 60000);
|
|
},
|
|
|
|
async fetchUnread() {
|
|
try {
|
|
const res = await fetch(this.unreadUrl, {
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await res.json();
|
|
this.notifications = data.notifications || [];
|
|
this.unreadCount = data.unread_count || 0;
|
|
} catch (e) {
|
|
console.error('Failed to fetch notifications', e);
|
|
}
|
|
},
|
|
|
|
toggle() {
|
|
this.open = !this.open;
|
|
if (this.open) {
|
|
this.loading = true;
|
|
this.fetchUnread().finally(() => { this.loading = false; });
|
|
}
|
|
},
|
|
|
|
async markRead(id) {
|
|
const url = this.markReadUrl.replace('__ID__', id);
|
|
try {
|
|
await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
|
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
|
} catch (e) {
|
|
console.error('Failed to mark notification as read', e);
|
|
}
|
|
},
|
|
|
|
async markAllRead() {
|
|
try {
|
|
await fetch(this.markAllReadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
this.notifications = [];
|
|
this.unreadCount = 0;
|
|
} catch (e) {
|
|
console.error('Failed to mark all notifications as read', e);
|
|
}
|
|
},
|
|
|
|
getIconBg(icon) {
|
|
const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' };
|
|
return map[icon] || 'bg-slate-100';
|
|
},
|
|
|
|
getIconColor(icon) {
|
|
const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' };
|
|
return map[icon] || 'text-slate-500';
|
|
},
|
|
}));
|
|
|
|
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
|
|
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
|
|
Alpine.data('afia', (config = {}) => ({
|
|
open: false,
|
|
input: '',
|
|
loading: false,
|
|
messages: [
|
|
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
|
|
],
|
|
suggestions: config.suggestions || [],
|
|
init() {
|
|
window.addEventListener('afia-open', () => {
|
|
this.open = true;
|
|
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
|
|
});
|
|
},
|
|
close() { this.open = false; },
|
|
useSuggestion(s) { this.input = s; this.send(); },
|
|
scrollDown() {
|
|
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
|
|
},
|
|
async send() {
|
|
const text = this.input.trim();
|
|
if (!text || this.loading) return;
|
|
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
|
|
this.messages.push({ role: 'user', text });
|
|
this.input = '';
|
|
this.loading = true;
|
|
this.scrollDown();
|
|
try {
|
|
const res = await fetch(config.chatUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
|
|
body: JSON.stringify({ message: text, history }),
|
|
});
|
|
const data = await res.json();
|
|
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
|
} catch (e) {
|
|
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
|
}
|
|
this.loading = false;
|
|
this.scrollDown();
|
|
},
|
|
}));
|
|
|
|
function mobileKeyboardBottomOffset() {
|
|
const viewport = window.visualViewport;
|
|
if (!viewport) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
|
|
}
|
|
|
|
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
|
amount: config.amount ?? '',
|
|
loading: false,
|
|
errorMsg: config.errorMsg ?? '',
|
|
showSheet: false,
|
|
checkoutUrl: '',
|
|
paymentSheetStyle: '',
|
|
sheetBleedStyle: '',
|
|
|
|
init() {
|
|
this._syncSheet = () => {
|
|
if (window.innerWidth >= 768) {
|
|
this.paymentSheetStyle = '';
|
|
this.sheetBleedStyle = '';
|
|
return;
|
|
}
|
|
|
|
const offset = mobileKeyboardBottomOffset();
|
|
const safePad = offset > 0 ? '1.25rem' : 'max(1.25rem, env(safe-area-inset-bottom))';
|
|
this.paymentSheetStyle = `bottom: ${offset}px; padding-bottom: ${safePad};`;
|
|
this.sheetBleedStyle = `bottom: ${offset}px;`;
|
|
};
|
|
|
|
this._onViewportChange = () => this._syncSheet();
|
|
this._onFocusChange = () => {
|
|
requestAnimationFrame(this._syncSheet);
|
|
setTimeout(this._syncSheet, 150);
|
|
setTimeout(this._syncSheet, 350);
|
|
};
|
|
|
|
window.visualViewport?.addEventListener('resize', this._onViewportChange);
|
|
window.visualViewport?.addEventListener('scroll', this._onViewportChange);
|
|
document.addEventListener('focusin', this._onFocusChange);
|
|
document.addEventListener('focusout', this._onFocusChange);
|
|
|
|
if (window.innerWidth < 768) {
|
|
document.documentElement.classList.add('mini-payment-page');
|
|
}
|
|
|
|
this._syncSheet();
|
|
},
|
|
|
|
destroy() {
|
|
window.visualViewport?.removeEventListener('resize', this._onViewportChange);
|
|
window.visualViewport?.removeEventListener('scroll', this._onViewportChange);
|
|
document.removeEventListener('focusin', this._onFocusChange);
|
|
document.removeEventListener('focusout', this._onFocusChange);
|
|
document.documentElement.classList.remove('mini-payment-page');
|
|
},
|
|
|
|
async submitPay() {
|
|
const value = parseFloat(this.amount);
|
|
if (!value || value <= 0) {
|
|
this.errorMsg = 'Enter an amount greater than zero.';
|
|
return;
|
|
}
|
|
|
|
this.errorMsg = '';
|
|
this.loading = true;
|
|
|
|
try {
|
|
const res = await fetch(config.payUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': config.csrf,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({ amount: value }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || data.error || data.message) {
|
|
this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.';
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
if (!data.checkout_url) {
|
|
this.errorMsg = 'Could not start payment. Please try again.';
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
this.checkoutUrl = data.checkout_url;
|
|
this.showSheet = true;
|
|
} catch (e) {
|
|
this.errorMsg = 'Network error. Please try again.';
|
|
this.loading = false;
|
|
}
|
|
},
|
|
}));
|
|
|
|
Alpine.data('topbarSearch', (config = {}) => ({
|
|
query: config.initialQuery || '',
|
|
results: Array.isArray(config.initialResults) ? config.initialResults : [],
|
|
open: !!config.openOnInit,
|
|
loading: false,
|
|
active: 0,
|
|
_abort: null,
|
|
searchUrl: config.searchUrl || '/search',
|
|
|
|
init() {
|
|
if (config.autoFocus) {
|
|
this.$nextTick(() => this.$refs.input?.focus());
|
|
}
|
|
},
|
|
|
|
onFocus() {
|
|
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
|
|
},
|
|
|
|
async search() {
|
|
const q = this.query.trim();
|
|
if (q.length < 2) { this.results = []; this.open = false; return; }
|
|
|
|
this.loading = true;
|
|
this.open = true;
|
|
this.active = 0;
|
|
|
|
if (this._abort) this._abort.abort();
|
|
this._abort = new AbortController();
|
|
|
|
try {
|
|
const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, {
|
|
signal: this._abort.signal,
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await res.json();
|
|
this.results = data.results || [];
|
|
} catch (e) {
|
|
if (e.name !== 'AbortError') this.results = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
moveDown() { if (this.active < this.results.length - 1) this.active++; },
|
|
moveUp() { if (this.active > 0) this.active--; },
|
|
go() {
|
|
if (this.results[this.active]) window.location.href = this.results[this.active].url;
|
|
},
|
|
}));
|
|
|
|
// Invoice line-items editor (create/edit invoice form).
|
|
Alpine.data('invoiceForm', (initialLines = [], products = []) => ({
|
|
lines: initialLines.length ? initialLines : [{ description: '', quantity: 1, unit_price: '' }],
|
|
products: Array.isArray(products) ? products : [],
|
|
selectedProduct: '',
|
|
addLine() {
|
|
this.lines.push({ description: '', quantity: 1, unit_price: '' });
|
|
},
|
|
removeLine(index) {
|
|
if (this.lines.length > 1) {
|
|
this.lines.splice(index, 1);
|
|
}
|
|
},
|
|
addProductLine() {
|
|
const p = this.products.find((x) => String(x.id) === String(this.selectedProduct));
|
|
if (!p) return;
|
|
const line = {
|
|
description: p.name || '',
|
|
quantity: 1,
|
|
unit_price: (Number(p.unit_price_minor || 0) / 100).toFixed(2),
|
|
};
|
|
// Replace a single empty starter row, otherwise append.
|
|
if (this.lines.length === 1 && !this.lines[0].description && !this.lines[0].unit_price) {
|
|
this.lines[0] = line;
|
|
} else {
|
|
this.lines.push(line);
|
|
}
|
|
this.selectedProduct = '';
|
|
},
|
|
}));
|
|
|
|
|
|
// Wallet balance peek for the avatar dropdown.
|
|
Alpine.data('walletWidget', (config = {}) => ({
|
|
display: '…',
|
|
async load() {
|
|
if (! config.url) {
|
|
this.display = 'View wallet';
|
|
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
|
const data = await res.json();
|
|
this.display = data.available ? data.formatted : 'View wallet';
|
|
} catch (e) {
|
|
this.display = 'View wallet';
|
|
}
|
|
},
|
|
}));
|
|
|
|
window.Alpine = Alpine;
|
|
registerLadillConfirmStore(Alpine);
|
|
|
|
Alpine.start();
|