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" 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 { func LogOutHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
cookies := r.Header.Get("Cookie") cookies := r.Header.Get("Cookie")

View File

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

View File

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

View File

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

View File

@@ -5,67 +5,136 @@ import (
"encoding/json" "encoding/json"
"html/template" "html/template"
"net/http" "net/http"
"strconv" "regexp"
"strings"
"time" "time"
"decor-by-hannahs/internal/auth" ory "github.com/ory/client-go"
"decor-by-hannahs/internal/db" "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) { return func(w http.ResponseWriter, r *http.Request) {
type data struct { type data struct {
Title string Title string
ActivePage 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) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "Method is not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method is not allowed", http.StatusMethodNotAllowed)
return return
} }
user, err := auth.GetUser(r.Context()) cookies := r.Header.Get("Cookie")
if err != nil { 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) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return 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 { var in struct {
ServiceID string `json:"service_id"` ServiceOption string `json:"service_option"`
EventDate string `json:"event_date"` EventType string `json:"event_type"`
Address string `json:"address"` EventDate string `json:"event_date"`
Notes string `json:"notes"` Address string `json:"address"`
Notes string `json:"notes"`
} }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
if valid, msg := validateAustralianAddress(in.Address); !valid {
http.Error(w, msg, http.StatusBadRequest)
return
}
eventTime, err := time.Parse(time.RFC3339, in.EventDate) eventTime, err := time.Parse(time.RFC3339, in.EventDate)
if err != nil { if err != nil {
http.Error(w, "Invalid date", http.StatusBadRequest) http.Error(w, "Invalid date", http.StatusBadRequest)
return 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} userID := sql.NullInt64{Int64: user.ID, Valid: true}
booking, err := q.CreateBooking(r.Context(), db.CreateBookingParams{ booking, err := q.CreateBooking(r.Context(), db.CreateBookingParams{
UserID: userID, UserID: userID,
ServiceID: svcID, ServiceID: sql.NullInt64{},
EventDate: eventTime, EventDate: eventTime,
Address: sqlNullString(in.Address), Address: sqlNullString(in.Address),
Notes: sqlNullString(in.Notes), Notes: sqlNullString(in.Notes),
Status: sql.NullString{String: "pending", Valid: true}, Status: sql.NullString{String: "pending", Valid: true},
ServiceOption: sqlNullString(in.ServiceOption),
EventType: sqlNullString(in.EventType),
}) })
if err != nil { if err != nil {
http.Error(w, "Failed to create booking", http.StatusInternalServerError) http.Error(w, "Failed to create booking", http.StatusInternalServerError)

View File

@@ -4,6 +4,9 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
ory "github.com/ory/client-go"
) )
func sqlNullString(s string) sql.NullString { func sqlNullString(s string) sql.NullString {
@@ -13,6 +16,13 @@ func sqlNullString(s string) sql.NullString {
return sql.NullString{String: s, Valid: true} 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 { func sqlNullUUID(s string) sql.NullString {
if s == "" { if s == "" {
return sql.NullString{} return sql.NullString{}
@@ -24,3 +34,24 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) 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" "path/filepath"
"sort" "sort"
ory "github.com/ory/client-go"
"decor-by-hannahs/internal/db" "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) { return func(w http.ResponseWriter, r *http.Request) {
log.Printf("HomeHandler called: method=%s path=%s", r.Method, r.URL.Path) log.Printf("HomeHandler called: method=%s path=%s", r.Method, r.URL.Path)
if r.URL.Path != "/" { if r.URL.Path != "/" {
@@ -42,11 +44,19 @@ func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
}) })
type data struct { type data struct {
Title string Title string
Photos []Photo Photos []Photo
ActivePage string 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) log.Printf("Error executing home.tmpl: %v", err)
http.Error(w, "Failed to render page", http.StatusInternalServerError) http.Error(w, "Failed to render page", http.StatusInternalServerError)
} }

View File

@@ -1,77 +1,99 @@
package handlers package handlers
import ( import (
"database/sql"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"os"
"strconv" "strconv"
"decor-by-hannahs/internal/auth" ory "github.com/ory/client-go"
"decor-by-hannahs/internal/db" "decor-by-hannahs/internal/db"
) )
type BookingWithService struct { type BookingWithService struct {
ID int64 ID int64
ServiceName string ServiceName string
ServiceDescription string ServiceDescription string
ServicePriceCents int32 ServicePriceCents int32
EventDate string ServiceOption string
Address string EventType string
Notes string EventDate string
Status string Address string
CreatedAt string Notes string
Status string
CreatedAt string
} }
type ProfileData struct { type ProfileData struct {
Authenticated bool Authenticated bool
Email string Email string
DisplayName string DisplayName string
ID string ID string
ActivePage string ActivePage string
Bookings []BookingWithService Bookings []BookingWithService
OrySettingsURL string 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) { 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()) cookies := r.Header.Get("Cookie")
if err == nil { session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
pd.Authenticated = true
pd.Email = user.Email if err == nil && session != nil && session.Active != nil && *session.Active {
pd.ID = strconv.FormatInt(user.ID, 10) email := getEmailFromSession(session)
pd.DisplayName = user.Email if email != "" {
oryID := sql.NullString{String: session.Identity.Id, Valid: true}
bookings, err := q.ListBookingsWithServiceByUser(r.Context(), user.ID) user, err := q.GetUserByOryID(r.Context(), oryID)
if err != nil { if err != nil {
log.Printf("Error fetching bookings: %v", err) user, err = q.CreateUser(r.Context(), db.CreateUserParams{
} else { Email: email,
for _, b := range bookings { OryIdentityID: oryID,
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"),
}) })
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 err == nil {
if oryAPIURL == "" { pd.Authenticated = true
tunnelPort := os.Getenv("TUNNEL_PORT") pd.Email = user.Email
if tunnelPort == "" { pd.ID = strconv.FormatInt(user.ID, 10)
tunnelPort = "4000" 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 { 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> <svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-calendar"/></svg>
Book Book
</a> </a>
{{if .Authenticated}}
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}> <a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg> <svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
Profile Profile
</a> </a>
{{else}}
<a href="{{.OryLoginURL}}">
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
Login
</a>
{{end}}
</nav> </nav>
</div> </div>
</div> </div>
@@ -58,7 +65,11 @@
<a href="/catalog" {{if eq .ActivePage "catalog"}}class="active"{{end}}>Catalog</a> <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="/gallery" {{if eq .ActivePage "gallery"}}class="active"{{end}}>Gallery</a>
<a href="/booking" {{if eq .ActivePage "booking"}}class="active"{{end}}>Book</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> <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> </nav>
<button id="themeToggle" class="btn btn-outline" aria-label="Toggle theme"> <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> <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"> <div class="card">
<form id="bookingForm"> <form id="bookingForm">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="service_id">Service ID</label> <label class="form-label" for="service_option">Service Option</label>
<input class="form-input" type="text" id="service_id" name="service_id" placeholder="Enter service ID" required> <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>
<div class="form-group"> <div class="form-group">
@@ -41,8 +61,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="address">Address</label> <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="Event location"> <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>
<div class="form-group"> <div class="form-group">
@@ -62,18 +83,63 @@
{{template "footer"}} {{template "footer"}}
{{template "theme-script"}} {{template "theme-script"}}
<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){ document.getElementById('bookingForm').addEventListener('submit', async function(e){
e.preventDefault(); e.preventDefault();
const f = e.target; const f = e.target;
const submitBtn = f.querySelector('button[type="submit"]'); const submitBtn = f.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent; const originalText = submitBtn.textContent;
const addressValidation = validateAustralianAddress(f.address.value);
if (!addressValidation.valid) {
alert(addressValidation.message);
return;
}
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...'; submitBtn.textContent = 'Submitting...';
const body = { const body = {
user_id: "", 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(), event_date: new Date(f.event_date.value).toISOString(),
address: f.address.value, address: f.address.value,
notes: f.notes.value notes: f.notes.value

View File

@@ -57,6 +57,7 @@
<main> <main>
<div style="max-width: 1000px; margin: 0 auto;"> <div style="max-width: 1000px; margin: 0 auto;">
{{if .Authenticated}}
<div style="margin-bottom: 2rem;"> <div style="margin-bottom: 2rem;">
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Welcome, {{.DisplayName}}</h1> <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> <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 class="booking-card">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;"> <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
<div> <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> <p style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Booked on {{.CreatedAt}}</p>
</div> </div>
<span class="status-badge status-{{.Status}}">{{.Status}}</span> <span class="status-badge status-{{.Status}}">{{.Status}}</span>
@@ -116,13 +117,6 @@
<span style="font-size: 0.875rem;">{{.Address}}</span> <span style="font-size: 0.875rem;">{{.Address}}</span>
</div> </div>
{{end}} {{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> </div>
{{if .Notes}} {{if .Notes}}
<div style="padding: 0.75rem; background: hsl(var(--muted) / 0.3); border-radius: 6px; font-size: 0.875rem;"> <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> </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> </div>
</main> </main>