fixed some login and booking bugs

This commit is contained in:
tumillanino
2025-10-28 13:36:05 +11:00
parent dad3ec655f
commit 5d7f0a8eb3
11 changed files with 402 additions and 141 deletions

View File

@@ -6,6 +6,12 @@ import (
ory "github.com/ory/client-go"
)
func LoginHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, tunnelURL+"/ui/login", http.StatusSeeOther)
}
}
func LogOutHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookies := r.Header.Get("Cookie")

View File

@@ -12,29 +12,33 @@ import (
)
const createBooking = `-- name: CreateBooking :one
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status, service_option, event_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
`
type CreateBookingParams struct {
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
Status sql.NullString `json:"status"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
Status sql.NullString `json:"status"`
ServiceOption sql.NullString `json:"service_option"`
EventType sql.NullString `json:"event_type"`
}
type CreateBookingRow struct {
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ServiceOption sql.NullString `json:"service_option"`
EventType sql.NullString `json:"event_type"`
}
func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (CreateBookingRow, error) {
@@ -45,6 +49,8 @@ func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (C
arg.Address,
arg.Notes,
arg.Status,
arg.ServiceOption,
arg.EventType,
)
var i CreateBookingRow
err := row.Scan(
@@ -56,25 +62,29 @@ func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (C
&i.Notes,
&i.CreatedAt,
&i.Status,
&i.ServiceOption,
&i.EventType,
)
return i, err
}
const getBooking = `-- name: GetBooking :one
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
FROM bookings
WHERE id = $1
`
type GetBookingRow struct {
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ServiceOption sql.NullString `json:"service_option"`
EventType sql.NullString `json:"event_type"`
}
func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, error) {
@@ -89,26 +99,30 @@ func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, erro
&i.Notes,
&i.CreatedAt,
&i.Status,
&i.ServiceOption,
&i.EventType,
)
return i, err
}
const listBookingsByUser = `-- name: ListBookingsByUser :many
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
FROM bookings
WHERE user_id = $1
ORDER BY event_date DESC
`
type ListBookingsByUserRow struct {
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Address sql.NullString `json:"address"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ServiceOption sql.NullString `json:"service_option"`
EventType sql.NullString `json:"event_type"`
}
func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64) ([]ListBookingsByUserRow, error) {
@@ -129,6 +143,8 @@ func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64)
&i.Notes,
&i.CreatedAt,
&i.Status,
&i.ServiceOption,
&i.EventType,
); err != nil {
return nil, err
}
@@ -153,6 +169,8 @@ SELECT
b.notes,
b.created_at,
b.status,
b.service_option,
b.event_type,
s.name as service_name,
s.description as service_description,
s.price_cents as service_price_cents
@@ -171,6 +189,8 @@ type ListBookingsWithServiceByUserRow struct {
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Status sql.NullString `json:"status"`
ServiceOption sql.NullString `json:"service_option"`
EventType sql.NullString `json:"event_type"`
ServiceName string `json:"service_name"`
ServiceDescription sql.NullString `json:"service_description"`
ServicePriceCents int32 `json:"service_price_cents"`
@@ -194,6 +214,8 @@ func (q *Queries) ListBookingsWithServiceByUser(ctx context.Context, userID sql.
&i.Notes,
&i.CreatedAt,
&i.Status,
&i.ServiceOption,
&i.EventType,
&i.ServiceName,
&i.ServiceDescription,
&i.ServicePriceCents,

View File

@@ -10,14 +10,16 @@ import (
)
type Booking struct {
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Address sql.NullString `json:"address"`
Status sql.NullString `json:"status"`
ID int64 `json:"id"`
UserID sql.NullInt64 `json:"user_id"`
ServiceID sql.NullInt64 `json:"service_id"`
EventDate time.Time `json:"event_date"`
Notes sql.NullString `json:"notes"`
CreatedAt sql.NullTime `json:"created_at"`
Address sql.NullString `json:"address"`
Status sql.NullString `json:"status"`
ServiceOption sql.NullString `json:"service_option"`
EventType sql.NullString `json:"event_type"`
}
type GalleryPhoto struct {

View File

@@ -1,10 +1,10 @@
-- name: CreateBooking :one
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status;
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status, service_option, event_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type;
-- name: ListBookingsByUser :many
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
FROM bookings
WHERE user_id = $1
ORDER BY event_date DESC;
@@ -19,6 +19,8 @@ SELECT
b.notes,
b.created_at,
b.status,
b.service_option,
b.event_type,
s.name as service_name,
s.description as service_description,
s.price_cents as service_price_cents
@@ -28,6 +30,6 @@ WHERE b.user_id = $1
ORDER BY b.event_date DESC;
-- name: GetBooking :one
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
FROM bookings
WHERE id = $1;

View File

@@ -5,67 +5,136 @@ import (
"encoding/json"
"html/template"
"net/http"
"strconv"
"regexp"
"strings"
"time"
"decor-by-hannahs/internal/auth"
ory "github.com/ory/client-go"
"decor-by-hannahs/internal/db"
)
func BookingPageHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
func validateAustralianAddress(address string) (bool, string) {
if len(strings.TrimSpace(address)) < 10 {
return false, "Address is too short. Please provide a complete address."
}
australianStates := []string{"NSW", "VIC", "QLD", "SA", "WA", "TAS", "NT", "ACT"}
hasState := false
upperAddress := strings.ToUpper(address)
for _, state := range australianStates {
if strings.Contains(upperAddress, state) {
hasState = true
break
}
}
postcodePattern := regexp.MustCompile(`\b\d{4}\b`)
hasPostcode := postcodePattern.MatchString(address)
if !hasState && !hasPostcode {
return false, "Please include an Australian state (NSW, VIC, QLD, etc.) and postcode in your address."
}
if !hasState {
return false, "Please include an Australian state (NSW, VIC, QLD, SA, WA, TAS, NT, or ACT) in your address."
}
if !hasPostcode {
return false, "Please include a 4-digit postcode in your address."
}
return true, ""
}
func BookingPageHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
type data struct {
Title string
ActivePage string
Title string
ActivePage string
Authenticated bool
OryLoginURL string
}
if err := tmpl.ExecuteTemplate(w, "booking.tmpl", data{
Title: "Book a Service",
ActivePage: "booking",
Authenticated: isAuthenticated(r, oryClient),
OryLoginURL: "/login",
}); err != nil {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
return
}
_ = tmpl.ExecuteTemplate(w, "booking.tmpl", data{Title: "Book a Service", ActivePage: "booking"})
}
}
func BookingAPIHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
func BookingAPIHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method is not allowed", http.StatusMethodNotAllowed)
return
}
user, err := auth.GetUser(r.Context())
if err != nil {
cookies := r.Header.Get("Cookie")
session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
if err != nil || session == nil || session.Active == nil || !*session.Active {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
email := getEmailFromSession(session)
if email == "" {
http.Error(w, "No email in session", http.StatusBadRequest)
return
}
oryID := sql.NullString{String: session.Identity.Id, Valid: true}
user, err := q.GetUserByOryID(r.Context(), oryID)
if err != nil {
user, err = q.CreateUser(r.Context(), db.CreateUserParams{
Email: email,
OryIdentityID: oryID,
})
if err != nil {
http.Error(w, "Failed to get or create user", http.StatusInternalServerError)
return
}
}
var in struct {
ServiceID string `json:"service_id"`
EventDate string `json:"event_date"`
Address string `json:"address"`
Notes string `json:"notes"`
ServiceOption string `json:"service_option"`
EventType string `json:"event_type"`
EventDate string `json:"event_date"`
Address string `json:"address"`
Notes string `json:"notes"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if valid, msg := validateAustralianAddress(in.Address); !valid {
http.Error(w, msg, http.StatusBadRequest)
return
}
eventTime, err := time.Parse(time.RFC3339, in.EventDate)
if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest)
return
}
svc, err := strconv.ParseInt(in.ServiceID, 10, 64)
if err != nil {
http.Error(w, "Invalid Service ID", http.StatusBadRequest)
return
}
svcID := sql.NullInt64{Int64: svc, Valid: true}
userID := sql.NullInt64{Int64: user.ID, Valid: true}
booking, err := q.CreateBooking(r.Context(), db.CreateBookingParams{
UserID: userID,
ServiceID: svcID,
EventDate: eventTime,
Address: sqlNullString(in.Address),
Notes: sqlNullString(in.Notes),
Status: sql.NullString{String: "pending", Valid: true},
UserID: userID,
ServiceID: sql.NullInt64{},
EventDate: eventTime,
Address: sqlNullString(in.Address),
Notes: sqlNullString(in.Notes),
Status: sql.NullString{String: "pending", Valid: true},
ServiceOption: sqlNullString(in.ServiceOption),
EventType: sqlNullString(in.EventType),
})
if err != nil {
http.Error(w, "Failed to create booking", http.StatusInternalServerError)

View File

@@ -4,6 +4,9 @@ import (
"database/sql"
"encoding/json"
"net/http"
"os"
ory "github.com/ory/client-go"
)
func sqlNullString(s string) sql.NullString {
@@ -13,6 +16,13 @@ func sqlNullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: true}
}
func nullStringToString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
func sqlNullUUID(s string) sql.NullString {
if s == "" {
return sql.NullString{}
@@ -24,3 +34,24 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func getOryAPIURL() string {
oryAPIURL := os.Getenv("ORY_API_URL")
if oryAPIURL == "" {
tunnelPort := os.Getenv("TUNNEL_PORT")
if tunnelPort == "" {
tunnelPort = "4000"
}
oryAPIURL = "http://localhost:" + tunnelPort
}
return oryAPIURL
}
func isAuthenticated(r *http.Request, oryClient *ory.APIClient) bool {
cookies := r.Header.Get("Cookie")
session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
if err != nil {
return false
}
return session != nil && session.Active != nil && *session.Active
}

View File

@@ -8,10 +8,12 @@ import (
"path/filepath"
"sort"
ory "github.com/ory/client-go"
"decor-by-hannahs/internal/db"
)
func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
func HomeHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("HomeHandler called: method=%s path=%s", r.Method, r.URL.Path)
if r.URL.Path != "/" {
@@ -42,11 +44,19 @@ func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
})
type data struct {
Title string
Photos []Photo
ActivePage string
Title string
Photos []Photo
ActivePage string
Authenticated bool
OryLoginURL string
}
if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{Title: "Home", Photos: photos, ActivePage: "home"}); err != nil {
if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{
Title: "Home",
Photos: photos,
ActivePage: "home",
Authenticated: isAuthenticated(r, oryClient),
OryLoginURL: "/login",
}); err != nil {
log.Printf("Error executing home.tmpl: %v", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}

View File

@@ -1,77 +1,99 @@
package handlers
import (
"database/sql"
"html/template"
"log"
"net/http"
"os"
"strconv"
"decor-by-hannahs/internal/auth"
ory "github.com/ory/client-go"
"decor-by-hannahs/internal/db"
)
type BookingWithService struct {
ID int64
ServiceName string
ServiceDescription string
ServicePriceCents int32
EventDate string
Address string
Notes string
Status string
CreatedAt string
ID int64
ServiceName string
ServiceDescription string
ServicePriceCents int32
ServiceOption string
EventType string
EventDate string
Address string
Notes string
Status string
CreatedAt string
}
type ProfileData struct {
Authenticated bool
Email string
DisplayName string
ID string
ActivePage string
Bookings []BookingWithService
OrySettingsURL string
Authenticated bool
Email string
DisplayName string
ID string
ActivePage string
Bookings []BookingWithService
OrySettingsURL string
OryLoginURL string
}
func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
func ProfileHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
pd := ProfileData{ActivePage: "profile"}
oryAPIURL := getOryAPIURL()
pd := ProfileData{
ActivePage: "profile",
OryLoginURL: "/login",
OrySettingsURL: oryAPIURL + "/ui/settings",
}
user, err := auth.GetUser(r.Context())
if err == nil {
pd.Authenticated = true
pd.Email = user.Email
pd.ID = strconv.FormatInt(user.ID, 10)
pd.DisplayName = user.Email
bookings, err := q.ListBookingsWithServiceByUser(r.Context(), user.ID)
if err != nil {
log.Printf("Error fetching bookings: %v", err)
} else {
for _, b := range bookings {
pd.Bookings = append(pd.Bookings, BookingWithService{
ID: b.ID,
ServiceName: b.ServiceName,
ServiceDescription: nullStringToString(b.ServiceDescription),
ServicePriceCents: b.ServicePriceCents,
EventDate: b.EventDate.Format("January 2, 2006"),
Address: nullStringToString(b.Address),
Notes: nullStringToString(b.Notes),
Status: b.Status,
CreatedAt: b.CreatedAt.Time.Format("Jan 2, 2006"),
cookies := r.Header.Get("Cookie")
session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
if err == nil && session != nil && session.Active != nil && *session.Active {
email := getEmailFromSession(session)
if email != "" {
oryID := sql.NullString{String: session.Identity.Id, Valid: true}
user, err := q.GetUserByOryID(r.Context(), oryID)
if err != nil {
user, err = q.CreateUser(r.Context(), db.CreateUserParams{
Email: email,
OryIdentityID: oryID,
})
if err != nil {
log.Printf("Failed to create user: %v", err)
} else {
log.Printf("Created new user: %s (ID: %d)", email, user.ID)
}
}
}
oryAPIURL := os.Getenv("ORY_API_URL")
if oryAPIURL == "" {
tunnelPort := os.Getenv("TUNNEL_PORT")
if tunnelPort == "" {
tunnelPort = "4000"
if err == nil {
pd.Authenticated = true
pd.Email = user.Email
pd.ID = strconv.FormatInt(user.ID, 10)
pd.DisplayName = user.Email
bookings, err := q.ListBookingsWithServiceByUser(r.Context(), sql.NullInt64{Int64: user.ID, Valid: true})
if err != nil {
log.Printf("Error fetching bookings: %v", err)
} else {
for _, b := range bookings {
pd.Bookings = append(pd.Bookings, BookingWithService{
ID: b.ID,
ServiceName: b.ServiceName,
ServiceDescription: nullStringToString(b.ServiceDescription),
ServicePriceCents: b.ServicePriceCents,
ServiceOption: nullStringToString(b.ServiceOption),
EventType: nullStringToString(b.EventType),
EventDate: b.EventDate.Format("January 2, 2006"),
Address: nullStringToString(b.Address),
Notes: nullStringToString(b.Notes),
Status: nullStringToString(b.Status),
CreatedAt: b.CreatedAt.Time.Format("Jan 2, 2006"),
})
}
}
}
oryAPIURL = "http://localhost:" + tunnelPort
}
pd.OrySettingsURL = oryAPIURL + "/ui/settings"
}
if err := tmpl.ExecuteTemplate(w, "profile.tmpl", pd); err != nil {
@@ -80,3 +102,18 @@ func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
}
}
}
func getEmailFromSession(session *ory.Session) string {
if session.Identity.Traits == nil {
return ""
}
traits, ok := session.Identity.Traits.(map[string]interface{})
if !ok {
return ""
}
email, ok := traits["email"].(string)
if !ok {
return ""
}
return email
}

View File

@@ -27,10 +27,17 @@
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-calendar"/></svg>
Book
</a>
{{if .Authenticated}}
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
Profile
</a>
{{else}}
<a href="{{.OryLoginURL}}">
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
Login
</a>
{{end}}
</nav>
</div>
</div>
@@ -58,7 +65,11 @@
<a href="/catalog" {{if eq .ActivePage "catalog"}}class="active"{{end}}>Catalog</a>
<a href="/gallery" {{if eq .ActivePage "gallery"}}class="active"{{end}}>Gallery</a>
<a href="/booking" {{if eq .ActivePage "booking"}}class="active"{{end}}>Book</a>
{{if .Authenticated}}
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>Profile</a>
{{else}}
<a href="{{.OryLoginURL}}" class="btn btn-primary" style="padding: 0.5rem 1rem; text-decoration: none;">Login</a>
{{end}}
</nav>
<button id="themeToggle" class="btn btn-outline" aria-label="Toggle theme">
<svg id="sunIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>

View File

@@ -31,8 +31,28 @@
<div class="card">
<form id="bookingForm">
<div class="form-group">
<label class="form-label" for="service_id">Service ID</label>
<input class="form-input" type="text" id="service_id" name="service_id" placeholder="Enter service ID" required>
<label class="form-label" for="service_option">Service Option</label>
<select class="form-input" id="service_option" name="service_option" required>
<option value="">Select a service option...</option>
<option value="Small Bundle">Small Bundle</option>
<option value="Medium Bundle">Medium Bundle</option>
<option value="Large Bundle">Large Bundle</option>
<option value="Balloons Only">Balloons Only</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="event_type">Event Type</label>
<select class="form-input" id="event_type" name="event_type" required>
<option value="">Select an event type...</option>
<option value="Birthday">Birthday</option>
<option value="Wedding">Wedding</option>
<option value="Anniversary">Anniversary</option>
<option value="Baby Shower">Baby Shower</option>
<option value="Graduation">Graduation</option>
<option value="Corporate Event">Corporate Event</option>
<option value="Other">Other</option>
</select>
</div>
<div class="form-group">
@@ -41,8 +61,9 @@
</div>
<div class="form-group">
<label class="form-label" for="address">Address</label>
<input class="form-input" type="text" id="address" name="address" placeholder="Event location">
<label class="form-label" for="address">Address <span style="color: hsl(var(--destructive));">*</span></label>
<input class="form-input" type="text" id="address" name="address" placeholder="123 Main St, Sydney NSW 2000" required>
<small style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Please include street, suburb, state, and postcode</small>
</div>
<div class="form-group">
@@ -62,18 +83,63 @@
{{template "footer"}}
{{template "theme-script"}}
<script>
function validateAustralianAddress(address) {
if (!address || address.trim().length < 10) {
return { valid: false, message: 'Address is too short. Please provide a complete address.' };
}
const australianStates = ['NSW', 'VIC', 'QLD', 'SA', 'WA', 'TAS', 'NT', 'ACT'];
const hasState = australianStates.some(state =>
address.toUpperCase().includes(state) ||
address.toUpperCase().includes(state.toLowerCase())
);
const postcodePattern = /\b\d{4}\b/;
const hasPostcode = postcodePattern.test(address);
if (!hasState && !hasPostcode) {
return {
valid: false,
message: 'Please include an Australian state (NSW, VIC, QLD, etc.) and postcode in your address.'
};
}
if (!hasState) {
return {
valid: false,
message: 'Please include an Australian state (NSW, VIC, QLD, SA, WA, TAS, NT, or ACT) in your address.'
};
}
if (!hasPostcode) {
return {
valid: false,
message: 'Please include a 4-digit postcode in your address.'
};
}
return { valid: true };
}
document.getElementById('bookingForm').addEventListener('submit', async function(e){
e.preventDefault();
const f = e.target;
const submitBtn = f.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
const addressValidation = validateAustralianAddress(f.address.value);
if (!addressValidation.valid) {
alert(addressValidation.message);
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
const body = {
user_id: "",
service_id: f.service_id.value,
service_option: f.service_option.value,
event_type: f.event_type.value,
event_date: new Date(f.event_date.value).toISOString(),
address: f.address.value,
notes: f.notes.value

View File

@@ -57,6 +57,7 @@
<main>
<div style="max-width: 1000px; margin: 0 auto;">
{{if .Authenticated}}
<div style="margin-bottom: 2rem;">
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Welcome, {{.DisplayName}}</h1>
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">Manage your account and bookings</p>
@@ -92,7 +93,7 @@
<div class="booking-card">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
<div>
<h4 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem;">{{.ServiceName}}</h4>
<h4 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem;">{{.ServiceOption}}{{if .EventType}} - {{.EventType}}{{end}}</h4>
<p style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Booked on {{.CreatedAt}}</p>
</div>
<span class="status-badge status-{{.Status}}">{{.Status}}</span>
@@ -116,13 +117,6 @@
<span style="font-size: 0.875rem;">{{.Address}}</span>
</div>
{{end}}
<div style="display: flex; gap: 0.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
<span style="font-size: 0.875rem;">Price: <strong>${{printf "%.2f" (divf .ServicePriceCents 100)}}</strong></span>
</div>
</div>
{{if .Notes}}
<div style="padding: 0.75rem; background: hsl(var(--muted) / 0.3); border-radius: 6px; font-size: 0.875rem;">
@@ -137,6 +131,17 @@
</div>
</div>
</div>
{{else}}
<div style="margin-bottom: 2rem; text-align: center; padding: 4rem 2rem;">
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: 0 auto 2rem; color: hsl(var(--muted-foreground));">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 1rem;">Login Required</h1>
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem; margin-bottom: 2rem;">Please log in to view your profile and manage your bookings</p>
<a href="{{.OryLoginURL}}" class="btn btn-primary" style="text-decoration: none; display: inline-block;">Login or Sign Up</a>
</div>
{{end}}
</div>
</main>