feat: single OTP field, event metadata on page, delete event
- OTP: 6 separate inputs → single 6-digit field with copy/paste support - Event page: shows name, currency, exchange rate at top - Edit toggle: unlock to edit fields, lock to save (via PUT) - Delete button with confirmation popup - DeleteEvent handler + DB function + route - Backward compatible: collectOTP strips non-digits
This commit is contained in:
parent
0748af7c26
commit
bb34528bf5
6 changed files with 103 additions and 27 deletions
|
|
@ -287,6 +287,16 @@ func UpdateEventStatus(db *sql.DB, id, status string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// DeleteEvent removes an event and all its expenses from the database.
|
||||
func DeleteEvent(db *sql.DB, id string) error {
|
||||
_, err := db.Exec("DELETE FROM events WHERE id = ?", id)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] database: DeleteEvent(%s): %v",
|
||||
time.Now().Format(time.RFC3339), id, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateEvent updates the base currency and exchange rate of an existing event.
|
||||
func UpdateEvent(db *sql.DB, id, baseCurrency string, exchangeRate float64) error {
|
||||
_, err := db.Exec("UPDATE events SET base_currency = ?, exchange_rate = ? WHERE id = ?", baseCurrency, exchangeRate, id)
|
||||
|
|
|
|||
|
|
@ -315,13 +315,16 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func collectOTP(r *http.Request) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 6; i++ {
|
||||
digit := r.FormValue(fmt.Sprintf("digit_%d", i))
|
||||
if digit == "" {
|
||||
return ""
|
||||
code := r.FormValue("otp_code")
|
||||
// Strip any non-digit characters (paste may include spaces/dashes).
|
||||
code = strings.Map(func(r rune) rune {
|
||||
if r >= '0' && r <= '9' {
|
||||
return r
|
||||
}
|
||||
b.WriteString(digit)
|
||||
return -1
|
||||
}, code)
|
||||
if len(code) != 6 {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
return code
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,3 +360,24 @@ func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /events/{id} — DeleteEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DeleteEvent removes an event and its expenses after ownership verification.
|
||||
func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
event, err := database.GetEventByID(h.DB, eventID)
|
||||
if err != nil || event == nil || event.UserID != getUserID(r) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := database.DeleteEvent(h.DB, eventID); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err)
|
||||
http.Error(w, "Failed to delete event", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,30 +49,15 @@ func loadTemplates() {
|
|||
log.Printf("Loaded %d templates", len(templates))
|
||||
}
|
||||
|
||||
// otpFormHTML is the inline OTP form template fragment with auto-advance.
|
||||
// otpFormHTML is the inline OTP form template fragment with single 6-digit field.
|
||||
const otpFormHTML = `
|
||||
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML">
|
||||
<input type="hidden" name="email" value="{{.Email}}">
|
||||
{{if .Error}}<div class="error-message" style="color: #fca5a5; background: #450a0a; border: 1px solid #7f1d1d; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}}
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
|
||||
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
|
||||
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
|
||||
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
|
||||
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
|
||||
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
|
||||
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
|
||||
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
|
||||
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
|
||||
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
|
||||
oninput="if(this.value)this.nextElementSibling.focus()" onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
|
||||
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc;"
|
||||
onkeydown="if(event.key==='Backspace'&&!this.value)this.previousElementSibling?.focus()">
|
||||
<div class="form-group" style="margin: 1rem 0;">
|
||||
<input type="text" name="otp_code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required
|
||||
placeholder="Enter 6-digit code"
|
||||
style="width: 100%; padding: 1rem; font-size: 1.5rem; text-align: center; letter-spacing: 0.75rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc; box-sizing: border-box;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Verify Code</button>
|
||||
</form>`
|
||||
|
|
|
|||
1
main.go
1
main.go
|
|
@ -207,6 +207,7 @@ func main() {
|
|||
r.Post("/events", eventHandler.CreateEvent)
|
||||
r.Put("/events/{id}", eventHandler.UpdateEvent)
|
||||
r.Get("/events/{id}/edit", eventHandler.EditEvent)
|
||||
r.Delete("/events/{id}", eventHandler.DeleteEvent)
|
||||
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
|
||||
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,62 @@
|
|||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Event Metadata -->
|
||||
<div style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Event Details</div>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button id="edit-toggle-btn" class="btn btn-secondary btn-sm" onclick="toggleEdit()" style="font-size: 0.75rem;">🔒 Edit</button>
|
||||
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem; color: #fca5a5; border-color: #7f1d1d;"
|
||||
onclick="if(confirm('Delete this event and all its receipts?')){hx.trigger('#delete-event','click')}">🗑️</button>
|
||||
<div hx-delete="/events/{{.Event.ID}}" hx-target="body" hx-push-url="true" id="delete-event" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="event-meta-form" hx-put="/events/{{.Event.ID}}" hx-target="body" hx-push-url="true">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2">
|
||||
<label class="form-label">Event</label>
|
||||
<input type="text" name="name" value="{{.Event.Name}}" readonly
|
||||
style="width:100%; padding:0.5rem; font-size:0.9rem; background:transparent; border:1px solid transparent; border-radius:0.375rem; color:var(--color-text);">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Claim Currency</label>
|
||||
<input type="text" name="base_currency" value="{{.Event.BaseCurrency}}" readonly maxlength="3"
|
||||
style="width:100%; padding:0.5rem; font-size:0.9rem; background:transparent; border:1px solid transparent; border-radius:0.375rem; color:var(--color-text); text-transform:uppercase;">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1">
|
||||
<label class="form-label">Rate</label>
|
||||
<input type="text" name="exchange_rate" value="{{printf "%.6f" .Event.ExchangeRate}}" readonly
|
||||
style="width:100%; padding:0.5rem; font-size:0.9rem; background:transparent; border:1px solid transparent; border-radius:0.375rem; color:var(--color-text-muted);">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleEdit() {
|
||||
var inputs = document.querySelectorAll('#event-meta-form input');
|
||||
var btn = document.getElementById('edit-toggle-btn');
|
||||
var isEditing = btn.textContent.includes('Unlock');
|
||||
inputs.forEach(function(inp) {
|
||||
inp.readOnly = isEditing;
|
||||
if (isEditing) {
|
||||
inp.style.background = 'transparent';
|
||||
inp.style.borderColor = 'transparent';
|
||||
inp.style.color = 'var(--color-text)';
|
||||
} else {
|
||||
inp.style.background = 'var(--color-card)';
|
||||
inp.style.borderColor = 'var(--color-border)';
|
||||
inp.style.color = 'var(--color-text)';
|
||||
}
|
||||
});
|
||||
btn.textContent = isEditing ? '🔒 Edit' : '🔓 Save';
|
||||
if (!isEditing) {
|
||||
document.getElementById('event-meta-form').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Receipt List -->
|
||||
<div id="expense-list">
|
||||
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">
|
||||
|
|
|
|||
Loading…
Reference in a new issue