The “overflow” is for when people want to book plots for more guests than is permitted, which the system would need to add a new plot to the “shopping cart”, as it were; not implemented yet. The ask zone preferences is to whether show the corresponding input on the booking form, that it was done implicitly when the campsite type had options, because up until now it was only for plots, but it is no longer the case, thus i need to know when to show it; now it is explicit.
346 lines
10 KiB
Go
346 lines
10 KiB
Go
/*
|
|
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
package booking
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"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/redsys"
|
|
"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 := newPublicPage(r.Context(), company, conn, user.Locale)
|
|
_ = r.ParseForm()
|
|
page.Form.CampsiteType.FillValue(r)
|
|
page.Form.ArrivalDate.FillValue(r)
|
|
page.Form.DepartureDate.FillValue(r)
|
|
page.MustRender(w, r, user, company, conn)
|
|
case http.MethodPost:
|
|
makeReservation(w, r, user, company, conn)
|
|
default:
|
|
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
|
}
|
|
case "success":
|
|
handleSuccessfulPayment(w, r, user, company, conn)
|
|
case "failure":
|
|
handleFailedPayment(w, r, user, company, conn)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
}
|
|
|
|
func makeReservation(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
|
|
f := newBookingForm(r.Context(), company, conn, user.Locale)
|
|
if err := f.Parse(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
|
|
panic(err)
|
|
} else if !ok {
|
|
if !httplib.IsHTMxRequest(r) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
}
|
|
page := newPublicPageWithForm(f)
|
|
page.MustRender(w, r, user, company, conn)
|
|
return
|
|
}
|
|
|
|
schema := httplib.Protocol(r)
|
|
authority := httplib.Host(r)
|
|
request := &redsys.Request{
|
|
TransactionType: redsys.TransactionTypeCharge,
|
|
Amount: "12.34",
|
|
OrderNumber: randomOrderNumber(),
|
|
Product: "Test Booking",
|
|
SuccessURL: fmt.Sprintf("%s://%s/%s/booking/success", schema, authority, user.Locale.Language),
|
|
FailureURL: fmt.Sprintf("%s://%s/%s/booking/failure", schema, authority, user.Locale.Language),
|
|
NotificationURL: fmt.Sprintf("%s://%s/%s/booking/notification", schema, authority, user.Locale.Language),
|
|
ConsumerLanguage: user.Locale.Language,
|
|
}
|
|
signed, err := redsys.SignRequest(r.Context(), conn, company, request)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
page := newPaymentPage(signed)
|
|
page.MustRender(w, r, user, company, conn)
|
|
}
|
|
|
|
func randomOrderNumber() string {
|
|
bytes := make([]byte, 6)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(bytes)
|
|
}
|
|
|
|
type publicPage struct {
|
|
*template.PublicPage
|
|
Form *bookingForm
|
|
}
|
|
|
|
func newPublicPage(ctx context.Context, company *auth.Company, conn *database.Conn, l *locale.Locale) *publicPage {
|
|
return newPublicPageWithForm(newBookingForm(ctx, company, conn, l))
|
|
}
|
|
|
|
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)
|
|
template.MustRenderPublic(w, r, user, company, "booking.gohtml", p)
|
|
}
|
|
|
|
type bookingForm struct {
|
|
FullName *form.Input
|
|
Address *form.Input
|
|
PostalCode *form.Input
|
|
City *form.Input
|
|
Country *form.Select
|
|
Email *form.Input
|
|
Phone *form.Input
|
|
CampsiteType *form.Select
|
|
CampsiteTypeOptions map[string][]*campsiteTypeOption
|
|
ArrivalDate *form.Input
|
|
DepartureDate *form.Input
|
|
ZonePreferences map[string]*form.Input
|
|
ACSICard *form.Checkbox
|
|
Agreement *form.Checkbox
|
|
}
|
|
|
|
type campsiteTypeOption struct {
|
|
Label string
|
|
Min int
|
|
Max int
|
|
Input *form.Input
|
|
}
|
|
|
|
func newBookingForm(ctx context.Context, company *auth.Company, conn *database.Conn, l *locale.Locale) *bookingForm {
|
|
var typeSelectOptions []*form.Option
|
|
zonePreferences := make(map[string]*form.Input)
|
|
rows, err := conn.Query(ctx, `
|
|
select type.slug
|
|
, coalesce(i18n.name, type.name) as l10n_name
|
|
, ask_zone_preferences
|
|
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)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
option := &form.Option{}
|
|
var ask bool
|
|
if err = rows.Scan(&option.Value, &option.Label, &ask); err != nil {
|
|
panic(err)
|
|
}
|
|
typeSelectOptions = append(typeSelectOptions, option)
|
|
if ask {
|
|
zonePreferences[option.Value] = &form.Input{
|
|
Name: "zone_preferences_" + option.Value,
|
|
}
|
|
}
|
|
}
|
|
if rows.Err() != nil {
|
|
panic(rows.Err())
|
|
}
|
|
|
|
return &bookingForm{
|
|
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",
|
|
},
|
|
CampsiteType: &form.Select{
|
|
Name: "campsite_type",
|
|
Options: typeSelectOptions,
|
|
},
|
|
CampsiteTypeOptions: mustGetCampsiteTypeOptions(ctx, conn, company, l),
|
|
ArrivalDate: &form.Input{
|
|
Name: "arrival_date",
|
|
},
|
|
DepartureDate: &form.Input{
|
|
Name: "departure_date",
|
|
},
|
|
ZonePreferences: zonePreferences,
|
|
ACSICard: &form.Checkbox{
|
|
Name: "acsi_card",
|
|
},
|
|
Agreement: &form.Checkbox{
|
|
Name: "agreement",
|
|
},
|
|
}
|
|
}
|
|
|
|
func mustGetCampsiteTypeOptions(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) map[string][]*campsiteTypeOption {
|
|
rows, err := conn.Query(ctx, `
|
|
select 'campsite_type_option_' || option.campsite_type_option_id
|
|
, slug
|
|
, 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 company_id = $2
|
|
and campsite_type.active
|
|
order by option.position, l10_name
|
|
`, l.Language, company.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer rows.Close()
|
|
options := make(map[string][]*campsiteTypeOption)
|
|
for rows.Next() {
|
|
var slug string
|
|
option := &campsiteTypeOption{
|
|
Input: &form.Input{},
|
|
}
|
|
if err = rows.Scan(&option.Input.Name, &slug, &option.Label, &option.Input.Val, &option.Min, &option.Max); err != nil {
|
|
panic(err)
|
|
}
|
|
options[slug] = append(options[slug], option)
|
|
}
|
|
if rows.Err() != nil {
|
|
panic(rows.Err())
|
|
}
|
|
return options
|
|
}
|
|
|
|
func (f *bookingForm) Parse(r *http.Request) error {
|
|
if err := r.ParseForm(); err != nil {
|
|
return err
|
|
}
|
|
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.CampsiteType.FillValue(r)
|
|
f.ArrivalDate.FillValue(r)
|
|
f.DepartureDate.FillValue(r)
|
|
f.ACSICard.FillValue(r)
|
|
f.Agreement.FillValue(r)
|
|
for _, preferences := range f.ZonePreferences {
|
|
preferences.FillValue(r)
|
|
}
|
|
for _, options := range f.CampsiteTypeOptions {
|
|
for _, option := range options {
|
|
option.Input.FillValue(r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *bookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
|
|
v := form.NewValidator(l)
|
|
|
|
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 false, 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 false, err
|
|
}
|
|
}
|
|
var validBefore bool
|
|
v.CheckSelectedOptions(f.CampsiteType, l.GettextNoop("Selected campsite type is not valid."))
|
|
if v.CheckRequired(f.ArrivalDate, l.GettextNoop("Arrival date can not be empty")) {
|
|
if v.CheckValidDate(f.ArrivalDate, l.GettextNoop("Arrival date must be a valid date.")) {
|
|
validBefore = true
|
|
}
|
|
}
|
|
if v.CheckRequired(f.DepartureDate, l.GettextNoop("Departure date can not be empty")) {
|
|
if v.CheckValidDate(f.DepartureDate, l.GettextNoop("Departure date must be a valid date.")) && validBefore {
|
|
v.CheckDateAfter(f.DepartureDate, f.ArrivalDate, l.GettextNoop("The departure date must be after the arrival date."))
|
|
}
|
|
}
|
|
v.Check(f.Agreement, f.Agreement.Checked, l.GettextNoop("It is mandatory to agree to the reservation conditions."))
|
|
for _, options := range f.CampsiteTypeOptions {
|
|
for _, option := range 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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return v.AllOK, nil
|
|
}
|