frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)

This commit is contained in:
2026-08-05 13:46:50 +10:00
parent b846c2c58e
commit 133e375b6b
31 changed files with 1591 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
// Command migrate applies db/schema.sql to DATABASE_URL.
// Usage: go run ./cmd/migrate [-schema db/schema.sql]
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
schemaPath := flag.String("schema", "db/schema.sql", "path to schema file")
flag.Parse()
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set")
os.Exit(1)
}
sqlBytes, err := os.ReadFile(*schemaPath)
if err != nil {
fmt.Fprintln(os.Stderr, "error reading schema:", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "error connecting:", err)
os.Exit(1)
}
defer pool.Close()
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
fmt.Fprintln(os.Stderr, "error applying schema:", err)
os.Exit(1)
}
fmt.Println("schema applied to", dsn)
}

49
frontend/cmd/seed/main.go Normal file
View File

@@ -0,0 +1,49 @@
// Command seed inserts test tag codes TEST000001..TEST000025.
// Idempotent: already-existing codes are skipped.
package main
import (
"context"
"errors"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"wherewoof/frontend/internal/db"
)
const count = 25
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set")
os.Exit(1)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
fmt.Fprintln(os.Stderr, "connect:", err)
os.Exit(1)
}
defer pool.Close()
q := db.New(pool)
for i := 1; i <= count; i++ {
code := fmt.Sprintf("TEST%06d", i)
if _, err := q.InsertTag(ctx, code); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
fmt.Println("skip (exists)", code)
continue
}
fmt.Fprintln(os.Stderr, "insert", code, ":", err)
os.Exit(1)
}
fmt.Println("seeded", code)
}
fmt.Printf("done: %d test tags available\n", count)
}

18
frontend/go.mod Normal file
View File

@@ -0,0 +1,18 @@
module wherewoof/frontend
go 1.26
require (
github.com/gorilla/sessions v1.4.0
github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.54.0
)
require (
github.com/gorilla/securecookie v1.1.2 // indirect
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/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)

34
frontend/go.sum Normal file
View File

@@ -0,0 +1,34 @@
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/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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/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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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=

View File

@@ -0,0 +1,18 @@
// Package auth provides password hashing and session handling for WhereWoof.
package auth
import "golang.org/x/crypto/bcrypt"
// HashPassword returns a bcrypt hash of the given plaintext password.
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// CheckPassword reports whether the plaintext password matches the bcrypt hash.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@@ -0,0 +1,51 @@
package auth
import (
"net/http"
"github.com/gorilla/sessions"
)
// SessionName is the cookie name for authenticated sessions.
const SessionName = "ww_session"
// Store is the signed + encrypted cookie store, initialised by InitSessionStore.
var Store *sessions.CookieStore
// InitSessionStore creates the cookie store with the server secret.
func InitSessionStore(secret string) {
Store = sessions.NewCookieStore([]byte(secret))
Store.Options = &sessions.Options{
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 30 * 24 * 3600, // 30 days
}
}
// GetUserID returns the authenticated user id from the request, if any.
func GetUserID(r *http.Request) (int64, bool) {
if Store == nil {
return 0, false
}
s, err := Store.Get(r, SessionName)
if err != nil {
return 0, false
}
id, ok := s.Values["user_id"].(int64)
return id, ok
}
// SetUserID records the user id in the session cookie.
func SetUserID(w http.ResponseWriter, r *http.Request, id int64) error {
s, _ := Store.Get(r, SessionName)
s.Values["user_id"] = id
return s.Save(r, w)
}
// Clear destroys the session (logout).
func Clear(w http.ResponseWriter, r *http.Request) error {
s, _ := Store.Get(r, SessionName)
s.Options.MaxAge = -1
return s.Save(r, w)
}

View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,44 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"github.com/jackc/pgx/v5/pgtype"
)
type Scan struct {
ID int64 `json:"id"`
TagID int64 `json:"tag_id"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
Lat pgtype.Float8 `json:"lat"`
Lng pgtype.Float8 `json:"lng"`
LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
AlertSent bool `json:"alert_sent"`
}
type Tag struct {
ID int64 `json:"id"`
TagCode string `json:"tag_code"`
OwnerID pgtype.Int8 `json:"owner_id"`
Status string `json:"status"`
ItemType pgtype.Text `json:"item_type"`
Description pgtype.Text `json:"description"`
PhotoUrl pgtype.Text `json:"photo_url"`
Phone pgtype.Text `json:"phone"`
Address pgtype.Text `json:"address"`
Notes pgtype.Text `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}

