545 lines
17 KiB
Go
545 lines
17 KiB
Go
/*
|
||
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
|
||
* SPDX-License-Identifier: AGPL-3.0-only
|
||
*/
|
||
|
||
package booking
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"dev.tandem.ws/tandem/camper/pkg/auth"
|
||
"dev.tandem.ws/tandem/camper/pkg/database"
|
||
"dev.tandem.ws/tandem/camper/pkg/form"
|
||
httplib "dev.tandem.ws/tandem/camper/pkg/http"
|
||
"dev.tandem.ws/tandem/camper/pkg/locale"
|
||
"dev.tandem.ws/tandem/camper/pkg/template"
|
||
)
|
||
|
||
type PublicHandler struct {
|
||
}
|
||
|
||
func NewPublicHandler() *PublicHandler {
|
||
return &PublicHandler{}
|
||
}
|
||
|
||
func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var head string
|
||
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
|
||
|
||
switch head {
|
||
case "":
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
page, err := newPublicPage(r, company, conn, user.Locale)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
page.MustRender(w, r, user, company, conn)
|
||
case http.MethodPost:
|
||
requestPayment(w, r, user, company, conn)
|
||
default:
|
||
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||
}
|
||
default:
|
||
http.NotFound(w, r)
|
||
}
|
||
})
|
||
}
|
||
|
||
type publicPage struct {
|
||
*template.PublicPage
|
||
Form *bookingForm
|
||
}
|
||
|
||
func newPublicPage(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*publicPage, error) {
|
||
f, err := newBookingForm(r, company, conn, l)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return newPublicPageWithForm(f), nil
|
||
}
|
||
|
||
func newPublicPageWithForm(form *bookingForm) *publicPage {
|
||
return &publicPage{
|
||
PublicPage: template.NewPublicPage(),
|
||
Form: form,
|
||
}
|
||
}
|
||
|
||
func (p *publicPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
|
||
p.Setup(r, user, company, conn)
|
||
var err error
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
if httplib.IsHTMxRequest(r) {
|
||
template.MustRenderPublicNoLayout(w, r, user, company, "booking/fields.gohtml", p)
|
||
} else {
|
||
template.MustRenderPublicFiles(w, r, user, company, p, "booking/page.gohtml", "booking/fields.gohtml")
|
||
}
|
||
}
|
||
|
||
type bookingForm struct {
|
||
CampsiteType *form.Select
|
||
PaymentSlug *form.Input
|
||
Dates *DateFields
|
||
Guests *bookingGuestFields
|
||
Options *bookingOptionFields
|
||
Customer *bookingCustomerFields
|
||
Cart *bookingCart
|
||
}
|
||
|
||
type DateFields struct {
|
||
MinNights int
|
||
MaxNights int
|
||
ArrivalDate *bookingDateInput
|
||
DepartureDate *bookingDateInput
|
||
}
|
||
|
||
type bookingDateInput struct {
|
||
*form.Input
|
||
MinDate time.Time
|
||
MaxDate time.Time
|
||
}
|
||
|
||
type bookingGuestFields struct {
|
||
MaxGuests int
|
||
OverflowAllowed bool
|
||
Overflow bool
|
||
NumberAdults *form.Input
|
||
NumberTeenagers *form.Input
|
||
NumberChildren *form.Input
|
||
NumberDogs *form.Input
|
||
Error error
|
||
}
|
||
|
||
type bookingOptionFields struct {
|
||
Legend string
|
||
ZonePreferences *form.Input
|
||
Options []*campsiteTypeOption
|
||
}
|
||
|
||
type campsiteTypeOption struct {
|
||
ID int
|
||
Label string
|
||
Min int
|
||
Max int
|
||
Input *form.Input
|
||
}
|
||
|
||
type bookingCustomerFields struct {
|
||
FullName *form.Input
|
||
Address *form.Input
|
||
PostalCode *form.Input
|
||
City *form.Input
|
||
Country *form.Select
|
||
Email *form.Input
|
||
Phone *form.Input
|
||
ACSICard *form.Checkbox
|
||
Agreement *form.Checkbox
|
||
}
|
||
|
||
func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*bookingForm, error) {
|
||
if err := r.ParseForm(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
f := &bookingForm{
|
||
CampsiteType: &form.Select{
|
||
Name: "campsite_type",
|
||
Options: form.MustGetOptions(r.Context(), conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID),
|
||
},
|
||
PaymentSlug: &form.Input{
|
||
Name: "payment_slug",
|
||
},
|
||
}
|
||
f.CampsiteType.FillValue(r)
|
||
f.PaymentSlug.FillValue(r)
|
||
campsiteType := f.CampsiteType.String()
|
||
if campsiteType == "" {
|
||
return f, nil
|
||
}
|
||
if !f.CampsiteType.ValidOptionsSelected() {
|
||
f.CampsiteType.Error = errors.New(l.Gettext("Selected campsite type is not valid."))
|
||
return f, nil
|
||
}
|
||
|
||
var err error
|
||
f.Dates, err = NewDateFields(r.Context(), conn, campsiteType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
f.Dates.FillValues(r, l)
|
||
if f.Dates.ArrivalDate.Val == "" || f.Dates.ArrivalDate.Error != nil || f.Dates.DepartureDate.Val == "" || f.Dates.DepartureDate.Error != nil {
|
||
return f, nil
|
||
}
|
||
|
||
f.Guests, err = newBookingGuestFields(r.Context(), conn, campsiteType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
f.Guests.FillValues(r, l)
|
||
|
||
f.Options, err = newBookingOptionFields(r.Context(), conn, campsiteType, l)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if f.Options != nil {
|
||
f.Options.FillValues(r)
|
||
}
|
||
|
||
f.Customer = newBookingCustomerFields(r.Context(), conn, l)
|
||
f.Customer.FillValues(r)
|
||
|
||
f.Cart, err = newBookingCart(r.Context(), conn, f, campsiteType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return f, nil
|
||
}
|
||
|
||
func (f *bookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
|
||
v := form.NewValidator(l)
|
||
|
||
if f.Dates == nil {
|
||
return false, errors.New("no booking date fields")
|
||
}
|
||
if f.Guests == nil {
|
||
return false, errors.New("no guests fields")
|
||
}
|
||
if f.Customer == nil {
|
||
return false, errors.New("no customer fields")
|
||
}
|
||
if f.Cart == nil {
|
||
return false, errors.New("no booking cart")
|
||
}
|
||
|
||
v.CheckSelectedOptions(f.CampsiteType, l.GettextNoop("Selected campsite type is not valid."))
|
||
f.Dates.Valid(v, l)
|
||
f.Guests.Valid(v, l)
|
||
f.Options.Valid(v, l)
|
||
if f.Options != nil {
|
||
if err := f.Customer.Valid(ctx, conn, v, l); err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
return v.AllOK, nil
|
||
}
|
||
|
||
func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string) (*DateFields, error) {
|
||
row := conn.QueryRow(ctx, `
|
||
select lower(bookable_nights),
|
||
upper(bookable_nights) - 1,
|
||
greatest(min(lower(season_range)), current_timestamp::date),
|
||
max(upper(season_range))
|
||
from campsite_type
|
||
join campsite_type_cost using (campsite_type_id)
|
||
join season_calendar using (season_id)
|
||
where campsite_type.slug = $1
|
||
and season_range >> daterange(date_trunc('year', current_timestamp)::date, date_trunc('year', current_timestamp)::date + 1)
|
||
group by bookable_nights;
|
||
`, campsiteType)
|
||
f := &DateFields{
|
||
ArrivalDate: &bookingDateInput{
|
||
Input: &form.Input{Name: "arrival_date"},
|
||
},
|
||
DepartureDate: &bookingDateInput{
|
||
Input: &form.Input{Name: "departure_date"},
|
||
},
|
||
}
|
||
if err := row.Scan(&f.MinNights, &f.MaxNights, &f.ArrivalDate.MinDate, &f.DepartureDate.MaxDate); err != nil {
|
||
return nil, err
|
||
}
|
||
f.ArrivalDate.MaxDate = f.DepartureDate.MaxDate.AddDate(0, 0, -f.MinNights)
|
||
f.DepartureDate.MinDate = f.ArrivalDate.MinDate.AddDate(0, 0, f.MinNights)
|
||
|
||
return f, nil
|
||
}
|
||
|
||
func (f *DateFields) FillValues(r *http.Request, l *locale.Locale) {
|
||
f.ArrivalDate.FillValue(r)
|
||
f.DepartureDate.FillValue(r)
|
||
|
||
if f.ArrivalDate.Val != "" {
|
||
arrivalDate, err := time.Parse(database.ISODateFormat, f.ArrivalDate.Val)
|
||
if err != nil {
|
||
f.ArrivalDate.Error = errors.New(l.Gettext("Arrival date must be a valid date."))
|
||
return
|
||
}
|
||
f.DepartureDate.MinDate = arrivalDate.AddDate(0, 0, f.MinNights)
|
||
maxDate := arrivalDate.AddDate(0, 0, f.MaxNights)
|
||
if maxDate.Before(f.DepartureDate.MaxDate) {
|
||
f.DepartureDate.MaxDate = maxDate
|
||
}
|
||
|
||
if f.DepartureDate.Val == "" {
|
||
f.DepartureDate.Val = f.DepartureDate.MinDate.Format(database.ISODateFormat)
|
||
} else {
|
||
departureDate, err := time.Parse(database.ISODateFormat, f.DepartureDate.Val)
|
||
if err != nil {
|
||
f.DepartureDate.Error = errors.New(l.Gettext("Departure date must be a valid date."))
|
||
return
|
||
}
|
||
if departureDate.Before(f.DepartureDate.MinDate) {
|
||
f.DepartureDate.Val = f.DepartureDate.MinDate.Format(database.ISODateFormat)
|
||
} else if departureDate.After(f.DepartureDate.MaxDate) {
|
||
f.DepartureDate.Val = f.DepartureDate.MaxDate.Format(database.ISODateFormat)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (f *DateFields) Valid(v *form.Validator, l *locale.Locale) {
|
||
var validBefore bool
|
||
if v.CheckRequired(f.ArrivalDate.Input, l.GettextNoop("Arrival date can not be empty")) {
|
||
if v.CheckValidDate(f.ArrivalDate.Input, l.GettextNoop("Arrival date must be a valid date.")) {
|
||
if v.CheckMinDate(f.ArrivalDate.Input, f.ArrivalDate.MinDate, fmt.Sprintf(l.Gettext("Arrival date must be %s or after."), f.ArrivalDate.MinDate.Format(database.ISODateFormat))) {
|
||
v.CheckMaxDate(f.ArrivalDate.Input, f.ArrivalDate.MaxDate, fmt.Sprintf(l.Gettext("Arrival date must be %s or before."), f.ArrivalDate.MaxDate.Format(database.ISODateFormat)))
|
||
}
|
||
}
|
||
}
|
||
if v.CheckRequired(f.DepartureDate.Input, l.GettextNoop("Departure date can not be empty")) {
|
||
if v.CheckValidDate(f.DepartureDate.Input, l.GettextNoop("Departure date must be a valid date.")) && validBefore {
|
||
if v.CheckMinDate(f.DepartureDate.Input, f.DepartureDate.MinDate, fmt.Sprintf(l.Gettext("Departure date must be %s or after."), f.DepartureDate.MinDate.Format(database.ISODateFormat))) {
|
||
v.CheckMaxDate(f.DepartureDate.Input, f.DepartureDate.MaxDate, fmt.Sprintf(l.Gettext("Departure date must be %s or before."), f.DepartureDate.MaxDate.Format(database.ISODateFormat)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteType string) (*bookingGuestFields, error) {
|
||
f := &bookingGuestFields{
|
||
NumberAdults: &form.Input{Name: "number_adults"},
|
||
NumberTeenagers: &form.Input{Name: "number_teenagers"},
|
||
NumberChildren: &form.Input{Name: "number_children"},
|
||
}
|
||
row := conn.QueryRow(ctx, `
|
||
select max_campers
|
||
, overflow_allowed
|
||
, pet.cost_per_night is not null as dogs_allowed
|
||
from campsite_type
|
||
left join campsite_type_pet_cost as pet using (campsite_type_id)
|
||
where slug = $1
|
||
`, campsiteType)
|
||
var dogsAllowed bool
|
||
if err := row.Scan(&f.MaxGuests, &f.OverflowAllowed, &dogsAllowed); err != nil {
|
||
return nil, err
|
||
}
|
||
if dogsAllowed {
|
||
f.NumberDogs = &form.Input{Name: "number_dogs"}
|
||
}
|
||
return f, nil
|
||
}
|
||
|
||
func (f *bookingGuestFields) FillValues(r *http.Request, l *locale.Locale) {
|
||
numGuests := 0
|
||
numGuests += fillNumericField(f.NumberAdults, r, 1)
|
||
numGuests += fillNumericField(f.NumberTeenagers, r, 0)
|
||
numGuests += fillNumericField(f.NumberChildren, r, 0)
|
||
if f.NumberDogs != nil {
|
||
fillNumericField(f.NumberDogs, r, 0)
|
||
}
|
||
if numGuests > f.MaxGuests {
|
||
if f.OverflowAllowed {
|
||
f.Overflow = true
|
||
} else {
|
||
f.Error = fmt.Errorf(l.Gettext("There can be at most %d guests in this accommodation."), f.MaxGuests)
|
||
}
|
||
}
|
||
}
|
||
|
||
func fillNumericField(input *form.Input, r *http.Request, min int) int {
|
||
input.FillValue(r)
|
||
if input.Val == "" {
|
||
input.Val = strconv.Itoa(min)
|
||
return min
|
||
}
|
||
val, err := strconv.Atoi(input.Val)
|
||
if err != nil {
|
||
input.Val = strconv.Itoa(min)
|
||
val = min
|
||
}
|
||
return val
|
||
}
|
||
|
||
func (f *bookingGuestFields) Valid(v *form.Validator, l *locale.Locale) {
|
||
if v.CheckRequired(f.NumberAdults, l.GettextNoop("Number of adults can not be empty")) {
|
||
if v.CheckValidInteger(f.NumberAdults, l.GettextNoop("Number of adults must be an integer.")) {
|
||
v.CheckMinInteger(f.NumberAdults, 1, l.GettextNoop("There must be at least one adult."))
|
||
}
|
||
}
|
||
if v.CheckRequired(f.NumberTeenagers, l.GettextNoop("Number of teenagers can not be empty")) {
|
||
if v.CheckValidInteger(f.NumberTeenagers, l.GettextNoop("Number of teenagers must be an integer.")) {
|
||
v.CheckMinInteger(f.NumberTeenagers, 0, l.GettextNoop("Number of teenagers can not be negative."))
|
||
}
|
||
}
|
||
if v.CheckRequired(f.NumberChildren, l.GettextNoop("Number of children can not be empty")) {
|
||
if v.CheckValidInteger(f.NumberChildren, l.GettextNoop("Number of children must be an integer.")) {
|
||
v.CheckMinInteger(f.NumberChildren, 0, l.GettextNoop("Number of children can not be negative."))
|
||
}
|
||
}
|
||
if v.CheckRequired(f.NumberDogs, l.GettextNoop("Number of dogs can not be empty")) {
|
||
if v.CheckValidInteger(f.NumberDogs, l.GettextNoop("Number of dogs must be an integer.")) {
|
||
v.CheckMinInteger(f.NumberDogs, 0, l.GettextNoop("Number of dogs can not be negative."))
|
||
}
|
||
}
|
||
}
|
||
|
||
func newBookingOptionFields(ctx context.Context, conn *database.Conn, campsiteType string, l *locale.Locale) (*bookingOptionFields, error) {
|
||
f := &bookingOptionFields{}
|
||
|
||
row := conn.QueryRow(ctx, `
|
||
select coalesce(i18n.name, campsite_type.name)
|
||
, ask_zone_preferences
|
||
from campsite_type
|
||
left join campsite_type_i18n as i18n on i18n.campsite_type_id = campsite_type.campsite_type_id and i18n.lang_tag = $1
|
||
where slug = $2
|
||
`, l.Language, campsiteType)
|
||
var askZonePreferences bool
|
||
if err := row.Scan(&f.Legend, &askZonePreferences); err != nil {
|
||
return nil, err
|
||
}
|
||
if askZonePreferences {
|
||
f.ZonePreferences = &form.Input{Name: "zone_preferences"}
|
||
}
|
||
|
||
rows, err := conn.Query(ctx, `
|
||
select option.campsite_type_option_id
|
||
, 'campsite_type_option_' || option.campsite_type_option_id
|
||
, coalesce(i18n.name, option.name) as l10_name
|
||
, lower(range)::text
|
||
, lower(range)
|
||
, upper(range)
|
||
from campsite_type_option as option
|
||
join campsite_type using (campsite_type_id)
|
||
left join campsite_type_option_i18n as i18n on i18n.campsite_type_option_id = option.campsite_type_option_id and i18n.lang_tag = $1
|
||
where slug = $2
|
||
and campsite_type.active
|
||
order by option.position, l10_name
|
||
`, l.Language, campsiteType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
option := &campsiteTypeOption{
|
||
Input: &form.Input{},
|
||
}
|
||
if err = rows.Scan(&option.ID, &option.Input.Name, &option.Label, &option.Input.Val, &option.Min, &option.Max); err != nil {
|
||
return nil, err
|
||
}
|
||
f.Options = append(f.Options, option)
|
||
}
|
||
if rows.Err() != nil {
|
||
return nil, rows.Err()
|
||
}
|
||
|
||
if f.ZonePreferences == nil && len(f.Options) == 0 {
|
||
return nil, nil
|
||
}
|
||
return f, nil
|
||
}
|
||
|
||
func (f *bookingOptionFields) FillValues(r *http.Request) {
|
||
if f.ZonePreferences != nil {
|
||
f.ZonePreferences.FillValue(r)
|
||
}
|
||
for _, option := range f.Options {
|
||
fillNumericField(option.Input, r, option.Min)
|
||
}
|
||
}
|
||
|
||
func (f *bookingOptionFields) Valid(v *form.Validator, l *locale.Locale) {
|
||
for _, option := range f.Options {
|
||
if v.CheckRequired(option.Input, fmt.Sprintf(l.Gettext("%s can not be empty"), option.Label)) {
|
||
if v.CheckValidInteger(option.Input, fmt.Sprintf(l.Gettext("%s must be an integer."), option.Label)) {
|
||
if v.CheckMinInteger(option.Input, option.Min, fmt.Sprintf(l.Gettext("%s must be %d or greater."), option.Label, option.Min)) {
|
||
v.CheckMaxInteger(option.Input, option.Max, fmt.Sprintf(l.Gettext("%s must be at most %d."), option.Label, option.Max))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *locale.Locale) *bookingCustomerFields {
|
||
return &bookingCustomerFields{
|
||
FullName: &form.Input{
|
||
Name: "full_name",
|
||
},
|
||
Address: &form.Input{
|
||
Name: "address",
|
||
},
|
||
PostalCode: &form.Input{
|
||
Name: "postal_code",
|
||
},
|
||
City: &form.Input{
|
||
Name: "city",
|
||
},
|
||
Country: &form.Select{
|
||
Name: "country",
|
||
Options: form.MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language),
|
||
},
|
||
Email: &form.Input{
|
||
Name: "email",
|
||
},
|
||
Phone: &form.Input{
|
||
Name: "phone",
|
||
},
|
||
ACSICard: &form.Checkbox{
|
||
Name: "acsi_card",
|
||
},
|
||
Agreement: &form.Checkbox{
|
||
Name: "agreement",
|
||
},
|
||
}
|
||
}
|
||
|
||
func (f *bookingCustomerFields) FillValues(r *http.Request) {
|
||
f.FullName.FillValue(r)
|
||
f.Address.FillValue(r)
|
||
f.PostalCode.FillValue(r)
|
||
f.City.FillValue(r)
|
||
f.Country.FillValue(r)
|
||
f.Email.FillValue(r)
|
||
f.Phone.FillValue(r)
|
||
f.ACSICard.FillValue(r)
|
||
f.Agreement.FillValue(r)
|
||
}
|
||
|
||
func (f *bookingCustomerFields) Valid(ctx context.Context, conn *database.Conn, v *form.Validator, l *locale.Locale) error {
|
||
var country string
|
||
if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) {
|
||
country = f.Country.Selected[0]
|
||
}
|
||
|
||
if v.CheckRequired(f.FullName, l.GettextNoop("Full name can not be empty.")) {
|
||
v.CheckMinLength(f.FullName, 1, l.GettextNoop("Full name must have at least one letter."))
|
||
}
|
||
|
||
if country != "" && f.PostalCode.Val != "" {
|
||
if _, err := v.CheckValidPostalCode(ctx, conn, f.PostalCode, country, l.GettextNoop("This postal code is not valid.")); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
|
||
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
|
||
}
|
||
if v.CheckRequired(f.Phone, l.GettextNoop("Phone can not be empty.")) {
|
||
if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
v.Check(f.Agreement, f.Agreement.Checked, l.GettextNoop("It is mandatory to agree to the reservation conditions."))
|
||
return nil
|
||
}
|