This commit is contained in:
tumillanino
2025-10-28 12:36:37 +11:00
parent 186d6768fb
commit dad3ec655f
31 changed files with 2256 additions and 0 deletions

19
internal/auth/handlers.go Normal file
View File

@@ -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)
}
}

112
internal/auth/middleware.go Normal file
View File

@@ -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
}

212
internal/db/bookings.sql.go Normal file
View File

@@ -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
}

68
internal/db/data.go Normal file
View File

@@ -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
}

31
internal/db/db.go Normal file
View File

@@ -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,
}
}

View File

@@ -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
}

44
internal/db/models.go Normal file
View File

@@ -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"`
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 *;

View File

@@ -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
}

90
internal/db/users.sql.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

54
internal/handlers/home.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,110 @@
{{define "header"}}
<!-- Navigation Drawer -->
<div id="nav-drawer" class="drawer">
<div class="drawer-overlay"></div>
<div class="drawer-content">
<div class="drawer-header">
<div class="drawer-title">Menu</div>
<button class="drawer-close" aria-label="Close menu">
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-x"/></svg>
</button>
</div>
<div class="drawer-body">
<nav class="drawer-nav">
<a href="/" {{if eq .ActivePage "home"}}class="active"{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-home"/></svg>
Home
</a>
<a href="/catalog" {{if eq .ActivePage "catalog"}}class="active"{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-catalog"/></svg>
Catalog
</a>
<a href="/gallery" {{if eq .ActivePage "gallery"}}class="active"{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-image"/></svg>
Gallery
</a>
<a href="/booking" {{if eq .ActivePage "booking"}}class="active"{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-calendar"/></svg>
Book
</a>
<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>
</nav>
</div>
</div>
</div>
<!-- Header -->
<header class="header-bar">
<div style="display: flex; align-items: center; gap: 1rem;">
<button class="menu-btn" onclick="window.navDrawer?.toggle()" aria-label="Toggle menu">
<div class="menu-icon">
<span></span>
<span></span>
<span></span>
</div>
</button>
<a href="/" class="header-brand" style="position: relative;">
<img src="/assets/balloon.png" alt="Decor By Hannah's">
Decor By Hannah's
<img src="/assets/party-icons/party-hat.png" alt="" style="position: absolute; top: -8px; right: -15px; width: 25px; height: 25px; transform: rotate(20deg);">
</a>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<nav class="header-nav">
<a href="/" {{if eq .ActivePage "home"}}class="active"{{end}}>Home</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="/booking" {{if eq .ActivePage "booking"}}class="active"{{end}}>Book</a>
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>Profile</a>
</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>
<svg id="moonIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
</button>
</div>
</header>
{{end}}
{{define "footer"}}
<footer style="position: relative;">
<img src="/assets/party-icons/penguin-wearing-party-hat.png" alt="" style="position: absolute; bottom: 10px; left: 20px; width: 50px; height: 50px; opacity: 0.6;">
<img src="/assets/party-icons/party-hat-with-horn.png" alt="" style="position: absolute; bottom: 10px; right: 20px; width: 50px; height: 50px; opacity: 0.6; transform: rotate(-20deg);">
<small>© 2025 Decor By Hannah's. All rights reserved.</small>
</footer>
{{end}}
{{define "theme-script"}}
<script>
(function() {
const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.getElementById('sunIcon');
const moonIcon = document.getElementById('moonIcon');
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'light';
html.setAttribute('data-theme', savedTheme);
updateIcons(savedTheme);
function updateIcons(theme) {
if (theme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
themeToggle.addEventListener('click', () => {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateIcons(newTheme);
});
})();
</script>
{{end}}

106
internal/tmpl/booking.tmpl Normal file
View File

@@ -0,0 +1,106 @@
{{define "booking.tmpl"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Book a Service - Decor By Hannahs</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script>
(function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
})();
</script>
<script src="/static/js/alpine.js" defer></script>
<script src="/static/htmx/htmx.min.js" defer></script>
<script src="/static/js/drawer.js" defer></script>
</head>
<body>
{{template "header" .}}
<main>
<div style="max-width: 600px; margin: 0 auto;">
<div style="margin-bottom: 2rem; position: relative;">
<img src="/assets/party-icons/birthday-cake.png" alt="Birthday cake" style="position: absolute; top: -20px; right: -40px; width: 90px; height: 90px; opacity: 0.85;">
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Book a Service</h1>
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">Fill out the form below to request a booking</p>
</div>
<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>
</div>
<div class="form-group">
<label class="form-label" for="event_date">Event Date & Time</label>
<input class="form-input" type="datetime-local" id="event_date" name="event_date" required>
</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">
</div>
<div class="form-group">
<label class="form-label" for="notes">Additional Notes</label>
<textarea class="form-textarea" id="notes" name="notes" placeholder="Any special requests or details..."></textarea>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" style="flex: 1;">Request Booking</button>
<a href="/catalog" class="btn btn-outline">Back to Catalog</a>
</div>
</form>
</div>
</div>
</main>
{{template "footer"}}
{{template "theme-script"}}
<script>
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;
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
const body = {
user_id: "",
service_id: f.service_id.value,
event_date: new Date(f.event_date.value).toISOString(),
address: f.address.value,
notes: f.notes.value
};
try {
const res = await fetch('/api/bookings', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (res.ok) {
alert('Booking requested successfully! We will contact you soon.');
f.reset();
} else {
const error = await res.text();
alert('Error creating booking: ' + error);
}
} catch (err) {
alert('Network error. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
</script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,64 @@
{{define "catalog.tmpl"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Catalog - Decor By Hannahs</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script>
(function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
})();
</script>
<script src="/static/js/alpine.js" defer></script>
<script src="/static/htmx/htmx.min.js" defer></script>
<script src="/static/js/drawer.js" defer></script>
</head>
<body>
{{template "header" .}}
<main>
<div style="margin-bottom: 2rem; position: relative;">
<img src="/assets/party-icons/party-hat-with-horn.png" alt="Party horn" style="position: absolute; top: -10px; right: 20px; width: 70px; height: 70px; transform: rotate(-15deg); opacity: 0.9;">
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Our Services</h1>
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">Browse our decoration packages and services</p>
</div>
<div class="grid grid-cols-2">
{{ range .Services }}
<div class="card">
<div class="card-header">
<h3 class="card-title">{{ .Name }}</h3>
{{ if .ImageUrl.Valid }}
<div style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;">
<span class="badge" style="background: hsl(var(--accent));">Featured</span>
</div>
{{ end }}
</div>
<div class="card-content">
<p>{{ .Description }}</p>
{{ if .ImageUrl.Valid }}
<img src="{{ .ImageUrl.String }}" alt="{{ .Name }}" style="width: 100%; height: 200px; object-fit: cover; border-radius: var(--radius); margin-top: 1rem;">
{{ end }}
</div>
<div class="card-footer">
<a href="/booking" class="btn btn-primary">Book This Service</a>
<a href="#" class="btn btn-outline">Learn More</a>
</div>
</div>
{{ else }}
<div class="card" style="grid-column: 1 / -1; text-align: center; padding: 3rem;">
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">No services available yet. Check back soon!</p>
</div>
{{ end }}
</div>
</main>
{{template "footer"}}
{{template "theme-script"}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "gallery.tmpl"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>See Our Work - Decor By Hannahs</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script>
(function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
})();
</script>
<script src="/static/js/alpine.js" defer></script>
<script src="/static/htmx/htmx.min.js" defer></script>
<script src="/static/js/drawer.js" defer></script>
</head>
<body>
{{template "header" .}}
<main>
<div style="margin-bottom: 2rem; position: relative;">
<img src="/assets/party-icons/happy-sun-wearing-party-hat.png" alt="Happy sun" style="position: absolute; top: -15px; left: 260px; width: 80px; height: 80px; opacity: 0.9;">
<img src="/assets/party-icons/jester-hat.png" alt="Jester hat" style="position: absolute; top: -10px; right: 30px; width: 65px; height: 65px; transform: rotate(25deg); opacity: 0.85;">
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">See Our Work</h1>
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">Browse our recent decoration projects</p>
</div>
<div class="grid grid-cols-3">
{{ range .Photos }}
<div class="card">
<img src="{{ .URL }}" alt="{{ if .Caption }}{{ .Caption }}{{ else }}Gallery photo{{ end }}" style="width: 100%; height: 250px; object-fit: cover; border-radius: var(--radius);">
{{ if .Caption }}
<div class="card-content">
<p>{{ .Caption }}</p>
</div>
{{ end }}
</div>
{{ else }}
<div class="card" style="grid-column: 1 / -1; text-align: center; padding: 3rem;">
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">No photos available yet. Check back soon!</p>
</div>
{{ end }}
</div>
</main>
{{template "footer"}}
{{template "theme-script"}}
</body>
</html>
{{end}}

176
internal/tmpl/home.tmpl Normal file
View File

@@ -0,0 +1,176 @@
{{define "home.tmpl"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home - Decor By Hannahs</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script>
(function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
})();
</script>
<script src="/static/js/alpine.js" defer></script>
<script src="/static/htmx/htmx.min.js" defer></script>
<script src="/static/js/drawer.js" defer></script>
</head>
<body>
{{template "header" .}}
<main>
<div class="hero" style="position: relative;">
<img src="/assets/party-icons/penguin-wearing-party-hat.png" alt="Party penguin" style="position: absolute; top: 20px; right: 10%; width: 100px; height: 100px; opacity: 0.8; animation: float 3s ease-in-out infinite;">
<img src="/assets/party-icons/jester-hat.png" alt="Jester hat" style="position: absolute; bottom: 20px; left: 10%; width: 80px; height: 80px; opacity: 0.8; animation: float 4s ease-in-out infinite; animation-delay: 1s;">
<h1>Welcome to Decor By Hannah's</h1>
<p>We take care of all the headache for your party decorations so you don't have to</p>
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 2rem; flex-wrap: wrap;">
<a href="/catalog" class="btn btn-primary">Browse Services</a>
<a href="/booking" class="btn btn-outline">Book Now</a>
</div>
</div>
<style>
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
</style>
<div class="grid grid-cols-3" style="margin-top: 4rem;">
<div class="card" style="position: relative; overflow: visible;">
<img src="/assets/party-icons/party-hat.png" alt="Party hat" style="position: absolute; top: -50px; left: -45px; width: 80px; height: 80px; transform: rotate(-25deg); z-index: 10;"> <div class="card-header">
<h3 class="card-title">Professional Setup</h3>
<p class="card-description">Expert decoration services</p>
</div>
<div class="card-content">
<p>Our experienced team handles everything from setup to teardown, ensuring your event looks perfect.</p>
</div>
</div>
<div class="card" style="position: relative; overflow: visible;">
<img src="/assets/party-icons/birthday-cake.png" alt="Birthday cake" style="position: absolute; bottom: -15px; left: -15px; width: 70px; height: 70px; z-index: 10;">
<div class="card-header">
<h3 class="card-title">Custom Themes</h3>
<p class="card-description">Tailored to your vision</p>
</div>
<div class="card-content">
<p>Choose from our curated themes or work with us to create something uniquely yours.</p>
</div>
</div>
<div class="card" style="position: relative; overflow: visible;">
<img src="/assets/party-icons/happy-sun-wearing-party-hat.png" alt="Happy sun" style="position: absolute; top: -25px; right: -10px; width: 90px; height: 90px; z-index: 10;">
<div class="card-header">
<h3 class="card-title">Stress-Free Planning</h3>
<p class="card-description">We handle the details</p>
</div>
<div class="card-content">
<p>Focus on enjoying your event while we take care of all the decoration logistics.</p>
</div>
</div>
</div>
{{ if .Photos }}
<div style="margin-top: 4rem;">
<h2 style="font-size: 2rem; font-weight: 700; text-align: center; margin-bottom: 2rem;">See Our Work</h2>
<div class="carousel" id="carousel">
<div class="carousel-container">
<div class="carousel-track" id="carouselTrack">
{{ range .Photos }}
<div class="carousel-slide">
<img src="{{ .URL }}" alt="{{ if .Caption }}{{ .Caption }}{{ else }}Gallery photo{{ end }}" style="width: 100%; height: 500px; object-fit: cover; border-radius: var(--radius);">
{{ if .Caption }}
<p style="text-align: center; margin-top: 1rem; color: hsl(var(--muted-foreground));">{{ .Caption }}</p>
{{ end }}
</div>
{{ end }}
</div>
</div>
<button class="carousel-btn carousel-btn-prev" id="prevBtn" aria-label="Previous slide">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<button class="carousel-btn carousel-btn-next" id="nextBtn" aria-label="Next slide">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
<div class="carousel-indicators" id="indicators"></div>
</div>
</div>
{{ end }}
</main>
{{template "footer"}}
{{template "theme-script"}}
<script>
(function() {
const track = document.getElementById('carouselTrack');
if (!track) return;
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const indicators = document.getElementById('indicators');
const slides = track.querySelectorAll('.carousel-slide');
const totalSlides = slides.length;
let currentSlide = 0;
let autoplayInterval;
if (totalSlides === 0) return;
for (let i = 0; i < totalSlides; i++) {
const btn = document.createElement('button');
btn.className = 'carousel-indicator';
btn.setAttribute('aria-label', `Go to slide ${i + 1}`);
btn.addEventListener('click', () => goToSlide(i));
indicators.appendChild(btn);
}
function updateCarousel() {
track.style.transform = `translateX(-${currentSlide * 100}%)`;
const indicatorBtns = indicators.querySelectorAll('.carousel-indicator');
indicatorBtns.forEach((btn, i) => {
btn.classList.toggle('active', i === currentSlide);
});
}
function goToSlide(index) {
currentSlide = index;
updateCarousel();
resetAutoplay();
}
function nextSlide() {
currentSlide = (currentSlide + 1) % totalSlides;
updateCarousel();
}
function prevSlide() {
currentSlide = (currentSlide - 1 + totalSlides) % totalSlides;
updateCarousel();
resetAutoplay();
}
function startAutoplay() {
autoplayInterval = setInterval(nextSlide, 5000);
}
function resetAutoplay() {
clearInterval(autoplayInterval);
startAutoplay();
}
prevBtn.addEventListener('click', prevSlide);
nextBtn.addEventListener('click', () => {
nextSlide();
resetAutoplay();
});
updateCarousel();
startAutoplay();
})();
</script>
</body>
</html>
{{end}}

124
internal/tmpl/layout.tmpl Normal file
View File

@@ -0,0 +1,124 @@
{{define "layout.tmpl"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Decor By Hannahs{{end}}</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script src="/static/js/alpine.js" defer></script>
<script src="/static/htmx/htmx.min.js" defer></script>
<script src="/static/js/drawer.js" defer></script>
<link rel="icon" href="/assets/balloon.png" type="image/png">
</head>
<body>
<!-- Navigation Drawer -->
<div id="nav-drawer" class="drawer">
<div class="drawer-overlay"></div>
<div class="drawer-content">
<div class="drawer-header">
<div class="drawer-title">Menu</div>
<button class="drawer-close" aria-label="Close menu">
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-x"/></svg>
</button>
</div>
<div class="drawer-body">
<nav class="drawer-nav">
<a href="/" {{block "nav-home-active" .}}{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-home"/></svg>
Home
</a>
<a href="/catalog" {{block "nav-catalog-active" .}}{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-catalog"/></svg>
Catalog
</a>
<a href="/gallery" {{block "nav-gallery-active" .}}{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-image"/></svg>
Gallery
</a>
<a href="/booking" {{block "nav-booking-active" .}}{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-calendar"/></svg>
Book
</a>
<a href="/profile" {{block "nav-profile-active" .}}{{end}}>
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
Profile
</a>
</nav>
</div>
</div>
</div>
<!-- Header -->
<header class="header-bar">
<div style="display: flex; align-items: center; gap: 1rem;">
<button class="menu-btn" onclick="window.navDrawer?.toggle()" aria-label="Toggle menu">
<div class="menu-icon">
<span></span>
<span></span>
<span></span>
</div>
</button>
<a href="/" class="header-brand">
<img src="/assets/balloon.png" alt="Decor By Hannah's">
Decor By Hannah's
</a>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<nav class="header-nav">
<a href="/" {{block "header-home-active" .}}{{end}}>Home</a>
<a href="/catalog" {{block "header-catalog-active" .}}{{end}}>Catalog</a>
<a href="/gallery" {{block "header-gallery-active" .}}{{end}}>Gallery</a>
<a href="/booking" {{block "header-booking-active" .}}{{end}}>Book</a>
<a href="/profile" {{block "header-profile-active" .}}{{end}}>Profile</a>
</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>
<svg id="moonIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
</button>
</div>
</header>
<main>
{{block "content" .}}{{end}}
</main>
<footer>
<small>© 2025 Decor By Hannah's. All rights reserved.</small>
</footer>
<script>
(function() {
const themeToggle = document.getElementById('themeToggle');
const sunIcon = document.getElementById('sunIcon');
const moonIcon = document.getElementById('moonIcon');
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme') || 'light';
html.setAttribute('data-theme', savedTheme);
updateIcons(savedTheme);
function updateIcons(theme) {
if (theme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
themeToggle.addEventListener('click', () => {
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateIcons(newTheme);
});
})();
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,163 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redirecting - Decor By Hannah's</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script src="/static/htmx/htmx.min.js"></script>
<link rel="icon" href="/assets/balloon.png" type="image/png">
<style>
.toast {
position: fixed;
top: 20px;
right: 20px;
background: var(--primary-color);
color: white;
padding: 1rem 1.5rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 300px;
animation: slideIn 0.3s ease-out;
z-index: 9999;
}
.toast.success {
background: #10b981;
}
.toast.info {
background: #3b82f6;
}
.toast.warning {
background: #f59e0b;
}
.toast.error {
background: #ef4444;
}
.toast-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.toast-message {
flex: 1;
font-weight: 500;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast.hiding {
animation: slideOut 0.3s ease-in forwards;
}
.redirect-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 2rem;
}
.redirect-content {
max-width: 500px;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 2rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="toast-container"></div>
<div class="redirect-container">
<div class="redirect-content">
<div class="spinner"></div>
<h1>Redirecting...</h1>
<p>You will be redirected to the home page shortly.</p>
</div>
</div>
<script>
(function() {
function showToast(message, type) {
const container = document.getElementById('toast-container');
const icons = {
success: '<svg class="toast-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>',
info: '<svg class="toast-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
warning: '<svg class="toast-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>',
error: '<svg class="toast-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>'
};
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
${icons[type] || icons.info}
<span class="toast-message">${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
container.removeChild(toast);
}, 300);
}, 3000);
}
const message = {{.Message}};
const type = {{.Type}};
showToast(message, type);
setTimeout(() => {
window.location.href = '/';
}, 2000);
})();
</script>
</body>
</html>

147
internal/tmpl/profile.tmpl Normal file
View File

@@ -0,0 +1,147 @@
{{define "profile.tmpl"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profile - Decor By Hannahs</title>
<link rel="stylesheet" href="/static/css/styles.css">
<script>
(function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
})();
</script>
<script src="/static/js/alpine.js" defer></script>
<script src="/static/htmx/htmx.min.js" defer></script>
<script src="/static/js/drawer.js" defer></script>
<style>
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-pending {
background: hsl(var(--warning) / 0.1);
color: hsl(var(--warning));
}
.status-confirmed {
background: hsl(var(--success) / 0.1);
color: hsl(var(--success));
}
.status-completed {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
}
.status-cancelled {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
.booking-card {
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
transition: box-shadow 0.2s;
}
.booking-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
</head>
<body>
{{template "header" .}}
<main>
<div style="max-width: 1000px; margin: 0 auto;">
<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>
</div>
<div class="grid grid-cols-1" style="gap: 1.5rem;">
<div class="card">
<div class="card-header">
<h3 class="card-title">Account Information</h3>
<p class="card-description">Your personal details</p>
</div>
<div class="card-content">
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<strong>Email:</strong> {{.Email}}
</div>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="{{.OrySettingsURL}}" class="btn btn-outline">Account Settings</a>
<a href="/logout" class="btn btn-outline" style="color: hsl(var(--destructive)); border-color: hsl(var(--destructive));">Logout</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Your Bookings</h3>
<p class="card-description">View and manage your service bookings</p>
</div>
<div class="card-content">
{{if .Bookings}}
{{range .Bookings}}
<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>
<p style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Booked on {{.CreatedAt}}</p>
</div>
<span class="status-badge status-{{.Status}}">{{.Status}}</span>
</div>
<div style="display: grid; gap: 0.5rem; margin-bottom: 0.75rem;">
<div style="display: flex; gap: 0.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span style="font-size: 0.875rem;">Event Date: <strong>{{.EventDate}}</strong></span>
</div>
{{if .Address}}
<div style="display: flex; gap: 0.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<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;">
<strong>Notes:</strong> {{.Notes}}
</div>
{{end}}
</div>
{{end}}
{{else}}
<p style="color: hsl(var(--muted-foreground));">No bookings yet. <a href="/booking" style="color: hsl(var(--primary)); text-decoration: underline;">Book your first service</a></p>
{{end}}
</div>
</div>
</div>
</div>
</main>
{{template "footer"}}
{{template "theme-script"}}
</body>
</html>
{{end}}