View File

@@ -0,0 +1,28 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
GetTagByID(ctx context.Context, id int64) (Tag, error)
GetUserByEmail(ctx context.Context, email string) (User, error)
GetUserByID(ctx context.Context, id int64) (User, error)
InsertTag(ctx context.Context, tagCode string) (Tag, error)
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
}
var _ Querier = (*Queries)(nil)

View File

@@ -0,0 +1,47 @@
-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
-- name: ListTagsByOwner :many
SELECT * FROM tags WHERE owner_id = $1 ORDER BY created_at DESC;
-- name: GetTagByCode :one
SELECT * FROM tags WHERE tag_code = $1;
-- name: GetTagByID :one
SELECT * FROM tags WHERE id = $1;
-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1;
-- name: BindTag :one
UPDATE tags
SET owner_id = $1, updated_at = now()
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
RETURNING *;
-- name: UpdateTagDetails :one
UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
WHERE id=$1
RETURNING *;
-- name: ClearTagOwner :one
UPDATE tags
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
WHERE id=$1
RETURNING *;
-- name: SetTagStatus :exec
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
-- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1)
RETURNING *;

View File

@@ -0,0 +1,319 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: queries.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const bindTag = `-- name: BindTag :one
UPDATE tags
SET owner_id = $1, updated_at = now()
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
type BindTagParams struct {
OwnerID pgtype.Int8 `json:"owner_id"`
TagCode string `json:"tag_code"`
}
func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
row := q.db.QueryRow(ctx, bindTag, arg.OwnerID, arg.TagCode)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const clearTagOwner = `-- name: ClearTagOwner :one
UPDATE tags
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
row := q.db.QueryRow(ctx, clearTagOwner, id)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const countTagsByOwner = `-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1
`
func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) {
row := q.db.QueryRow(ctx, countTagsByOwner, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, name, phone, created_at
`
type CreateUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.Phone,
)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Phone,
&i.CreatedAt,
)
return i, err
}
const getTagByCode = `-- name: GetTagByCode :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE tag_code = $1
`
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
row := q.db.QueryRow(ctx, getTagByCode, tagCode)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getTagByID = `-- name: GetTagByID :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE id = $1
`
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
row := q.db.QueryRow(ctx, getTagByID, id)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Phone,
&i.CreatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Phone,
&i.CreatedAt,
)
return i, err
}
const insertTag = `-- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1)
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
row := q.db.QueryRow(ctx, insertTag, tagCode)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listTagsByOwner = `-- name: ListTagsByOwner :many
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
rows, err := q.db.Query(ctx, listTagsByOwner, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Tag
for rows.Next() {
var i Tag
if err := rows.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setTagStatus = `-- name: SetTagStatus :exec
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
`
type SetTagStatusParams struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) error {
_, err := q.db.Exec(ctx, setTagStatus, arg.ID, arg.Status)
return err
}
const updateTagDetails = `-- name: UpdateTagDetails :one
UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
type UpdateTagDetailsParams struct {
ID int64 `json:"id"`
ItemType pgtype.Text `json:"item_type"`
Description pgtype.Text `json:"description"`
PhotoUrl pgtype.Text `json:"photo_url"`
Phone pgtype.Text `json:"phone"`
Address pgtype.Text `json:"address"`
Notes pgtype.Text `json:"notes"`
}
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
row := q.db.QueryRow(ctx, updateTagDetails,
arg.ID,
arg.ItemType,
arg.Description,
arg.PhotoUrl,
arg.Phone,
arg.Address,
arg.Notes,
)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,98 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
func (a *App) RegisterPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "register", "Create account", nil, "")
}
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
name := strings.TrimSpace(r.FormValue("name"))
if email == "" || password == "" {
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
return
}
if len(password) < 8 {
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
return
}
if len([]byte(password)) > 72 {
a.render(w, r, "register", "Create account", nil, "Password must be 72 bytes or fewer.")
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := a.Queries.CreateUser(r.Context(), db.CreateUserParams{
Email: email,
PasswordHash: hash,
Name: pgtype.Text{String: name, Valid: name != ""},
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
a.render(w, r, "register", "Create account", nil, "That email is already registered.")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := auth.SetUserID(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "login", "Log in", nil, "")
}
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
user, err := a.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
return
}
if !auth.CheckPassword(user.PasswordHash, password) {
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
return
}
if err := auth.SetUserID(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
_ = auth.Clear(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -0,0 +1,103 @@
// Package handlers wires templates, auth, and database queries for the
// WhereWoof GOAT front-end.
package handlers
import (
"html/template"
"net/http"
"strings"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
// Templates maps a page key to its parsed template set (base + page + partials).
// Each page is parsed as its own set so the shared "content" block name
// doesn't collide across pages.
type Templates map[string]*template.Template
// App holds dependencies shared by all handlers.
type App struct {
Queries *db.Queries
Tpl Templates
}
// New returns an App with the given query layer and template sets.
func New(queries *db.Queries, tpl Templates) *App {
return &App{Queries: queries, Tpl: tpl}
}
// PageData is the root data passed to the base layout.
type PageData struct {
CurrentUser *db.User
Title string
Error string
Data any
}
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// LoadTemplates parses every page's template set from templates/.
func LoadTemplates() (Templates, error) {
const dir = "templates"
base := dir + "/base.html"
pages := map[string][]string{
"index": {dir + "/index.html"},
"register": {dir + "/register.html"},
"login": {dir + "/login.html"},
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html"},
"edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"},
"notfound": {dir + "/not-found.html"},
}
funcs := template.FuncMap{"title": titleCase}
tpl := make(Templates, len(pages))
for name, files := range pages {
paths := append([]string{base}, files...)
t, err := template.New(name).Funcs(funcs).ParseFiles(paths...)
if err != nil {
return nil, err
}
tpl[name] = t
}
return tpl, nil
}
// render executes the page's base layout with a PageData populated from the
// authenticated user (if any).
func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, data any, errMsg string) {
pd := PageData{Title: title, Data: data, Error: errMsg}
if uid, ok := auth.GetUserID(r); ok {
if u, err := a.Queries.GetUserByID(r.Context(), uid); err == nil {
pd.CurrentUser = &u
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Tpl[page].ExecuteTemplate(w, "base", pd); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
// renderPartial executes a named partial (e.g. "account-panel") for HTMX swaps.
func (a *App) renderPartial(w http.ResponseWriter, page, partial string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Tpl[page].ExecuteTemplate(w, partial, data); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
// RequireAuth redirects unauthenticated requests to /login.
func (a *App) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.GetUserID(r); !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
)
type publicData struct {
ID int64
TagCode string
Status string
ItemType pgtype.Text
Description pgtype.Text
PhotoUrl pgtype.Text
Phone pgtype.Text
Address pgtype.Text
Notes pgtype.Text
IsOwner bool
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "index", "Home", nil, "")
}
// PublicTag renders the unauthenticated tag page: setup prompt, return details,
// or unavailable (suspended / not found).
func (a *App) PublicTag(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("tag_code")
tag, err := a.Queries.GetTagByCode(r.Context(), code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.render(w, r, "notfound", "Tag not found", nil, "")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
pd := publicData{
ID: tag.ID,
TagCode: tag.TagCode,
Status: tag.Status,
ItemType: tag.ItemType,
Description: tag.Description,
PhotoUrl: tag.PhotoUrl,
Phone: tag.Phone,
Address: tag.Address,
Notes: tag.Notes,
}
if uid, ok := auth.GetUserID(r); ok && tag.OwnerID.Valid && tag.OwnerID.Int64 == uid {
pd.IsOwner = true
}
a.render(w, r, "public", "Found item", pd, "")
}

View File

@@ -0,0 +1,152 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
const maxTagsPerAccount = 20
type accountData struct {
Tags []db.Tag
AddError string
}
func ownerID(uid int64) pgtype.Int8 {
return pgtype.Int8{Int64: uid, Valid: true}
}
func (a *App) Account(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.render(w, r, "account", "My Tags", accountData{Tags: tags}, "")
}
// AddTag binds a tag code to the current account (HTMX: returns account-panel).
func (a *App) AddTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
code := strings.ToUpper(strings.TrimSpace(r.FormValue("tag_code")))
oid := ownerID(uid)
data := accountData{}
if code == "" {
data.AddError = "Enter a tag code."
} else {
cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if cnt >= maxTagsPerAccount {
data.AddError = "Limit reached: each account can hold 20 tags."
} else {
_, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
data.AddError = "Tag not found, or already claimed by another account."
} else {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
}
}
tags, err := a.Queries.ListTagsByOwner(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data.Tags = tags
a.renderPartial(w, "account", "account-panel", data)
}
func (a *App) EditTagPage(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "suspended" {
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
return
}
a.render(w, r, "edit", "Edit tag", tag, "")
}
func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "suspended" {
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
return
}
params := db.UpdateTagDetailsParams{
ID: tag.ID,
ItemType: textOrNil(strings.TrimSpace(r.FormValue("item_type"))),
Description: textOrNil(strings.TrimSpace(r.FormValue("description"))),
PhotoUrl: textOrNil(strings.TrimSpace(r.FormValue("photo_url"))),
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
}
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
// DeleteTag reverts a tag to unset so it can be re-bound (HTMX: account-panel).
func (a *App) DeleteTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if _, err := a.Queries.ClearTagOwner(r.Context(), tag.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.renderPartial(w, "account", "account-panel", accountData{Tags: tags})
}
// loadOwnedTag fetches a tag by path id and verifies it belongs to uid.
func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (db.Tag, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return db.Tag{}, false
}
tag, err := a.Queries.GetTagByID(r.Context(), id)
if err != nil || !tag.OwnerID.Valid || tag.OwnerID.Int64 != uid {
http.NotFound(w, r)
return db.Tag{}, false
}
return tag, true
}
func textOrNil(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: s != ""}
}

71
frontend/main.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"context"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
"wherewoof/frontend/internal/handlers"
)
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL not set")
}
secret := os.Getenv("SESSION_SECRET")
if secret == "" {
log.Fatal("SESSION_SECRET not set")
}
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":3020"
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
log.Fatal("connect:", err)
}
defer pool.Close()
auth.InitSessionStore(secret)
tpl, err := handlers.LoadTemplates()
if err != nil {
log.Fatal("templates:", err)
}
app := handlers.New(db.New(pool), tpl)
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
mux.HandleFunc("GET /{$}", app.Home)
mux.HandleFunc("/{path...}", http.NotFound)
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
mux.HandleFunc("GET /register", app.RegisterPage)
mux.HandleFunc("POST /register", app.Register)
mux.HandleFunc("GET /login", app.LoginPage)
mux.HandleFunc("POST /login", app.Login)
mux.HandleFunc("POST /logout", app.Logout)
mux.Handle("GET /account", app.RequireAuth(app.Account))
mux.Handle("POST /account/tags", app.RequireAuth(app.AddTag))
mux.Handle("GET /account/tags/{id}/edit", app.RequireAuth(app.EditTagPage))
mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag))
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Println("wherewoof listening on", addr)
log.Fatal(srv.ListenAndServe())
}

