feat: groups price calculator admin crud, booking/offer flow and api
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user