fixed some login and booking bugs
This commit is contained in:
@@ -6,6 +6,12 @@ import (
|
|||||||
ory "github.com/ory/client-go"
|
ory "github.com/ory/client-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func LoginHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, tunnelURL+"/ui/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func LogOutHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc {
|
func LogOutHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cookies := r.Header.Get("Cookie")
|
cookies := r.Header.Get("Cookie")
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const createBooking = `-- name: CreateBooking :one
|
const createBooking = `-- name: CreateBooking :one
|
||||||
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status)
|
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status, service_option, event_type)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status
|
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateBookingParams struct {
|
type CreateBookingParams struct {
|
||||||
@@ -24,6 +24,8 @@ type CreateBookingParams struct {
|
|||||||
Address sql.NullString `json:"address"`
|
Address sql.NullString `json:"address"`
|
||||||
Notes sql.NullString `json:"notes"`
|
Notes sql.NullString `json:"notes"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
|
ServiceOption sql.NullString `json:"service_option"`
|
||||||
|
EventType sql.NullString `json:"event_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBookingRow struct {
|
type CreateBookingRow struct {
|
||||||
@@ -35,6 +37,8 @@ type CreateBookingRow struct {
|
|||||||
Notes sql.NullString `json:"notes"`
|
Notes sql.NullString `json:"notes"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
|
ServiceOption sql.NullString `json:"service_option"`
|
||||||
|
EventType sql.NullString `json:"event_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (CreateBookingRow, error) {
|
func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (CreateBookingRow, error) {
|
||||||
@@ -45,6 +49,8 @@ func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (C
|
|||||||
arg.Address,
|
arg.Address,
|
||||||
arg.Notes,
|
arg.Notes,
|
||||||
arg.Status,
|
arg.Status,
|
||||||
|
arg.ServiceOption,
|
||||||
|
arg.EventType,
|
||||||
)
|
)
|
||||||
var i CreateBookingRow
|
var i CreateBookingRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -56,12 +62,14 @@ func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (C
|
|||||||
&i.Notes,
|
&i.Notes,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.ServiceOption,
|
||||||
|
&i.EventType,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBooking = `-- name: GetBooking :one
|
const getBooking = `-- name: GetBooking :one
|
||||||
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
|
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -75,6 +83,8 @@ type GetBookingRow struct {
|
|||||||
Notes sql.NullString `json:"notes"`
|
Notes sql.NullString `json:"notes"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
|
ServiceOption sql.NullString `json:"service_option"`
|
||||||
|
EventType sql.NullString `json:"event_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, error) {
|
func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, error) {
|
||||||
@@ -89,12 +99,14 @@ func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, erro
|
|||||||
&i.Notes,
|
&i.Notes,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.ServiceOption,
|
||||||
|
&i.EventType,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listBookingsByUser = `-- name: ListBookingsByUser :many
|
const listBookingsByUser = `-- name: ListBookingsByUser :many
|
||||||
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
|
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY event_date DESC
|
ORDER BY event_date DESC
|
||||||
@@ -109,6 +121,8 @@ type ListBookingsByUserRow struct {
|
|||||||
Notes sql.NullString `json:"notes"`
|
Notes sql.NullString `json:"notes"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
|
ServiceOption sql.NullString `json:"service_option"`
|
||||||
|
EventType sql.NullString `json:"event_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64) ([]ListBookingsByUserRow, error) {
|
func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64) ([]ListBookingsByUserRow, error) {
|
||||||
@@ -129,6 +143,8 @@ func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64)
|
|||||||
&i.Notes,
|
&i.Notes,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.ServiceOption,
|
||||||
|
&i.EventType,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -153,6 +169,8 @@ SELECT
|
|||||||
b.notes,
|
b.notes,
|
||||||
b.created_at,
|
b.created_at,
|
||||||
b.status,
|
b.status,
|
||||||
|
b.service_option,
|
||||||
|
b.event_type,
|
||||||
s.name as service_name,
|
s.name as service_name,
|
||||||
s.description as service_description,
|
s.description as service_description,
|
||||||
s.price_cents as service_price_cents
|
s.price_cents as service_price_cents
|
||||||
@@ -171,6 +189,8 @@ type ListBookingsWithServiceByUserRow struct {
|
|||||||
Notes sql.NullString `json:"notes"`
|
Notes sql.NullString `json:"notes"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
|
ServiceOption sql.NullString `json:"service_option"`
|
||||||
|
EventType sql.NullString `json:"event_type"`
|
||||||
ServiceName string `json:"service_name"`
|
ServiceName string `json:"service_name"`
|
||||||
ServiceDescription sql.NullString `json:"service_description"`
|
ServiceDescription sql.NullString `json:"service_description"`
|
||||||
ServicePriceCents int32 `json:"service_price_cents"`
|
ServicePriceCents int32 `json:"service_price_cents"`
|
||||||
@@ -194,6 +214,8 @@ func (q *Queries) ListBookingsWithServiceByUser(ctx context.Context, userID sql.
|
|||||||
&i.Notes,
|
&i.Notes,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Status,
|
&i.Status,
|
||||||
|
&i.ServiceOption,
|
||||||
|
&i.EventType,
|
||||||
&i.ServiceName,
|
&i.ServiceName,
|
||||||
&i.ServiceDescription,
|
&i.ServiceDescription,
|
||||||
&i.ServicePriceCents,
|
&i.ServicePriceCents,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type Booking struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
Address sql.NullString `json:"address"`
|
Address sql.NullString `json:"address"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
|
ServiceOption sql.NullString `json:"service_option"`
|
||||||
|
EventType sql.NullString `json:"event_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GalleryPhoto struct {
|
type GalleryPhoto struct {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
-- name: CreateBooking :one
|
-- name: CreateBooking :one
|
||||||
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status)
|
INSERT INTO bookings (user_id, service_id, event_date, address, notes, status, service_option, event_type)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status;
|
RETURNING id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type;
|
||||||
|
|
||||||
-- name: ListBookingsByUser :many
|
-- name: ListBookingsByUser :many
|
||||||
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
|
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY event_date DESC;
|
ORDER BY event_date DESC;
|
||||||
@@ -19,6 +19,8 @@ SELECT
|
|||||||
b.notes,
|
b.notes,
|
||||||
b.created_at,
|
b.created_at,
|
||||||
b.status,
|
b.status,
|
||||||
|
b.service_option,
|
||||||
|
b.event_type,
|
||||||
s.name as service_name,
|
s.name as service_name,
|
||||||
s.description as service_description,
|
s.description as service_description,
|
||||||
s.price_cents as service_price_cents
|
s.price_cents as service_price_cents
|
||||||
@@ -28,6 +30,6 @@ WHERE b.user_id = $1
|
|||||||
ORDER BY b.event_date DESC;
|
ORDER BY b.event_date DESC;
|
||||||
|
|
||||||
-- name: GetBooking :one
|
-- name: GetBooking :one
|
||||||
SELECT id, user_id, service_id, event_date, address, notes, created_at, status
|
SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|||||||
@@ -5,38 +5,105 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"decor-by-hannahs/internal/auth"
|
ory "github.com/ory/client-go"
|
||||||
|
|
||||||
"decor-by-hannahs/internal/db"
|
"decor-by-hannahs/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BookingPageHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
func validateAustralianAddress(address string) (bool, string) {
|
||||||
|
if len(strings.TrimSpace(address)) < 10 {
|
||||||
|
return false, "Address is too short. Please provide a complete address."
|
||||||
|
}
|
||||||
|
|
||||||
|
australianStates := []string{"NSW", "VIC", "QLD", "SA", "WA", "TAS", "NT", "ACT"}
|
||||||
|
hasState := false
|
||||||
|
upperAddress := strings.ToUpper(address)
|
||||||
|
for _, state := range australianStates {
|
||||||
|
if strings.Contains(upperAddress, state) {
|
||||||
|
hasState = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
postcodePattern := regexp.MustCompile(`\b\d{4}\b`)
|
||||||
|
hasPostcode := postcodePattern.MatchString(address)
|
||||||
|
|
||||||
|
if !hasState && !hasPostcode {
|
||||||
|
return false, "Please include an Australian state (NSW, VIC, QLD, etc.) and postcode in your address."
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasState {
|
||||||
|
return false, "Please include an Australian state (NSW, VIC, QLD, SA, WA, TAS, NT, or ACT) in your address."
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPostcode {
|
||||||
|
return false, "Please include a 4-digit postcode in your address."
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func BookingPageHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
type data struct {
|
type data struct {
|
||||||
Title string
|
Title string
|
||||||
ActivePage string
|
ActivePage string
|
||||||
|
Authenticated bool
|
||||||
|
OryLoginURL string
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "booking.tmpl", data{
|
||||||
|
Title: "Book a Service",
|
||||||
|
ActivePage: "booking",
|
||||||
|
Authenticated: isAuthenticated(r, oryClient),
|
||||||
|
OryLoginURL: "/login",
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_ = tmpl.ExecuteTemplate(w, "booking.tmpl", data{Title: "Book a Service", ActivePage: "booking"})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BookingAPIHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
func BookingAPIHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "Method is not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method is not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := auth.GetUser(r.Context())
|
cookies := r.Header.Get("Cookie")
|
||||||
if err != nil {
|
session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
|
||||||
|
|
||||||
|
if err != nil || session == nil || session.Active == nil || !*session.Active {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
email := getEmailFromSession(session)
|
||||||
|
if email == "" {
|
||||||
|
http.Error(w, "No email in session", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oryID := sql.NullString{String: session.Identity.Id, Valid: true}
|
||||||
|
user, err := q.GetUserByOryID(r.Context(), oryID)
|
||||||
|
if err != nil {
|
||||||
|
user, err = q.CreateUser(r.Context(), db.CreateUserParams{
|
||||||
|
Email: email,
|
||||||
|
OryIdentityID: oryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to get or create user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var in struct {
|
var in struct {
|
||||||
ServiceID string `json:"service_id"`
|
ServiceOption string `json:"service_option"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
EventDate string `json:"event_date"`
|
EventDate string `json:"event_date"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
@@ -45,27 +112,29 @@ func BookingAPIHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc
|
|||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if valid, msg := validateAustralianAddress(in.Address); !valid {
|
||||||
|
http.Error(w, msg, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
eventTime, err := time.Parse(time.RFC3339, in.EventDate)
|
eventTime, err := time.Parse(time.RFC3339, in.EventDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, err := strconv.ParseInt(in.ServiceID, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Invalid Service ID", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
svcID := sql.NullInt64{Int64: svc, Valid: true}
|
|
||||||
userID := sql.NullInt64{Int64: user.ID, Valid: true}
|
userID := sql.NullInt64{Int64: user.ID, Valid: true}
|
||||||
|
|
||||||
booking, err := q.CreateBooking(r.Context(), db.CreateBookingParams{
|
booking, err := q.CreateBooking(r.Context(), db.CreateBookingParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ServiceID: svcID,
|
ServiceID: sql.NullInt64{},
|
||||||
EventDate: eventTime,
|
EventDate: eventTime,
|
||||||
Address: sqlNullString(in.Address),
|
Address: sqlNullString(in.Address),
|
||||||
Notes: sqlNullString(in.Notes),
|
Notes: sqlNullString(in.Notes),
|
||||||
Status: sql.NullString{String: "pending", Valid: true},
|
Status: sql.NullString{String: "pending", Valid: true},
|
||||||
|
ServiceOption: sqlNullString(in.ServiceOption),
|
||||||
|
EventType: sqlNullString(in.EventType),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to create booking", http.StatusInternalServerError)
|
http.Error(w, "Failed to create booking", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
ory "github.com/ory/client-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sqlNullString(s string) sql.NullString {
|
func sqlNullString(s string) sql.NullString {
|
||||||
@@ -13,6 +16,13 @@ func sqlNullString(s string) sql.NullString {
|
|||||||
return sql.NullString{String: s, Valid: true}
|
return sql.NullString{String: s, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullStringToString(ns sql.NullString) string {
|
||||||
|
if ns.Valid {
|
||||||
|
return ns.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func sqlNullUUID(s string) sql.NullString {
|
func sqlNullUUID(s string) sql.NullString {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
@@ -24,3 +34,24 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getOryAPIURL() string {
|
||||||
|
oryAPIURL := os.Getenv("ORY_API_URL")
|
||||||
|
if oryAPIURL == "" {
|
||||||
|
tunnelPort := os.Getenv("TUNNEL_PORT")
|
||||||
|
if tunnelPort == "" {
|
||||||
|
tunnelPort = "4000"
|
||||||
|
}
|
||||||
|
oryAPIURL = "http://localhost:" + tunnelPort
|
||||||
|
}
|
||||||
|
return oryAPIURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuthenticated(r *http.Request, oryClient *ory.APIClient) bool {
|
||||||
|
cookies := r.Header.Get("Cookie")
|
||||||
|
session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return session != nil && session.Active != nil && *session.Active
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
ory "github.com/ory/client-go"
|
||||||
|
|
||||||
"decor-by-hannahs/internal/db"
|
"decor-by-hannahs/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
func HomeHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Printf("HomeHandler called: method=%s path=%s", r.Method, r.URL.Path)
|
log.Printf("HomeHandler called: method=%s path=%s", r.Method, r.URL.Path)
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
@@ -45,8 +47,16 @@ func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
|||||||
Title string
|
Title string
|
||||||
Photos []Photo
|
Photos []Photo
|
||||||
ActivePage string
|
ActivePage string
|
||||||
|
Authenticated bool
|
||||||
|
OryLoginURL string
|
||||||
}
|
}
|
||||||
if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{Title: "Home", Photos: photos, ActivePage: "home"}); err != nil {
|
if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{
|
||||||
|
Title: "Home",
|
||||||
|
Photos: photos,
|
||||||
|
ActivePage: "home",
|
||||||
|
Authenticated: isAuthenticated(r, oryClient),
|
||||||
|
OryLoginURL: "/login",
|
||||||
|
}); err != nil {
|
||||||
log.Printf("Error executing home.tmpl: %v", err)
|
log.Printf("Error executing home.tmpl: %v", err)
|
||||||
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
http.Error(w, "Failed to render page", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"decor-by-hannahs/internal/auth"
|
ory "github.com/ory/client-go"
|
||||||
|
|
||||||
"decor-by-hannahs/internal/db"
|
"decor-by-hannahs/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ type BookingWithService struct {
|
|||||||
ServiceName string
|
ServiceName string
|
||||||
ServiceDescription string
|
ServiceDescription string
|
||||||
ServicePriceCents int32
|
ServicePriceCents int32
|
||||||
|
ServiceOption string
|
||||||
|
EventType string
|
||||||
EventDate string
|
EventDate string
|
||||||
Address string
|
Address string
|
||||||
Notes string
|
Notes string
|
||||||
@@ -31,20 +34,45 @@ type ProfileData struct {
|
|||||||
ActivePage string
|
ActivePage string
|
||||||
Bookings []BookingWithService
|
Bookings []BookingWithService
|
||||||
OrySettingsURL string
|
OrySettingsURL string
|
||||||
|
OryLoginURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
func ProfileHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
pd := ProfileData{ActivePage: "profile"}
|
oryAPIURL := getOryAPIURL()
|
||||||
|
pd := ProfileData{
|
||||||
|
ActivePage: "profile",
|
||||||
|
OryLoginURL: "/login",
|
||||||
|
OrySettingsURL: oryAPIURL + "/ui/settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies := r.Header.Get("Cookie")
|
||||||
|
session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute()
|
||||||
|
|
||||||
|
if err == nil && session != nil && session.Active != nil && *session.Active {
|
||||||
|
email := getEmailFromSession(session)
|
||||||
|
if email != "" {
|
||||||
|
oryID := sql.NullString{String: session.Identity.Id, Valid: true}
|
||||||
|
user, err := q.GetUserByOryID(r.Context(), oryID)
|
||||||
|
if err != nil {
|
||||||
|
user, err = q.CreateUser(r.Context(), db.CreateUserParams{
|
||||||
|
Email: email,
|
||||||
|
OryIdentityID: oryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create user: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Created new user: %s (ID: %d)", email, user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user, err := auth.GetUser(r.Context())
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
pd.Authenticated = true
|
pd.Authenticated = true
|
||||||
pd.Email = user.Email
|
pd.Email = user.Email
|
||||||
pd.ID = strconv.FormatInt(user.ID, 10)
|
pd.ID = strconv.FormatInt(user.ID, 10)
|
||||||
pd.DisplayName = user.Email
|
pd.DisplayName = user.Email
|
||||||
|
|
||||||
bookings, err := q.ListBookingsWithServiceByUser(r.Context(), user.ID)
|
bookings, err := q.ListBookingsWithServiceByUser(r.Context(), sql.NullInt64{Int64: user.ID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error fetching bookings: %v", err)
|
log.Printf("Error fetching bookings: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -54,24 +82,18 @@ func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
|||||||
ServiceName: b.ServiceName,
|
ServiceName: b.ServiceName,
|
||||||
ServiceDescription: nullStringToString(b.ServiceDescription),
|
ServiceDescription: nullStringToString(b.ServiceDescription),
|
||||||
ServicePriceCents: b.ServicePriceCents,
|
ServicePriceCents: b.ServicePriceCents,
|
||||||
|
ServiceOption: nullStringToString(b.ServiceOption),
|
||||||
|
EventType: nullStringToString(b.EventType),
|
||||||
EventDate: b.EventDate.Format("January 2, 2006"),
|
EventDate: b.EventDate.Format("January 2, 2006"),
|
||||||
Address: nullStringToString(b.Address),
|
Address: nullStringToString(b.Address),
|
||||||
Notes: nullStringToString(b.Notes),
|
Notes: nullStringToString(b.Notes),
|
||||||
Status: b.Status,
|
Status: nullStringToString(b.Status),
|
||||||
CreatedAt: b.CreatedAt.Time.Format("Jan 2, 2006"),
|
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 {
|
if err := tmpl.ExecuteTemplate(w, "profile.tmpl", pd); err != nil {
|
||||||
@@ -80,3 +102,18 @@ func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEmailFromSession(session *ory.Session) string {
|
||||||
|
if session.Identity.Traits == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
traits, ok := session.Identity.Traits.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
email, ok := traits["email"].(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,10 +27,17 @@
|
|||||||
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-calendar"/></svg>
|
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-calendar"/></svg>
|
||||||
Book
|
Book
|
||||||
</a>
|
</a>
|
||||||
|
{{if .Authenticated}}
|
||||||
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>
|
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>
|
||||||
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
|
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
|
||||||
Profile
|
Profile
|
||||||
</a>
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="{{.OryLoginURL}}">
|
||||||
|
<svg class="drawer-nav-icon"><use href="/static/icons.svg#icon-user"/></svg>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +65,11 @@
|
|||||||
<a href="/catalog" {{if eq .ActivePage "catalog"}}class="active"{{end}}>Catalog</a>
|
<a href="/catalog" {{if eq .ActivePage "catalog"}}class="active"{{end}}>Catalog</a>
|
||||||
<a href="/gallery" {{if eq .ActivePage "gallery"}}class="active"{{end}}>Gallery</a>
|
<a href="/gallery" {{if eq .ActivePage "gallery"}}class="active"{{end}}>Gallery</a>
|
||||||
<a href="/booking" {{if eq .ActivePage "booking"}}class="active"{{end}}>Book</a>
|
<a href="/booking" {{if eq .ActivePage "booking"}}class="active"{{end}}>Book</a>
|
||||||
|
{{if .Authenticated}}
|
||||||
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>Profile</a>
|
<a href="/profile" {{if eq .ActivePage "profile"}}class="active"{{end}}>Profile</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="{{.OryLoginURL}}" class="btn btn-primary" style="padding: 0.5rem 1rem; text-decoration: none;">Login</a>
|
||||||
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
<button id="themeToggle" class="btn btn-outline" aria-label="Toggle theme">
|
<button id="themeToggle" class="btn btn-outline" aria-label="Toggle theme">
|
||||||
<svg id="sunIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
<svg id="sunIcon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||||
|
|||||||
@@ -31,8 +31,28 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<form id="bookingForm">
|
<form id="bookingForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="service_id">Service ID</label>
|
<label class="form-label" for="service_option">Service Option</label>
|
||||||
<input class="form-input" type="text" id="service_id" name="service_id" placeholder="Enter service ID" required>
|
<select class="form-input" id="service_option" name="service_option" required>
|
||||||
|
<option value="">Select a service option...</option>
|
||||||
|
<option value="Small Bundle">Small Bundle</option>
|
||||||
|
<option value="Medium Bundle">Medium Bundle</option>
|
||||||
|
<option value="Large Bundle">Large Bundle</option>
|
||||||
|
<option value="Balloons Only">Balloons Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="event_type">Event Type</label>
|
||||||
|
<select class="form-input" id="event_type" name="event_type" required>
|
||||||
|
<option value="">Select an event type...</option>
|
||||||
|
<option value="Birthday">Birthday</option>
|
||||||
|
<option value="Wedding">Wedding</option>
|
||||||
|
<option value="Anniversary">Anniversary</option>
|
||||||
|
<option value="Baby Shower">Baby Shower</option>
|
||||||
|
<option value="Graduation">Graduation</option>
|
||||||
|
<option value="Corporate Event">Corporate Event</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -41,8 +61,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="address">Address</label>
|
<label class="form-label" for="address">Address <span style="color: hsl(var(--destructive));">*</span></label>
|
||||||
<input class="form-input" type="text" id="address" name="address" placeholder="Event location">
|
<input class="form-input" type="text" id="address" name="address" placeholder="123 Main St, Sydney NSW 2000" required>
|
||||||
|
<small style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Please include street, suburb, state, and postcode</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -62,18 +83,63 @@
|
|||||||
{{template "footer"}}
|
{{template "footer"}}
|
||||||
{{template "theme-script"}}
|
{{template "theme-script"}}
|
||||||
<script>
|
<script>
|
||||||
|
function validateAustralianAddress(address) {
|
||||||
|
if (!address || address.trim().length < 10) {
|
||||||
|
return { valid: false, message: 'Address is too short. Please provide a complete address.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const australianStates = ['NSW', 'VIC', 'QLD', 'SA', 'WA', 'TAS', 'NT', 'ACT'];
|
||||||
|
const hasState = australianStates.some(state =>
|
||||||
|
address.toUpperCase().includes(state) ||
|
||||||
|
address.toUpperCase().includes(state.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const postcodePattern = /\b\d{4}\b/;
|
||||||
|
const hasPostcode = postcodePattern.test(address);
|
||||||
|
|
||||||
|
if (!hasState && !hasPostcode) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: 'Please include an Australian state (NSW, VIC, QLD, etc.) and postcode in your address.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasState) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: 'Please include an Australian state (NSW, VIC, QLD, SA, WA, TAS, NT, or ACT) in your address.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPostcode) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: 'Please include a 4-digit postcode in your address.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('bookingForm').addEventListener('submit', async function(e){
|
document.getElementById('bookingForm').addEventListener('submit', async function(e){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const f = e.target;
|
const f = e.target;
|
||||||
const submitBtn = f.querySelector('button[type="submit"]');
|
const submitBtn = f.querySelector('button[type="submit"]');
|
||||||
const originalText = submitBtn.textContent;
|
const originalText = submitBtn.textContent;
|
||||||
|
|
||||||
|
const addressValidation = validateAustralianAddress(f.address.value);
|
||||||
|
if (!addressValidation.valid) {
|
||||||
|
alert(addressValidation.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Submitting...';
|
submitBtn.textContent = 'Submitting...';
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
user_id: "",
|
user_id: "",
|
||||||
service_id: f.service_id.value,
|
service_option: f.service_option.value,
|
||||||
|
event_type: f.event_type.value,
|
||||||
event_date: new Date(f.event_date.value).toISOString(),
|
event_date: new Date(f.event_date.value).toISOString(),
|
||||||
address: f.address.value,
|
address: f.address.value,
|
||||||
notes: f.notes.value
|
notes: f.notes.value
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div style="max-width: 1000px; margin: 0 auto;">
|
<div style="max-width: 1000px; margin: 0 auto;">
|
||||||
|
{{if .Authenticated}}
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="margin-bottom: 2rem;">
|
||||||
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Welcome, {{.DisplayName}}</h1>
|
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;">Welcome, {{.DisplayName}}</h1>
|
||||||
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">Manage your account and bookings</p>
|
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem;">Manage your account and bookings</p>
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
<div class="booking-card">
|
<div class="booking-card">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
|
||||||
<div>
|
<div>
|
||||||
<h4 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem;">{{.ServiceName}}</h4>
|
<h4 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem;">{{.ServiceOption}}{{if .EventType}} - {{.EventType}}{{end}}</h4>
|
||||||
<p style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Booked on {{.CreatedAt}}</p>
|
<p style="color: hsl(var(--muted-foreground)); font-size: 0.875rem;">Booked on {{.CreatedAt}}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge status-{{.Status}}">{{.Status}}</span>
|
<span class="status-badge status-{{.Status}}">{{.Status}}</span>
|
||||||
@@ -116,13 +117,6 @@
|
|||||||
<span style="font-size: 0.875rem;">{{.Address}}</span>
|
<span style="font-size: 0.875rem;">{{.Address}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<line x1="12" y1="1" x2="12" y2="23"></line>
|
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
|
||||||
</svg>
|
|
||||||
<span style="font-size: 0.875rem;">Price: <strong>${{printf "%.2f" (divf .ServicePriceCents 100)}}</strong></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{if .Notes}}
|
{{if .Notes}}
|
||||||
<div style="padding: 0.75rem; background: hsl(var(--muted) / 0.3); border-radius: 6px; font-size: 0.875rem;">
|
<div style="padding: 0.75rem; background: hsl(var(--muted) / 0.3); border-radius: 6px; font-size: 0.875rem;">
|
||||||
@@ -137,6 +131,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="margin-bottom: 2rem; text-align: center; padding: 4rem 2rem;">
|
||||||
|
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: 0 auto 2rem; color: hsl(var(--muted-foreground));">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
<h1 style="font-size: 2.5rem; font-weight: 700; margin-bottom: 1rem;">Login Required</h1>
|
||||||
|
<p style="color: hsl(var(--muted-foreground)); font-size: 1.125rem; margin-bottom: 2rem;">Please log in to view your profile and manage your bookings</p>
|
||||||
|
<a href="{{.OryLoginURL}}" class="btn btn-primary" style="text-decoration: none; display: inline-block;">Login or Sign Up</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user