tag-lifecycle: owner close (status closed, gating + page) + move (copy details to new tag, close source) + legacy ?productid= redirect — verified with real tags 6 & 17
This commit is contained in:
@@ -103,6 +103,10 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
|
||||
if !tag.Phone.Valid {
|
||||
return false, ""
|
||||
}
|
||||
// Closed or suspended tags never alert (page shows unavailable).
|
||||
if tag.Status == "closed" || tag.Status == "suspended" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Customer pause (admin kill-switch): paused owner -> record only.
|
||||
if tag.OwnerID.Valid {
|
||||
|
||||
@@ -24,6 +24,11 @@ type publicData struct {
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
// Legacy physical QR URLs: /?x=5&productid=CODE -> /t/CODE
|
||||
if pid := r.URL.Query().Get("productid"); pid != "" {
|
||||
http.Redirect(w, r, "/t/"+pid, http.StatusFound)
|
||||
return
|
||||
}
|
||||
a.render(w, r, "index", "Home", nil, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,88 @@ func (a *App) UploadPhoto(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, `<p class="mt-2 text-xs text-green-700">Photo uploaded ✓ — refresh to see it on the tag page.</p>`)
|
||||
}
|
||||
|
||||
// CloseTag lets an owner close (retire) their tag. Data is kept; the tag page
|
||||
// shows unavailable and alerts stop (HTMX: returns account-panel).
|
||||
func (a *App) CloseTag(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
tag, ok := a.loadOwnedTag(w, r, uid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if tag.Status == "closed" {
|
||||
a.renderAccountPanel(w, r, uid, "This tag is already closed.")
|
||||
return
|
||||
}
|
||||
if err := a.Queries.SetTagStatus(r.Context(), db.SetTagStatusParams{ID: tag.ID, Status: "closed"}); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.renderAccountPanel(w, r, uid, "")
|
||||
}
|
||||
|
||||
// MoveTag moves a tag's details to another (unset) tag code, then closes the
|
||||
// source tag — the broken-tag replacement flow (HTMX: returns account-panel).
|
||||
func (a *App) MoveTag(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := auth.GetUserID(r)
|
||||
src, ok := a.loadOwnedTag(w, r, uid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetCode := strings.TrimSpace(r.FormValue("new_tag_code")) // preset IDs are case-sensitive — trim only
|
||||
if targetCode == "" {
|
||||
a.renderAccountPanel(w, r, uid, "Enter the new tag's code.")
|
||||
return
|
||||
}
|
||||
if targetCode == src.TagCode {
|
||||
a.renderAccountPanel(w, r, uid, "The new tag must be different from the current tag.")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := a.Queries.GetTagByCode(r.Context(), targetCode)
|
||||
if err != nil {
|
||||
a.renderAccountPanel(w, r, uid, "That tag code wasn't found. Check the code on the new tag.")
|
||||
return
|
||||
}
|
||||
if target.OwnerID.Valid || target.Status != "unset" {
|
||||
a.renderAccountPanel(w, r, uid, "That tag is already claimed or not available.")
|
||||
return
|
||||
}
|
||||
|
||||
// Bind the target to this owner, copy all details, then close the source.
|
||||
if _, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: ownerID(uid), TagCode: targetCode}); err != nil {
|
||||
a.renderAccountPanel(w, r, uid, "Could not claim the new tag.")
|
||||
return
|
||||
}
|
||||
if _, err := a.Queries.UpdateTagDetails(r.Context(), db.UpdateTagDetailsParams{
|
||||
ID: target.ID,
|
||||
ItemType: src.ItemType,
|
||||
Description: src.Description,
|
||||
PhotoUrl: src.PhotoUrl,
|
||||
Phone: src.Phone,
|
||||
Address: src.Address,
|
||||
Notes: src.Notes,
|
||||
SmsEnabled: src.SmsEnabled,
|
||||
}); err != nil {
|
||||
a.renderAccountPanel(w, r, uid, "Could not copy the details.")
|
||||
return
|
||||
}
|
||||
if err := a.Queries.SetTagStatus(r.Context(), db.SetTagStatusParams{ID: src.ID, Status: "closed"}); err != nil {
|
||||
a.renderAccountPanel(w, r, uid, "Details moved, but the old tag couldn't be closed.")
|
||||
return
|
||||
}
|
||||
a.renderAccountPanel(w, r, uid, "")
|
||||
}
|
||||
|
||||
// renderAccountPanel refreshes the My Tags panel (HTMX target) with an optional error.
|
||||
func (a *App) renderAccountPanel(w http.ResponseWriter, r *http.Request, uid int64, errMsg string) {
|
||||
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, AddError: errMsg})
|
||||
}
|
||||
|
||||
func writeUploadMsg(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<p class="mt-2 text-xs text-red-600">%s</p>`, msg)
|
||||
|
||||
@@ -85,6 +85,8 @@ func main() {
|
||||
mux.Handle("GET /account/tags/{id}/edit/inline", app.RequireAuth(app.EditInlinePage))
|
||||
mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag))
|
||||
mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag))
|
||||
mux.Handle("POST /account/tags/{id}/close", app.RequireAuth(app.CloseTag))
|
||||
mux.Handle("POST /account/tags/{id}/move", app.RequireAuth(app.MoveTag))
|
||||
mux.Handle("POST /account/tags/{id}/photo", app.RequireAuth(app.UploadPhoto))
|
||||
mux.HandleFunc("GET /photos/{key...}", app.ServePhoto)
|
||||
|
||||
|
||||
@@ -17,14 +17,24 @@
|
||||
<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 if eq .Status "closed"}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-500">Closed</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">
|
||||
{{if ne .Status "closed"}}
|
||||
<a href="/account/tags/{{.ID}}/edit" hx-get="/account/tags/{{.ID}}/edit/inline" hx-target="closest tr" hx-swap="outerHTML" class="text-amber-600 hover:underline">Edit</a>
|
||||
{{end}}
|
||||
<form hx-post="/account/tags/{{.ID}}/close" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Close this tag? It will stop alerts but keep its details." class="inline">
|
||||
<button class="ml-3 text-stone-500 hover:underline">Close</button>
|
||||
</form>
|
||||
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline fade-row">
|
||||
<button class="ml-3 text-red-600 hover:underline">Remove</button>
|
||||
</form>
|
||||
<form hx-post="/account/tags/{{.ID}}/move" hx-target="#account-panel" hx-swap="outerHTML" class="inline" onsubmit="var c=prompt('Move details to which new tag code?'); if(!c)return false; this.querySelector('input').value=c;">
|
||||
<input type="hidden" name="new_tag_code" value="">
|
||||
<button class="ml-3 text-blue-600 hover:underline" type="submit">Move</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
<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 if eq .Data.Status "closed"}}
|
||||
<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 has been closed</h1>
|
||||
<p class="mt-2 text-stone-600">This tag is no longer active. If you found this item, please try to return it another way.</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">
|
||||
|
||||
Reference in New Issue
Block a user