Deploy Ladill Mini / deploy (push) Successful in 1m39s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
410 lines
14 KiB
JavaScript
410 lines
14 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: '',
|
|
accessCode: '',
|
|
publicKey: '',
|
|
returnUrl: '',
|
|
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();
|
|
|
|
// Non-JS form POST fallback flashes checkout payload — open Inline in-page.
|
|
if (config.bootstrapCheckoutUrl || config.bootstrapAccessCode) {
|
|
this.openInlineCheckout({
|
|
checkout_url: config.bootstrapCheckoutUrl || '',
|
|
access_code: config.bootstrapAccessCode || '',
|
|
public_key: config.bootstrapPublicKey || '',
|
|
callback_url: config.bootstrapCallbackUrl || '',
|
|
provider: config.bootstrapProvider || 'paystack',
|
|
});
|
|
}
|
|
},
|
|
|
|
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');
|
|
},
|
|
|
|
isSameOrigin(url) {
|
|
if (!url) return false;
|
|
try {
|
|
return new URL(url, window.location.href).origin === window.location.origin;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
accessCodeFromCheckoutUrl(url) {
|
|
if (!url) return '';
|
|
try {
|
|
const u = new URL(url, window.location.href);
|
|
const host = u.hostname.toLowerCase();
|
|
if (host !== 'checkout.paystack.com' && !host.endsWith('.paystack.com')) {
|
|
return '';
|
|
}
|
|
const code = u.pathname.replace(/^\/+/, '').split('/')[0] || '';
|
|
return /^[A-Za-z0-9_-]{6,}$/.test(code) ? code : '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
},
|
|
|
|
openInlineCheckout(data = {}) {
|
|
// Never navigate to checkout.paystack.com. Same-origin MoMo waiting can navigate.
|
|
if (data.provider === 'mtn_momo' && this.isSameOrigin(data.checkout_url)) {
|
|
window.location.assign(data.checkout_url);
|
|
return false;
|
|
}
|
|
|
|
const checkoutUrl = data.checkout_url || '';
|
|
let accessCode = data.access_code || '';
|
|
if (!accessCode) {
|
|
accessCode = this.accessCodeFromCheckoutUrl(checkoutUrl);
|
|
}
|
|
|
|
this.checkoutUrl = checkoutUrl;
|
|
this.accessCode = accessCode;
|
|
this.publicKey = data.public_key || '';
|
|
this.returnUrl = data.callback_url || '';
|
|
this.showSheet = true;
|
|
return true;
|
|
},
|
|
|
|
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;
|
|
this.checkoutUrl = '';
|
|
this.accessCode = '';
|
|
this.publicKey = '';
|
|
this.returnUrl = '';
|
|
// Open bottomsheet/modal immediately so payment feels in-app while checkout starts.
|
|
this.showSheet = 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.showSheet = false;
|
|
this.errorMsg = apiError || (`Could not start payment (${res.status}). Please try again.`);
|
|
return;
|
|
}
|
|
if (!data.checkout_url && !data.access_code) {
|
|
this.showSheet = false;
|
|
this.errorMsg = 'Could not start payment. Please try again.';
|
|
return;
|
|
}
|
|
|
|
if (!this.openInlineCheckout(data)) {
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
this.showSheet = false;
|
|
this.errorMsg = e?.name === 'AbortError'
|
|
? 'Payment is taking too long. Please try again.'
|
|
: 'Network error. Please try again.';
|
|
} finally {
|
|
clearTimeout(timer);
|
|
// Keep loading while the sheet is open — ladill-pay-opened / cancel / error clear it.
|
|
// Clearing here caused a form flash before Paystack's modal painted.
|
|
if (!this.showSheet) {
|
|
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();
|