12
frontend/sqlc.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: "2"
sql:
- engine: "postgresql"
queries: "internal/db/queries.sql"
schema: "../db/schema.sql"
gen:
go:
package: "db"
out: "internal/db"
sql_package: "pgx/v5"
emit_interface: true
emit_json_tags: true

View File

@@ -0,0 +1,16 @@
{{define "account-panel"}}
<div id="account-panel">
{{if .AddError}}<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{{.AddError}}</p>{{end}}
<div class="mt-4 rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
<form hx-post="/account/tags" hx-target="#account-panel" hx-swap="outerHTML" class="flex gap-2">
<input name="tag_code" type="text" placeholder="Enter tag code (e.g. TEST000001)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" required>
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Add tag</button>
</form>
</div>
<div id="tag-list">
{{template "tag-list" .Tags}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,7 @@
{{define "content"}}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">My Tags</h1>
<a href="/" class="text-sm text-stone-600 hover:underline">← Home</a>
</div>
{{template "account-panel" .Data}}
{{end}}

View File

@@ -0,0 +1,35 @@
{{define "base"}}
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · WhereWoof</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-full bg-stone-100 text-stone-900 antialiased">
<nav class="bg-stone-900 text-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<a href="/" class="text-lg font-bold tracking-tight">🐕 WhereWoof</a>
<div class="flex items-center gap-4 text-sm">
{{if .CurrentUser}}
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
<a href="/account" class="hover:underline">My Tags</a>
<form method="post" action="/logout" class="inline">
<button class="hover:underline">Log out</button>
</form>
{{else}}
<a href="/login" class="hover:underline">Log in</a>
<a href="/register" class="rounded bg-amber-500 px-3 py-1 font-semibold text-stone-900 hover:bg-amber-400">Register</a>
{{end}}
</div>
</div>
</nav>
<main class="mx-auto max-w-4xl px-4 py-8">
{{template "content" .}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "content"}}
<div class="mx-auto max-w-2xl text-center">
<h1 class="text-4xl font-extrabold tracking-tight">Lost something?<br><span class="text-amber-600">The tag brings it home.</span></h1>
<p class="mt-4 text-lg text-stone-600">
WhereWoof tags carry the return details for your dog, baggage, skis — anything you care about.
A finder scans the QR code or taps the NFC tag and sees exactly how to get it back to you.
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
<div class="text-2xl">🏷️</div>
<h2 class="mt-2 font-bold">I have a tag</h2>
<p class="mt-1 text-sm text-stone-600">Claim it and set up the return details for your item.</p>
<a href="/register" class="mt-4 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Set up my tag</a>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
<div class="text-2xl">🔍</div>
<h2 class="mt-2 font-bold">I found something</h2>
<p class="mt-1 text-sm text-stone-600">Scan the tag you found — the page shows the owner's return details.</p>
<a href="/account" class="mt-4 inline-block rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">My account</a>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,22 @@
{{define "content"}}
<div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Log in</h1>
<p class="mt-1 text-sm text-stone-600">Welcome back to WhereWoof.</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<label class="block text-sm font-medium" for="email">Email</label>
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<div>
<label class="block text-sm font-medium" for="password">Password</label>
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Log in</button>
</form>
<p class="mt-4 text-center text-sm text-stone-600">New to WhereWoof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
</div>
{{end}}

