import { Controller } from "@hotwired/stimulus"
let _pdfjsLib = null;
async function loadPdfjsLib() {
if (!_pdfjsLib) {
_pdfjsLib = await import('pdfjs-dist/webpack.mjs');
}
return _pdfjsLib;
}
/**
* Waiver Upload Wizard Controller
*
* Gathering-level workflow:
* Step 1: Select waiver type
* Step 2: Upload pages or attest not needed
* Step 3: Review & submit
*/
class WaiverUploadWizardController extends Controller {
static MAX_OPTIMIZED_IMAGE_DIMENSION = 2000
static OPTIMIZED_IMAGE_QUALITY = 0.75
static UPLOAD_TIMEOUT_MS = 5 * 60 * 1000
static targets = [
"step",
"stepIndicator",
"prevButton",
"nextButton",
"submitButton",
"submitButtonText",
"waiverTypeOption",
"waiverTypeSelect",
"pagesPreview",
"fileInput",
"reviewWaiverType",
"reviewPageCount",
"reviewPagesList",
"notesField",
"progressBar",
"uploadSection",
"attestSection",
"attestReasonList",
"attestNotes",
"reviewUploadSection",
"reviewAttestSection",
"reviewAttestReason",
"reviewAttestNotes",
"reviewAttestNotesSection",
"modeToggle",
"step3Lead"
]
static values = {
currentStep: { type: Number, default: 1 },
totalSteps: { type: Number, default: 3 },
gatheringId: Number,
gatheringPublicId: String,
maxFileSize: Number, // Maximum single file size in bytes
totalMaxSize: Number, // Maximum total upload size in bytes
preSelectedWaiverTypeId: Number, // Pre-selected waiver type ID from URL
attestUrl: String, // URL for attestation endpoint
gatheringViewUrl: String, // URL for gathering view page
mobileSelectUrl: String // URL for mobile select gathering page
}
connect() {
this.uploadedPages = []
this.uploadRequest = null
this.isDisconnected = false
this.selectedWaiverType = null
this.notes = ""
this.isAttestMode = false
this.attestReason = null
this.attestNotes = ""
if (this.hasPreSelectedWaiverTypeIdValue) {
setTimeout(() => {
const waiverTypeRadio = document.querySelector(
`input[name="waiver_type"][value="${this.preSelectedWaiverTypeIdValue}"]`
)
if (waiverTypeRadio) {
if (this.isWaiverTypeAttested(this.preSelectedWaiverTypeIdValue)) {
this.showError('This waiver type has been attested as not needed for this gathering.')
waiverTypeRadio.checked = false
return
}
waiverTypeRadio.checked = true
this.selectedWaiverType = {
id: this.preSelectedWaiverTypeIdValue,
name: waiverTypeRadio.dataset.name
}
this.checkAttestationAvailability()
}
}, 50)
}
this.showStep(1)
}
disconnect() {
this.isDisconnected = true
if (this.uploadRequest && this.uploadRequest.readyState !== XMLHttpRequest.DONE) {
this.uploadRequest.abort()
}
this.uploadRequest = null
}
// Step Navigation
nextStep() {
if (this.validateCurrentStep()) {
if (this.currentStepValue < this.totalStepsValue) {
this.currentStepValue++
this.showStep(this.currentStepValue)
}
}
}
prevStep() {
if (this.currentStepValue > 1) {
this.currentStepValue--
this.showStep(this.currentStepValue)
}
}
goToStep(event) {
const step = parseInt(event.currentTarget.dataset.step)
if (step < this.currentStepValue) {
this.currentStepValue = step
this.showStep(this.currentStepValue)
}
}
showStep(stepNumber) {
this.stepTargets.forEach(step => {
const isCurrentStep = parseInt(step.dataset.stepNumber) === stepNumber
step.classList.toggle('d-none', !isCurrentStep)
step.setAttribute('aria-hidden', isCurrentStep ? 'false' : 'true')
if (isCurrentStep) {
step.setAttribute('aria-current', 'step')
} else {
step.removeAttribute('aria-current')
}
})
// Update step indicators
this.updateStepIndicators(stepNumber)
// Update navigation buttons
this.updateNavigationButtons(stepNumber)
// Update progress bar
this.updateProgressBar(stepNumber)
// Perform step-specific actions
this.onStepChange(stepNumber)
}
updateStepIndicators(currentStep) {
this.stepIndicatorTargets.forEach(indicator => {
const step = parseInt(indicator.dataset.step)
indicator.classList.remove('active', 'completed')
indicator.removeAttribute('aria-current')
if (step === currentStep) {
indicator.classList.add('active')
indicator.setAttribute('aria-current', 'step')
} else if (step < currentStep) {
indicator.classList.add('completed')
}
})
}
updateNavigationButtons(stepNumber) {
// Previous button
if (this.hasPrevButtonTarget) {
if (stepNumber === 1) {
this.prevButtonTarget.classList.add('d-none')
this.prevButtonTarget.setAttribute('aria-hidden', 'true')
} else {
this.prevButtonTarget.classList.remove('d-none')
this.prevButtonTarget.setAttribute('aria-hidden', 'false')
}
}
// Next button
if (this.hasNextButtonTarget) {
if (stepNumber === this.totalStepsValue) {
this.nextButtonTarget.classList.add('d-none')
this.nextButtonTarget.setAttribute('aria-hidden', 'true')
} else {
this.nextButtonTarget.classList.remove('d-none')
this.nextButtonTarget.setAttribute('aria-hidden', 'false')
// Update button text based on step
if (stepNumber === 3) {
this.nextButtonTarget.innerHTML = '<i class="bi bi-arrow-right"></i> Review'
} else {
this.nextButtonTarget.innerHTML = '<i class="bi bi-arrow-right"></i> Next'
}
}
}
// Submit button
if (this.hasSubmitButtonTarget) {
if (stepNumber === this.totalStepsValue) {
this.submitButtonTarget.classList.remove('d-none')
this.submitButtonTarget.setAttribute('aria-hidden', 'false')
// Update submit button text based on mode
if (this.hasSubmitButtonTextTarget) {
this.submitButtonTextTarget.textContent = this.isAttestMode ? 'Submit Attestation' : 'Submit Waivers'
}
} else {
this.submitButtonTarget.classList.add('d-none')
this.submitButtonTarget.setAttribute('aria-hidden', 'true')
}
}
}
updateProgressBar(stepNumber) {
if (this.hasProgressBarTarget) {
const progress = (stepNumber / this.totalStepsValue) * 100
this.progressBarTarget.style.width = `${progress}%`
this.progressBarTarget.setAttribute('aria-valuenow', progress)
this.progressBarTarget.setAttribute('aria-valuetext', `Step ${stepNumber} of ${this.totalStepsValue}`)
}
}
onStepChange(stepNumber) {
switch (stepNumber) {
case 2:
this.checkAttestationAvailability()
break
case 3:
this.updateReviewSection()
break
}
}
// Check if attestation is available for selected waiver type
checkAttestationAvailability() {
if (!this.selectedWaiverType) {
return
}
// Find the waiver type to get exemption reasons
const waiverTypeRadio = document.querySelector(
`input[name="waiver_type"][value="${this.selectedWaiverType.id}"]`
)
let exemptionReasons = []
if (waiverTypeRadio && waiverTypeRadio.dataset.exemptionReasons) {
try {
exemptionReasons = JSON.parse(waiverTypeRadio.dataset.exemptionReasons)
} catch (e) {
console.error('Failed to parse exemption reasons:', e)
}
}
// Show/hide mode toggle and update lead text based on exemption reasons availability
if (exemptionReasons.length > 0) {
// Has exemption reasons - show toggle
if (this.hasModeToggleTarget) {
this.modeToggleTarget.classList.remove('d-none')
this.modeToggleTarget.setAttribute('aria-hidden', 'false')
}
if (this.hasStep3LeadTarget) {
this.step3LeadTarget.textContent = 'Add one or more pages to your waiver document, or attest that a waiver is not needed'
}
} else {
// No exemption reasons - hide toggle, force upload mode
if (this.hasModeToggleTarget) {
this.modeToggleTarget.classList.add('d-none')
this.modeToggleTarget.setAttribute('aria-hidden', 'true')
}
if (this.hasStep3LeadTarget) {
this.step3LeadTarget.textContent = 'Add one or more pages to your waiver document'
}
// Force upload mode
this.isAttestMode = false
const uploadRadio = document.getElementById('mode-upload')
if (uploadRadio) {
uploadRadio.checked = true
}
if (this.hasUploadSectionTarget && this.hasAttestSectionTarget) {
this.uploadSectionTarget.classList.remove('d-none')
this.uploadSectionTarget.setAttribute('aria-hidden', 'false')
this.attestSectionTarget.classList.add('d-none')
this.attestSectionTarget.setAttribute('aria-hidden', 'true')
}
}
// Populate attestation reasons if available
if (exemptionReasons.length > 0) {
this.populateAttestationReasons()
}
}
// Step 3: Mode Toggle (Upload vs Attest)
setModeUpload(event) {
this.isAttestMode = false
if (this.hasUploadSectionTarget && this.hasAttestSectionTarget) {
this.uploadSectionTarget.classList.remove('d-none')
this.uploadSectionTarget.setAttribute('aria-hidden', 'false')
this.attestSectionTarget.classList.add('d-none')
this.attestSectionTarget.setAttribute('aria-hidden', 'true')
}
}
setModeAttest(event) {
// Verify exemption reasons are available before allowing switch
if (!this.selectedWaiverType) return
const waiverTypeRadio = document.querySelector(
`input[name="waiver_type"][value="${this.selectedWaiverType.id}"]`
)
let exemptionReasons = []
if (waiverTypeRadio && waiverTypeRadio.dataset.exemptionReasons) {
try {
exemptionReasons = JSON.parse(waiverTypeRadio.dataset.exemptionReasons)
} catch (e) {
console.error('Failed to parse exemption reasons:', e)
}
}
if (exemptionReasons.length === 0) {
// No exemption reasons - prevent switching to attest mode
this.showError('Attestation is not available for this waiver type.')
const uploadRadio = document.getElementById('mode-upload')
if (uploadRadio) {
uploadRadio.checked = true
}
return
}
this.isAttestMode = true
if (this.hasUploadSectionTarget && this.hasAttestSectionTarget) {
this.uploadSectionTarget.classList.add('d-none')
this.uploadSectionTarget.setAttribute('aria-hidden', 'true')
this.attestSectionTarget.classList.remove('d-none')
this.attestSectionTarget.setAttribute('aria-hidden', 'false')
}
this.populateAttestationReasons()
}
populateAttestationReasons() {
if (!this.hasAttestReasonListTarget || !this.selectedWaiverType) return
// Find the waiver type to get exemption reasons
const waiverTypeRadio = document.querySelector(
`input[name="waiver_type"][value="${this.selectedWaiverType.id}"]`
)
let exemptionReasons = []
if (waiverTypeRadio && waiverTypeRadio.dataset.exemptionReasons) {
try {
exemptionReasons = JSON.parse(waiverTypeRadio.dataset.exemptionReasons)
} catch (e) {
console.error('Failed to parse exemption reasons:', e)
}
}
if (exemptionReasons.length === 0) {
this.attestReasonListTarget.innerHTML = `
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
No exemption reasons have been configured for this waiver type.
</div>
`
return
}
const legendId = `attest-reason-legend-${this.selectedWaiverType.id}`
let html = `
<fieldset>
<legend id="${legendId}" class="visually-hidden">Why is this waiver not needed?</legend>
<div class="list-group" aria-labelledby="${legendId}">
`
let hasMatchingSelection = false
exemptionReasons.forEach((reason, index) => {
const id = `attest_reason_${index}`
const isSelected = this.attestReason === reason
if (isSelected) {
hasMatchingSelection = true
}
html += `
<label class="list-group-item list-group-item-action">
<input class="form-check-input me-2" type="radio" name="attest_reason"
id="${id}" value="${this.escapeHtml(reason)}"
data-action="change->waiver-upload-wizard#selectAttestReason"
${isSelected ? 'checked' : ''}>
${this.escapeHtml(reason)}
</label>
`
})
html += `
</div>
</fieldset>
`
this.attestReasonListTarget.innerHTML = html
// Clear attestReason if the previously selected reason is not available for the current waiver type
if (!hasMatchingSelection) {
this.attestReason = null
}
}
selectAttestReason(event) {
this.attestReason = event.currentTarget.value
}
selectWaiverType(event) {
const option = event.currentTarget
if (option.disabled || option.dataset.attested === '1') {
option.checked = false
this.showError('This waiver type has been attested as not needed for this gathering.')
return
}
this.selectedWaiverType = {
id: parseInt(option.value),
name: option.dataset.name
}
this.checkAttestationAvailability()
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
isWaiverTypeAttested(waiverTypeId) {
const waiverTypeRadio = document.querySelector(
`input[name="waiver_type"][value="${waiverTypeId}"]`
)
if (!waiverTypeRadio) {
return false
}
return waiverTypeRadio.disabled || waiverTypeRadio.dataset.attested === '1'
}
// Step 3: Add Pages
triggerFileInput() {
if (this.hasFileInputTarget) {
this.fileInputTarget.click()
}
}
handleFileSelect(event) {
const files = Array.from(event.target.files)
// Get max file size (use configured value or fallback to 5MB)
const maxFileSize = this.hasMaxFileSizeValue ? this.maxFileSizeValue : (5 * 1024 * 1024)
const totalMaxSize = this.hasTotalMaxSizeValue ? this.totalMaxSizeValue : maxFileSize
// Calculate current total size
const currentTotalSize = this.uploadedPages.reduce((sum, page) => sum + page.size, 0)
files.forEach(file => {
// Validate file type
if (!this.isValidFile(file)) {
this.showError(`Invalid file type: ${file.name}. Please upload images (JPEG, PNG, GIF, BMP, WEBP) or PDF files.`)
return
}
// Validate individual file size
if (file.size > maxFileSize) {
const maxFormatted = this.formatBytes(maxFileSize)
const fileFormatted = this.formatBytes(file.size)
this.showError(`File too large: ${file.name} (${fileFormatted}). Maximum size per file is ${maxFormatted}.`)
return
}
// Check if adding this file would exceed total size limit
const newTotalSize = currentTotalSize + file.size
if (newTotalSize > totalMaxSize) {
const totalFormatted = this.formatBytes(newTotalSize)
const maxFormatted = this.formatBytes(totalMaxSize)
const currentFormatted = this.formatBytes(currentTotalSize)
const fileFormatted = this.formatBytes(file.size)
this.showError(
`Cannot add ${file.name} (${fileFormatted}). ` +
`Current total: ${currentFormatted}. ` +
`Adding this file would exceed the maximum total upload size of ${maxFormatted} ` +
`(would be ${totalFormatted}).`
)
return
}
// Add to uploaded pages
this.addPage(file)
})
// Clear input so same file can be selected again
event.target.value = ''
// Show total size info if we have files
if (this.uploadedPages.length > 0) {
this.updateTotalSizeDisplay()
}
}
isValidFile(file) {
const validTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
'image/webp',
'image/x-ms-bmp', // Alternative MIME type for BMP
'image/x-windows-bmp', // Another BMP variant
'application/pdf' // PDF files
]
return validTypes.includes(file.type) || file.name.toLowerCase().endsWith('.pdf')
}
async prepareUploadPages() {
const preparedPages = []
const totalImages = this.uploadedPages.filter(page => !page.isPdf).length
let preparedImages = 0
for (const page of this.uploadedPages) {
if (this.isDisconnected) {
throw new Error('Waiver upload was cancelled')
}
if (page.isPdf) {
preparedPages.push({ ...page, uploadFile: page.file })
continue
}
preparedImages++
const percent = Math.round(((preparedImages - 1) / Math.max(totalImages, 1)) * 100)
this.updateProcessingStatus(
'Preparing images',
percent,
`Optimizing image ${preparedImages} of ${totalImages} before upload...`
)
await this.waitForPaint()
try {
const uploadFile = await this.optimizeImage(page.file)
if (this.isDisconnected) {
throw new Error('Waiver upload was cancelled')
}
preparedPages.push({ ...page, uploadFile })
} catch (error) {
if (this.isDisconnected) {
throw error
}
console.warn(`Could not optimize ${page.name}; uploading the original file.`, error)
this.updateProcessingStatus(
'Preparing images',
percent,
`${page.name} could not be optimized and will be uploaded in its original form.`
)
preparedPages.push({ ...page, uploadFile: page.file })
}
}
const originalSize = this.uploadedPages.reduce((sum, page) => sum + page.file.size, 0)
const uploadSize = preparedPages.reduce((sum, page) => sum + page.uploadFile.size, 0)
const savedBytes = Math.max(0, originalSize - uploadSize)
const detail = savedBytes > 0
? `Ready to upload ${this.formatBytes(uploadSize)} (${this.formatBytes(savedBytes)} smaller).`
: `Ready to upload ${this.formatBytes(uploadSize)}.`
if (this.isDisconnected) {
throw new Error('Waiver upload was cancelled')
}
this.updateProcessingStatus('Images prepared', 100, detail)
return preparedPages
}
async optimizeImage(file) {
const image = await this.decodeImage(file)
const maxDimension = WaiverUploadWizardController.MAX_OPTIMIZED_IMAGE_DIMENSION
const scale = Math.min(1, maxDimension / Math.max(image.width, image.height))
const width = Math.max(1, Math.round(image.width * scale))
const height = Math.max(1, Math.round(image.height * scale))
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d', { alpha: false })
if (!context) {
if (typeof image.close === 'function') {
image.close()
}
throw new Error('Canvas image processing is unavailable')
}
canvas.width = width
canvas.height = height
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
context.drawImage(image, 0, 0, width, height)
if (typeof image.close === 'function') {
image.close()
}
const imageData = context.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let index = 0; index < pixels.length; index += 4) {
const grayscale = Math.round(
(pixels[index] * 0.299)
+ (pixels[index + 1] * 0.587)
+ (pixels[index + 2] * 0.114)
)
pixels[index] = grayscale
pixels[index + 1] = grayscale
pixels[index + 2] = grayscale
}
context.putImageData(imageData, 0, 0)
let blob
try {
blob = await this.canvasToBlob(
canvas,
'image/jpeg',
WaiverUploadWizardController.OPTIMIZED_IMAGE_QUALITY
)
} finally {
canvas.width = 0
canvas.height = 0
}
if (!blob || blob.size >= file.size) {
return file
}
const baseName = file.name.replace(/\.[^.]+$/, '') || 'waiver-page'
return new File([blob], `${baseName}.jpg`, {
type: 'image/jpeg',
lastModified: file.lastModified
})
}
async decodeImage(file) {
if (typeof createImageBitmap === 'function') {
return createImageBitmap(file)
}
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file)
const image = new Image()
image.onload = () => {
URL.revokeObjectURL(objectUrl)
resolve(image)
}
image.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error(`Unable to decode ${file.name}`))
}
image.src = objectUrl
})
}
canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob)
return
}
reject(new Error('Unable to create optimized image'))
}, type, quality)
})
}
waitForPaint() {
return new Promise(resolve => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve())
return
}
setTimeout(resolve, 0)
})
}
addPage(file) {
const pageNumber = this.uploadedPages.length + 1
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
const reader = new FileReader()
reader.onload = async (e) => {
const page = {
file: file,
dataUrl: e.target.result,
number: pageNumber,
name: file.name,
size: file.size,
isPdf: isPdf,
pdfPageCount: 0,
thumbnailUrl: null
}
// Generate thumbnail for PDFs
if (isPdf) {
try {
const pdfData = new Uint8Array(e.target.result.split(',')[1] ?
atob(e.target.result.split(',')[1]).split('').map(c => c.charCodeAt(0)) :
[])
if (pdfData.length > 0) {
const pdfjsLib = await loadPdfjsLib()
const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise
page.pdfPageCount = pdf.numPages
// Render first page as thumbnail
const pdfPage = await pdf.getPage(1)
const scale = 0.5
const viewport = pdfPage.getViewport({ scale })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await pdfPage.render({ canvasContext: context, viewport }).promise
page.thumbnailUrl = canvas.toDataURL('image/png')
}
} catch (err) {
console.warn('Could not generate PDF thumbnail:', err)
}
}
this.uploadedPages.push(page)
this.renderPages()
}
reader.readAsDataURL(file)
}
removePage(event) {
const index = parseInt(event.currentTarget.dataset.index)
this.uploadedPages.splice(index, 1)
// Renumber pages
this.uploadedPages.forEach((page, idx) => {
page.number = idx + 1
})
this.renderPages()
// Update total size display after removal
if (this.uploadedPages.length > 0) {
this.updateTotalSizeDisplay()
}
}
renderPages() {
if (!this.hasPagesPreviewTarget) return
if (this.uploadedPages.length === 0) {
this.pagesPreviewTarget.innerHTML = `
<div class="text-center text-muted py-5">
<i class="bi bi-file-earmark-image" style="font-size: 3rem;"></i>
<p class="mt-3">No pages added yet</p>
<p class="small">Click "Add Page" to select images or PDFs</p>
</div>
`
return
}
const html = this.uploadedPages.map((page, index) => {
// Generate preview: PDF thumbnail, PDF icon fallback, or image
let previewHtml
if (page.isPdf) {
if (page.thumbnailUrl) {
// Show rendered PDF thumbnail with page count badge
const pageCountBadge = page.pdfPageCount > 1
? `<span class="badge bg-danger position-absolute top-0 end-0 m-1">${page.pdfPageCount} pages</span>`
: ''
previewHtml = `<div class="position-relative">
<img src="${page.thumbnailUrl}"
class="img-thumbnail"
style="width: 100px; height: 130px; object-fit: contain; background: #f8f9fa;"
alt="PDF Preview">
${pageCountBadge}
</div>`
} else {
// Fallback to PDF icon
previewHtml = `<div class="d-flex align-items-center justify-content-center bg-light border rounded"
style="width: 100px; height: 130px;">
<div class="text-center">
<i class="bi bi-file-earmark-pdf text-danger" style="font-size: 2.5rem;"></i>
<div class="small text-muted mt-1">PDF</div>
</div>
</div>`
}
} else {
previewHtml = `<img src="${page.dataUrl}"
class="img-thumbnail"
style="width: 100px; height: 130px; object-fit: cover;"
alt="Page ${page.number}">`
}
// Show page count info for multi-page PDFs
const pageInfo = page.isPdf && page.pdfPageCount > 1
? `<small class="text-info">${page.pdfPageCount} pages</small><br>`
: ''
return `
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body p-2">
<div class="d-flex align-items-start">
<div class="flex-shrink-0">
${previewHtml}
</div>
<div class="flex-grow-1 ms-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>Page ${page.number}</strong><br>
${pageInfo}<small class="text-muted">${this.escapeHtml(page.name)}</small><br>
<small class="text-muted">${this.formatFileSize(page.size)}</small>
</div>
<button type="button"
class="btn btn-sm btn-outline-danger"
data-index="${index}"
data-action="click->waiver-upload-wizard#removePage">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`}).join('')
this.pagesPreviewTarget.innerHTML = html
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// Step 3: Review
updateReviewSection() {
// Waiver Type
if (this.hasReviewWaiverTypeTarget && this.selectedWaiverType) {
this.reviewWaiverTypeTarget.textContent = this.selectedWaiverType.name
}
// Show/hide sections based on mode
if (this.isAttestMode) {
// Attestation Mode
if (this.hasReviewUploadSectionTarget) {
this.reviewUploadSectionTarget.classList.add('d-none')
this.reviewUploadSectionTarget.setAttribute('aria-hidden', 'true')
}
if (this.hasReviewAttestSectionTarget) {
this.reviewAttestSectionTarget.classList.remove('d-none')
this.reviewAttestSectionTarget.setAttribute('aria-hidden', 'false')
}
// Display attestation reason
if (this.hasReviewAttestReasonTarget) {
this.reviewAttestReasonTarget.textContent = this.attestReason || 'Not selected'
}
// Get notes from attest section
if (this.hasAttestNotesTarget) {
this.attestNotes = this.attestNotesTarget.value
}
// Display attestation notes if provided
if (this.hasReviewAttestNotesTarget && this.hasReviewAttestNotesSectionTarget) {
if (this.attestNotes && this.attestNotes.trim().length > 0) {
this.reviewAttestNotesTarget.textContent = this.attestNotes
this.reviewAttestNotesSectionTarget.classList.remove('d-none')
this.reviewAttestNotesSectionTarget.setAttribute('aria-hidden', 'false')
} else {
this.reviewAttestNotesSectionTarget.classList.add('d-none')
this.reviewAttestNotesSectionTarget.setAttribute('aria-hidden', 'true')
}
}
} else {
// Upload Mode
if (this.hasReviewUploadSectionTarget) {
this.reviewUploadSectionTarget.classList.remove('d-none')
this.reviewUploadSectionTarget.setAttribute('aria-hidden', 'false')
}
if (this.hasReviewAttestSectionTarget) {
this.reviewAttestSectionTarget.classList.add('d-none')
this.reviewAttestSectionTarget.setAttribute('aria-hidden', 'true')
}
// Page Count - calculate total pages including multi-page PDFs
const totalPages = this.uploadedPages.reduce((sum, page) => {
return sum + (page.pdfPageCount > 1 ? page.pdfPageCount : 1)
}, 0)
if (this.hasReviewPageCountTarget) {
if (totalPages !== this.uploadedPages.length) {
this.reviewPageCountTarget.textContent = `${this.uploadedPages.length} files (${totalPages} total pages)`
} else {
this.reviewPageCountTarget.textContent = this.uploadedPages.length
}
}
// Pages List
if (this.hasReviewPagesListTarget) {
const html = this.uploadedPages.map(page => {
// Use thumbnail for PDFs, dataUrl for images
const imgSrc = page.isPdf && page.thumbnailUrl ? page.thumbnailUrl : page.dataUrl
const imgStyle = page.isPdf
? 'height: 200px; object-fit: contain; background: #f8f9fa;'
: 'height: 200px; object-fit: cover;'
// Show page count badge for multi-page PDFs
const pageCountBadge = page.isPdf && page.pdfPageCount > 1
? `<span class="badge bg-danger position-absolute top-0 end-0 m-2">${page.pdfPageCount} pages</span>`
: ''
// PDF icon overlay if no thumbnail available
const pdfFallback = page.isPdf && !page.thumbnailUrl
? `<div class="d-flex align-items-center justify-content-center" style="height: 200px; background: #f8f9fa;">
<div class="text-center">
<i class="bi bi-file-earmark-pdf text-danger" style="font-size: 4rem;"></i>
<div class="text-muted">PDF Document</div>
${page.pdfPageCount > 1 ? `<div class="text-info">${page.pdfPageCount} pages</div>` : ''}
</div>
</div>`
: `<img src="${imgSrc}" class="card-img-top" style="${imgStyle}" alt="Page ${page.number}">`
return `
<div class="col-md-3 mb-3">
<div class="card position-relative">
${pdfFallback}
${pageCountBadge}
<div class="card-body p-2 text-center">
<small>${page.isPdf ? 'PDF' : 'Page'} ${page.number}</small>
</div>
</div>
</div>
`}).join('')
this.reviewPagesListTarget.innerHTML = html
}
// Get notes if available
if (this.hasNotesFieldTarget) {
this.notes = this.notesFieldTarget.value
}
}
}
validateWaiverType() {
if (!this.selectedWaiverType) {
this.showError("Please select a waiver type")
return false
}
if (this.isWaiverTypeAttested(this.selectedWaiverType.id)) {
this.showError("This waiver type has been attested as not needed for this gathering.")
return false
}
return true
}
validateUploadOrAttest() {
if (this.isAttestMode) {
if (!this.attestReason) {
this.showError("Please select a reason for the exemption")
return false
}
return true
}
if (this.uploadedPages.length === 0) {
this.showError("Please add at least one page")
return false
}
return true
}
validateReview() {
return this.validateWaiverType() && this.validateUploadOrAttest()
}
// Form Submission
async submitForm(event) {
event.preventDefault()
if (!this.validateReview()) {
return
}
// Disable submit button and show processing page immediately
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = true
}
this.showProcessingStep()
// Track when we started (for minimum 2-second display)
const startTime = Date.now()
try {
if (this.isAttestMode) {
// Submit attestation
await this.submitAttestation(startTime)
} else {
// Submit waiver upload
await this.submitWaiverUpload(startTime)
}
} catch (error) {
if (this.isDisconnected) {
return
}
console.error('Submission error:', error)
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = false
}
if (this.hasSubmitButtonTextTarget) {
this.submitButtonTextTarget.textContent = this.isAttestMode ? 'Submit Attestation' : 'Submit Waivers'
}
this.restoreWizardAfterProcessing()
this.showError(error.message || 'An error occurred during submission. Please try again.')
}
}
async submitAttestation(startTime) {
const attestUrl = this.hasAttestUrlValue ? this.attestUrlValue : '/waivers/gathering-waivers/attest'
const response = await fetch(attestUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': this.getCsrfToken()
},
body: JSON.stringify({
gathering_id: this.gatheringIdValue,
waiver_type_id: this.selectedWaiverType.id,
reason: this.attestReason,
notes: this.attestNotes
})
})
try {
const data = await response.json()
if (response.ok && data.success) {
// Attestation succeeded
const elapsed = Date.now() - startTime
const remainingTime = Math.max(0, 2000 - elapsed)
setTimeout(() => {
if (this.isDisconnected) {
return
}
if (data.redirectUrl) {
window.location.href = data.redirectUrl
} else {
// Fallback redirect using gatheringViewUrl or construct from public_id
const fallbackUrl = this.hasGatheringViewUrlValue
? this.gatheringViewUrlValue
: `/gatherings/view/${this.gatheringPublicIdValue}?tab=gathering-waivers`
window.location.href = fallbackUrl
}
}, remainingTime)
} else {
// Attestation failed
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = false
}
if (this.hasSubmitButtonTextTarget) {
this.submitButtonTextTarget.textContent = 'Submit Attestation'
}
this.restoreWizardAfterProcessing()
this.showError(data.message || 'Attestation failed. Please try again.')
}
} catch (error) {
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = false
}
if (this.hasSubmitButtonTextTarget) {
this.submitButtonTextTarget.textContent = 'Submit Attestation'
}
this.restoreWizardAfterProcessing()
this.showError('Network error. Please try again.')
}
}
async submitWaiverUpload(startTime) {
const formData = new FormData()
const preparedPages = await this.prepareUploadPages()
if (this.isDisconnected) {
return
}
// Add gathering ID
formData.append('gathering_id', this.gatheringIdValue)
// Add waiver type
formData.append('waiver_type_id', this.selectedWaiverType.id)
// Add notes
formData.append('notes', this.notes)
// Add all page files
preparedPages.forEach(page => {
formData.append('waiver_images[]', page.uploadFile)
})
// If any PDF has a client-generated thumbnail, send the first one
const pdfWithThumbnail = this.uploadedPages.find(p => p.isPdf && p.thumbnailUrl)
if (pdfWithThumbnail) {
formData.append('client_thumbnail', pdfWithThumbnail.thumbnailUrl)
}
// Get CSRF token and add to form data (CakePHP expects it in the body)
const csrfToken = this.getCsrfToken()
if (csrfToken) {
formData.append('_csrfToken', csrfToken)
}
const response = await this.uploadFormData(formData)
if (response.ok) {
const data = response.data
this.updateProcessingStatus(
'Upload complete',
100,
'Your waiver was saved successfully. Redirecting...'
)
this.element.setAttribute('aria-busy', 'false')
// Calculate how long to wait (minimum 2 seconds total)
const elapsed = Date.now() - startTime
const remainingTime = Math.max(0, 2000 - elapsed)
// Wait for remaining time, then redirect
setTimeout(() => {
if (this.isDisconnected) {
return
}
if (data.redirectUrl) {
console.log('Redirecting to:', data.redirectUrl)
window.location.href = data.redirectUrl
} else {
// Fallback redirect using gatheringViewUrl or construct from gathering ID
const fallbackUrl = this.hasGatheringViewUrlValue
? this.gatheringViewUrlValue
: `/gatherings/view/${this.gatheringIdValue}`
window.location.href = fallbackUrl
}
}, remainingTime)
} else {
const data = response.data
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = false
}
if (this.hasSubmitButtonTextTarget) {
this.submitButtonTextTarget.textContent = 'Submit Waivers'
}
this.restoreWizardAfterProcessing()
this.showError(data.message || 'Upload failed. Please try again.')
}
}
uploadFormData(formData) {
return new Promise((resolve, reject) => {
if (this.isDisconnected) {
reject(new Error('Waiver upload was cancelled'))
return
}
const request = new XMLHttpRequest()
this.uploadRequest = request
request.open('POST', window.location.href)
request.setRequestHeader('X-Requested-With', 'XMLHttpRequest')
request.upload.addEventListener('progress', event => {
if (!event.lengthComputable) {
this.updateProcessingStatus(
'Uploading waiver',
null,
`Uploaded ${this.formatBytes(event.loaded)}...`
)
return
}
const percent = Math.round((event.loaded / event.total) * 100)
this.updateProcessingStatus(
'Uploading waiver',
percent,
`${this.formatBytes(event.loaded)} of ${this.formatBytes(event.total)} uploaded`
)
})
request.upload.addEventListener('load', () => {
this.updateProcessingStatus(
'Upload complete',
100,
'The server is converting and saving your waiver. Please keep this page open.'
)
})
request.addEventListener('load', () => {
this.uploadRequest = null
let data = {}
try {
data = request.responseText ? JSON.parse(request.responseText) : {}
} catch (error) {
console.error('Failed to parse waiver upload response:', error)
}
resolve({
ok: request.status >= 200 && request.status < 300,
status: request.status,
data
})
})
request.addEventListener('error', () => {
this.uploadRequest = null
reject(new Error('Network error while uploading waiver'))
})
request.addEventListener('abort', () => {
this.uploadRequest = null
reject(new Error('Waiver upload was cancelled'))
})
request.timeout = WaiverUploadWizardController.UPLOAD_TIMEOUT_MS
request.addEventListener('timeout', () => {
this.uploadRequest = null
reject(new Error('The waiver upload timed out. Please try again.'))
})
this.updateProcessingStatus(
'Uploading waiver',
0,
`Starting upload of ${this.formatBytes(Array.from(formData.values())
.filter(value => value instanceof File)
.reduce((sum, file) => sum + file.size, 0))}...`
)
request.send(formData)
})
}
showProcessingStep() {
// Show processing message based on mode
let processingHtml
if (this.isAttestMode) {
processingHtml = `
<div class="text-center py-5" data-waiver-processing tabindex="-1">
<div class="mb-4">
<div class="spinner-border text-primary" role="status" style="width: 5rem; height: 5rem;">
<span class="visually-hidden">Processing...</span>
</div>
</div>
<h2 class="mb-3">Processing Your Attestation</h2>
<p class="lead text-muted mb-4">
Please wait while we record your attestation...
</p>
<div class="alert alert-info d-inline-block">
<i class="bi bi-shield-check"></i>
Attesting that a waiver is not needed for this gathering
</div>
</div>
`
} else {
processingHtml = `
<div class="text-center py-5 px-3" data-waiver-processing tabindex="-1">
<div class="mb-4">
<div class="spinner-border text-primary" role="status" style="width: 5rem; height: 5rem;">
<span class="visually-hidden">Waiver upload in progress</span>
</div>
</div>
<h2 class="mb-3">Uploading Your Waiver</h2>
<p class="lead mb-3" role="status" aria-live="polite" aria-atomic="true"
data-waiver-processing-phase>
Preparing images
</p>
<div class="progress mx-auto mb-3" style="max-width: 32rem; height: 1.5rem;">
<div class="progress-bar progress-bar-striped" role="progressbar"
data-waiver-processing-progress style="width: 0%;"
aria-label="Waiver upload progress" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="0">
<span data-waiver-processing-percent>0%</span>
</div>
</div>
<p class="text-muted mb-3" data-waiver-processing-detail>
Preparing ${this.uploadedPages.length} page(s) for upload...
</p>
<p class="small text-muted mb-0">
Please keep this page open until the upload completes.
</p>
</div>
`
}
const container = this.element.querySelector('.wizard-container') || this.element
this.processingContainer = container
this.processingElementStates = Array.from(container.children).map(element => ({
element,
hidden: element.classList.contains('d-none'),
ariaHidden: element.getAttribute('aria-hidden')
}))
this.processingElementStates.forEach(({ element }) => {
element.classList.add('d-none')
element.setAttribute('aria-hidden', 'true')
})
container.insertAdjacentHTML('beforeend', processingHtml)
this.element.setAttribute('aria-busy', 'true')
this.processingRegion = container.querySelector('[data-waiver-processing]')
this.processingRegion?.focus()
}
restoreWizardAfterProcessing() {
this.processingRegion?.remove()
this.processingElementStates?.forEach(({ element, hidden, ariaHidden }) => {
element.classList.toggle('d-none', hidden)
if (ariaHidden === null) {
element.removeAttribute('aria-hidden')
} else {
element.setAttribute('aria-hidden', ariaHidden)
}
})
this.processingRegion = null
this.processingElementStates = null
this.processingContainer = null
this.element.setAttribute('aria-busy', 'false')
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.focus()
}
}
updateProcessingStatus(phase, percent, detail) {
const phaseElement = this.element.querySelector('[data-waiver-processing-phase]')
const detailElement = this.element.querySelector('[data-waiver-processing-detail]')
const progressElement = this.element.querySelector('[data-waiver-processing-progress]')
const percentElement = this.element.querySelector('[data-waiver-processing-percent]')
if (phaseElement && phaseElement.textContent.trim() !== phase) {
phaseElement.textContent = phase
}
if (detailElement) {
detailElement.textContent = detail
}
if (!progressElement || !percentElement) {
return
}
if (percent === null) {
progressElement.removeAttribute('aria-valuenow')
progressElement.classList.add('progress-bar-animated')
percentElement.textContent = ''
return
}
const boundedPercent = Math.max(0, Math.min(100, percent))
progressElement.classList.remove('progress-bar-animated')
progressElement.style.width = `${boundedPercent}%`
progressElement.setAttribute('aria-valuenow', boundedPercent)
percentElement.textContent = `${boundedPercent}%`
}
// Validation
validateCurrentStep() {
switch (this.currentStepValue) {
case 1: return this.validateWaiverType()
case 2: return this.validateUploadOrAttest()
case 3: return this.validateReview()
default: return true
}
}
// Error Handling
showError(message) {
if (this.isDisconnected) {
return
}
const escapedMessage = this.escapeHtml(message)
// Check if we're showing the processing screen (wizard container innerHTML was replaced)
const container = this.element.querySelector('.wizard-container') || this.element
const isProcessing = container.querySelector('[data-waiver-processing]') !== null
|| (container.querySelector('h2')
&& container.querySelector('h2').textContent.includes('Processing Your Attestation'))
if (isProcessing) {
this.element.setAttribute('aria-busy', 'false')
// We're in the processing screen, so we can't show a toast
// Check if we're in mobile mode by checking the URL
const isMobile = window.location.pathname.includes('mobile-upload')
if (isMobile) {
// Redirect to mobile card using mobileSelectUrl if available, otherwise construct URL
const mobileUrl = this.hasMobileSelectUrlValue
? `${this.mobileSelectUrlValue}?error=${encodeURIComponent(message)}`
: `/waivers/gathering-waivers/mobile-select-gathering?error=${encodeURIComponent(message)}`
window.location.href = mobileUrl
} else {
// Desktop mode - redirect back to the gathering with a flash message
const gatheringPublicId = this.gatheringPublicIdValue
const desktopUrl = this.hasGatheringViewUrlValue
? `${this.gatheringViewUrlValue}&error=${encodeURIComponent(message)}`
: `/gatherings/view/${gatheringPublicId}?tab=gathering-waivers&error=${encodeURIComponent(message)}`
window.location.href = desktopUrl
}
return
}
// Create toast notification
const toastHtml = `
<div class="toast align-items-center text-white bg-danger border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-exclamation-triangle me-2"></i>${escapedMessage}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`
// Add to toast container or create one
let toastContainer = document.querySelector('.toast-container')
if (!toastContainer) {
toastContainer = document.createElement('div')
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'
document.body.appendChild(toastContainer)
}
toastContainer.insertAdjacentHTML('beforeend', toastHtml)
const toastElement = toastContainer.lastElementChild
const toast = new bootstrap.Toast(toastElement)
toast.show()
// Remove after hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove()
})
}
getCsrfToken() {
// Try to get from meta tag first (CakePHP default)
const metaTag = document.querySelector('meta[name="csrf-token"]')
|| document.querySelector('meta[name="csrfToken"]')
if (metaTag) {
return metaTag.content
}
// Try to get from cookie as fallback
const match = document.cookie.match(/csrfToken=([^;]+)/)
if (match) {
return match[1]
}
// Try to get from hidden input in any form
const hiddenInput = document.querySelector('input[name="_csrfToken"]')
if (hiddenInput) {
return hiddenInput.value
}
console.error('CSRF token not found')
return ''
}
/**
* Format bytes to human-readable string
*
* @param {number} bytes - Size in bytes
* @param {number} decimals - Number of decimal places
* @returns {string} Formatted size string
*/
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
/**
* Update the display to show current total size
*/
updateTotalSizeDisplay() {
const totalSize = this.uploadedPages.reduce((sum, page) => sum + page.size, 0)
const totalFormatted = this.formatBytes(totalSize)
// If we're getting close to the limit, show a warning
if (this.hasTotalMaxSizeValue) {
const percentUsed = (totalSize / this.totalMaxSizeValue) * 100
if (percentUsed > 80 && percentUsed <= 100) {
// Show warning when using 80-100% of limit
const remaining = this.totalMaxSizeValue - totalSize
const remainingFormatted = this.formatBytes(remaining)
const maxFormatted = this.formatBytes(this.totalMaxSizeValue)
console.warn(
`Upload size warning: ${totalFormatted} of ${maxFormatted} used. ` +
`${remainingFormatted} remaining.`
)
}
}
}
}
// Register controller
if (!window.Controllers) {
window.Controllers = {}
}
window.Controllers["waiver-upload-wizard"] = WaiverUploadWizardController
export default WaiverUploadWizardController