feat: groups price calculator admin crud, booking/offer flow and api

This commit is contained in:
Björn Fromme
2026-08-03 15:25:10 +02:00
parent eabed8295a
commit 9d2aa11fdb
258 changed files with 16231 additions and 582 deletions
@@ -0,0 +1,84 @@
import { Controller } from '@hotwired/stimulus'
import { useFetch } from '../mixins/use_fetch'
import Autocomplete from '@trevoreyre/autocomplete-js'
export default class extends Controller {
static targets = [ 'input', 'field', 'resetButton', 'searchIcon' ]
static values = { url: String, choices: Array, selected: Boolean }
initialize() {
useFetch(this)
}
connect() {
const searchHandler = input => {
return new Promise(resolve => {
// Check input minimum length
if (input.length < 2) {
return resolve([])
}
// Search choices if provided
if (this.choicesValue.length > 0) {
return resolve(this.choicesValue.filter(choice => {
return -1 !== choice.text.toLowerCase().search(input.toLowerCase())
}))
}
// Use API otherwise
let data = { search: input }
fetch(this.urlValue, { method: 'POST', body: JSON.stringify(data), credentials: 'include' })
.then(this.checkStatus)
.then(this.parseJSON)
.then(data => {
resolve(data)
})
})
}
const submitHandler = result => {
this.fieldTarget.value = result.value
this.selectedValue = true
this.element.dispatchEvent(new CustomEvent('select', {
detail: { result }
}))
}
this.autocomplete = new Autocomplete(this.element, {
search: searchHandler,
getResultValue: result => result.text,
submitOnEnter: true,
onSubmit: submitHandler,
debounceTime: 250,
})
if (this.fieldTarget.value) {
this.selectedValue = true
}
// prevent form submission when selecting entry in autocomplete list with enter key
this.element.addEventListener('keypress', e => {
if (e.key === 'Enter') {
e.preventDefault()
}
})
}
reset() {
this.inputTarget.value = null
this.fieldTarget.value = null
this.selectedValue = false
this.element.dispatchEvent(new CustomEvent('reset'))
}
selectedValueChanged() {
this.resetButtonTarget.classList.toggle('hidden', false === this.selectedValue)
this.searchIconTarget.classList.toggle('hidden', this.selectedValue)
}
disconnect() {
this.autocomplete.destroy()
}
}
@@ -0,0 +1,37 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
content: String,
label: String,
}
connect() {
this.originalLabel = this.element.innerText
}
disconnect() {
clearTimeout(this.resetTimer)
}
copy() {
navigator.clipboard.writeText(this.contentValue).then(() => {
this.dispatch('copied', {
detail: {
content: this.contentValue,
}
})
}).catch((error) => {
console.error('Failed to copy to clipboard:', error)
})
if (this.hasLabelValue) {
this.element.innerText = this.labelValue
clearTimeout(this.resetTimer)
this.resetTimer = setTimeout(() => {
this.element.innerText = this.originalLabel
}, 3000)
}
}
}
@@ -0,0 +1,70 @@
import { Controller } from '@hotwired/stimulus'
import { Calendar } from 'vanilla-calendar-pro'
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['field', 'hiddenField']
static values = {
minDate: String,
maxDate: String,
disableWeekends: Boolean,
disabledDates: Array,
}
connect() {
const options = {
inputMode: true,
locale: 'de-DE',
selectedTheme: 'light',
selectedDates: this.hiddenFieldTarget.value ? [this.hiddenFieldTarget.value] : [],
onClickDate: (self) => {
const iso = self.context.selectedDates[0] ?? null
this.hiddenFieldTarget.value = iso ?? ''
if (iso) {
const [year, month, day] = iso.split('-')
this.fieldTarget.value = `${day}.${month}.${year}`
} else {
this.fieldTarget.value = ''
}
this.element.dispatchEvent(new CustomEvent('datepicker:picked', {
detail: { date: this.fieldTarget.value }
}))
},
onHide: () => {
this.element.dispatchEvent(new CustomEvent('datepicker:closed'))
},
}
if (this.minDateValue) options.dateMin = this.minDateValue
if (this.maxDateValue) options.dateMax = this.maxDateValue
if (this.disableWeekendsValue) options.disableWeekdays = [0, 6]
if (this.disabledDatesValue.length) options.disableDates = this.disabledDatesValue
this.picker = new Calendar(this.fieldTarget, options)
this.picker.init()
this.handleManualInput = () => {
const value = this.fieldTarget.value.trim()
if (!value) {
this.hiddenFieldTarget.value = ''
this.picker.set({ selectedDates: [] }, { dates: true })
} else {
const match = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/)
if (match) {
const [, day, month, year] = match
const iso = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
this.hiddenFieldTarget.value = iso
this.picker.set({ selectedDates: [iso] }, { dates: true })
}
}
this.element.dispatchEvent(new CustomEvent('datepicker:closed'))
}
this.fieldTarget.addEventListener('change', this.handleManualInput)
}
disconnect() {
this.fieldTarget.removeEventListener('change', this.handleManualInput)
this.picker.destroy()
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Controller } from '@hotwired/stimulus'
import GLightbox from 'glightbox'
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['item']
connect() {
this.lightbox = GLightbox({
elements: this.itemTargets.map((el) => ({
href: el.dataset.galleryHref,
type: 'image',
alt: el.dataset.galleryAlt ?? '',
})),
})
this.itemTargets.forEach((el, index) => {
el.addEventListener('click', (e) => {
e.preventDefault()
this.lightbox.openAt(index)
})
})
}
disconnect() {
this.lightbox.destroy()
}
}
@@ -0,0 +1,36 @@
import { Controller } from '@hotwired/stimulus'
import { useClickOutside } from 'stimulus-use'
export default class extends Controller {
static targets = [ 'label', 'dropdown', 'choice' ]
static values = { placeholder: String }
connect() {
this.update()
useClickOutside(this)
}
toggle() {
this.dropdownTarget.classList.toggle('hidden')
}
update() {
let labels = []
this.choiceTargets.forEach((choice) => {
if (choice.checked) {
labels.push(choice.dataset.label)
}
this.labelTarget.innerText = labels.join(', ')
})
if (0 === labels.length) {
this.labelTarget.innerText = this.placeholderValue
}
}
clickOutside(e) {
this.dropdownTarget.classList.toggle('hidden', true)
}
}
@@ -0,0 +1,166 @@
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['mount', 'error', 'message', 'resetButton', 'day', 'dateFrom', 'dateTo', 'submit']
static values = {
hotelCode: String,
selectedFrom: String,
selectedTo: String,
calendarRefreshUrl: String,
}
connect() {
this.startDate = null
this.endDate = null
this.sessionRestored = false
// Re-apply highlights after every HTMX month swap (navigation or initial load).
this.mountTarget.addEventListener('htmx:afterSwap', () => requestAnimationFrame(() => this.initAfterLoad()))
this.mountTarget.addEventListener('htmx:responseError', () => this.showError())
}
initAfterLoad() {
// Restore from session values exactly once on the first swap.
if (!this.sessionRestored) {
this.sessionRestored = true
const from = this.selectedFromValue
const to = this.selectedToValue
if (from && to) {
this.startDate = from
this.endDate = to
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden')
}
}
this.updateDisplay()
}
selectDate(event) {
const btn = event.currentTarget
btn.blur()
const date = btn.dataset.date
const status = btn.dataset.status
if (!status || status === 'blocked' || status === 'past') return
if (this.startDate && this.endDate) {
this.resetSelection()
}
if (!this.startDate) {
if (status === 'checkout-only') {
this.showMessage('Bitte wähle einen Anreisetag.')
return
}
this.startDate = date
this.endDate = null
this.updateDisplay()
this.showMessage('Bitte wähle das Abreisedatum.')
return
}
if (date === this.startDate) {
this.resetSelection()
return
}
const [from, to] = date < this.startDate ? [date, this.startDate] : [this.startDate, date]
if (this.hasBlockedInRange(from, to)) {
this.resetSelection()
this.showMessage('Dieser Zeitraum enthält nicht verfügbare Daten.')
return
}
const startBtn = this.dayTargets.find(b => b.dataset.date === from)
const minNights = parseInt(startBtn?.dataset.minNights) || 0
const nights = (new Date(to) - new Date(from)) / 86400000
if (minNights > 0 && nights < minNights) {
this.showMessage(`Weniger als ${minNights} Nächte nur auf Anfrage.`)
} else {
this.hideMessage()
}
this.startDate = from
this.endDate = to
this.updateDisplay()
this.activateSubmit(from, to)
this.saveAndRefreshSummary(from, to)
this.dispatch('range-select', { detail: { dateFrom: from, dateTo: to } })
}
hasBlockedInRange(from, to) {
for (const btn of this.dayTargets) {
const d = btn.dataset.date
const s = btn.dataset.status
if (d >= from && d < to && (s === 'blocked' || s === 'past' || s === 'checkout-only')) return true
}
return false
}
updateDisplay() {
for (const btn of this.dayTargets) {
const d = btn.dataset.date
btn.classList.remove('pc-day--pending-start', 'pc-day--selected-start', 'pc-day--selected-end', 'pc-day--in-range')
if (!this.startDate) continue
if (d === this.startDate) {
btn.classList.add(this.endDate ? 'pc-day--selected-start' : 'pc-day--pending-start')
} else if (this.endDate) {
if (d === this.endDate) btn.classList.add('pc-day--selected-end')
else if (d > this.startDate && d < this.endDate) btn.classList.add('pc-day--in-range')
}
}
}
activateSubmit(dateFrom, dateTo) {
if (this.hasDateFromTarget) this.dateFromTarget.value = dateFrom
if (this.hasDateToTarget) this.dateToTarget.value = dateTo
if (this.hasSubmitTarget) {
this.submitTarget.disabled = false
this.submitTarget.classList.remove('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
}
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.remove('hidden')
}
resetSelection() {
this.startDate = null
this.endDate = null
this.updateDisplay()
this.hideMessage()
if (this.hasDateFromTarget) this.dateFromTarget.value = ''
if (this.hasDateToTarget) this.dateToTarget.value = ''
if (this.hasSubmitTarget) {
this.submitTarget.disabled = true
this.submitTarget.classList.add('opacity-50', 'cursor-not-allowed', 'pointer-events-none')
}
if (this.hasResetButtonTarget) this.resetButtonTarget.classList.add('hidden')
this.saveAndRefreshSummary()
}
reset() {
this.resetSelection()
}
saveAndRefreshSummary(dateFrom = '', dateTo = '') {
if (!this.hasCalendarRefreshUrlValue) return
htmx.ajax('POST', this.calendarRefreshUrlValue, {
values: { date_from: dateFrom, date_to: dateTo },
swap: 'none',
target: this.element,
})
}
showMessage(text) {
if (this.hasMessageTarget) this.messageTarget.textContent = text
}
hideMessage() {
if (this.hasMessageTarget) this.messageTarget.textContent = ''
}
showError() {
if (this.hasErrorTarget) this.errorTarget.classList.remove('hidden')
}
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { Controller } from '@hotwired/stimulus'
import tippy from 'tippy.js'
import tippy, { hideAll } from 'tippy.js'
export default class extends Controller {
static targets = ['trigger', 'template']
@@ -27,6 +27,7 @@ export default class extends Controller {
allowHTML: true,
interactive: true,
appendTo: document.body,
onShow(instance) { hideAll({ exclude: instance }) },
}
}
if (this.hasTriggerTarget) {