the rest
This commit is contained in:
18
go.mod
Normal file
18
go.mod
Normal file
@@ -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
|
||||
)
|
||||
32
go.sum
Normal file
32
go.sum
Normal file
@@ -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=
|
||||
19
internal/auth/handlers.go
Normal file
19
internal/auth/handlers.go
Normal 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
112
internal/auth/middleware.go
Normal 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
212
internal/db/bookings.sql.go
Normal 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
68
internal/db/data.go
Normal 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
31
internal/db/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
123
internal/db/gallery_photos.sql.go
Normal file
123
internal/db/gallery_photos.sql.go
Normal 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
44
internal/db/models.go
Normal 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"`
|
||||
}
|
||||
33
internal/db/queries/bookings.sql
Normal file
33
internal/db/queries/bookings.sql
Normal 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;
|
||||
19
internal/db/queries/gallery_photos.sql
Normal file
19
internal/db/queries/gallery_photos.sql
Normal 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;
|
||||
14
internal/db/queries/services.sql
Normal file
14
internal/db/queries/services.sql
Normal 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;
|
||||
16
internal/db/queries/users.sql
Normal file
16
internal/db/queries/users.sql
Normal 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 *;
|
||||
96
internal/db/services.sql.go
Normal file
96
internal/db/services.sql.go
Normal 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
90
internal/db/users.sql.go
Normal 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
|
||||
}
|
||||
76
internal/handlers/booking.go
Normal file
76
internal/handlers/booking.go
Normal 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)
|
||||
}
|
||||
}
|
||||
25
internal/handlers/catalog.go
Normal file
25
internal/handlers/catalog.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
50
internal/handlers/gallery.go
Normal file
50
internal/handlers/gallery.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
internal/handlers/helpers.go
Normal file
26
internal/handlers/helpers.go
Normal 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
54
internal/handlers/home.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
59
internal/handlers/ory_redirect.go
Normal file
59
internal/handlers/ory_redirect.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/handlers/profile.go
Normal file
82
internal/handlers/profile.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
110
internal/tmpl/_partials.tmpl
Normal file
110
internal/tmpl/_partials.tmpl
Normal 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
106
internal/tmpl/booking.tmpl
Normal 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}}
|
||||
64
internal/tmpl/catalog.tmpl
Normal file
64
internal/tmpl/catalog.tmpl
Normal 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}}
|
||||
53
internal/tmpl/gallery.tmpl
Normal file
53
internal/tmpl/gallery.tmpl
Normal 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
176
internal/tmpl/home.tmpl
Normal 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
124
internal/tmpl/layout.tmpl
Normal 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}}
|
||||
163
internal/tmpl/ory_redirect.tmpl
Normal file
163
internal/tmpl/ory_redirect.tmpl
Normal 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
147
internal/tmpl/profile.tmpl
Normal 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}}
|
||||
Reference in New Issue
Block a user