View File

@@ -0,0 +1,8 @@
{{define "content"}}
<div class="mx-auto max-w-md text-center">
<div class="text-5xl">🔎</div>
<h1 class="mt-4 text-2xl font-bold">Tag not found</h1>
<p class="mt-2 text-stone-600">We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.</p>
<a href="/" class="mt-6 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Go home</a>
</div>
{{end}}

View File

@@ -0,0 +1,26 @@
{{define "content"}}
<div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Create your account</h1>
<p class="mt-1 text-sm text-stone-600">Register to claim your WhereWoof tag.</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
<form method="post" action="/register" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<label class="block text-sm font-medium" for="name">Name</label>
<input id="name" name="name" type="text" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<div>
<label class="block text-sm font-medium" for="email">Email</label>
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<div>
<label class="block text-sm font-medium" for="password">Password</label>
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" minlength="8" required>
</div>
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Create account</button>
</form>
<p class="mt-4 text-center text-sm text-stone-600">Already have an account? <a href="/login" class="text-amber-600 hover:underline">Log in</a></p>
</div>
{{end}}

View File

@@ -0,0 +1,49 @@
{{define "content"}}
<div class="mx-auto max-w-xl">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Edit tag</h1>
<a href="/account" class="text-sm text-stone-600 hover:underline">← My Tags</a>
</div>
<p class="mt-1 font-mono text-xs text-stone-500">Tag {{.Data.TagCode}}</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
<form method="post" action="/account/tags/{{.Data.ID}}/edit" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<label class="block text-sm font-medium">Item type</label>
<select name="item_type" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
<option value="">Select…</option>
<option value="dog" {{if eq .Data.ItemType.String "dog"}}selected{{end}}>Dog</option>
<option value="cat" {{if eq .Data.ItemType.String "cat"}}selected{{end}}>Cat</option>
<option value="baggage" {{if eq .Data.ItemType.String "baggage"}}selected{{end}}>Baggage</option>
<option value="skis" {{if eq .Data.ItemType.String "skis"}}selected{{end}}>Skis</option>
<option value="other" {{if eq .Data.ItemType.String "other"}}selected{{end}}>Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">Description</label>
<textarea name="description" rows="3" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Description.Valid}}{{.Data.Description.String}}{{end}}</textarea>
</div>
<div>
<label class="block text-sm font-medium">Photo URL</label>
<input name="photo_url" type="url" value="{{if .Data.PhotoUrl.Valid}}{{.Data.PhotoUrl.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="https://…">
</div>
<div>
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
<input name="phone" type="tel" value="{{if .Data.Phone.Valid}}{{.Data.Phone.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="+61…">
</div>
<div>
<label class="block text-sm font-medium">Address</label>
<input name="address" type="text" value="{{if .Data.Address.Valid}}{{.Data.Address.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium">Notes</label>
<textarea name="notes" rows="2" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Notes.Valid}}{{.Data.Notes.String}}{{end}}</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Save details</button>
<a href="/account" class="rounded border border-stone-300 px-4 py-2 text-sm font-medium hover:bg-stone-50">Cancel</a>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "tag-list"}}
{{if .}}
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
<table class="w-full text-left text-sm">
<thead class="bg-stone-50 text-stone-600">
<tr>
<th class="px-4 py-2 font-medium">Tag</th>
<th class="px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Item</th>
<th class="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{{range .}}
<tr class="border-t border-stone-100">
<td class="px-4 py-2 font-mono text-xs">{{.TagCode}}</td>
<td class="px-4 py-2">
{{if eq .Status "active"}}<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Active</span>
{{else if eq .Status "suspended"}}<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">Suspended</span>
{{else}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Unset</span>{{end}}
</td>
<td class="px-4 py-2">{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}}</td>
<td class="px-4 py-2 text-right">
<a href="/account/tags/{{.ID}}/edit" class="text-amber-600 hover:underline">Edit</a>
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline">
<button class="ml-3 text-red-600 hover:underline">Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="mt-6 text-center text-stone-500">No tags yet. Enter your tag code above to claim it.</p>
{{end}}
{{end}}

