scan-flow: geolocation scan, location-aware SMS throttle (log sender), finder contact, sms_enabled toggle, branding — 19/19 phase-2 scenarios pass
This commit is contained in:
@@ -32,6 +32,7 @@ type Tag struct {
|
||||
Notes pgtype.Text `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
SmsEnabled bool `json:"sms_enabled"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
||||
@@ -15,12 +15,17 @@ type Querier interface {
|
||||
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)
|
||||
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
|
||||
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, 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)
|
||||
InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error)
|
||||
InsertTag(ctx context.Context, tagCode string) (Tag, error)
|
||||
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
|
||||
SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error
|
||||
SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error
|
||||
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
|
||||
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ 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()
|
||||
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, sms_enabled=$8, status='active', updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING *;
|
||||
|
||||
@@ -45,3 +45,26 @@ UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
|
||||
-- name: InsertTag :one
|
||||
INSERT INTO tags (tag_code) VALUES ($1)
|
||||
RETURNING *;
|
||||
|
||||
-- name: InsertScan :one
|
||||
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLastAlertByTag :one
|
||||
SELECT * FROM scans
|
||||
WHERE tag_id = $1 AND alert_sent = TRUE
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetLatestScanByTag :one
|
||||
SELECT * FROM scans
|
||||
WHERE tag_id = $1
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: SetScanAlertSent :exec
|
||||
UPDATE scans SET alert_sent = $2 WHERE id = $1;
|
||||
|
||||
-- name: SetScanPhone :exec
|
||||
UPDATE scans SET scanner_phone = $2 WHERE id = $1;
|
||||
|
||||
@@ -15,7 +15,7 @@ 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
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
|
||||
`
|
||||
|
||||
type BindTagParams struct {
|
||||
@@ -39,6 +39,7 @@ func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -47,7 +48,7 @@ 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
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
|
||||
`
|
||||
|
||||
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
|
||||
@@ -66,6 +67,7 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -113,8 +115,54 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLastAlertByTag = `-- name: GetLastAlertByTag :one
|
||||
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
|
||||
WHERE tag_id = $1 AND alert_sent = TRUE
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error) {
|
||||
row := q.db.QueryRow(ctx, getLastAlertByTag, tagID)
|
||||
var i Scan
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagID,
|
||||
&i.ScannedAt,
|
||||
&i.Lat,
|
||||
&i.Lng,
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLatestScanByTag = `-- name: GetLatestScanByTag :one
|
||||
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
|
||||
WHERE tag_id = $1
|
||||
ORDER BY scanned_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error) {
|
||||
row := q.db.QueryRow(ctx, getLatestScanByTag, tagID)
|
||||
var i Scan
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagID,
|
||||
&i.ScannedAt,
|
||||
&i.Lat,
|
||||
&i.Lng,
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
)
|
||||
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
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled FROM tags WHERE tag_code = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
|
||||
@@ -133,12 +181,13 @@ func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
)
|
||||
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
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled FROM tags WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
|
||||
@@ -157,6 +206,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -197,9 +247,45 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertScan = `-- name: InsertScan :one
|
||||
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent
|
||||
`
|
||||
|
||||
type InsertScanParams struct {
|
||||
TagID int64 `json:"tag_id"`
|
||||
Lat pgtype.Float8 `json:"lat"`
|
||||
Lng pgtype.Float8 `json:"lng"`
|
||||
LocationShared bool `json:"location_shared"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
|
||||
row := q.db.QueryRow(ctx, insertScan,
|
||||
arg.TagID,
|
||||
arg.Lat,
|
||||
arg.Lng,
|
||||
arg.LocationShared,
|
||||
arg.ScannerPhone,
|
||||
)
|
||||
var i Scan
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TagID,
|
||||
&i.ScannedAt,
|
||||
&i.Lat,
|
||||
&i.Lng,
|
||||
&i.LocationShared,
|
||||
&i.ScannerPhone,
|
||||
&i.AlertSent,
|
||||
)
|
||||
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
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
|
||||
`
|
||||
|
||||
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
|
||||
@@ -218,12 +304,13 @@ func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
)
|
||||
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
|
||||
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
|
||||
@@ -248,6 +335,7 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -259,6 +347,34 @@ func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]T
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setScanAlertSent = `-- name: SetScanAlertSent :exec
|
||||
UPDATE scans SET alert_sent = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type SetScanAlertSentParams struct {
|
||||
ID int64 `json:"id"`
|
||||
AlertSent bool `json:"alert_sent"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetScanAlertSent(ctx context.Context, arg SetScanAlertSentParams) error {
|
||||
_, err := q.db.Exec(ctx, setScanAlertSent, arg.ID, arg.AlertSent)
|
||||
return err
|
||||
}
|
||||
|
||||
const setScanPhone = `-- name: SetScanPhone :exec
|
||||
UPDATE scans SET scanner_phone = $2 WHERE id = $1
|
||||
`
|
||||
|
||||
type SetScanPhoneParams struct {
|
||||
ID int64 `json:"id"`
|
||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error {
|
||||
_, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone)
|
||||
return err
|
||||
}
|
||||
|
||||
const setTagStatus = `-- name: SetTagStatus :exec
|
||||
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
|
||||
`
|
||||
@@ -275,9 +391,9 @@ func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) erro
|
||||
|
||||
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()
|
||||
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, sms_enabled=$8, 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
|
||||
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at, sms_enabled
|
||||
`
|
||||
|
||||
type UpdateTagDetailsParams struct {
|
||||
@@ -288,6 +404,7 @@ type UpdateTagDetailsParams struct {
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Address pgtype.Text `json:"address"`
|
||||
Notes pgtype.Text `json:"notes"`
|
||||
SmsEnabled bool `json:"sms_enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
|
||||
@@ -299,6 +416,7 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
|
||||
arg.Phone,
|
||||
arg.Address,
|
||||
arg.Notes,
|
||||
arg.SmsEnabled,
|
||||
)
|
||||
var i Tag
|
||||
err := row.Scan(
|
||||
@@ -314,6 +432,7 @@ func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsPara
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.SmsEnabled,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"wherewoof/frontend/internal/auth"
|
||||
"wherewoof/frontend/internal/db"
|
||||
"wherewoof/frontend/internal/sms"
|
||||
)
|
||||
|
||||
// Templates maps a page key to its parsed template set (base + page + partials).
|
||||
@@ -20,11 +21,12 @@ type Templates map[string]*template.Template
|
||||
type App struct {
|
||||
Queries *db.Queries
|
||||
Tpl Templates
|
||||
Sender sms.Sender
|
||||
}
|
||||
|
||||
// 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}
|
||||
// New returns an App with the given query layer, template sets, and SMS sender.
|
||||
func New(queries *db.Queries, tpl Templates, sender sms.Sender) *App {
|
||||
return &App{Queries: queries, Tpl: tpl, Sender: sender}
|
||||
}
|
||||
|
||||
// PageData is the root data passed to the base layout.
|
||||
|
||||
194
frontend/internal/handlers/scan.go
Normal file
194
frontend/internal/handlers/scan.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"wherewoof/frontend/internal/db"
|
||||
"wherewoof/frontend/internal/sms"
|
||||
)
|
||||
|
||||
const (
|
||||
alertWindow = 10 * time.Minute
|
||||
alertMinDistance = 250.0 // metres
|
||||
)
|
||||
|
||||
// ScanRequest is the JSON body posted by the geolocation script.
|
||||
type ScanRequest struct {
|
||||
Lat *float64 `json:"lat"`
|
||||
Lng *float64 `json:"lng"`
|
||||
}
|
||||
|
||||
// Scan records a scan (with optional location) and alerts the owner per the
|
||||
// location-aware throttle rules: within the 10-minute window an alert is only
|
||||
// re-sent when the new location is more than 250 m from the last alerted spot.
|
||||
func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
|
||||
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "tag not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var req ScanRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
hasLoc := req.Lat != nil && req.Lng != nil
|
||||
|
||||
var lat, lng pgtype.Float8
|
||||
if hasLoc {
|
||||
lat = pgtype.Float8{Float64: *req.Lat, Valid: true}
|
||||
lng = pgtype.Float8{Float64: *req.Lng, Valid: true}
|
||||
}
|
||||
|
||||
scan, err := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
||||
TagID: tag.ID,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
LocationShared: hasLoc,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng)
|
||||
if alertSent {
|
||||
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
|
||||
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent})
|
||||
}
|
||||
|
||||
// shouldAlert applies the throttle rules and sms_enabled flag.
|
||||
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64) bool {
|
||||
if !tag.SmsEnabled {
|
||||
return false
|
||||
}
|
||||
if !tag.Phone.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
|
||||
if err != nil {
|
||||
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert
|
||||
}
|
||||
|
||||
// Within the window?
|
||||
if time.Since(last.ScannedAt.Time) < alertWindow {
|
||||
// Re-alert only if we have a location and it moved >250 m.
|
||||
if !hasLoc || !last.Lat.Valid || !last.Lng.Valid {
|
||||
return false
|
||||
}
|
||||
return sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// alertOwner sends the SMS to the owner and reports success.
|
||||
func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool {
|
||||
if a.Sender == nil {
|
||||
return false
|
||||
}
|
||||
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng)
|
||||
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
||||
fmt.Println("alert sms failed:", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// alertMessage builds the owner alert: item type + name, time, maps link, tag link.
|
||||
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
|
||||
itemType := "Item"
|
||||
if tag.ItemType.Valid {
|
||||
itemType = titleCase(tag.ItemType.String)
|
||||
}
|
||||
name := strings.TrimSpace(tag.Description.String)
|
||||
if len(name) > 30 {
|
||||
name = name[:30] + "…"
|
||||
}
|
||||
label := itemType
|
||||
if name != "" {
|
||||
label = itemType + ", " + name
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm"))
|
||||
if lat != nil && lng != nil {
|
||||
msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng)
|
||||
} else {
|
||||
msg += " The finder didn't share a location."
|
||||
}
|
||||
msg += fmt.Sprintf(" See: https://where-woof.com/t/%s", tag.TagCode)
|
||||
return msg
|
||||
}
|
||||
|
||||
// FinderContact stores a finder's number on the latest scan and alerts the owner.
|
||||
func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "tag not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
phone := sms.NormalizeAU(strings.TrimSpace(r.FormValue("phone")))
|
||||
if len(phone) < 8 || len(phone) > 15 {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">Please enter a valid phone number.</p>`)
|
||||
return
|
||||
}
|
||||
|
||||
latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// No scan yet: record one carrying the finder's number.
|
||||
_, err = a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
||||
TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true},
|
||||
})
|
||||
} else {
|
||||
err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}})
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil {
|
||||
itemType := "item"
|
||||
if tag.ItemType.Valid {
|
||||
itemType = tag.ItemType.String
|
||||
}
|
||||
msg := fmt.Sprintf("Where Woof: a finder of your %s left their number: %s. See: https://where-woof.com/t/%s",
|
||||
itemType, phone, tag.TagCode)
|
||||
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
||||
fmt.Println("contact sms failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, `<p class="mt-4 rounded border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800">Thanks! The owner has been notified with your number.</p>`)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -105,6 +105,7 @@ func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
|
||||
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
|
||||
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
|
||||
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
|
||||
SmsEnabled: r.FormValue("sms_enabled") == "on",
|
||||
}
|
||||
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
|
||||
16
frontend/internal/sms/geo.go
Normal file
16
frontend/internal/sms/geo.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package sms
|
||||
|
||||
import "math"
|
||||
|
||||
const earthRadiusM = 6371000.0
|
||||
|
||||
// HaversineMeters returns the great-circle distance between two points
|
||||
// (lat/lng in decimal degrees) in metres.
|
||||
func HaversineMeters(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
rad := func(d float64) float64 { return d * math.Pi / 180 }
|
||||
dLat := rad(lat2 - lat1)
|
||||
dLng := rad(lng2 - lng1)
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
|
||||
return 2 * earthRadiusM * math.Asin(math.Sqrt(a))
|
||||
}
|
||||
21
frontend/internal/sms/normalize.go
Normal file
21
frontend/internal/sms/normalize.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package sms
|
||||
|
||||
import "strings"
|
||||
|
||||
// NormalizeAU normalises a phone number to international AU format without a
|
||||
// leading '+' or zero:
|
||||
//
|
||||
// "+61432374487" -> "61432374487"
|
||||
// "0432374487" -> "61432374487"
|
||||
func NormalizeAU(phone string) string {
|
||||
s := strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, phone)
|
||||
if strings.HasPrefix(s, "0") && len(s) >= 10 {
|
||||
s = "61" + s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
9
frontend/internal/sms/sender.go
Normal file
9
frontend/internal/sms/sender.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package sms provides a swappable SMS sending abstraction for WhereWoof.
|
||||
// The log sender is the default so development and tests never send real SMS.
|
||||
package sms
|
||||
|
||||
// Sender sends an SMS to a destination number in international format
|
||||
// (no leading '+', no leading zero — see NormalizeAU).
|
||||
type Sender interface {
|
||||
Send(to, body string) error
|
||||
}
|
||||
52
frontend/internal/sms/sms_test.go
Normal file
52
frontend/internal/sms/sms_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHaversineMeters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lat1, lng1 float64
|
||||
lat2, lng2 float64
|
||||
wantMeters float64
|
||||
tolerancePct float64
|
||||
}{
|
||||
{"same point", -33.86, 151.21, -33.86, 151.21, 0, 0},
|
||||
{"Sydney CBD to Parramatta (~20km)", -33.8688, 151.2093, -33.8125, 151.0011, 20200, 0.02},
|
||||
{"~250m at Sydney latitude", -33.86, 151.21, -33.86, 151.2128, 250, 0.20},
|
||||
{"antipodal-ish long distance", 0, 0, 0, 180, 20037500, 0.01},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := HaversineMeters(tc.lat1, tc.lng1, tc.lat2, tc.lng2)
|
||||
if tc.wantMeters == 0 {
|
||||
if got > 1 {
|
||||
t.Fatalf("same point: got %.1f m, want ~0", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
diff := math.Abs(got-tc.wantMeters) / tc.wantMeters
|
||||
if diff > tc.tolerancePct {
|
||||
t.Fatalf("got %.1f m, want ~%.1f m (error %.1f%%)", got, tc.wantMeters, diff*100)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAU(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"+61432374487", "61432374487"},
|
||||
{"0432374487", "61432374487"},
|
||||
{"61432374487", "61432374487"},
|
||||
{"+61 432 374 487", "61432374487"},
|
||||
{"(04) 3237 4487", "61432374487"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := NormalizeAU(tc.in); got != tc.want {
|
||||
t.Errorf("NormalizeAU(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
frontend/internal/sms/smsglobal.go
Normal file
85
frontend/internal/sms/smsglobal.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package sms
|
||||
|
||||
// ⚠️ VERIFY BEFORE REAL SMS (scan-flow task 9): the exact canonical-string
|
||||
// format for the HMAC signature and the JSON field names must be confirmed
|
||||
// against the account holder's MXT docs / their existing integration before
|
||||
// the real-SMS test. The Sender interface keeps this implementation swappable
|
||||
// if the auth scheme differs.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client sends SMS via the SMSGlobal REST API.
|
||||
type Client struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
from string // empty => SMSGlobal shared/pooled number
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns an SMSGlobal client. from may be empty to use the shared pool.
|
||||
func New(apiKey, apiSecret, from string) *Client {
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
from: from,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Send posts a message to the given destination (international format).
|
||||
func (c *Client) Send(to, body string) error {
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"message": body,
|
||||
"to": to,
|
||||
"from": c.from,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.smsglobal.com/sms/", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
date := time.Now().UTC().Format(http.TimeFormat)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Date", date)
|
||||
req.Header.Set("Authorization", c.authorization(req, payload, date))
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("smsglobal: status %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorization builds the REST v1 HMAC-SHA256 signature:
|
||||
// base64(HMAC-SHA256(canonicalString, apiSecret)), sent as "key:signature".
|
||||
// Canonical string per SMSGlobal docs: method&path&accept&date&contentType&contentMd5.
|
||||
func (c *Client) authorization(req *http.Request, body []byte, date string) string {
|
||||
bodyHash := md5.Sum(body)
|
||||
canonical := fmt.Sprintf("%s&%s&%s&%s&%s&%s",
|
||||
req.Method, req.URL.Path, "application/json", date, "application/json",
|
||||
base64.StdEncoding.EncodeToString(bodyHash[:]))
|
||||
mac := hmac.New(sha256.New, []byte(c.apiSecret))
|
||||
mac.Write([]byte(canonical))
|
||||
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
return fmt.Sprintf("%s:%s", c.apiKey, sig)
|
||||
}
|
||||
13
frontend/internal/sms/smslog.go
Normal file
13
frontend/internal/sms/smslog.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package sms
|
||||
|
||||
import "log"
|
||||
|
||||
// LogSender writes messages to the log instead of sending.
|
||||
// Used automatically when no SMS credentials are configured.
|
||||
type LogSender struct{}
|
||||
|
||||
// Send logs the would-be SMS.
|
||||
func (LogSender) Send(to, body string) error {
|
||||
log.Printf("SMS to %s: %s", to, body)
|
||||
return nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"wherewoof/frontend/internal/auth"
|
||||
"wherewoof/frontend/internal/db"
|
||||
"wherewoof/frontend/internal/handlers"
|
||||
"wherewoof/frontend/internal/sms"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -42,13 +43,21 @@ func main() {
|
||||
log.Fatal("templates:", err)
|
||||
}
|
||||
|
||||
app := handlers.New(db.New(pool), tpl)
|
||||
// SMS sender: log-only unless real SMSGlobal credentials are configured.
|
||||
var sender sms.Sender = sms.LogSender{}
|
||||
if key := os.Getenv("SMS_API_KEY"); key != "" {
|
||||
sender = sms.New(key, os.Getenv("SMS_API_SECRET"), os.Getenv("SMS_FROM"))
|
||||
}
|
||||
|
||||
app := handlers.New(db.New(pool), tpl, sender)
|
||||
|
||||
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("POST /t/{tag_code}/scan", app.Scan)
|
||||
mux.HandleFunc("POST /t/{tag_code}/contact", app.FinderContact)
|
||||
mux.HandleFunc("GET /register", app.RegisterPage)
|
||||
mux.HandleFunc("POST /register", app.Register)
|
||||
mux.HandleFunc("GET /login", app.LoginPage)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · WhereWoof</title>
|
||||
<title>{{.Title}} · Where Woof !</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>
|
||||
@@ -12,7 +12,7 @@
|
||||
<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>
|
||||
<a href="/" class="text-lg font-bold tracking-tight">🐕 Where Woof</a>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
{{if .CurrentUser}}
|
||||
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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.
|
||||
Where Woof 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>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{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>
|
||||
<p class="mt-1 text-sm text-stone-600">Welcome back to Where Woof.</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}}
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
<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>
|
||||
<p class="mt-4 text-center text-sm text-stone-600">New to Where Woof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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>
|
||||
<p class="mt-2 text-stone-600">We couldn't find a Where Woof 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}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{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>
|
||||
<p class="mt-1 text-sm text-stone-600">Register to claim your Where Woof 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}}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
<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>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="sms_enabled" value="on" {{if .Data.SmsEnabled}}checked{{end}} class="h-4 w-4">
|
||||
Send me SMS alerts when this tag is scanned
|
||||
</label>
|
||||
<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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
<p class="mt-2 text-stone-600">This Where Woof 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>
|
||||
@@ -22,7 +22,7 @@
|
||||
<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>
|
||||
<p class="mt-1 text-sm text-stone-300">This item has a Where Woof tag. Here's how to return it:</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{{if .Data.PhotoUrl.Valid}}
|
||||
@@ -37,6 +37,10 @@
|
||||
<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>
|
||||
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
|
||||
<dt class="font-medium">Text the owner</dt>
|
||||
<dd><a href="sms:{{.Data.Phone.String}}?&body=Hi! I found your {{if .Data.ItemType.Valid}}{{.Data.ItemType.String}}{{else}}item{{end}}{{if .Data.Description.Valid}}, {{.Data.Description.String}}{{end}}. My phone number is: " class="font-semibold text-amber-600 hover:underline">Send an SMS</a></dd>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Data.Address.Valid}}
|
||||
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
|
||||
@@ -51,14 +55,51 @@
|
||||
</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>
|
||||
|
||||
<div class="mt-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p><strong>Help the owner find it:</strong> sharing your location sends them a map link. No location? No problem — the page still works.</p>
|
||||
<button id="recheck-btn" type="button" class="mt-2 rounded bg-amber-500 px-3 py-1.5 text-xs font-semibold text-stone-900 hover:bg-amber-400">📍 Re-check my location</button>
|
||||
</div>
|
||||
|
||||
<div id="contact-area" class="mt-4">
|
||||
<form hx-post="/t/{{.Data.TagCode}}/contact" hx-target="#contact-area" hx-swap="innerHTML" class="flex gap-2">
|
||||
<input name="phone" type="tel" placeholder="Leave your number for the owner (optional)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" pattern="[0-9+ ]{8,16}">
|
||||
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Send number</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{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>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var code = {{.Data.TagCode | printf "%q"}};
|
||||
var btn = document.getElementById("recheck-btn");
|
||||
function sendScan(lat, lng) {
|
||||
fetch("/t/" + code + "/scan", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(lat != null ? {lat: lat, lng: lng} : {})
|
||||
});
|
||||
}
|
||||
function tryLocation() {
|
||||
if (!navigator.geolocation) { sendScan(null, null); return; }
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (pos) {
|
||||
if (btn) btn.classList.add("hidden");
|
||||
sendScan(pos.coords.latitude, pos.coords.longitude);
|
||||
},
|
||||
function () { sendScan(null, null); },
|
||||
{timeout: 8000, maximumAge: 60000}
|
||||
);
|
||||
}
|
||||
if (btn) btn.addEventListener("click", tryLocation);
|
||||
tryLocation();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user