Promo: order page + red banner + contact form; fix admin login rehash
- /order page: single tag $5.50 / 10-pack $22.00 (GST-incl), qty via Stripe Payment Links (single 1-25, pack 1-2), thanks state - Red 'Protect & recover your valuables — ORDER NOW' banner in base layout (every page incl. found-dog tag page) + footer promo links - Contact page: form -> Gmail SMTP (env SMTP_*), hello@where-woof.com - Fix admin login 500: User.getAuthPasswordName() = password_hash so Laravel 13 rehash-on-login writes the correct column (was trying UPDATE users SET password = ...)
This commit is contained in:
@@ -15,6 +15,8 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
use HasFactory, Notifiable, Billable;
|
use HasFactory, Notifiable, Billable;
|
||||||
|
|
||||||
protected $table = 'users';
|
protected $table = 'users';
|
||||||
|
// Shared schema uses password_hash, not Laravel's default 'password'.
|
||||||
|
protected $authPasswordName = 'password_hash';
|
||||||
public $timestamps = false; // shared schema: created_at only, set by DB default
|
public $timestamps = false; // shared schema: created_at only, set by DB default
|
||||||
protected $fillable = ['email', 'password_hash', 'name', 'phone', 'is_admin', 'stripe_id', 'sms_credits'];
|
protected $fillable = ['email', 'password_hash', 'name', 'phone', 'is_admin', 'stripe_id', 'sms_credits'];
|
||||||
protected $hidden = ['password_hash'];
|
protected $hidden = ['password_hash'];
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func LoadTemplates() (Templates, error) {
|
|||||||
"about": {dir + "/about.html"},
|
"about": {dir + "/about.html"},
|
||||||
"what": {dir + "/what.html"},
|
"what": {dir + "/what.html"},
|
||||||
"contact": {dir + "/contact.html"},
|
"contact": {dir + "/contact.html"},
|
||||||
|
"order": {dir + "/order.html"},
|
||||||
"register": {dir + "/register.html"},
|
"register": {dir + "/register.html"},
|
||||||
"login": {dir + "/login.html"},
|
"login": {dir + "/login.html"},
|
||||||
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
|
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html", dir + "/tag-edit-inline.html"},
|
||||||
|
|||||||
@@ -14,5 +14,11 @@ func (a *App) WhatIsThis(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Contact renders the contact page.
|
// Contact renders the contact page.
|
||||||
func (a *App) Contact(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Contact(w http.ResponseWriter, r *http.Request) {
|
||||||
a.render(w, r, "contact", "Contact", nil, "")
|
a.render(w, r, "contact", "Contact", contactData{}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// contactData passed to the contact page (form submission state).
|
||||||
|
type contactData struct {
|
||||||
|
Sent bool
|
||||||
|
Error string
|
||||||
}
|
}
|
||||||
92
frontend/internal/handlers/promo.go
Normal file
92
frontend/internal/handlers/promo.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// orderData passes the Stripe Payment Links (env-configured) to the order page.
|
||||||
|
// Links are bought via Stripe-hosted checkout; the buyer chooses quantity there
|
||||||
|
// (single 1-25, 10-pack 1-2). Prices: $5.00 + GST, $20.00 + GST (fee handled
|
||||||
|
// by Stripe Managed Payments).
|
||||||
|
type orderData struct {
|
||||||
|
SingleLink string
|
||||||
|
PackLink string
|
||||||
|
Thanks bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order renders the public ordering page.
|
||||||
|
func (a *App) Order(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.render(w, r, "order", "Order tags",
|
||||||
|
orderData{
|
||||||
|
SingleLink: os.Getenv("ORDER_SINGLE_LINK"),
|
||||||
|
PackLink: os.Getenv("ORDER_PACK_LINK"),
|
||||||
|
Thanks: r.URL.Query().Get("thanks") == "1",
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContactSubmit receives the contact form and emails the team via SMTP
|
||||||
|
// (env: SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS, CONTACT_TO).
|
||||||
|
// Returns the contact page with a success/error notice.
|
||||||
|
func (a *App) ContactSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
email := strings.TrimSpace(r.FormValue("email"))
|
||||||
|
subject := strings.TrimSpace(r.FormValue("subject"))
|
||||||
|
message := strings.TrimSpace(r.FormValue("message"))
|
||||||
|
|
||||||
|
errMsg := ""
|
||||||
|
if name == "" || email == "" || message == "" {
|
||||||
|
errMsg = "Please fill in your name, email and message."
|
||||||
|
} else if !strings.Contains(email, "@") {
|
||||||
|
errMsg = "Please enter a valid email address."
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg == "" {
|
||||||
|
if err := sendContactEmail(name, email, subject, message); err != nil {
|
||||||
|
fmt.Println("contact email failed:", err)
|
||||||
|
errMsg = "Sorry — we couldn't send your message right now. Please email us directly at hello@where-woof.com."
|
||||||
|
} else {
|
||||||
|
// Success: show a confirmation instead of re-rendering the form.
|
||||||
|
a.render(w, r, "contact", "Get in touch", contactData{Sent: true}, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, r, "contact", "Get in touch", contactData{Error: errMsg}, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendContactEmail sends the form contents to CONTACT_TO via Gmail SMTP
|
||||||
|
// (ENV: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, CONTACT_TO).
|
||||||
|
func sendContactEmail(name, email, subject, message string) error {
|
||||||
|
host := os.Getenv("SMTP_HOST")
|
||||||
|
port := os.Getenv("SMTP_PORT")
|
||||||
|
user := os.Getenv("SMTP_USER")
|
||||||
|
pass := os.Getenv("SMTP_PASS")
|
||||||
|
to := os.Getenv("CONTACT_TO")
|
||||||
|
if host == "" || user == "" || pass == "" || to == "" {
|
||||||
|
return fmt.Errorf("SMTP not configured")
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
port = "587"
|
||||||
|
}
|
||||||
|
|
||||||
|
subj := subject
|
||||||
|
if subj == "" {
|
||||||
|
subj = "Where Woof contact form"
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("From: %s <%s>\nSubject: %s\n\n%s", name, email, subj, message)
|
||||||
|
|
||||||
|
msg := []byte("To: " + to + "\r\n" +
|
||||||
|
"From: " + user + "\r\n" +
|
||||||
|
"Subject: " + subj + "\r\n" +
|
||||||
|
"MIME-Version: 1.0\r\n" +
|
||||||
|
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||||
|
"\r\n" + body + "\r\n")
|
||||||
|
|
||||||
|
addr := host + ":" + port
|
||||||
|
auth := smtp.PlainAuth("", user, pass, host)
|
||||||
|
return smtp.SendMail(addr, auth, user, []string{to}, msg)
|
||||||
|
}
|
||||||
@@ -70,6 +70,8 @@ func main() {
|
|||||||
mux.HandleFunc("GET /about", app.About)
|
mux.HandleFunc("GET /about", app.About)
|
||||||
mux.HandleFunc("GET /what-is-this", app.WhatIsThis)
|
mux.HandleFunc("GET /what-is-this", app.WhatIsThis)
|
||||||
mux.HandleFunc("GET /contact", app.Contact)
|
mux.HandleFunc("GET /contact", app.Contact)
|
||||||
|
mux.HandleFunc("POST /contact", app.ContactSubmit)
|
||||||
|
mux.HandleFunc("GET /order", app.Order)
|
||||||
mux.HandleFunc("/{path...}", app.LegacyNotFound)
|
mux.HandleFunc("/{path...}", app.LegacyNotFound)
|
||||||
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
|
mux.HandleFunc("GET /t/{tag_code}", app.PublicTag)
|
||||||
mux.HandleFunc("GET /s/{code}", app.ServeShort)
|
mux.HandleFunc("GET /s/{code}", app.ServeShort)
|
||||||
|
|||||||
@@ -40,9 +40,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<!-- Promo banner: consistent across every page -> order page -->
|
||||||
|
<div class="bg-red-600 text-white">
|
||||||
|
<a href="/order" class="mx-auto flex max-w-4xl items-center justify-center gap-2 px-4 py-2.5 text-center text-sm font-bold tracking-wide hover:bg-red-500">
|
||||||
|
<span>🛡️ Protect & recover your valuables</span>
|
||||||
|
<span class="rounded bg-white px-2 py-0.5 text-xs font-extrabold text-red-600">ORDER NOW — tags from $5</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<main class="mx-auto max-w-4xl px-4 py-8">
|
<main class="mx-auto max-w-4xl px-4 py-8">
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
</main>
|
</main>
|
||||||
|
<footer class="border-t border-stone-200 py-6">
|
||||||
|
<div class="mx-auto flex max-w-4xl flex-wrap items-center justify-center gap-x-6 gap-y-2 px-4 text-sm text-stone-500">
|
||||||
|
<a href="/order" class="font-semibold text-red-600 hover:underline">🛡️ Order tags — from $5</a>
|
||||||
|
<a href="/about" class="hover:underline">About</a>
|
||||||
|
<a href="/what-is-this" class="hover:underline">What is this?</a>
|
||||||
|
<a href="/contact" class="hover:underline">Get in touch</a>
|
||||||
|
<span>🐕 Where Woof — every item deserves a way home</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,9 +2,46 @@
|
|||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<h1 class="text-3xl font-extrabold tracking-tight">Get in touch</h1>
|
<h1 class="text-3xl font-extrabold tracking-tight">Get in touch</h1>
|
||||||
<p class="mt-4 text-stone-700">Questions, lost-tag help, or bulk orders? We'd love to hear from you.</p>
|
<p class="mt-4 text-stone-700">Questions, lost-tag help, or bulk orders? We'd love to hear from you.</p>
|
||||||
|
|
||||||
|
{{if .Data.Sent}}
|
||||||
|
<div class="mt-6 rounded-xl border border-green-200 bg-green-50 p-6 text-center shadow-sm">
|
||||||
|
<div class="text-4xl">📨</div>
|
||||||
|
<h2 class="mt-2 text-lg font-bold text-green-800">Message sent!</h2>
|
||||||
|
<p class="mt-1 text-green-700">Thanks for reaching out — we'll get back to you shortly (usually within a day).</p>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="mt-6 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
{{if .Data.Error}}
|
||||||
|
<p class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{{.Data.Error}}</p>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/contact" class="space-y-4">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-stone-700" for="name">Your name</label>
|
||||||
|
<input id="name" name="name" required class="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none" placeholder="Jane Smith">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-stone-700" for="email">Your email</label>
|
||||||
|
<input id="email" name="email" type="email" required class="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none" placeholder="you@example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-stone-700" for="subject">Subject</label>
|
||||||
|
<input id="subject" name="subject" class="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none" placeholder="Bulk order for a park / trail">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-stone-700" for="message">Message</label>
|
||||||
|
<textarea id="message" name="message" required rows="5" class="mt-1 w-full rounded-lg border border-stone-300 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none" placeholder="Tell us what you need…"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="rounded-full bg-stone-900 px-6 py-2.5 text-sm font-bold text-white hover:bg-stone-700">Send message</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="mt-6 space-y-3 rounded-xl border border-stone-200 bg-white p-6 text-sm shadow-sm">
|
<div class="mt-6 space-y-3 rounded-xl border border-stone-200 bg-white p-6 text-sm shadow-sm">
|
||||||
<p><strong>Email:</strong> <a href="mailto:info@where-woof.com" class="text-amber-600 hover:underline">info@where-woof.com</a></p>
|
<p><strong>Email:</strong> <a href="mailto:hello@where-woof.com" class="text-amber-600 hover:underline">hello@where-woof.com</a></p>
|
||||||
<p><strong>Website:</strong> <a href="/" class="text-amber-600 hover:underline">where-woof.com</a></p>
|
<p><strong>Website:</strong> <a href="/" class="text-amber-600 hover:underline">where-woof.com</a></p>
|
||||||
|
<p><strong>Order tags:</strong> <a href="/order" class="text-amber-600 hover:underline">yeah, take my money</a> — from $5 + GST.</p>
|
||||||
<p class="text-stone-500">For bulk tags (parks, trails, campgrounds) mention your use case and volume.</p>
|
<p class="text-stone-500">For bulk tags (parks, trails, campgrounds) mention your use case and volume.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
60
frontend/templates/order.html
Normal file
60
frontend/templates/order.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
{{if .Data.Thanks}}
|
||||||
|
<div class="mb-6 rounded-xl border border-green-200 bg-green-50 p-6 text-center shadow-sm">
|
||||||
|
<div class="text-4xl">✅</div>
|
||||||
|
<h2 class="mt-2 text-xl font-bold text-green-800">Thanks for your order!</h2>
|
||||||
|
<p class="mt-1 text-green-700">We've received your payment and will ship your tags soon. Watch your inbox for the email from <strong>hello@where-woof.com</strong> with activation details.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-extrabold tracking-tight">Order tags</h1>
|
||||||
|
<p class="mt-2 text-stone-700">Stick a Where Woof tag on anything you don't want to lose — a dog collar, a school bag, ski gear, a toolbox. If it's found, whoever finds it scans the tag and you get an SMS with its location, instantly.</p>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||||
|
<!-- Single tag -->
|
||||||
|
<div class="flex flex-col rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="text-4xl">🏷️</div>
|
||||||
|
<h2 class="mt-3 text-xl font-bold">Single tag</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">One tag for one item. Plastic laminate, QR + NFC.</p>
|
||||||
|
<p class="mt-4 text-2xl font-extrabold">$5.00 <span class="text-sm font-normal text-stone-500">+ GST ($5.50)</span></p>
|
||||||
|
<p class="mt-1 text-xs text-stone-500">+ $10/yr subscription (50 SMS alerts included)</p>
|
||||||
|
<div class="mt-auto pt-5">
|
||||||
|
<a href="{{.Data.SingleLink}}" class="block rounded-full bg-stone-900 px-5 py-3 text-center font-bold text-white hover:bg-stone-700">
|
||||||
|
Order now
|
||||||
|
</a>
|
||||||
|
<p class="mt-2 text-center text-xs text-stone-500">Need 2, 3 … 7? Pick the quantity at checkout (up to 25).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 10-pack -->
|
||||||
|
<div class="flex flex-col rounded-2xl border-2 border-red-600 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-4xl">🎒🐾</div>
|
||||||
|
<span class="rounded-full bg-red-600 px-2.5 py-1 text-xs font-extrabold text-white">BEST VALUE</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-3 text-xl font-bold">10-pack</h2>
|
||||||
|
<p class="mt-1 text-sm text-stone-600">Tag the whole family — collars, bags, keys, skis.</p>
|
||||||
|
<p class="mt-4 text-2xl font-extrabold">$20.00 <span class="text-sm font-normal text-stone-500">+ GST ($22.00)</span></p>
|
||||||
|
<p class="mt-1 text-xs text-stone-500">$2 per tag · + $10/yr subscription (50 SMS alerts included)</p>
|
||||||
|
<div class="mt-auto pt-5">
|
||||||
|
<a href="{{.Data.PackLink}}" class="block rounded-full bg-red-600 px-5 py-3 text-center font-bold text-white hover:bg-red-500">
|
||||||
|
Order the 10-pack
|
||||||
|
</a>
|
||||||
|
<p class="mt-2 text-center text-xs text-stone-500">Up to 2 packs per account (20 tags, fits the 25-tag limit).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 rounded-xl border border-stone-200 bg-white p-6 text-sm shadow-sm">
|
||||||
|
<h3 class="font-bold">How it works</h3>
|
||||||
|
<ol class="mt-2 list-decimal space-y-1 pl-5 text-stone-700">
|
||||||
|
<li>Order your tags — they arrive ready to scan (QR + NFC).</li>
|
||||||
|
<li>When they arrive, create a free account and add each tag.</li>
|
||||||
|
<li>Set the item's name and your phone number.</li>
|
||||||
|
<li>If it's ever lost and found, you get an SMS with a map link — instantly.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mt-3 text-stone-500">Questions? <a href="/contact" class="text-amber-600 hover:underline">Get in touch</a> — hello@where-woof.com</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
4
todo.txt
4
todo.txt
@@ -26,3 +26,7 @@ x 2026-08-08 2026-08-08 Billing phase +
|
|||||||
(A) Do we tie this all into an established Open Source inventory payment system - keep qick dash for now? +area:CONCEPT
|
(A) Do we tie this all into an established Open Source inventory payment system - keep qick dash for now? +area:CONCEPT
|
||||||
(A) Order-tags. Tie in the inventory system? +area:Where-woof
|
(A) Order-tags. Tie in the inventory system? +area:Where-woof
|
||||||
(A) General webiste pages - what is this? Get in touch? About Us. +area:Where-woof
|
(A) General webiste pages - what is this? Get in touch? About Us. +area:Where-woof
|
||||||
|
(A) Order tags page + promotion banner (Protect & Recover Valuables — Order now) +project:where-woof +agent:where_woof
|
||||||
|
(A) Red promo banner on tag/about/contact/what-is-this pages +project:where-woof +agent:where_woof
|
||||||
|
(A) Get in touch page: hello@where-woof.com + contact form +project:where-woof +agent:where_woof
|
||||||
|
(C) Verify coordinates in scan/location flow +project:where-woof +agent:where_woof
|
||||||
|
|||||||
Reference in New Issue
Block a user