From dad3ec655f06f1c7274b14b0b9f702693a4a0ca9 Mon Sep 17 00:00:00 2001 From: tumillanino Date: Tue, 28 Oct 2025 12:36:37 +1100 Subject: [PATCH] the rest --- go.mod | 18 +++ go.sum | 32 ++++ internal/auth/handlers.go | 19 +++ internal/auth/middleware.go | 112 +++++++++++++ internal/db/bookings.sql.go | 212 +++++++++++++++++++++++++ internal/db/data.go | 68 ++++++++ internal/db/db.go | 31 ++++ internal/db/gallery_photos.sql.go | 123 ++++++++++++++ internal/db/models.go | 44 +++++ internal/db/queries/bookings.sql | 33 ++++ internal/db/queries/gallery_photos.sql | 19 +++ internal/db/queries/services.sql | 14 ++ internal/db/queries/users.sql | 16 ++ internal/db/services.sql.go | 96 +++++++++++ internal/db/users.sql.go | 90 +++++++++++ internal/handlers/booking.go | 76 +++++++++ internal/handlers/catalog.go | 25 +++ internal/handlers/gallery.go | 50 ++++++ internal/handlers/helpers.go | 26 +++ internal/handlers/home.go | 54 +++++++ internal/handlers/ory_redirect.go | 59 +++++++ internal/handlers/profile.go | 82 ++++++++++ internal/tmpl/_partials.tmpl | 110 +++++++++++++ internal/tmpl/booking.tmpl | 106 +++++++++++++ internal/tmpl/catalog.tmpl | 64 ++++++++ internal/tmpl/gallery.tmpl | 53 +++++++ internal/tmpl/home.tmpl | 176 ++++++++++++++++++++ internal/tmpl/layout.tmpl | 124 +++++++++++++++ internal/tmpl/ory_redirect.tmpl | 163 +++++++++++++++++++ internal/tmpl/profile.tmpl | 147 +++++++++++++++++ sqlc.yaml | 14 ++ 31 files changed, 2256 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/handlers.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/db/bookings.sql.go create mode 100644 internal/db/data.go create mode 100644 internal/db/db.go create mode 100644 internal/db/gallery_photos.sql.go create mode 100644 internal/db/models.go create mode 100644 internal/db/queries/bookings.sql create mode 100644 internal/db/queries/gallery_photos.sql create mode 100644 internal/db/queries/services.sql create mode 100644 internal/db/queries/users.sql create mode 100644 internal/db/services.sql.go create mode 100644 internal/db/users.sql.go create mode 100644 internal/handlers/booking.go create mode 100644 internal/handlers/catalog.go create mode 100644 internal/handlers/gallery.go create mode 100644 internal/handlers/helpers.go create mode 100644 internal/handlers/home.go create mode 100644 internal/handlers/ory_redirect.go create mode 100644 internal/handlers/profile.go create mode 100644 internal/tmpl/_partials.tmpl create mode 100644 internal/tmpl/booking.tmpl create mode 100644 internal/tmpl/catalog.tmpl create mode 100644 internal/tmpl/gallery.tmpl create mode 100644 internal/tmpl/home.tmpl create mode 100644 internal/tmpl/layout.tmpl create mode 100644 internal/tmpl/ory_redirect.tmpl create mode 100644 internal/tmpl/profile.tmpl create mode 100644 sqlc.yaml diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bc364fe --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module decor-by-hannahs + +go 1.25.3 + +require ( + github.com/jackc/pgx/v5 v5.7.6 + github.com/ory/client-go v1.22.7 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.27.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8d3f23b --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/ory/client-go v1.22.7 h1:ZYkE/EG/4m+1USTn8Rc4alJvz8sUSoiZvTrPRRAB8hA= +github.com/ory/client-go v1.22.7/go.mod h1:o/1hF5MKq3gyn9nUWZF/VVz35nitCzsGfIwl5SXVJ1Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go new file mode 100644 index 0000000..4b25773 --- /dev/null +++ b/internal/auth/handlers.go @@ -0,0 +1,19 @@ +package auth + +import ( + "net/http" + + ory "github.com/ory/client-go" +) + +func LogOutHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookies := r.Header.Get("Cookie") + logoutFlow, _, err := oryClient.FrontendAPI.CreateBrowserLogoutFlow(r.Context()).Cookie(cookies).Execute() + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.Redirect(w, r, logoutFlow.LogoutUrl, http.StatusSeeOther) + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..cb3b52e --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,112 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "log" + "net/http" + + ory "github.com/ory/client-go" + + "decor-by-hannahs/internal/db" +) + +type contextKey string + +const sessionContextKey contextKey = "req.session" + +func SessionMiddleware(oryClient *ory.APIClient, tunnelURL string) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookies := r.Header.Get("Cookie") + + session, resp, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute() + if err != nil { + log.Printf("Session check failed: %v", err) + if resp != nil { + log.Printf("Response status: %d", resp.StatusCode) + } + log.Printf("Redirecting to login: %s/ui/login", tunnelURL) + http.Redirect(w, r, tunnelURL+"/ui/login", http.StatusSeeOther) + return + } + + if session == nil || !*session.Active { + log.Printf("Session inactive, redirecting to login") + http.Redirect(w, r, tunnelURL+"/ui/login", http.StatusSeeOther) + return + } + + ctx := context.WithValue(r.Context(), sessionContextKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) + } + } +} + +func AuthMiddleware(oryClient *ory.APIClient, queries *db.Queries) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, err := GetSession(r.Context()) + if err != nil { + 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 := queries.GetUserByOryID(r.Context(), oryID) + if err != nil { + user, err = queries.CreateUser(r.Context(), db.CreateUserParams{ + Email: email, + OryIdentityID: oryID, + }) + if err != nil { + log.Printf("Failed to create user: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + log.Printf("Created new user: %s (ID: %d)", email, user.ID) + } + + ctx := context.WithValue(r.Context(), "user", user) + next.ServeHTTP(w, r.WithContext(ctx)) + } + } +} + +func GetSession(ctx context.Context) (*ory.Session, error) { + session, ok := ctx.Value(sessionContextKey).(*ory.Session) + if !ok || session == nil { + return nil, errors.New("session not found in context") + } + return session, nil +} + +func GetUser(ctx context.Context) (*db.User, error) { + user, ok := ctx.Value("user").(*db.User) + if !ok || user == nil { + return nil, errors.New("user not found in context") + } + return user, nil +} + +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 +} diff --git a/internal/db/bookings.sql.go b/internal/db/bookings.sql.go new file mode 100644 index 0000000..4488a35 --- /dev/null +++ b/internal/db/bookings.sql.go @@ -0,0 +1,212 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: bookings.sql + +package db + +import ( + "context" + "database/sql" + "time" +) + +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 +` + +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"` +} + +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"` +} + +func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (CreateBookingRow, error) { + row := q.db.QueryRowContext(ctx, createBooking, + arg.UserID, + arg.ServiceID, + arg.EventDate, + arg.Address, + arg.Notes, + arg.Status, + ) + var i CreateBookingRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.ServiceID, + &i.EventDate, + &i.Address, + &i.Notes, + &i.CreatedAt, + &i.Status, + ) + return i, err +} + +const getBooking = `-- name: GetBooking :one +SELECT id, user_id, service_id, event_date, address, notes, created_at, status +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"` +} + +func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, error) { + row := q.db.QueryRowContext(ctx, getBooking, id) + var i GetBookingRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.ServiceID, + &i.EventDate, + &i.Address, + &i.Notes, + &i.CreatedAt, + &i.Status, + ) + return i, err +} + +const listBookingsByUser = `-- name: ListBookingsByUser :many +SELECT id, user_id, service_id, event_date, address, notes, created_at, status +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"` +} + +func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64) ([]ListBookingsByUserRow, error) { + rows, err := q.db.QueryContext(ctx, listBookingsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListBookingsByUserRow{} + for rows.Next() { + var i ListBookingsByUserRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ServiceID, + &i.EventDate, + &i.Address, + &i.Notes, + &i.CreatedAt, + &i.Status, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBookingsWithServiceByUser = `-- name: ListBookingsWithServiceByUser :many +SELECT + b.id, + b.user_id, + b.service_id, + b.event_date, + b.address, + b.notes, + b.created_at, + b.status, + s.name as service_name, + s.description as service_description, + s.price_cents as service_price_cents +FROM bookings b +JOIN services s ON b.service_id = s.id +WHERE b.user_id = $1 +ORDER BY b.event_date DESC +` + +type ListBookingsWithServiceByUserRow 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"` + ServiceName string `json:"service_name"` + ServiceDescription sql.NullString `json:"service_description"` + ServicePriceCents int32 `json:"service_price_cents"` +} + +func (q *Queries) ListBookingsWithServiceByUser(ctx context.Context, userID sql.NullInt64) ([]ListBookingsWithServiceByUserRow, error) { + rows, err := q.db.QueryContext(ctx, listBookingsWithServiceByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListBookingsWithServiceByUserRow{} + for rows.Next() { + var i ListBookingsWithServiceByUserRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.ServiceID, + &i.EventDate, + &i.Address, + &i.Notes, + &i.CreatedAt, + &i.Status, + &i.ServiceName, + &i.ServiceDescription, + &i.ServicePriceCents, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/data.go b/internal/db/data.go new file mode 100644 index 0000000..3a6f1a4 --- /dev/null +++ b/internal/db/data.go @@ -0,0 +1,68 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + "strconv" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func OpenDB(ctx context.Context) (*sql.DB, error) { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + host := getenv("DB_HOST", "localhost") + port := getenv("DB_PORT", "5432") + user := getenv("DB_USER", "party") + pass := getenv("DB_PASS", "secret") + name := getenv("DB_NAME", "partydb") + ssl := getenv("DB_SSLMODE", "disable") + dsn = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, name, ssl) + } + + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + + // Connection pool tuning + if v := os.Getenv("DB_MAX_OPEN_CONNS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + db.SetMaxOpenConns(n) + } + } else { + db.SetMaxOpenConns(25) + } + if v := os.Getenv("DB_MAX_IDLE_CONNS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + db.SetMaxIdleConns(n) + } + } else { + db.SetMaxIdleConns(25) + } + if v := os.Getenv("DB_CONN_MAX_LIFETIME"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + db.SetConnMaxLifetime(d) + } + } else { + db.SetConnMaxLifetime(30 * time.Minute) + } + + ctxPing, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := db.PingContext(ctxPing); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..cd5bbb8 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/gallery_photos.sql.go b/internal/db/gallery_photos.sql.go new file mode 100644 index 0000000..765065b --- /dev/null +++ b/internal/db/gallery_photos.sql.go @@ -0,0 +1,123 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: gallery_photos.sql + +package db + +import ( + "context" + "database/sql" +) + +const createGalleryPhoto = `-- name: CreateGalleryPhoto :one +INSERT INTO gallery_photos (image_url, caption, created_at, show_on_home) +VALUES ($1, $2, $3, $4) +RETURNING id, image_url, caption, created_at, show_on_home +` + +type CreateGalleryPhotoParams struct { + ImageUrl string `json:"image_url"` + Caption sql.NullString `json:"caption"` + CreatedAt sql.NullTime `json:"created_at"` + ShowOnHome sql.NullBool `json:"show_on_home"` +} + +func (q *Queries) CreateGalleryPhoto(ctx context.Context, arg CreateGalleryPhotoParams) (GalleryPhoto, error) { + row := q.db.QueryRowContext(ctx, createGalleryPhoto, + arg.ImageUrl, + arg.Caption, + arg.CreatedAt, + arg.ShowOnHome, + ) + var i GalleryPhoto + err := row.Scan( + &i.ID, + &i.ImageUrl, + &i.Caption, + &i.CreatedAt, + &i.ShowOnHome, + ) + return i, err +} + +const deleteGalleryPhoto = `-- name: DeleteGalleryPhoto :exec +DELETE FROM gallery_photos +WHERE id = $1 +` + +func (q *Queries) DeleteGalleryPhoto(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteGalleryPhoto, id) + return err +} + +const listGalleryPhotos = `-- name: ListGalleryPhotos :many +SELECT id, image_url, caption, created_at, show_on_home +FROM gallery_photos +ORDER BY created_at DESC +` + +func (q *Queries) ListGalleryPhotos(ctx context.Context) ([]GalleryPhoto, error) { + rows, err := q.db.QueryContext(ctx, listGalleryPhotos) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GalleryPhoto{} + for rows.Next() { + var i GalleryPhoto + if err := rows.Scan( + &i.ID, + &i.ImageUrl, + &i.Caption, + &i.CreatedAt, + &i.ShowOnHome, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHomeCarouselPhotos = `-- name: ListHomeCarouselPhotos :many +SELECT id, image_url, caption, created_at, show_on_home +FROM gallery_photos +WHERE show_on_home = true +ORDER BY created_at DESC +` + +func (q *Queries) ListHomeCarouselPhotos(ctx context.Context) ([]GalleryPhoto, error) { + rows, err := q.db.QueryContext(ctx, listHomeCarouselPhotos) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GalleryPhoto{} + for rows.Next() { + var i GalleryPhoto + if err := rows.Scan( + &i.ID, + &i.ImageUrl, + &i.Caption, + &i.CreatedAt, + &i.ShowOnHome, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..30d211a --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,44 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "database/sql" + "time" +) + +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"` +} + +type GalleryPhoto struct { + ID int64 `json:"id"` + ImageUrl string `json:"image_url"` + Caption sql.NullString `json:"caption"` + CreatedAt sql.NullTime `json:"created_at"` + ShowOnHome sql.NullBool `json:"show_on_home"` +} + +type Service struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description sql.NullString `json:"description"` + PriceCents int32 `json:"price_cents"` + CreatedAt sql.NullTime `json:"created_at"` +} + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + CreatedAt sql.NullTime `json:"created_at"` + OryIdentityID sql.NullString `json:"ory_identity_id"` +} diff --git a/internal/db/queries/bookings.sql b/internal/db/queries/bookings.sql new file mode 100644 index 0000000..c3448fb --- /dev/null +++ b/internal/db/queries/bookings.sql @@ -0,0 +1,33 @@ +-- 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; + +-- name: ListBookingsByUser :many +SELECT id, user_id, service_id, event_date, address, notes, created_at, status +FROM bookings +WHERE user_id = $1 +ORDER BY event_date DESC; + +-- name: ListBookingsWithServiceByUser :many +SELECT + b.id, + b.user_id, + b.service_id, + b.event_date, + b.address, + b.notes, + b.created_at, + b.status, + s.name as service_name, + s.description as service_description, + s.price_cents as service_price_cents +FROM bookings b +JOIN services s ON b.service_id = s.id +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 +FROM bookings +WHERE id = $1; diff --git a/internal/db/queries/gallery_photos.sql b/internal/db/queries/gallery_photos.sql new file mode 100644 index 0000000..c11c04e --- /dev/null +++ b/internal/db/queries/gallery_photos.sql @@ -0,0 +1,19 @@ +-- name: ListGalleryPhotos :many +SELECT id, image_url, caption, created_at, show_on_home +FROM gallery_photos +ORDER BY created_at DESC; + +-- name: ListHomeCarouselPhotos :many +SELECT id, image_url, caption, created_at, show_on_home +FROM gallery_photos +WHERE show_on_home = true +ORDER BY created_at DESC; + +-- name: CreateGalleryPhoto :one +INSERT INTO gallery_photos (image_url, caption, created_at, show_on_home) +VALUES ($1, $2, $3, $4) +RETURNING id, image_url, caption, created_at, show_on_home; + +-- name: DeleteGalleryPhoto :exec +DELETE FROM gallery_photos +WHERE id = $1; diff --git a/internal/db/queries/services.sql b/internal/db/queries/services.sql new file mode 100644 index 0000000..1e5961e --- /dev/null +++ b/internal/db/queries/services.sql @@ -0,0 +1,14 @@ +-- name: ListServices :many +SELECT id, name, description, price_cents, created_at +FROM services +ORDER BY id DESC; + +-- name: GetService :one +SELECT id, name, description, price_cents, created_at +FROM services +WHERE id = $1; + +-- name: CreateService :one +INSERT INTO services (name, description, price_cents, created_at) +VALUES ($1, $2, $3, $4) +RETURNING id, name, description, price_cents, created_at; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql new file mode 100644 index 0000000..b48bb90 --- /dev/null +++ b/internal/db/queries/users.sql @@ -0,0 +1,16 @@ +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = $1 LIMIT 1; + +-- name: GetUserByOryID :one +SELECT * FROM users WHERE ory_identity_id = $1 LIMIT 1; + +-- name: CreateUser :one +INSERT INTO users (email, ory_identity_id) +VALUES ($1, $2) +RETURNING *; + +-- name: UpdateUserOryID :one +UPDATE users +SET ory_identity_id = $1 +WHERE email = $2 +RETURNING *; diff --git a/internal/db/services.sql.go b/internal/db/services.sql.go new file mode 100644 index 0000000..6fc92db --- /dev/null +++ b/internal/db/services.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: services.sql + +package db + +import ( + "context" + "database/sql" +) + +const createService = `-- name: CreateService :one +INSERT INTO services (name, description, price_cents, created_at) +VALUES ($1, $2, $3, $4) +RETURNING id, name, description, price_cents, created_at +` + +type CreateServiceParams struct { + Name string `json:"name"` + Description sql.NullString `json:"description"` + PriceCents int32 `json:"price_cents"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) CreateService(ctx context.Context, arg CreateServiceParams) (Service, error) { + row := q.db.QueryRowContext(ctx, createService, + arg.Name, + arg.Description, + arg.PriceCents, + arg.CreatedAt, + ) + var i Service + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.PriceCents, + &i.CreatedAt, + ) + return i, err +} + +const getService = `-- name: GetService :one +SELECT id, name, description, price_cents, created_at +FROM services +WHERE id = $1 +` + +func (q *Queries) GetService(ctx context.Context, id int64) (Service, error) { + row := q.db.QueryRowContext(ctx, getService, id) + var i Service + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.PriceCents, + &i.CreatedAt, + ) + return i, err +} + +const listServices = `-- name: ListServices :many +SELECT id, name, description, price_cents, created_at +FROM services +ORDER BY id DESC +` + +func (q *Queries) ListServices(ctx context.Context) ([]Service, error) { + rows, err := q.db.QueryContext(ctx, listServices) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Service{} + for rows.Next() { + var i Service + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.PriceCents, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go new file mode 100644 index 0000000..b289e3c --- /dev/null +++ b/internal/db/users.sql.go @@ -0,0 +1,90 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package db + +import ( + "context" + "database/sql" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users (email, ory_identity_id) +VALUES ($1, $2) +RETURNING id, email, created_at, ory_identity_id +` + +type CreateUserParams struct { + Email string `json:"email"` + OryIdentityID sql.NullString `json:"ory_identity_id"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.OryIdentityID) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.OryIdentityID, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, created_at, ory_identity_id FROM users WHERE email = $1 LIMIT 1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.OryIdentityID, + ) + return i, err +} + +const getUserByOryID = `-- name: GetUserByOryID :one +SELECT id, email, created_at, ory_identity_id FROM users WHERE ory_identity_id = $1 LIMIT 1 +` + +func (q *Queries) GetUserByOryID(ctx context.Context, oryIdentityID sql.NullString) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByOryID, oryIdentityID) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.OryIdentityID, + ) + return i, err +} + +const updateUserOryID = `-- name: UpdateUserOryID :one +UPDATE users +SET ory_identity_id = $1 +WHERE email = $2 +RETURNING id, email, created_at, ory_identity_id +` + +type UpdateUserOryIDParams struct { + OryIdentityID sql.NullString `json:"ory_identity_id"` + Email string `json:"email"` +} + +func (q *Queries) UpdateUserOryID(ctx context.Context, arg UpdateUserOryIDParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserOryID, arg.OryIdentityID, arg.Email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.CreatedAt, + &i.OryIdentityID, + ) + return i, err +} diff --git a/internal/handlers/booking.go b/internal/handlers/booking.go new file mode 100644 index 0000000..7718733 --- /dev/null +++ b/internal/handlers/booking.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "html/template" + "net/http" + "strconv" + "time" + + "decor-by-hannahs/internal/auth" + "decor-by-hannahs/internal/db" +) + +func BookingPageHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + type data struct { + Title string + ActivePage string + } + _ = tmpl.ExecuteTemplate(w, "booking.tmpl", data{Title: "Book a Service", ActivePage: "booking"}) + } +} + +func BookingAPIHandler(q *db.Queries, tmpl *template.Template) 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 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var in struct { + ServiceID string `json:"service_id"` + 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 + } + 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}, + }) + if err != nil { + http.Error(w, "Failed to create booking", http.StatusInternalServerError) + return + } + writeJSON(w, booking) + } +} diff --git a/internal/handlers/catalog.go b/internal/handlers/catalog.go new file mode 100644 index 0000000..b697d24 --- /dev/null +++ b/internal/handlers/catalog.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "html/template" + "net/http" + + "decor-by-hannahs/internal/db" +) + +func CatalogHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + svcs, err := q.ListServices(r.Context()) + if err != nil { + http.Error(w, "unable to load services", http.StatusInternalServerError) + return + } + type data struct { + Services []db.Service + ActivePage string + } + if err := tmpl.ExecuteTemplate(w, "catalog.tmpl", data{Services: svcs, ActivePage: "catalog"}); err != nil { + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } + } +} diff --git a/internal/handlers/gallery.go b/internal/handlers/gallery.go new file mode 100644 index 0000000..b45dfda --- /dev/null +++ b/internal/handlers/gallery.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "html/template" + "net/http" + "os" + "path/filepath" + "sort" + + "decor-by-hannahs/internal/db" +) + +type Photo struct { + URL string + Caption string +} + +func GalleryHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var photos []Photo + + galleryDir := "assets/gallery-photos" + entries, err := os.ReadDir(galleryDir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() { + ext := filepath.Ext(entry.Name()) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" { + photos = append(photos, Photo{ + URL: "/assets/gallery-photos/" + entry.Name(), + Caption: "", + }) + } + } + } + } + + sort.Slice(photos, func(i, j int) bool { + return photos[i].URL < photos[j].URL + }) + + type data struct { + Photos []Photo + ActivePage string + } + if err := tmpl.ExecuteTemplate(w, "gallery.tmpl", data{Photos: photos, ActivePage: "gallery"}); err != nil { + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } + } +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go new file mode 100644 index 0000000..5925f3d --- /dev/null +++ b/internal/handlers/helpers.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" +) + +func sqlNullString(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +func sqlNullUUID(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..e9cc415 --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,54 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "sort" + + "decor-by-hannahs/internal/db" +) + +func HomeHandler(q *db.Queries, tmpl *template.Template) 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 != "/" { + http.NotFound(w, r) + return + } + + var photos []Photo + + carouselDir := "assets/carousel-photos" + entries, err := os.ReadDir(carouselDir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() { + ext := filepath.Ext(entry.Name()) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" { + photos = append(photos, Photo{ + URL: "/assets/carousel-photos/" + entry.Name(), + Caption: "", + }) + } + } + } + } + + sort.Slice(photos, func(i, j int) bool { + return photos[i].URL < photos[j].URL + }) + + type data struct { + Title string + Photos []Photo + ActivePage string + } + if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{Title: "Home", Photos: photos, ActivePage: "home"}); err != nil { + log.Printf("Error executing home.tmpl: %v", err) + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } + } +} diff --git a/internal/handlers/ory_redirect.go b/internal/handlers/ory_redirect.go new file mode 100644 index 0000000..39f6e66 --- /dev/null +++ b/internal/handlers/ory_redirect.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "html/template" + "net/http" +) + +type OryRedirectData struct { + Title string + ActivePage string + Flow string + Message string + Type string +} + +func OryRedirectHandler(tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + flow := r.URL.Query().Get("flow") + + var message string + var msgType string + + switch flow { + case "login": + message = "Successfully logged in!" + msgType = "success" + case "registration": + message = "Account created successfully!" + msgType = "success" + case "settings": + message = "Settings updated successfully!" + msgType = "success" + case "verification": + message = "Email verified successfully!" + msgType = "success" + case "recovery": + message = "Password recovered successfully!" + msgType = "success" + case "logout": + message = "Successfully logged out!" + msgType = "info" + default: + message = "Action completed successfully!" + msgType = "success" + } + + data := OryRedirectData{ + Title: "Redirect", + ActivePage: "", + Flow: flow, + Message: message, + Type: msgType, + } + + if err := tmpl.ExecuteTemplate(w, "ory_redirect.tmpl", data); err != nil { + http.Error(w, "Failed to render page", http.StatusInternalServerError) + } + } +} diff --git a/internal/handlers/profile.go b/internal/handlers/profile.go new file mode 100644 index 0000000..1ca6daa --- /dev/null +++ b/internal/handlers/profile.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + "os" + "strconv" + + "decor-by-hannahs/internal/auth" + "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 +} + +type ProfileData struct { + Authenticated bool + Email string + DisplayName string + ID string + ActivePage string + Bookings []BookingWithService + OrySettingsURL string +} + +func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + pd := ProfileData{ActivePage: "profile"} + + 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"), + }) + } + } + + oryAPIURL := os.Getenv("ORY_API_URL") + if oryAPIURL == "" { + tunnelPort := os.Getenv("TUNNEL_PORT") + if tunnelPort == "" { + tunnelPort = "4000" + } + oryAPIURL = "http://localhost:" + tunnelPort + } + pd.OrySettingsURL = oryAPIURL + "/ui/settings" + } + + if err := tmpl.ExecuteTemplate(w, "profile.tmpl", pd); err != nil { + http.Error(w, "failed to render profile", http.StatusInternalServerError) + return + } + } +} diff --git a/internal/tmpl/_partials.tmpl b/internal/tmpl/_partials.tmpl new file mode 100644 index 0000000..56a42d9 --- /dev/null +++ b/internal/tmpl/_partials.tmpl @@ -0,0 +1,110 @@ +{{define "header"}} + + + + +
+
+ + + Decor By Hannah's + Decor By Hannah's + + +
+
+ + +
+
+{{end}} + +{{define "footer"}} + +{{end}} + +{{define "theme-script"}} + +{{end}} diff --git a/internal/tmpl/booking.tmpl b/internal/tmpl/booking.tmpl new file mode 100644 index 0000000..73e834b --- /dev/null +++ b/internal/tmpl/booking.tmpl @@ -0,0 +1,106 @@ +{{define "booking.tmpl"}} + + + + + +Book a Service - Decor By Hannahs + + + + + + + + +{{template "header" .}} + +
+
+
+Birthday cake +

Book a Service

+

Fill out the form below to request a booking

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +Back to Catalog +
+
+
+
+
+ +{{template "footer"}} +{{template "theme-script"}} + + + +{{end}} diff --git a/internal/tmpl/catalog.tmpl b/internal/tmpl/catalog.tmpl new file mode 100644 index 0000000..2179ad5 --- /dev/null +++ b/internal/tmpl/catalog.tmpl @@ -0,0 +1,64 @@ +{{define "catalog.tmpl"}} + + + + + +Catalog - Decor By Hannahs + + + + + + + + +{{template "header" .}} + +
+
+Party horn +

Our Services

+

Browse our decoration packages and services

+
+ +
+{{ range .Services }} +
+
+

{{ .Name }}

+{{ if .ImageUrl.Valid }} +
+Featured +
+{{ end }} +
+
+

{{ .Description }}

+{{ if .ImageUrl.Valid }} +{{ .Name }} +{{ end }} +
+ +
+{{ else }} +
+

No services available yet. Check back soon!

+
+{{ end }} +
+
+ +{{template "footer"}} +{{template "theme-script"}} + + +{{end}} diff --git a/internal/tmpl/gallery.tmpl b/internal/tmpl/gallery.tmpl new file mode 100644 index 0000000..ca26cb8 --- /dev/null +++ b/internal/tmpl/gallery.tmpl @@ -0,0 +1,53 @@ +{{define "gallery.tmpl"}} + + + + + +See Our Work - Decor By Hannahs + + + + + + + + +{{template "header" .}} + +
+
+Happy sun +Jester hat +

See Our Work

+

Browse our recent decoration projects

+
+ +
+{{ range .Photos }} +
+{{ if .Caption }}{{ .Caption }}{{ else }}Gallery photo{{ end }} +{{ if .Caption }} +
+

{{ .Caption }}

+
+{{ end }} +
+{{ else }} +
+

No photos available yet. Check back soon!

+
+{{ end }} +
+
+ +{{template "footer"}} +{{template "theme-script"}} + + +{{end}} diff --git a/internal/tmpl/home.tmpl b/internal/tmpl/home.tmpl new file mode 100644 index 0000000..0a110c2 --- /dev/null +++ b/internal/tmpl/home.tmpl @@ -0,0 +1,176 @@ +{{define "home.tmpl"}} + + + + + +Home - Decor By Hannahs + + + + + + + + +{{template "header" .}} + +
+
+Party penguin +Jester hat +

Welcome to Decor By Hannah's

+

We take care of all the headache for your party decorations so you don't have to

+
+Browse Services +Book Now +
+
+ + + +
+
+Party hat
+

Professional Setup

+

Expert decoration services

+
+
+

Our experienced team handles everything from setup to teardown, ensuring your event looks perfect.

+
+
+ +
+Birthday cake +
+

Custom Themes

+

Tailored to your vision

+
+
+

Choose from our curated themes or work with us to create something uniquely yours.

+
+
+ +
+Happy sun +
+

Stress-Free Planning

+

We handle the details

+
+
+

Focus on enjoying your event while we take care of all the decoration logistics.

+
+
+
+ +{{ if .Photos }} +
+

See Our Work

+ +
+{{ end }} +
+ +{{template "footer"}} + +{{template "theme-script"}} + + + +{{end}} diff --git a/internal/tmpl/layout.tmpl b/internal/tmpl/layout.tmpl new file mode 100644 index 0000000..ca4af9e --- /dev/null +++ b/internal/tmpl/layout.tmpl @@ -0,0 +1,124 @@ +{{define "layout.tmpl"}} + + + + + +{{block "title" .}}Decor By Hannahs{{end}} + + + + + + + + + + + + +
+
+ + + Decor By Hannah's + Decor By Hannah's + +
+
+ + +
+
+ +
+{{block "content" .}}{{end}} +
+ + + + +{{block "scripts" .}}{{end}} + + +{{end}} diff --git a/internal/tmpl/ory_redirect.tmpl b/internal/tmpl/ory_redirect.tmpl new file mode 100644 index 0000000..67bd09a --- /dev/null +++ b/internal/tmpl/ory_redirect.tmpl @@ -0,0 +1,163 @@ + + + + + +Redirecting - Decor By Hannah's + + + + + + + +
+ +
+
+
+

Redirecting...

+

You will be redirected to the home page shortly.

+
+
+ + + + + diff --git a/internal/tmpl/profile.tmpl b/internal/tmpl/profile.tmpl new file mode 100644 index 0000000..191a8e5 --- /dev/null +++ b/internal/tmpl/profile.tmpl @@ -0,0 +1,147 @@ +{{define "profile.tmpl"}} + + + + + +Profile - Decor By Hannahs + + + + + + + + + +{{template "header" .}} + +
+
+
+

Welcome, {{.DisplayName}}

+

Manage your account and bookings

+
+ +
+
+
+

Account Information

+

Your personal details

+
+
+
+
+Email: {{.Email}} +
+ +
+
+
+ +
+
+

Your Bookings

+

View and manage your service bookings

+
+
+{{if .Bookings}} +{{range .Bookings}} +
+
+
+

{{.ServiceName}}

+

Booked on {{.CreatedAt}}

+
+{{.Status}} +
+
+
+ + + + + + +Event Date: {{.EventDate}} +
+{{if .Address}} +
+ + + + +{{.Address}} +
+{{end}} +
+ + + + +Price: ${{printf "%.2f" (divf .ServicePriceCents 100)}} +
+
+{{if .Notes}} +
+Notes: {{.Notes}} +
+{{end}} +
+{{end}} +{{else}} +

No bookings yet. Book your first service

+{{end}} +
+
+
+
+
+ +{{template "footer"}} +{{template "theme-script"}} + + +{{end}} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..99f0d37 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,14 @@ +version: "2" +sql: + - schema: "migrations" + queries: "internal/db/queries" + engine: "postgresql" + gen: + go: + package: "db" + out: "internal/db" + emit_json_tags: true + emit_prepared_queries: false + emit_interface: false + emit_empty_slices: true +