Deploy Ladill Mini / deploy (push) Successful in 55s
Standardize customer-facing Ladill Pay checkout shells across products, open them on all viewports, and escape iframe callbacks back to the product flow.
345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
import './bootstrap';
|
|
|
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
|
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
|
|
|
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;
|
|
|
|
window.Alpine = Alpine;
|
|
Alpine.plugin(collapse);
|
|
registerLadillClipboard(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;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 45000);
|
|
|
|
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 }),
|
|
signal: controller.signal,
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
const apiError = typeof data.error === 'string'
|
|
? data.error
|
|
: (typeof data.message === 'string' ? data.message : '');
|
|
if (!res.ok || apiError) {
|
|
this.errorMsg = apiError || (`Could not start payment (${res.status}). Please try again.`);
|
|
return;
|
|
}
|
|
if (!data.checkout_url) {
|
|
this.errorMsg = 'Could not start payment. Please try again.';
|
|
return;
|
|
}
|
|
|
|
// Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe
|
|
// (cross-origin iframe is blank / looks like "Pay did nothing").
|
|
const useSheet = data.provider !== 'mtn_momo';
|
|
if (useSheet) {
|
|
this.checkoutUrl = data.checkout_url;
|
|
this.showSheet = true;
|
|
} else {
|
|
window.location.assign(data.checkout_url);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
this.errorMsg = e?.name === 'AbortError'
|
|
? 'Payment is taking too long. Please try again.'
|
|
: 'Network error. Please try again.';
|
|
} finally {
|
|
clearTimeout(timer);
|
|
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;
|
|
},
|
|
}));
|
|
|
|
|
|
// 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();
|