Files
ladill-transfer/resources/js/app.js
T
isaaccladandCursor 6cd2a75498
Deploy Ladill Transfer / deploy (push) Successful in 31s
Fix transfer create uploads getting stuck and failing on submit.
All files now upload through chunked sessions only, the file picker no longer
rewrites the queue on change, and create submits upload_ids instead of a broken
multipart files field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:36:27 +00:00

516 lines
16 KiB
JavaScript

import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { uploadFileChunked } from './chunked-upload';
registerLadillModalHelpers();
import Alpine from 'alpinejs';
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);
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;
}
if (window.innerWidth < 768) {
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.loading = false;
} else {
window.location.href = data.checkout_url;
}
} 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;
},
}));
function formatUploadBytes(bytes) {
if (bytes >= 1048576) {
return `${(bytes / 1048576).toFixed(1)} MB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)} KB`;
}
return `${bytes} B`;
}
Alpine.data('transferCreateForm', (config = {}) => ({
fileItems: [],
submitting: false,
prepared: false,
_ignoreInputChange: false,
_uploadQueue: [],
_draining: false,
_drainPromise: null,
get uploading() {
return this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending');
},
get overallProgress() {
const items = this.fileItems.filter((item) => item.status !== 'error');
if (!items.length) {
return 0;
}
const total = items.reduce((sum, item) => sum + item.size, 0);
const done = items.reduce((sum, item) => {
if (item.status === 'ready') {
return sum + item.size;
}
if (item.status === 'uploading') {
return sum + (item.size * (item.progress || 0) / 100);
}
return sum;
}, 0);
return total ? Math.round((done / total) * 100) : 0;
},
formatBytes(bytes) {
return formatUploadBytes(bytes);
},
onFilesChange(event) {
if (this._ignoreInputChange) {
return;
}
const picked = Array.from(event.target.files || []);
if (!picked.length) {
return;
}
this.prepared = false;
const existingKeys = new Set(this.fileItems.map((item) => item.key));
for (const file of picked) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (existingKeys.has(key)) {
continue;
}
const item = {
id: (crypto.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random()}`,
key,
file,
name: file.name,
size: file.size,
status: 'pending',
progress: 0,
uploadId: null,
error: null,
};
this.fileItems.push(item);
this._uploadQueue.push(item);
}
this.resetFileInput(event.target);
void this.drainUploadQueue();
},
resetFileInput(input) {
this._ignoreInputChange = true;
input.value = '';
this._ignoreInputChange = false;
},
drainUploadQueue() {
if (this._drainPromise) {
return this._drainPromise;
}
this._drainPromise = (async () => {
this._draining = true;
while (this._uploadQueue.length) {
const item = this._uploadQueue.shift();
if (!item || item.status !== 'pending') {
continue;
}
await this.uploadFileItem(item);
}
this._draining = false;
this._drainPromise = null;
if (this._uploadQueue.length) {
void this.drainUploadQueue();
}
})();
return this._drainPromise;
},
async uploadFileItem(item) {
if (item.status !== 'pending') {
return;
}
item.status = 'uploading';
item.progress = 0;
try {
const result = await uploadFileChunked(item.file, {
initUrl: config.initUrl,
chunkUrl: config.chunkUrl,
finalizeUrl: config.finalizeUrl,
csrfToken: config.csrfToken,
onProgress: (percent) => {
item.progress = percent;
},
});
item.uploadId = result.uploadId;
item.status = 'ready';
item.progress = 100;
} catch (error) {
item.status = 'error';
item.error = error?.message || 'Upload failed.';
}
},
removeFileItem(id) {
const item = this.fileItems.find((entry) => entry.id === id);
if (!item || item.status === 'uploading') {
return;
}
this.fileItems = this.fileItems.filter((entry) => entry.id !== id);
this._uploadQueue = this._uploadQueue.filter((entry) => entry.id !== id);
},
async submit(event) {
if (this.prepared) {
return;
}
event.preventDefault();
const form = event.target;
if (!this.fileItems.length) {
window.alert('Add at least one file to share.');
return;
}
if (this.fileItems.some((item) => item.status === 'error')) {
window.alert('Remove failed uploads or choose the files again.');
return;
}
if (this.uploading) {
await this.drainUploadQueue();
}
if (this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending')) {
window.alert('Please wait for uploads to finish.');
return;
}
const readyItems = this.fileItems.filter((item) => item.status === 'ready' && item.uploadId);
if (!readyItems.length) {
window.alert('Add at least one file to share.');
return;
}
form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove());
for (const item of readyItems) {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'upload_ids[]';
hidden.value = item.uploadId;
form.appendChild(hidden);
}
this.submitting = true;
this.prepared = true;
form.submit();
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
Alpine.start();