Handle the booking cart entirely with HTMx
Besides the dynamic final cart, that was already handled by HTMx, i had
to check the maximum number of guests, whether the accommodation allows
“overflow”, whether dogs are allowed, and that the booking dates were
within the campground’s opening and closing dates.
I could do all of this with AlpineJS, but then i would have to add the
same validation to the backend, prior to accept the payment. Would not
make more sense to have them in a single place, namely the backend? With
HTMx i can do that.
However, i now have to create the form “piecemeal”, because i may not
have the whole information when the visitor arrives to the booking page,
and i still had the same problem as in commit d2858302efa—parsing the
whole form as is would leave guests and options field empty, rather than
at their minimum values.
One of the fieldsets in that booking form are the arrival and departure
dates, that are the sames we use in the campsite type’s page to
“preselect” these values. Since now are in a separate struct, i can
reuse the same type and validation logic for both pages, making my
JavaScript code useless, but requiring HTMx. I think this is a good
tradeoff, in fact.
2024-02-10 02:49:44 +00:00
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-FileCopyrightText: 2023 Oriol Carbonell <info@oriolcarbonell.cat>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.publicPage*/ -}}
{{ with .Form -}}
<fieldset
data-hx-get="/{{ currentLocale }}/booking"
data-hx-trigger="change"
>
<fieldset class="accommodation">
<legend>{{( pgettext "Accommodation" "title" )}}</legend>
{{ range .CampsiteType.Options -}}
<label><input type="radio" name="{{ $.Form.CampsiteType.Name }}" value="{{ .Value }}"
{{- if $.Form.CampsiteType.IsSelected .Value }} checked{{ end -}}
> {{ .Label }}</label><br>
{{- end }}
{{ template "error-message" .CampsiteType }}
</fieldset>
{{ with .Dates -}}
<fieldset class="booking-period">
<legend>{{( pgettext "Booking Period" "title" )}}</legend>
{{ with .ArrivalDate -}}
<label>
{{( pgettext "Arrival date" "input" )}}<br>
<input type="date" required
min="{{ formatDateAttr .MinDate }}"
max="{{ formatDateAttr .MaxDate }}"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .DepartureDate -}}
<label>
{{( pgettext "Departure date" "input" )}}<br>
<input type="date" required
min="{{ formatDateAttr .MinDate }}"
max="{{ formatDateAttr .MaxDate }}"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
{{- end }}
{{ with $guests := .Guests -}}
<fieldset class="guests campsite-options">
<legend>{{( pgettext "Guests" "title" )}}</legend>
{{ with .NumberAdults -}}
<label>
{{( pgettext "Adults aged 17 or older" "input" )}}<br>
<input type="number" required
name="{{ .Name }}" value="{{ .Val }}"
min="1"{{if not $guests.OverflowAllowed }} max="{{ $guests.MaxGuests }}"{{ end }}
{{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .NumberTeenagers -}}
<label>
{{( pgettext "Teenagers from 11 to 16 years old" "input" )}}<br>
<input type="number" required
name="{{ .Name }}" value="{{ .Val }}"
min="0"{{if not $guests.OverflowAllowed }} max="{{ $guests.MaxGuests | dec }}"{{ end }}
{{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .NumberChildren -}}
<label>
{{( pgettext "Children from 2 to 10 years old" "input" )}}<br>
<input type="number" required
name="{{ .Name }}" value="{{ .Val }}"
min="0"{{if not $guests.OverflowAllowed }} max="{{ $guests.MaxGuests | dec }}"{{ end }}
{{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
2024-02-11 21:06:00 +00:00
{{ if .Overflow -}}
<small>{{( gettext "Note: Due to guest capacity, we have added more accomodations to the booking, but we <strong>cannot</strong> guarantee that they will be next to each other." ) | raw}}</small>
{{- end }}
Handle the booking cart entirely with HTMx
Besides the dynamic final cart, that was already handled by HTMx, i had
to check the maximum number of guests, whether the accommodation allows
“overflow”, whether dogs are allowed, and that the booking dates were
within the campground’s opening and closing dates.
I could do all of this with AlpineJS, but then i would have to add the
same validation to the backend, prior to accept the payment. Would not
make more sense to have them in a single place, namely the backend? With
HTMx i can do that.
However, i now have to create the form “piecemeal”, because i may not
have the whole information when the visitor arrives to the booking page,
and i still had the same problem as in commit d2858302efa—parsing the
whole form as is would leave guests and options field empty, rather than
at their minimum values.
One of the fieldsets in that booking form are the arrival and departure
dates, that are the sames we use in the campsite type’s page to
“preselect” these values. Since now are in a separate struct, i can
reuse the same type and validation logic for both pages, making my
JavaScript code useless, but requiring HTMx. I think this is a good
tradeoff, in fact.
2024-02-10 02:49:44 +00:00
{{ if .Error -}}
<p class="error">{{ .Error }}</p>
{{- end }}
{{ if .NumberDogs -}}
{{ with .NumberDogs -}}
<label>
{{( pgettext "Dogs" "input" )}}<br>
<input type="number" required
name="{{ .Name }}" value="{{ .Val }}" min="0"
{{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{- else -}}
<small>{{( gettext "Note: This accommodation does <strong>not</strong> allow dogs.") | raw }}</small>
{{- end }}
</fieldset>
{{- end }}
{{ with .Options -}}
<fieldset class="campsite-options">
<legend>{{ .Legend }}</legend>
{{ with .ZonePreferences -}}
<label>
<span>
{{( pgettext "Area preferences (optional)" "input" )}}
<a href="/{{ currentLocale }}/campground?zones"
target="_blank">{{( gettext "Campground map" )}}</a>
</span><br>
<input type="text"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ range .Options -}}
<label>
{{ .Label }}<br>
<input type="number" required
name="{{ .Input.Name }}" value="{{ .Input.Val }}"
min="{{ .Min }}" max="{{ .Max }}"
{{ template "error-attrs" .Input }}
><br>
</label>
{{ template "error-message" .Input }}
{{- end }}
</fieldset>
{{- end }}
{{ with .Customer -}}
<fieldset class="customer-details">
<legend>{{( pgettext "Customer Details" "title" )}}</legend>
{{ with .FullName -}}
<label>
{{( pgettext "Full name" "input" )}}<br>
<input type="text" required autocomplete="name" minlength="2"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Address -}}
<label>
{{( pgettext "Address (optional)" "input" )}}<br>
<input type="text" autocomplete="billing street-address"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .PostalCode -}}
<label>
{{( pgettext "Postcode (optional)" "input" )}}<br>
<input type="text" autocomplete="billing postal-code"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .City -}}
<label>
{{( pgettext "Town or village (optional)" "input" )}}<br>
<input type="text"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Country -}}
<label>
{{( pgettext "Country" "input" )}}<br>
<select name="{{ .Name }}"
required autocomplete="country">
<option>{{( gettext "Choose a country" )}}</option>
{{ template "error-attrs" . }}>{{ template "list-options" . }}
</select><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Email -}}
<label>
{{( pgettext "Email" "input" )}}<br>
<input type="email" required autocomplete="email"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Phone -}}
<label>
{{( pgettext "Phone" "input" )}}<br>
<input type="tel" required autocomplete="tel"
name="{{ .Name }}" value="{{ .Val }}" {{ template "error-attrs" . }}
><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .ACSICard -}}
<label class="full-row">
<input type="checkbox" name="{{ .Name }}" {{ if .Checked}}checked{{ end }}
{{ template "error-attrs" . }}
> {{( pgettext "ACSI card? (optional)" "input" )}}</label><br>
{{ template "error-message" . }}
{{- end }}
{{ with .Agreement -}}
<label class="full-row">
<input type="checkbox" required name="{{ .Name }}" {{ if .Checked}}checked{{ end }}
{{ template "error-attrs" . }}
> {{ printf ( pgettext "I have read and I accept %[1]sthe reservation conditions%[2]s" "input" ) (printf "<a href=\"/%s/legal/reservation\" rel=\"terms-of-service\" target=\"_blank\">" currentLocale) (print "</a>") | raw }}
</label><br>
{{ template "error-message" . }}
{{- end }}
</fieldset>
{{- end }}
</fieldset>
{{ with .Cart -}}
<footer>
<dl class="cart">
{{ range .Lines -}}
<div>
<dt>{{ .Units}} x {{( pgettext .Concept "cart" )}}</dt>
<dd>{{ formatPrice .Subtotal }}</dd>
</div>
{{- end }}
<div class="total">
<dt>{{( pgettext "Total" "cart" )}}</dt>
<dd>{{ formatPrice .Total }}</dd>
</div>
</dl>
<div class="credit-cards">
<!-- @formatter:off -->
<svg role="img" viewBox="0 0 42.3907 13.69036"><title>VISA</title><path d="M16.09513.24198l-5.54984,13.24128h-3.62085L4.19335,2.91611c-.16581-.65087-.30998-.88932-.81425-1.16353-.8233-.44665-2.18289-.86569-3.37909-1.12578L.08125.24198h5.82847c.74294,0,1.41081.49456,1.57949,1.35009l1.44238,7.66208L12.49636.24176h3.59878v.00022ZM30.28226,9.1601c.01457-3.49479-4.83251-3.68731-4.79918-5.24847.01038-.47513.46276-.98028,1.45298-1.10922.4908-.06425,1.8431-.11326,3.37689.59258l.60164-2.80793c-.82419-.29916-1.88461-.58706-3.20401-.58706-3.38572,0-5.76842,1.79983-5.78851,4.37704-.02186,1.90625,1.7007,2.96998,2.99847,3.60341,1.33508.64866,1.78305,1.0655,1.77797,1.64572-.00949.88821-1.06484,1.28032-2.05108,1.29578-1.72167.02649-2.72072-.46585-3.51709-.83611l-.62084,2.90088c.80034.36716,2.27761.68752,3.80918.70364,3.59855,0,5.95255-1.77753,5.96359-4.53026M39.22267,13.48326h3.16803L39.62538.24198h-2.92406c-.65749,0-1.2121.38284-1.45761.97145l-5.14007,12.26983h3.59679l.71402-1.97778h4.3947l.41353,1.97778ZM35.40068,8.79161l1.80292-4.97161,1.03768,4.97161h-2.8406ZM20.98925.24198l-2.83244,13.24128h-3.42524L17.56511.24198h3.42414Z" fill="#1434cb" stroke-width="0"/></svg>
<svg role="img" viewBox="0 0 48 48"><title>MasterCard</title><path d="M31.09306,9.96519c7.83455,0,14.18572,6.2836,14.18572,14.03481s-6.35117,14.03481-14.18572,14.03481c-2.58375,0-5.00617-.68341-7.09266-1.87749l.06674-.03867c4.20318-2.43597,7.02592-6.95117,7.02592-12.11864s-2.82274-9.68267-7.02592-12.11864l-.06715-.03867h0c2.08689-1.19408,4.50931-1.87749,7.09306-1.87749Z" fill="#f5a623" fill-rule="evenodd" stroke-width="0"/><path d="M16.90694,38.03481c-7.83456,0-14.18572-6.2836-14.18572-14.03481s6.35116-14.03481,14.18572-14.03481c2.58376,0,5.00617.68341,7.09266,1.87749l-.06674.03867c-4.20318,2.43597-7.02592,6.95117-7.02592,12.11864s2.82274,9.68267,7.02592,12.11864l.06715.03867h0c-2.08689,1.19408-4.50931,1.87749-7.09306,1.87749Z" fill="#e00034" fill-rule="evenodd" stroke-width="0"/><path d="M24.00184,11.84385c4.23911,2.42694,7.09102,6.96204,7.09102,12.15615s-2.85191,9.72921-7.09102,12.15615c-4.24187-2.42549-7.0947-6.96121-7.0947-12.15615,0-5.16747,2.82274-9.68267,7.02591-12.11864l.06878-.0375Z" fill="#ff5a00" fill-rule="evenodd" stroke-width="0"/></svg>
<svg role="img" viewBox="0 0 48 48"><title>Maestro</title><path d="M15.44756,42.31041v-2.15301c.05204-.70249-.48134-1.31392-1.18383-1.35945-.05204-.00651-.11058-.00651-.16261,0-.48134-.03252-.94316.20164-1.20334.61143-.24067-.39678-.66997-.63094-1.13179-.61143-.40328-.01951-.78705.17562-1.00821.50736v-.4228h-.74802v3.4279h.75453v-1.89933c-.06505-.43581.24067-.84559.68298-.90413.05204-.00651.10407-.00651.16261-.00651.49435,0,.74802.32523.74802.90413v1.90583h.75453v-1.89933c-.05854-.44231.25368-.84559.69599-.90413.04553-.00651.09757-.00651.1431-.00651.50736,0,.75453.32523.75453.90413v1.90583h.74152ZM19.64299,40.59971v-1.7172h-.75453v.41629c-.25368-.33173-.65696-.52036-1.07976-.50085-.98869,0-1.79526.80656-1.79526,1.79526s.80656,1.79525,1.79526,1.79525c.41629.01301.81957-.16912,1.07976-.50085v.41629h.74802l.00651-1.70419ZM16.87205,40.59971c.03252-.5724.52687-1.00821,1.09927-.97568.5724.03252,1.0082.52687.97568,1.09927-.03252.54638-.48134.97568-1.02772.97568-.5659.01301-1.03422-.4358-1.04723-1.0017v-.09757h0ZM35.59865,38.80445c.24717,0,.48784.04553.7155.1366.22115.08456.41629.21465.58541.37726.16912.16261.29921.35775.39028.5724.18863.46182.18863.97568,0,1.4375-.09106.21465-.22116.40979-.39028.5724-.16912.16261-.36426.2927-.58541.37726-.47483.18213-.9952.18213-1.47003,0-.21465-.08456-.41629-.21465-.5789-.37726-.16261-.16261-.29271-.35775-.38377-.5724-.18863-.46182-.18863-.97568,0-1.4375.09106-.21465.22115-.40979.38377-.5724.16912-.16261.36426-.2927.5789-.37726.23416-.09757.48784-.14961.74802-.14961l.0065.01301ZM35.59865,39.51995c-.1431,0-.2927.02602-.4293.07805-.13009.05204-.24717.13009-.33824.22766-.09757.10407-.17562.22766-.22766.35775-.11058.2862-.11058.60492,0,.89112.05204.1366.13009.25368.22766.35775.09757.09757.21465.17562.33824.22766.27319.10407.5789.10407.8521,0,.1366-.05204.25368-.12359.35775-.22766.09757-.10407.17562-.22766.22766-.35775.11058-.2862.11058-.60492,0-.89112-.05204-.1366-.13009-.25368-.22766-.35775-.10407-.09757-.22766-.17562-.35775-.22766-.1366-.06505-.2797-.09757-.4293-.09757l.0065.01951ZM23.70183,40.59971c0-1.07975-.66997-1.79525-1.62614-1.79525-.98869.01301-1.78225.82608-1.76924,1.82127.01301.98869.82608,1.78225,1.82127,1.76924.51386.01951,1.00821-.15611,1.40498-.48134l-.35775-.55289c-.2862.22766-.63745.35125-.9952.35775-.51386.04553-.96918-.32523-1.02772-.83909h2.54328c.00651-.09106.00651-.18213.00651-.2797ZM21.14554,40.294c.02602-.47483.4293-.83909.90413-.82608.46833-.01301.8586.36425.87161.83258h0l-1.77574-.0065ZM26.84353,39.75412c-.32523-.18863-.69599-.2927-1.07975-.2927-.40979,0-.65046.14961-.65046.40328s.26018.2927.57891.33824l.35775.05204c.74802.11058,1.19684.4228,1.19684,1.02772s-.5724,1.11878-1.56109,1.11878c-.52687.01301-1.04723-.1431-1.48304-.44231l.35775-.57891c.33173.24717.72851.37076,1.1383.35775.50736,0,.78055-.14961.78055-.41629,0-.19514-.19514-.29921-.60492-.35775l-.35775-.05204c-.76754-.11058-1.18383-.45532-1.18383-1.01471,0-.68298.55939-1.09927,1.43751-1.09927.48784-.01951.97568.11058,1.39848.35775l-.32523.59842ZM30.42754,39.56549h-1.21635v1.54808c0,.35775.12359.5724.49435.5724.23416-.0065.46182-.07155.66346-.19514l.21465.63745c-.2797.17562-.60492.26669-.93015.26669-.88462,0-1.19033-.47483-1.19033-1.26839v-1.56109h-.69599v-.68298h.69599v-1.04073h.75453v1.04073h1.21635v.68298h-.00651ZM33.00984,38.79795c.18213,0,.35775.03252.53337.09106l-.22766.7155c-.1496-.05854-.30571-.09106-.46833-.08456-.48784,0-.7155.31872-.7155.88462v1.91884h-.74802v-3.4279h.74152v.41629c.18863-.31872.53337-.51386.90413-.50085l-.01951-.01301ZM38.17446,41.80956c.04553,0,.09106.00651.1366.02602.03903.01951.07805.03903.11057.07155.03252.03252.05854.06505.07806.11058.03903.08456.03903.18213,0,.26669-.01951.03903-.04553.07806-.07806.11058-.03252.03252-.07155.05204-.11057.07155-.04553.01951-.09106.02602-.1366.02602-.1366,0-.26669-.08456-.32523-.20815-.03903-.08456-.03903-.18213,0-.26669.01951-.03903.04553-.07805.07805-.11058.03252-.03252.07155-.05204.11058-.07155.03903-.01301.07805-.02602.11708-.02602h.01951ZM38.17446,42.41448c.03903,0,.07155-.00651.10407-.01951s.05854-.03252.08456-.05854c.10408-.104
<!-- @formatter:on -->
</div>
<button type="submit"{{if not .Enabled }} disabled{{ end }}>{{( pgettext "Book" "action" )}} <span>→</span>
</button>
</footer>
{{- end }}
{{- end }}