View File

@@ -0,0 +1,64 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
{{if eq .Data.Status "unset"}}
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🏷️</div>
<h1 class="mt-4 text-2xl font-bold">This tag isn't set up yet</h1>
<p class="mt-2 text-stone-600">This WhereWoof tag hasn't been claimed. If this is your tag, log in and add it to your account to set up the return details.</p>
<div class="mt-6 flex justify-center gap-3">
<a href="/login" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Log in</a>
<a href="/register" class="rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">Create account</a>
</div>
</div>
{{else if eq .Data.Status "suspended"}}
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🚫</div>
<h1 class="mt-4 text-2xl font-bold">This tag is unavailable</h1>
<p class="mt-2 text-stone-600">The owner has disabled this tag. Please try another way to return the item.</p>
</div>
{{else}}
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
<div class="bg-stone-900 px-6 py-5 text-white">
<h1 class="text-2xl font-bold">
{{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found!
</h1>
<p class="mt-1 text-sm text-stone-300">This item has a WhereWoof tag. Here's how to return it:</p>
</div>
<div class="p-6">
{{if .Data.PhotoUrl.Valid}}
<img src="{{.Data.PhotoUrl.String}}" alt="Item photo" class="mb-4 h-56 w-full rounded-lg object-cover">
{{end}}
{{if .Data.Description.Valid}}
<p class="text-stone-700">{{.Data.Description.String}}</p>
{{end}}
<dl class="mt-4 space-y-2 text-sm">
{{if .Data.Phone.Valid}}
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Call the owner</dt>
<dd><a href="tel:{{.Data.Phone.String}}" class="font-semibold text-amber-600 hover:underline">{{.Data.Phone.String}}</a></dd>
</div>
{{end}}
{{if .Data.Address.Valid}}
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Return to</dt>
<dd class="text-right">{{.Data.Address.String}}</dd>
</div>
{{end}}
{{if .Data.Notes.Valid}}
<div class="rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Notes</dt>
<dd class="mt-1 text-stone-600">{{.Data.Notes.String}}</dd>
</div>
{{end}}
</dl>
<p class="mt-6 rounded bg-amber-50 px-4 py-3 text-sm text-amber-800 border border-amber-200">
Found this item? Please contact the owner to arrange the return. Thank you for helping!
</p>
{{if .Data.IsOwner}}
<p class="mt-4 text-center text-sm"><a href="/account/tags/{{.Data.ID}}/edit" class="text-amber-600 hover:underline">✏️ Edit this tag's details</a></p>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}