diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..b694854
Binary files /dev/null and b/.DS_Store differ
diff --git a/data/subscribers.db b/data/subscribers.db
new file mode 100644
index 0000000..0da6dc2
Binary files /dev/null and b/data/subscribers.db differ
diff --git a/devEmails/20260214_201443_bag_exchange___password_reset.html b/devEmails/20260214_201443_bag_exchange___password_reset.html
new file mode 100644
index 0000000..7969794
--- /dev/null
+++ b/devEmails/20260214_201443_bag_exchange___password_reset.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/devEmails/20260214_202435_bag_exchange___verify_your_email.html b/devEmails/20260214_202435_bag_exchange___verify_your_email.html
new file mode 100644
index 0000000..026cc54
--- /dev/null
+++ b/devEmails/20260214_202435_bag_exchange___verify_your_email.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/docs/todo-auth.md b/docs/todo-auth.md
new file mode 100644
index 0000000..f700bd6
--- /dev/null
+++ b/docs/todo-auth.md
@@ -0,0 +1,47 @@
+# TODO - Prossimi Passi Auth
+
+## 1. Iscrizione al servizio
+- [ ] Definire modello `users` in SQLite (`id`, `email`, `password_hash`, `email_verified`, `created_at`, `updated_at`).
+- [ ] Creare endpoint `POST /api/auth/register` con validazione input (email, password forte).
+- [ ] Hash password con Argon2id o bcrypt (mai salvare password in chiaro).
+- [ ] Bloccare email duplicate con vincolo `UNIQUE` e gestione errore chiara.
+- [ ] Aggiungere form UI registrazione (email, password, conferma password, accettazione termini).
+- [ ] Mostrare messaggi UX chiari (successo/errore/utente gia registrato).
+- [ ] Preparare invio email verifica account (token con scadenza).
+
+## 2. Login
+- [ ] Creare endpoint `POST /api/auth/login`.
+- [ ] Verificare credenziali in modo sicuro (confronto hash password).
+- [ ] Introdurre sessione con cookie `HttpOnly` + `SameSite=Lax` (+ `Secure` in HTTPS).
+- [ ] Aggiungere endpoint `POST /api/auth/logout` per invalidare sessione.
+- [ ] Aggiungere endpoint `GET /api/auth/me` per stato utente autenticato.
+- [ ] Costruire pagina/modal login con gestione errori (credenziali errate, account non verificato).
+- [ ] Rate limit su login per mitigare brute force.
+
+## 3. Recupero password
+- [ ] Endpoint `POST /api/auth/forgot-password` (sempre risposta neutra per privacy).
+- [ ] Generare token monouso con scadenza breve (es. 30-60 min).
+- [ ] Salvare token hashato nel DB (`password_resets` o campi dedicati in `users`).
+- [ ] Inviare email con link di reset.
+- [ ] Endpoint `POST /api/auth/reset-password` con validazione token + nuova password.
+- [ ] Invalidare token dopo utilizzo.
+- [ ] Forzare logout da sessioni vecchie dopo reset password.
+
+## 4. Sicurezza e compliance (trasversale)
+- [ ] CSRF protection per endpoint sensibili con cookie/sessione.
+- [ ] Logging minimale senza dati sensibili (no password/token in log).
+- [ ] Policy password minima (lunghezza, complessita, blacklist password comuni).
+- [ ] Audit su cookie e header di sicurezza (`Content-Security-Policy`, `X-Frame-Options`, `Referrer-Policy`).
+- [ ] Aggiornare privacy policy per raccolta dati (IP, user-agent, email).
+
+## 5. Testing
+- [ ] Test unitari validazione auth.
+- [ ] Test integrazione endpoint register/login/forgot/reset.
+- [ ] Test casi limite: token scaduto, email duplicata, password debole.
+- [ ] Test E2E flusso completo: registrazione -> login -> logout -> recupero password.
+
+## 6. Delivery
+- [ ] Definire variabili env (`APP_BASE_URL`, `SMTP_*`, `SESSION_SECRET`).
+- [ ] Preparare migrazione DB iniziale per tabelle auth.
+- [ ] Setup provider email (SMTP o API) con ambiente dev/staging/prod.
+- [ ] Checklist rilascio con smoke test post-deploy.
diff --git a/docs/video.txt b/docs/video.txt
new file mode 100644
index 0000000..87a22f3
--- /dev/null
+++ b/docs/video.txt
@@ -0,0 +1,18 @@
+[0-5s]
+Hai borse che non usi più?
+
+[5-15s]
+Con Bag Exchange puoi scambiarle in modo semplice, sicuro e sostenibile.
+
+[15-30s]
+Pubblica la tua borsa con foto e descrizione.
+Ricevi proposte da persone con gusti simili.
+Conferma lo scambio in pochi passaggi.
+
+[30-45s]
+Meno sprechi, più stile.
+Un modo intelligente per rinnovare il guardaroba senza comprare sempre nuovo.
+
+[45-60s]
+Stiamo sviluppando Bag Exchange.
+Lascia la tua email e resta aggiornata sul lancio.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4a1e8bd
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,30 @@
+module prada.ch
+
+go 1.25.4
+
+require github.com/gofiber/fiber/v3 v3.0.0
+
+require (
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/gofiber/schema v1.6.0 // indirect
+ github.com/gofiber/utils/v2 v2.0.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/klauspost/compress v1.18.3 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/tinylib/msgp v1.6.3 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.69.0 // indirect
+ golang.org/x/crypto v0.47.0 // indirect
+ golang.org/x/net v0.49.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
+ golang.org/x/text v0.33.0 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/sqlite v1.34.5 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7124363
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,63 @@
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
+github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
+github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
+github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
+github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
+github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
+github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
+github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
+github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
+github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
+golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
+modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..75fbbe6
--- /dev/null
+++ b/main.go
@@ -0,0 +1,909 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/mail"
+ "net/smtp"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/gofiber/fiber/v3"
+ "golang.org/x/crypto/bcrypt"
+ _ "modernc.org/sqlite"
+)
+
+type landingData struct {
+ Brand string
+ InitialTitle string
+ FooterText string
+ AssetVersion string
+}
+
+type resetPasswordPageData struct {
+ Token string
+}
+
+type subscribeRequest struct {
+ Email string `json:"email"`
+ BrowserData map[string]any `json:"browserData"`
+}
+
+type registerRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ ConfirmPassword string `json:"confirmPassword"`
+}
+
+type loginRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type forgotPasswordRequest struct {
+ Email string `json:"email"`
+}
+
+type resetPasswordRequest struct {
+ Token string `json:"token"`
+ Password string `json:"password"`
+ ConfirmPassword string `json:"confirmPassword"`
+}
+
+type resendVerificationRequest struct {
+ Email string `json:"email"`
+}
+
+type smtpConfig struct {
+ Host string
+ Port int
+ User string
+ Password string
+}
+
+const subscriptionCookieName = "bag_exchange_subscribed"
+const sessionCookieName = "bag_exchange_session"
+const sessionDurationDays = 7
+const passwordResetDurationMinutes = 60
+const emailVerificationDurationHours = 24
+
+func main() {
+ loadDotEnv(".env")
+
+ db, err := initDB("data/subscribers.db")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer db.Close()
+
+ mailConfig := getSMTPConfigFromEnv()
+
+ landingTemplate := template.Must(template.ParseFiles("templates/index.html"))
+ howToWorkTemplate := template.Must(template.ParseFiles("templates/howtowork.html"))
+ loginTemplate := template.Must(template.ParseFiles("templates/login.html"))
+ signupTemplate := template.Must(template.ParseFiles("templates/signup.html"))
+ forgotPasswordTemplate := template.Must(template.ParseFiles("templates/forgot_password.html"))
+ resetPasswordTemplate := template.Must(template.ParseFiles("templates/reset_password.html"))
+ assetVersion := buildAssetVersion("static/css/main.css", "static/js/i18n.js")
+
+ app := fiber.New()
+ app.Get("/static/*", func(c fiber.Ctx) error {
+ relativePath := filepath.Clean(c.Params("*"))
+ if relativePath == "." || strings.HasPrefix(relativePath, "..") {
+ return c.SendStatus(fiber.StatusNotFound)
+ }
+ return c.SendFile(filepath.Join("static", relativePath))
+ })
+ app.Post("/api/subscribe", func(c fiber.Ctx) error {
+ var req subscribeRequest
+ if err := json.Unmarshal(c.Body(), &req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid payload"})
+ }
+
+ email := strings.ToLower(strings.TrimSpace(req.Email))
+ if _, err := mail.ParseAddress(email); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid email"})
+ }
+
+ emailHash := hashEmail(email)
+ if c.Cookies(subscriptionCookieName) == emailHash {
+ return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"})
+ }
+
+ browserData := "{}"
+ if len(req.BrowserData) > 0 {
+ payload, err := json.Marshal(req.BrowserData)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid browser data"})
+ }
+ browserData = string(payload)
+ }
+
+ _, err = db.Exec(`
+ INSERT INTO subscribers (email, ip_address, user_agent, accept_language, browser_data)
+ VALUES (?, ?, ?, ?, ?)
+ `, email, c.IP(), c.Get("User-Agent"), c.Get("Accept-Language"), browserData)
+ if err != nil {
+ if isUniqueConstraint(err) {
+ setSubscriptionCookie(c, emailHash)
+ return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"})
+ }
+ log.Printf("unable to insert subscriber: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable to save subscription"})
+ }
+
+ setSubscriptionCookie(c, emailHash)
+ return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "subscribed"})
+ })
+ app.Post("/api/auth/register", func(c fiber.Ctx) error {
+ var req registerRequest
+ if err := json.Unmarshal(c.Body(), &req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
+ }
+
+ email := strings.ToLower(strings.TrimSpace(req.Email))
+ if _, err := mail.ParseAddress(email); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_email"})
+ }
+
+ if req.Password != req.ConfirmPassword {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"})
+ }
+
+ if !isStrongPassword(req.Password) {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"})
+ }
+
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ log.Printf("unable to hash password: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
+ }
+
+ result, err := db.Exec(`
+ INSERT INTO users (email, password_hash, email_verified)
+ VALUES (?, ?, ?)
+ `, email, string(passwordHash), 0)
+ if err != nil {
+ if isUniqueConstraint(err) {
+ return c.Status(fiber.StatusConflict).JSON(map[string]string{"error": "email_exists"})
+ }
+ log.Printf("unable to register user: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
+ }
+
+ userID, err := result.LastInsertId()
+ if err != nil {
+ log.Printf("unable to read new user id: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
+ }
+
+ verifyToken, err := createEmailVerificationToken(db, userID)
+ if err != nil {
+ log.Printf("unable to create verify token: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"})
+ }
+
+ if mailConfig.isConfigured() {
+ verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", getAppBaseURL(), verifyToken)
+ if err := sendEmailVerificationEmail(mailConfig, email, verifyURL); err != nil {
+ log.Printf("unable to send verification email: %v", err)
+ }
+ }
+
+ return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "registered_pending_verification"})
+ })
+ app.Post("/api/auth/login", func(c fiber.Ctx) error {
+ var req loginRequest
+ if err := json.Unmarshal(c.Body(), &req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
+ }
+
+ email := strings.ToLower(strings.TrimSpace(req.Email))
+ if _, err := mail.ParseAddress(email); err != nil || req.Password == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"})
+ }
+
+ var userID int64
+ var passwordHash string
+ var emailVerified int
+ err := db.QueryRow(`
+ SELECT id, password_hash, email_verified
+ FROM users
+ WHERE email = ?
+ `, email).Scan(&userID, &passwordHash, &emailVerified)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"})
+ }
+ log.Printf("unable to fetch user for login: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"})
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"})
+ }
+ if emailVerified == 0 {
+ return c.Status(fiber.StatusForbidden).JSON(map[string]string{"error": "email_not_verified"})
+ }
+
+ sessionToken, err := generateSessionToken()
+ if err != nil {
+ log.Printf("unable to generate session token: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"})
+ }
+
+ expiresAt := time.Now().AddDate(0, 0, sessionDurationDays).Unix()
+ _, err = db.Exec(`
+ INSERT INTO sessions (user_id, token_hash, expires_at)
+ VALUES (?, ?, ?)
+ `, userID, hashToken(sessionToken), expiresAt)
+ if err != nil {
+ log.Printf("unable to create session: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"})
+ }
+
+ setSessionCookie(c, sessionToken, expiresAt)
+ return c.Status(fiber.StatusOK).JSON(map[string]any{
+ "status": "authenticated",
+ "email": email,
+ })
+ })
+ app.Post("/api/auth/logout", func(c fiber.Ctx) error {
+ sessionToken := c.Cookies(sessionCookieName)
+ if sessionToken != "" {
+ if _, err := db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, hashToken(sessionToken)); err != nil {
+ log.Printf("unable to delete session: %v", err)
+ }
+ }
+ clearSessionCookie(c)
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "logged_out"})
+ })
+ app.Get("/api/auth/me", func(c fiber.Ctx) error {
+ sessionToken := c.Cookies(sessionCookieName)
+ if sessionToken == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false})
+ }
+
+ var userID int64
+ var email string
+ err := db.QueryRow(`
+ SELECT users.id, users.email
+ FROM sessions
+ JOIN users ON users.id = sessions.user_id
+ WHERE sessions.token_hash = ? AND sessions.expires_at > ?
+ `, hashToken(sessionToken), time.Now().Unix()).Scan(&userID, &email)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ clearSessionCookie(c)
+ return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false})
+ }
+ log.Printf("unable to resolve session: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_fetch_session"})
+ }
+
+ return c.Status(fiber.StatusOK).JSON(map[string]any{
+ "authenticated": true,
+ "userId": userID,
+ "email": email,
+ })
+ })
+ app.Post("/api/auth/forgot-password", func(c fiber.Ctx) error {
+ var req forgotPasswordRequest
+ if err := json.Unmarshal(c.Body(), &req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
+ }
+
+ email := strings.ToLower(strings.TrimSpace(req.Email))
+ if _, err := mail.ParseAddress(email); err != nil {
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ }
+
+ var userID int64
+ err := db.QueryRow(`SELECT id FROM users WHERE email = ?`, email).Scan(&userID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ }
+ log.Printf("unable to fetch user for forgot-password: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
+ }
+
+ resetToken, err := generateSessionToken()
+ if err != nil {
+ log.Printf("unable to generate reset token: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
+ }
+ expiresAt := time.Now().Add(time.Minute * passwordResetDurationMinutes).Unix()
+
+ _, err = db.Exec(`
+ INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
+ VALUES (?, ?, ?)
+ `, userID, hashToken(resetToken), expiresAt)
+ if err != nil {
+ log.Printf("unable to persist reset token: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
+ }
+
+ if mailConfig.isConfigured() {
+ resetURL := fmt.Sprintf("%s/reset-password?token=%s", getAppBaseURL(), resetToken)
+ if err := sendPasswordResetEmail(mailConfig, email, resetURL); err != nil {
+ log.Printf("unable to send reset email: %v", err)
+ }
+ } else {
+ log.Printf("smtp not configured: skip password reset email")
+ }
+
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ })
+ app.Post("/api/auth/resend-verification", func(c fiber.Ctx) error {
+ var req resendVerificationRequest
+ if err := json.Unmarshal(c.Body(), &req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
+ }
+
+ email := strings.ToLower(strings.TrimSpace(req.Email))
+ if _, err := mail.ParseAddress(email); err != nil {
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ }
+
+ var userID int64
+ var verified int
+ err := db.QueryRow(`SELECT id, email_verified FROM users WHERE email = ?`, email).Scan(&userID, &verified)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ }
+ log.Printf("unable to fetch user for resend verification: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
+ }
+ if verified != 0 {
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ }
+
+ verifyToken, err := createEmailVerificationToken(db, userID)
+ if err != nil {
+ log.Printf("unable to create verify token on resend: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"})
+ }
+ if mailConfig.isConfigured() {
+ verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", getAppBaseURL(), verifyToken)
+ if err := sendEmailVerificationEmail(mailConfig, email, verifyURL); err != nil {
+ log.Printf("unable to resend verification email: %v", err)
+ }
+ }
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"})
+ })
+ app.Post("/api/auth/reset-password", func(c fiber.Ctx) error {
+ var req resetPasswordRequest
+ if err := json.Unmarshal(c.Body(), &req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"})
+ }
+
+ token := strings.TrimSpace(req.Token)
+ if token == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_token"})
+ }
+
+ if req.Password != req.ConfirmPassword {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"})
+ }
+
+ if !isStrongPassword(req.Password) {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"})
+ }
+
+ var resetID int64
+ var userID int64
+ err := db.QueryRow(`
+ SELECT id, user_id
+ FROM password_reset_tokens
+ WHERE token_hash = ?
+ AND used_at IS NULL
+ AND expires_at > ?
+ ORDER BY id DESC
+ LIMIT 1
+ `, hashToken(token), time.Now().Unix()).Scan(&resetID, &userID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_or_expired_token"})
+ }
+ log.Printf("unable to fetch reset token: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+
+ passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+ if err != nil {
+ log.Printf("unable to hash password in reset: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+
+ tx, err := db.Begin()
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+
+ if _, err := tx.Exec(`UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?`, string(passwordHash), userID); err != nil {
+ tx.Rollback()
+ log.Printf("unable to update password: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+ if _, err := tx.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE id = ?`, time.Now().Unix(), resetID); err != nil {
+ tx.Rollback()
+ log.Printf("unable to mark reset token used: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+ if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
+ tx.Rollback()
+ log.Printf("unable to delete sessions after reset: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+ if err := tx.Commit(); err != nil {
+ log.Printf("unable to commit reset tx: %v", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"})
+ }
+
+ clearSessionCookie(c)
+ return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "password_reset"})
+ })
+ app.Get("/auth/verify-email", func(c fiber.Ctx) error {
+ token := strings.TrimSpace(c.Query("token"))
+ if token == "" {
+ return c.Status(fiber.StatusBadRequest).SendString("Invalid verification token.")
+ }
+
+ var tokenID int64
+ var userID int64
+ err := db.QueryRow(`
+ SELECT id, user_id
+ FROM email_verification_tokens
+ WHERE token_hash = ?
+ AND used_at IS NULL
+ AND expires_at > ?
+ ORDER BY id DESC
+ LIMIT 1
+ `, hashToken(token), time.Now().Unix()).Scan(&tokenID, &userID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.Status(fiber.StatusBadRequest).SendString("Verification link is invalid or expired.")
+ }
+ log.Printf("unable to validate verification token: %v", err)
+ return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
+ }
+
+ tx, err := db.Begin()
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
+ }
+ if _, err := tx.Exec(`UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE id = ?`, userID); err != nil {
+ tx.Rollback()
+ log.Printf("unable to mark email verified: %v", err)
+ return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
+ }
+ if _, err := tx.Exec(`UPDATE email_verification_tokens SET used_at = ? WHERE id = ?`, time.Now().Unix(), tokenID); err != nil {
+ tx.Rollback()
+ log.Printf("unable to mark verify token used: %v", err)
+ return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
+ }
+ if err := tx.Commit(); err != nil {
+ log.Printf("unable to commit verify email tx: %v", err)
+ return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.")
+ }
+
+ return c.SendString("Email verified successfully. You can now log in.")
+ })
+
+ app.Get("/", func(c fiber.Ctx) error {
+ data := landingData{
+ Brand: "Bag Exchange",
+ InitialTitle: "Bag Exchange | Swap bags and handbags",
+ FooterText: "© 2026 Bag Exchange · Bag and handbag exchange between individuals",
+ AssetVersion: assetVersion,
+ }
+
+ var out bytes.Buffer
+ if err := landingTemplate.Execute(&out, data); err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("unable to render page")
+ }
+
+ c.Type("html", "utf-8")
+ return c.SendString(out.String())
+ })
+ app.Get("/reset-password", func(c fiber.Ctx) error {
+ token := strings.TrimSpace(c.Query("token"))
+ var out bytes.Buffer
+ if err := resetPasswordTemplate.Execute(&out, resetPasswordPageData{Token: token}); err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("unable to render reset password page")
+ }
+ c.Type("html", "utf-8")
+ return c.SendString(out.String())
+ })
+ app.Get("/howtowork", func(c fiber.Ctx) error {
+ var out bytes.Buffer
+ if err := howToWorkTemplate.Execute(&out, nil); err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("unable to render howtowork page")
+ }
+ c.Type("html", "utf-8")
+ return c.SendString(out.String())
+ })
+ app.Get("/login", func(c fiber.Ctx) error {
+ var out bytes.Buffer
+ if err := loginTemplate.Execute(&out, nil); err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("unable to render login page")
+ }
+ c.Type("html", "utf-8")
+ return c.SendString(out.String())
+ })
+ app.Get("/signup", func(c fiber.Ctx) error {
+ var out bytes.Buffer
+ if err := signupTemplate.Execute(&out, nil); err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("unable to render signup page")
+ }
+ c.Type("html", "utf-8")
+ return c.SendString(out.String())
+ })
+ app.Get("/forgot-password", func(c fiber.Ctx) error {
+ var out bytes.Buffer
+ if err := forgotPasswordTemplate.Execute(&out, nil); err != nil {
+ return c.Status(fiber.StatusInternalServerError).SendString("unable to render forgot password page")
+ }
+ c.Type("html", "utf-8")
+ return c.SendString(out.String())
+ })
+
+ log.Fatal(app.Listen(":6081"))
+}
+
+func buildAssetVersion(paths ...string) string {
+ hasher := sha256.New()
+ for _, path := range paths {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ log.Printf("asset version fallback, unable to read %s: %v", path, err)
+ return "dev"
+ }
+ hasher.Write(content)
+ }
+ return hex.EncodeToString(hasher.Sum(nil))[:12]
+}
+
+func hashEmail(email string) string {
+ sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
+ return hex.EncodeToString(sum[:])
+}
+
+func initDB(path string) (*sql.DB, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, err
+ }
+
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+
+ schema := `
+ CREATE TABLE IF NOT EXISTS subscribers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ ip_address TEXT NOT NULL,
+ user_agent TEXT NOT NULL,
+ accept_language TEXT,
+ browser_data TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );`
+ if _, err := db.Exec(schema); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ userSchema := `
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ email_verified INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );`
+ if _, err := db.Exec(userSchema); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ sessionSchema := `
+ CREATE TABLE IF NOT EXISTS sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ );`
+ if _, err := db.Exec(sessionSchema); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ resetSchema := `
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ used_at INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ );`
+ if _, err := db.Exec(resetSchema); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ verifySchema := `
+ CREATE TABLE IF NOT EXISTS email_verification_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ used_at INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ );`
+ if _, err := db.Exec(verifySchema); err != nil {
+ db.Close()
+ return nil, err
+ }
+
+ return db, nil
+}
+
+func isUniqueConstraint(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(strings.ToLower(err.Error()), "unique")
+}
+
+func setSubscriptionCookie(c fiber.Ctx, value string) {
+ c.Cookie(&fiber.Cookie{
+ Name: subscriptionCookieName,
+ Value: value,
+ Path: "/",
+ HTTPOnly: false,
+ Secure: false,
+ Expires: time.Now().AddDate(1, 0, 0),
+ MaxAge: 60 * 60 * 24 * 365,
+ })
+}
+
+func setSessionCookie(c fiber.Ctx, value string, expiresAtUnix int64) {
+ c.Cookie(&fiber.Cookie{
+ Name: sessionCookieName,
+ Value: value,
+ Path: "/",
+ HTTPOnly: true,
+ Secure: false,
+ SameSite: "Lax",
+ Expires: time.Unix(expiresAtUnix, 0),
+ MaxAge: 60 * 60 * 24 * sessionDurationDays,
+ })
+}
+
+func clearSessionCookie(c fiber.Ctx) {
+ c.Cookie(&fiber.Cookie{
+ Name: sessionCookieName,
+ Value: "",
+ Path: "/",
+ HTTPOnly: true,
+ Secure: false,
+ SameSite: "Lax",
+ Expires: time.Unix(0, 0),
+ MaxAge: -1,
+ })
+}
+
+func hashToken(value string) string {
+ sum := sha256.Sum256([]byte(value))
+ return hex.EncodeToString(sum[:])
+}
+
+func generateSessionToken() (string, error) {
+ raw := make([]byte, 32)
+ if _, err := rand.Read(raw); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(raw), nil
+}
+
+func isStrongPassword(value string) bool {
+ if len(value) < 8 {
+ return false
+ }
+
+ hasLetter := false
+ hasDigit := false
+ for _, r := range value {
+ if unicode.IsLetter(r) {
+ hasLetter = true
+ }
+ if unicode.IsDigit(r) {
+ hasDigit = true
+ }
+ }
+ return hasLetter && hasDigit
+}
+
+func loadDotEnv(path string) {
+ file, err := os.Open(path)
+ if err != nil {
+ return
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+ if key == "" {
+ continue
+ }
+ if _, exists := os.LookupEnv(key); !exists {
+ os.Setenv(key, value)
+ }
+ }
+}
+
+func getSMTPConfigFromEnv() smtpConfig {
+ port := 587
+ if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" {
+ if p, err := strconv.Atoi(rawPort); err == nil {
+ port = p
+ }
+ }
+ return smtpConfig{
+ Host: strings.TrimSpace(os.Getenv("SMTP")),
+ Port: port,
+ User: strings.TrimSpace(os.Getenv("SMTP_USER")),
+ Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")),
+ }
+}
+
+func (s smtpConfig) isConfigured() bool {
+ return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0
+}
+
+func getAppBaseURL() string {
+ base := strings.TrimSpace(os.Getenv("SITE_URL"))
+ if base == "" {
+ base = strings.TrimSpace(os.Getenv("APP_BASE_URL"))
+ }
+ if base == "" {
+ return "http://localhost:6081"
+ }
+ return strings.TrimRight(base, "/")
+}
+
+func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error {
+ body, err := renderTransactionalTemplate("password_reset.html", map[string]string{
+ "ActionURL": resetURL,
+ })
+ if err != nil {
+ return err
+ }
+ return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body)
+}
+
+func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error {
+ body, err := renderTransactionalTemplate("verify_email.html", map[string]string{
+ "ActionURL": verifyURL,
+ })
+ if err != nil {
+ return err
+ }
+ return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body)
+}
+
+func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error {
+ if isDevEmailMode() {
+ return saveDevEmail(recipientEmail, subject, htmlBody)
+ }
+
+ auth := smtp.PlainAuth("", config.User, config.Password, config.Host)
+ message := "From: " + config.User + "\r\n" +
+ "To: " + recipientEmail + "\r\n" +
+ "Subject: " + subject + "\r\n" +
+ "MIME-Version: 1.0\r\n" +
+ "Content-Type: text/html; charset=UTF-8\r\n\r\n" +
+ htmlBody
+ return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message))
+}
+
+func renderTransactionalTemplate(templateName string, data any) (string, error) {
+ tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName))
+ if err != nil {
+ return "", err
+ }
+ var out bytes.Buffer
+ if err := tmpl.Execute(&out, data); err != nil {
+ return "", err
+ }
+ return out.String(), nil
+}
+
+func createEmailVerificationToken(db *sql.DB, userID int64) (string, error) {
+ token, err := generateSessionToken()
+ if err != nil {
+ return "", err
+ }
+ expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix()
+ _, err = db.Exec(`
+ INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
+ VALUES (?, ?, ?)
+ `, userID, hashToken(token), expiresAt)
+ if err != nil {
+ return "", err
+ }
+ return token, nil
+}
+
+func isDevEmailMode() bool {
+ return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev")
+}
+
+func saveDevEmail(recipientEmail, subject, htmlBody string) error {
+ const dir = "devEmails"
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+
+ filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject))
+ path := filepath.Join(dir, filename)
+
+ payload := "\n" + htmlBody
+
+ return os.WriteFile(path, []byte(payload), 0o644)
+}
+
+func sanitizeFilename(value string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(strings.TrimSpace(value)) {
+ switch {
+ case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+ b.WriteRune(r)
+ case r == ' ' || r == '-' || r == '_':
+ b.WriteRune('_')
+ }
+ }
+ name := strings.Trim(b.String(), "_")
+ if name == "" {
+ return "email"
+ }
+ return name
+}
diff --git a/static/css/main.css b/static/css/main.css
new file mode 100644
index 0000000..e89b7a5
--- /dev/null
+++ b/static/css/main.css
@@ -0,0 +1,351 @@
+:root {
+ --cream: #f5ede1;
+ --sand: #dfcfb7;
+ --ink: #202126;
+ --olive: #4f5e3d;
+ --coral: #e0795a;
+ --card: #fff9f0;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+ color: var(--ink);
+ background:
+ radial-gradient(circle at 10% 10%, #fff7eb 0%, transparent 42%),
+ radial-gradient(circle at 90% 85%, #f1e4d2 0%, transparent 38%),
+ linear-gradient(125deg, #f8f2e7 0%, #ebdfcd 100%);
+ min-height: 100vh;
+}
+
+.container {
+ width: min(1100px, 92vw);
+ margin: 0 auto;
+}
+
+header {
+ padding: 22px 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.logo {
+ font-size: 1.2rem;
+ letter-spacing: .06em;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.badge {
+ background: var(--olive);
+ color: #fff;
+ border-radius: 999px;
+ padding: 8px 14px;
+ font-size: .82rem;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+}
+
+.lang-wrap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(32, 33, 38, .07);
+ padding: 6px 10px;
+ border-radius: 10px;
+ font-size: .85rem;
+}
+
+.lang-flags {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ line-height: 1;
+}
+
+.lang-flags span {
+ font-size: .95rem;
+}
+
+.lang-wrap select {
+ border: 1px solid rgba(32, 33, 38, .2);
+ background: #fff;
+ color: var(--ink);
+ border-radius: 8px;
+ padding: 6px 8px;
+ font-weight: 600;
+ font-size: .85rem;
+}
+
+.hero {
+ padding: 40px 0 20px;
+ display: grid;
+ gap: 24px;
+ grid-template-columns: 1.1fr .9fr;
+ align-items: center;
+}
+
+h1 {
+ margin: 0 0 14px;
+ font-size: clamp(2rem, 5vw, 4rem);
+ line-height: 1.06;
+}
+
+.hero p {
+ font-size: 1.1rem;
+ line-height: 1.6;
+ max-width: 56ch;
+ margin: 0 0 26px;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.auth-status {
+ margin: 14px 0 0;
+ font-size: .92rem;
+ opacity: .8;
+}
+
+.auth-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.auth-logout {
+ margin-top: 10px;
+ padding: 8px 12px;
+ font-size: .82rem;
+}
+
+.btn {
+ border: none;
+ text-decoration: none;
+ border-radius: 14px;
+ padding: 13px 18px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform .2s ease, opacity .2s ease;
+}
+
+.btn:hover { transform: translateY(-2px); }
+
+.btn-primary {
+ background: var(--coral);
+ color: #fff;
+}
+
+.btn-secondary {
+ background: rgba(32,33,38,.07);
+ color: var(--ink);
+}
+
+.showcase {
+ background: linear-gradient(160deg, #fff9ef 0%, #f2e5d1 100%);
+ border: 1px solid rgba(32,33,38,.08);
+ border-radius: 28px;
+ padding: 24px;
+ box-shadow: 0 18px 40px rgba(69, 45, 20, .08);
+}
+
+.showcase h2 {
+ margin: 0 0 12px;
+ font-size: 1.25rem;
+}
+
+.showcase ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ gap: 10px;
+}
+
+.showcase li {
+ background: rgba(255,255,255,.65);
+ border-radius: 12px;
+ padding: 10px 12px;
+ font-size: .95rem;
+}
+
+.grid {
+ padding: 34px 0 70px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0,1fr));
+ gap: 16px;
+}
+
+.card {
+ background: var(--card);
+ border: 1px solid rgba(32,33,38,.08);
+ border-radius: 20px;
+ padding: 20px;
+ box-shadow: 0 10px 26px rgba(57, 39, 18, .06);
+}
+
+.card h3 {
+ margin-top: 0;
+ margin-bottom: 8px;
+ font-size: 1.06rem;
+}
+
+.card p {
+ margin: 0;
+ font-size: .95rem;
+ line-height: 1.5;
+}
+
+.community-note {
+ margin: 0 0 26px;
+ padding: 18px 20px;
+ border-radius: 16px;
+ background: rgba(255, 249, 240, .85);
+ border: 1px solid rgba(32, 33, 38, .08);
+}
+
+.community-note h3 {
+ margin: 0 0 8px;
+ font-size: 1.05rem;
+}
+
+.community-note p {
+ margin: 0;
+ line-height: 1.55;
+}
+
+footer {
+ padding: 20px 0 34px;
+ font-size: .88rem;
+ opacity: .76;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(20, 20, 24, .45);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ z-index: 30;
+}
+
+.modal-backdrop.is-open {
+ display: flex;
+}
+
+.modal-panel {
+ width: min(560px, 100%);
+ background: linear-gradient(160deg, #fffaf1 0%, #f2e6d6 100%);
+ border: 1px solid rgba(32,33,38,.12);
+ border-radius: 18px;
+ box-shadow: 0 20px 40px rgba(28, 23, 20, .22);
+ padding: 22px;
+ position: relative;
+}
+
+.modal-close {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ border: none;
+ background: rgba(32,33,38,.09);
+ color: var(--ink);
+ width: 34px;
+ height: 34px;
+ border-radius: 999px;
+ font-size: 1.2rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.modal-panel h3 {
+ margin: 0 0 12px;
+ font-size: 1.2rem;
+}
+
+.modal-list {
+ margin: 0;
+ padding-left: 20px;
+ display: grid;
+ gap: 10px;
+}
+
+.modal-list li {
+ line-height: 1.5;
+}
+
+.modal-desc {
+ margin: 0 0 14px;
+ line-height: 1.5;
+}
+
+.modal-link-wrap {
+ margin: 6px 0 0;
+}
+
+.modal-link {
+ color: #3b4a2b;
+ font-size: .9rem;
+ text-decoration: underline;
+}
+
+.subscribe-form {
+ display: grid;
+ gap: 10px;
+}
+
+.form-input {
+ width: 100%;
+ border: 1px solid rgba(32,33,38,.25);
+ background: #fff;
+ border-radius: 10px;
+ padding: 12px 14px;
+ font-size: .96rem;
+ color: var(--ink);
+}
+
+.form-input:focus {
+ outline: 2px solid rgba(224, 121, 90, .35);
+ border-color: var(--coral);
+}
+
+.subscribe-feedback {
+ margin: 8px 0 0;
+ padding: 12px 14px;
+ border-radius: 10px;
+ background: rgba(79, 94, 61, .12);
+ color: #2f3a23;
+ font-weight: 600;
+}
+
+.subscribe-error {
+ margin: 8px 0 0;
+ color: #a13225;
+ font-size: .92rem;
+}
+
+.is-hidden {
+ display: none !important;
+}
+
+@media (max-width: 920px) {
+ .hero { grid-template-columns: 1fr; }
+ .grid { grid-template-columns: 1fr; }
+}
diff --git a/static/js/i18n.js b/static/js/i18n.js
new file mode 100644
index 0000000..e6a1c0f
--- /dev/null
+++ b/static/js/i18n.js
@@ -0,0 +1,572 @@
+(function () {
+ var supported = ["en", "it", "fr", "de", "es"];
+ var fallback = "en";
+ var aliases = {
+ "en-us": "en",
+ "de-ch": "de",
+ "fr-ch": "fr"
+ };
+
+ var translations = {
+ en: {
+ pageTitle: "Bag Exchange | Swap bags and handbags",
+ languageLabel: "Language",
+ badge: "community beta",
+ heroTitle: "Give your bags a new life.",
+ heroDesc: "Swap bags and handbags in a simple, safe, and sustainable way. Upload your item, find real matches, and refresh your style without buying new every time.",
+ ctaPrimary: "Start swapping",
+ ctaLogin: "Login",
+ ctaLogout: "Logout",
+ ctaSecondary: "See how it works",
+ ctaRegister: "Create account",
+ howTitle: "How it works in 3 steps",
+ step1: "1. List your bag with photos and condition",
+ step2: "2. Receive offers from people with similar taste",
+ step3: "3. Confirm the swap through chat and tracked shipping",
+ card1Title: "Verified profiles only",
+ card1Desc: "We reduce risk with account verification, feedback, and transparent swap history.",
+ card2Title: "Save and add value",
+ card2Desc: "A smart way to renew your wardrobe while avoiding waste and unnecessary spending.",
+ card3Title: "Circular style",
+ card3Desc: "From daily bags to elegant clutches: every piece can find a new owner.",
+ communityTitle: "Videochat and grow your profile",
+ communityDesc: "You can videochat with people interested in your items or simply exchange opinions and advice. You can aspire to become a fashion bag influencer.",
+ footer: "© 2026 Bag Exchange · Bag and handbag exchange between individuals",
+ modalTitle: "How to prepare your exchange",
+ modalStep1: "Create a photo gallery of your bag.",
+ modalStep2: "Describe the product.",
+ modalStep3: "Add your exchange conditions.",
+ subscribeTitle: "We are building Bag Exchange.",
+ subscribeDesc: "Enter your email to stay updated.",
+ subscribePlaceholder: "Your email",
+ subscribeCta: "Keep me updated",
+ subscribeThanks: "Thank you. We will keep you updated.",
+ subscribeInvalidEmail: "Please enter a valid email address.",
+ subscribeError: "Something went wrong. Please try again.",
+ registerTitle: "Create your account",
+ registerDesc: "Register to start exchanging bags and join the community.",
+ registerEmailPlaceholder: "Your email",
+ registerPasswordPlaceholder: "Password",
+ registerConfirmPlaceholder: "Confirm password",
+ registerCta: "Create account",
+ registerThanks: "Registration completed. You can now log in when the auth area is live.",
+ registerInvalidEmail: "Please enter a valid email address.",
+ registerPasswordMismatch: "Passwords do not match.",
+ registerWeakPassword: "Use at least 8 characters with letters and numbers.",
+ registerEmailExists: "This email is already registered.",
+ registerError: "Unable to complete registration. Please try again.",
+ loginTitle: "Access your account",
+ loginDesc: "Log in to continue your exchange journey.",
+ loginEmailPlaceholder: "Your email",
+ loginPasswordPlaceholder: "Password",
+ loginCta: "Log in",
+ loginForgotLink: "Forgot your password?",
+ loginSuccess: "Login successful.",
+ loginInvalid: "Invalid email or password.",
+ loginNotVerified: "Email not verified. Check your inbox for the verification link.",
+ loginError: "Unable to log in. Please try again.",
+ authStatusGuest: "Not logged in.",
+ authStatusUserPrefix: "Logged in as:"
+ },
+ it: {
+ pageTitle: "Bag Exchange | Scambia borse e borsette",
+ languageLabel: "Lingua",
+ badge: "community beta",
+ heroTitle: "Dai nuova vita alle tue borse.",
+ heroDesc: "Scambia borse e borsette in modo semplice, sicuro e sostenibile. Carica il tuo articolo, trova match reali e rinnova il tuo stile senza comprare ogni volta da zero.",
+ ctaPrimary: "Inizia a scambiare",
+ ctaLogin: "Login",
+ ctaLogout: "Logout",
+ ctaSecondary: "Guarda come funziona",
+ ctaRegister: "Crea account",
+ howTitle: "Come funziona in 3 step",
+ step1: "1. Pubblica la tua borsa con foto e condizioni",
+ step2: "2. Ricevi proposte da persone con gusti simili",
+ step3: "3. Conferma lo scambio con chat e spedizione tracciata",
+ card1Title: "Solo profili verificati",
+ card1Desc: "Riduciamo i rischi grazie a verifica account, feedback e storico scambi trasparente.",
+ card2Title: "Risparmia e valorizza",
+ card2Desc: "Un modo intelligente per rinnovare il guardaroba evitando sprechi e spese inutili.",
+ card3Title: "Stile circolare",
+ card3Desc: "Dalle borse da giorno alle pochette eleganti: ogni pezzo trova una nuova proprietaria.",
+ communityTitle: "Videochat e crescita del tuo profilo",
+ communityDesc: "Hai la possibilita di videochattare con gli interessati ai tuoi prodotti o semplicemente per scambiarsi opinioni e consigli. Puoi aspirare a diventare una fashion bag influencer.",
+ footer: "© 2026 Bag Exchange · Scambio borse e borsette tra privati",
+ modalTitle: "Come preparare il tuo scambio",
+ modalStep1: "Crea una galleria di foto della tua borsa.",
+ modalStep2: "Descrivi il prodotto.",
+ modalStep3: "Inserisci le condizioni di scambio.",
+ subscribeTitle: "Stiamo sviluppando Bag Exchange.",
+ subscribeDesc: "Inserisci la tua email per rimanere aggiornato.",
+ subscribePlaceholder: "La tua email",
+ subscribeCta: "Tienimi aggiornato",
+ subscribeThanks: "Grazie. Ti terremo aggiornato.",
+ subscribeInvalidEmail: "Inserisci un indirizzo email valido.",
+ subscribeError: "Si e verificato un errore. Riprova.",
+ registerTitle: "Crea il tuo account",
+ registerDesc: "Registrati per iniziare a scambiare borse ed entrare nella community.",
+ registerEmailPlaceholder: "La tua email",
+ registerPasswordPlaceholder: "Password",
+ registerConfirmPlaceholder: "Conferma password",
+ registerCta: "Crea account",
+ registerThanks: "Registrazione completata. Potrai fare login quando l'area auth sara disponibile.",
+ registerInvalidEmail: "Inserisci un indirizzo email valido.",
+ registerPasswordMismatch: "Le password non coincidono.",
+ registerWeakPassword: "Usa almeno 8 caratteri con lettere e numeri.",
+ registerEmailExists: "Questa email e gia registrata.",
+ registerError: "Impossibile completare la registrazione. Riprova.",
+ loginTitle: "Accedi al tuo account",
+ loginDesc: "Effettua il login per continuare il tuo percorso di scambio.",
+ loginEmailPlaceholder: "La tua email",
+ loginPasswordPlaceholder: "Password",
+ loginCta: "Accedi",
+ loginForgotLink: "Hai dimenticato la password?",
+ loginSuccess: "Login effettuato con successo.",
+ loginInvalid: "Email o password non validi.",
+ loginNotVerified: "Email non verificata. Controlla la tua casella per il link di verifica.",
+ loginError: "Impossibile effettuare il login. Riprova.",
+ authStatusGuest: "Non autenticato.",
+ authStatusUserPrefix: "Connesso come:"
+ },
+ fr: {
+ pageTitle: "Bag Exchange | Echange de sacs et pochettes",
+ languageLabel: "Langue",
+ badge: "communaute beta",
+ heroTitle: "Donnez une nouvelle vie a vos sacs.",
+ heroDesc: "Echangez sacs et pochettes de facon simple, sure et durable. Publiez votre article, trouvez de vrais matchs et renouvelez votre style sans acheter neuf a chaque fois.",
+ ctaPrimary: "Commencer l'echange",
+ ctaLogin: "Connexion",
+ ctaLogout: "Deconnexion",
+ ctaSecondary: "Voir comment ca marche",
+ ctaRegister: "Creer un compte",
+ howTitle: "Comment ca marche en 3 etapes",
+ step1: "1. Publiez votre sac avec photos et etat",
+ step2: "2. Recevez des propositions de personnes au style proche",
+ step3: "3. Confirmez l'echange via chat et livraison suivie",
+ card1Title: "Profils verifies uniquement",
+ card1Desc: "Nous reduisons les risques avec verification des comptes, avis et historique d'echange transparent.",
+ card2Title: "Economisez et valorisez",
+ card2Desc: "Une facon intelligente de renouveler votre dressing en evitant le gaspillage et les depenses inutiles.",
+ card3Title: "Style circulaire",
+ card3Desc: "Du sac quotidien a la pochette elegante: chaque piece peut trouver une nouvelle proprietaire.",
+ communityTitle: "Videochat et visibilite mode",
+ communityDesc: "Vous pouvez faire une videochat avec les personnes interessees par vos articles ou simplement echanger des avis et des conseils. Vous pouvez aspirer a devenir une influenceuse mode des sacs.",
+ footer: "© 2026 Bag Exchange · Echange de sacs et pochettes entre particuliers",
+ modalTitle: "Comment preparer votre echange",
+ modalStep1: "Creez une galerie photo de votre sac.",
+ modalStep2: "Decrivez le produit.",
+ modalStep3: "Ajoutez vos conditions d'echange.",
+ subscribeTitle: "Nous developpons Bag Exchange.",
+ subscribeDesc: "Entrez votre email pour rester informe.",
+ subscribePlaceholder: "Votre email",
+ subscribeCta: "Me tenir informe",
+ subscribeThanks: "Merci. Nous vous tiendrons informe.",
+ subscribeInvalidEmail: "Veuillez entrer une adresse email valide.",
+ subscribeError: "Une erreur est survenue. Veuillez reessayer.",
+ registerTitle: "Creez votre compte",
+ registerDesc: "Inscrivez-vous pour commencer les echanges et rejoindre la communaute.",
+ registerEmailPlaceholder: "Votre email",
+ registerPasswordPlaceholder: "Mot de passe",
+ registerConfirmPlaceholder: "Confirmer le mot de passe",
+ registerCta: "Creer un compte",
+ registerThanks: "Inscription terminee. Vous pourrez vous connecter quand l'espace auth sera disponible.",
+ registerInvalidEmail: "Veuillez entrer une adresse email valide.",
+ registerPasswordMismatch: "Les mots de passe ne correspondent pas.",
+ registerWeakPassword: "Utilisez au moins 8 caracteres avec lettres et chiffres.",
+ registerEmailExists: "Cet email est deja enregistre.",
+ registerError: "Impossible de finaliser l'inscription. Veuillez reessayer.",
+ loginTitle: "Accedez a votre compte",
+ loginDesc: "Connectez-vous pour poursuivre votre parcours d'echange.",
+ loginEmailPlaceholder: "Votre email",
+ loginPasswordPlaceholder: "Mot de passe",
+ loginCta: "Connexion",
+ loginForgotLink: "Mot de passe oublie ?",
+ loginSuccess: "Connexion reussie.",
+ loginInvalid: "Email ou mot de passe invalide.",
+ loginNotVerified: "Email non verifiee. Verifiez votre boite de reception pour le lien de verification.",
+ loginError: "Impossible de se connecter. Veuillez reessayer.",
+ authStatusGuest: "Non connecte.",
+ authStatusUserPrefix: "Connecte en tant que :"
+ },
+ de: {
+ pageTitle: "Bag Exchange | Tausche Taschen und Handtaschen",
+ languageLabel: "Sprache",
+ badge: "community beta",
+ heroTitle: "Gib deinen Taschen ein neues Leben.",
+ heroDesc: "Tausche Taschen und Handtaschen einfach, sicher und nachhaltig. Lade dein Teil hoch, finde echte Matches und erneuere deinen Stil ohne standig neu zu kaufen.",
+ ctaPrimary: "Jetzt tauschen",
+ ctaLogin: "Login",
+ ctaLogout: "Logout",
+ ctaSecondary: "So funktioniert es",
+ ctaRegister: "Konto erstellen",
+ howTitle: "So funktioniert es in 3 Schritten",
+ step1: "1. Stelle deine Tasche mit Fotos und Zustand ein",
+ step2: "2. Erhalte Angebote von Menschen mit ahnlichem Geschmack",
+ step3: "3. Bestatige den Tausch per Chat und Sendungsverfolgung",
+ card1Title: "Nur verifizierte Profile",
+ card1Desc: "Wir reduzieren Risiken durch Kontoverifizierung, Bewertungen und transparente Tauschhistorie.",
+ card2Title: "Spare und schaffe Wert",
+ card2Desc: "Eine smarte Art, deine Garderobe zu erneuern und gleichzeitig Verschwendung und unnotige Ausgaben zu vermeiden.",
+ card3Title: "Kreislauf-Stil",
+ card3Desc: "Von Alltagstaschen bis zur eleganten Clutch: Jedes Stuck kann eine neue Besitzerin finden.",
+ communityTitle: "Videochat und Profilaufbau",
+ communityDesc: "Du kannst per Videochat mit Interessierten an deinen Produkten sprechen oder einfach Meinungen und Tipps austauschen. Du kannst darauf hinarbeiten, Fashion-Bag-Influencerin zu werden.",
+ footer: "© 2026 Bag Exchange · Taschen- und Handtaschentausch zwischen Privatpersonen",
+ modalTitle: "So bereitest du deinen Tausch vor",
+ modalStep1: "Erstelle eine Fotogalerie deiner Tasche.",
+ modalStep2: "Beschreibe das Produkt.",
+ modalStep3: "Hinterlege die Tauschbedingungen.",
+ subscribeTitle: "Wir entwickeln Bag Exchange.",
+ subscribeDesc: "Gib deine E-Mail ein, um auf dem Laufenden zu bleiben.",
+ subscribePlaceholder: "Deine E-Mail",
+ subscribeCta: "Auf dem Laufenden halten",
+ subscribeThanks: "Danke. Wir halten dich auf dem Laufenden.",
+ subscribeInvalidEmail: "Bitte gib eine gueltige E-Mail-Adresse ein.",
+ subscribeError: "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
+ registerTitle: "Erstelle dein Konto",
+ registerDesc: "Registriere dich, um mit dem Tauschen zu starten und der Community beizutreten.",
+ registerEmailPlaceholder: "Deine E-Mail",
+ registerPasswordPlaceholder: "Passwort",
+ registerConfirmPlaceholder: "Passwort bestaetigen",
+ registerCta: "Konto erstellen",
+ registerThanks: "Registrierung abgeschlossen. Du kannst dich einloggen, sobald der Auth-Bereich live ist.",
+ registerInvalidEmail: "Bitte gib eine gueltige E-Mail-Adresse ein.",
+ registerPasswordMismatch: "Die Passwoerter stimmen nicht ueberein.",
+ registerWeakPassword: "Mindestens 8 Zeichen mit Buchstaben und Zahlen verwenden.",
+ registerEmailExists: "Diese E-Mail ist bereits registriert.",
+ registerError: "Registrierung konnte nicht abgeschlossen werden. Bitte erneut versuchen.",
+ loginTitle: "Zugang zu deinem Konto",
+ loginDesc: "Melde dich an, um deine Austauschreise fortzusetzen.",
+ loginEmailPlaceholder: "Deine E-Mail",
+ loginPasswordPlaceholder: "Passwort",
+ loginCta: "Anmelden",
+ loginForgotLink: "Passwort vergessen?",
+ loginSuccess: "Login erfolgreich.",
+ loginInvalid: "Ungueltige E-Mail oder Passwort.",
+ loginNotVerified: "E-Mail nicht verifiziert. Bitte pruefe dein Postfach auf den Verifizierungslink.",
+ loginError: "Anmeldung nicht moeglich. Bitte erneut versuchen.",
+ authStatusGuest: "Nicht angemeldet.",
+ authStatusUserPrefix: "Angemeldet als:"
+ },
+ es: {
+ pageTitle: "Bag Exchange | Intercambio de bolsos y carteras",
+ languageLabel: "Idioma",
+ badge: "comunidad beta",
+ heroTitle: "Dale nueva vida a tus bolsos.",
+ heroDesc: "Intercambia bolsos y carteras de forma simple, segura y sostenible. Sube tu articulo, encuentra matches reales y renueva tu estilo sin comprar nuevo cada vez.",
+ ctaPrimary: "Empezar a intercambiar",
+ ctaLogin: "Iniciar sesion",
+ ctaLogout: "Cerrar sesion",
+ ctaSecondary: "Ver como funciona",
+ ctaRegister: "Crear cuenta",
+ howTitle: "Como funciona en 3 pasos",
+ step1: "1. Publica tu bolso con fotos y estado",
+ step2: "2. Recibe propuestas de personas con gustos similares",
+ step3: "3. Confirma el intercambio con chat y envio con seguimiento",
+ card1Title: "Solo perfiles verificados",
+ card1Desc: "Reducimos riesgos con verificacion de cuenta, valoraciones e historial de intercambios transparente.",
+ card2Title: "Ahorra y revaloriza",
+ card2Desc: "Una forma inteligente de renovar tu armario evitando desperdicios y gastos innecesarios.",
+ card3Title: "Estilo circular",
+ card3Desc: "Desde bolsos de diario hasta clutch elegante: cada pieza puede encontrar nueva duena.",
+ communityTitle: "Videochat y crecimiento de perfil",
+ communityDesc: "Puedes hacer videochat con personas interesadas en tus productos o simplemente intercambiar opiniones y consejos. Puedes aspirar a convertirte en una influencer de bolsos de moda.",
+ footer: "© 2026 Bag Exchange · Intercambio de bolsos y carteras entre particulares",
+ modalTitle: "Como preparar tu intercambio",
+ modalStep1: "Crea una galeria de fotos de tu bolso.",
+ modalStep2: "Describe el producto.",
+ modalStep3: "Indica las condiciones de intercambio.",
+ subscribeTitle: "Estamos desarrollando Bag Exchange.",
+ subscribeDesc: "Ingresa tu email para mantenerte al dia.",
+ subscribePlaceholder: "Tu email",
+ subscribeCta: "Mantenerme informado",
+ subscribeThanks: "Gracias. Te mantendremos informado.",
+ subscribeInvalidEmail: "Introduce un correo electronico valido.",
+ subscribeError: "Algo salio mal. Intentalo de nuevo.",
+ registerTitle: "Crea tu cuenta",
+ registerDesc: "Registrate para empezar a intercambiar y unirte a la comunidad.",
+ registerEmailPlaceholder: "Tu email",
+ registerPasswordPlaceholder: "Contrasena",
+ registerConfirmPlaceholder: "Confirmar contrasena",
+ registerCta: "Crear cuenta",
+ registerThanks: "Registro completado. Podras iniciar sesion cuando el area auth este disponible.",
+ registerInvalidEmail: "Introduce un correo electronico valido.",
+ registerPasswordMismatch: "Las contrasenas no coinciden.",
+ registerWeakPassword: "Usa al menos 8 caracteres con letras y numeros.",
+ registerEmailExists: "Este email ya esta registrado.",
+ registerError: "No se pudo completar el registro. Intentalo de nuevo.",
+ loginTitle: "Accede a tu cuenta",
+ loginDesc: "Inicia sesion para continuar tu recorrido de intercambio.",
+ loginEmailPlaceholder: "Tu email",
+ loginPasswordPlaceholder: "Contrasena",
+ loginCta: "Iniciar sesion",
+ loginForgotLink: "Has olvidado tu contrasena?",
+ loginSuccess: "Sesion iniciada correctamente.",
+ loginInvalid: "Email o contrasena no validos.",
+ loginNotVerified: "Email no verificado. Revisa tu bandeja de entrada para el enlace de verificacion.",
+ loginError: "No se pudo iniciar sesion. Intentalo de nuevo.",
+ authStatusGuest: "No autenticado.",
+ authStatusUserPrefix: "Conectado como:"
+ }
+ };
+ var currentLanguage = fallback;
+ var currentAuthEmail = "";
+
+ function normalizeLanguage(lang) {
+ if (!lang) return fallback;
+ var normalized = String(lang).toLowerCase().replace("_", "-");
+ if (aliases[normalized]) return aliases[normalized];
+ var base = normalized.split("-")[0];
+ if (supported.indexOf(base) !== -1) return base;
+ return fallback;
+ }
+
+ function getInitialLanguage() {
+ var params = new URLSearchParams(window.location.search);
+ var queryLang = params.get("lang");
+ if (queryLang) return normalizeLanguage(queryLang);
+
+ var saved = window.localStorage.getItem("preferredLang");
+ if (saved) return normalizeLanguage(saved);
+
+ var browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || fallback;
+ return normalizeLanguage(browserLang);
+ }
+
+ function applyLanguage(lang) {
+ var active = translations[lang] || translations[fallback];
+ currentLanguage = lang;
+
+ document.documentElement.lang = lang;
+ document.title = active.pageTitle;
+
+ var nodes = document.querySelectorAll("[data-i18n]");
+ for (var i = 0; i < nodes.length; i++) {
+ var key = nodes[i].getAttribute("data-i18n");
+ if (active[key]) nodes[i].textContent = active[key];
+ }
+
+ var placeholders = document.querySelectorAll("[data-i18n-placeholder]");
+ for (var j = 0; j < placeholders.length; j++) {
+ var placeholderKey = placeholders[j].getAttribute("data-i18n-placeholder");
+ if (active[placeholderKey]) placeholders[j].setAttribute("placeholder", active[placeholderKey]);
+ }
+
+ var selector = document.getElementById("language-select");
+ if (selector) selector.value = lang;
+
+ var authStatusNode = document.getElementById("auth-status");
+ if (authStatusNode) {
+ if (currentAuthEmail) {
+ authStatusNode.textContent = currentAuthEmail;
+ authStatusNode.classList.remove("is-hidden");
+ } else {
+ authStatusNode.textContent = "";
+ authStatusNode.classList.add("is-hidden");
+ }
+ }
+
+ var url = new URL(window.location.href);
+ url.searchParams.set("lang", lang);
+ window.history.replaceState({}, "", url.toString());
+ }
+
+ var initialLanguage = getInitialLanguage();
+ applyLanguage(initialLanguage);
+
+ var select = document.getElementById("language-select");
+ if (select) {
+ select.addEventListener("change", function (event) {
+ var chosen = normalizeLanguage(event.target.value);
+ window.localStorage.setItem("preferredLang", chosen);
+ applyLanguage(chosen);
+ });
+ }
+
+ function closeModal(modal) {
+ if (!modal) return;
+ modal.classList.remove("is-open");
+ modal.setAttribute("aria-hidden", "true");
+ }
+
+ function openModal(modal) {
+ if (!modal) return;
+ modal.classList.add("is-open");
+ modal.setAttribute("aria-hidden", "false");
+ }
+
+ function bindModal(openButtonId, modalId, closeButtonId) {
+ var modal = document.getElementById(modalId);
+ var openButton = document.getElementById(openButtonId);
+ var closeButton = document.getElementById(closeButtonId);
+ if (!modal || !openButton || !closeButton) return null;
+
+ openButton.addEventListener("click", function () {
+ openModal(modal);
+ });
+
+ closeButton.addEventListener("click", function () {
+ closeModal(modal);
+ });
+
+ modal.addEventListener("click", function (event) {
+ if (event.target === modal) {
+ closeModal(modal);
+ }
+ });
+
+ return modal;
+ }
+
+ var modals = [];
+ var subscribeModal = bindModal("start-swapping-btn", "subscribe-modal", "subscribe-close-btn");
+ if (subscribeModal) modals.push(subscribeModal);
+
+ var subscribeForm = document.getElementById("subscribe-form");
+ var subscribeEmailInput = document.getElementById("subscribe-email");
+ var subscribeSubmitButton = document.getElementById("subscribe-submit-btn");
+ var subscribeFeedback = document.getElementById("subscribe-feedback");
+ var subscribeError = document.getElementById("subscribe-error");
+ var loginButton = document.getElementById("login-btn");
+ var registerButton = document.getElementById("register-btn");
+ var logoutButton = document.getElementById("logout-btn");
+
+ function getActiveTranslation(key) {
+ var active = translations[currentLanguage] || translations[fallback];
+ return active[key] || "";
+ }
+
+ function setSubscribeState(subscribed) {
+ if (!subscribeForm || !subscribeFeedback) return;
+ subscribeForm.classList.toggle("is-hidden", subscribed);
+ subscribeFeedback.classList.toggle("is-hidden", !subscribed);
+ if (subscribed && subscribeError) {
+ subscribeError.classList.add("is-hidden");
+ subscribeError.textContent = "";
+ }
+ }
+
+ function setSubscribeError(message) {
+ if (!subscribeError) return;
+ subscribeError.textContent = message;
+ subscribeError.classList.remove("is-hidden");
+ }
+
+ function setAuthStatus(email) {
+ currentAuthEmail = email || "";
+ var authStatusNode = document.getElementById("auth-status");
+ if (!authStatusNode) return;
+ if (currentAuthEmail) {
+ authStatusNode.textContent = currentAuthEmail;
+ authStatusNode.classList.remove("is-hidden");
+ if (loginButton) loginButton.classList.add("is-hidden");
+ if (registerButton) registerButton.classList.add("is-hidden");
+ if (logoutButton) logoutButton.classList.remove("is-hidden");
+ } else {
+ authStatusNode.textContent = "";
+ authStatusNode.classList.add("is-hidden");
+ if (loginButton) loginButton.classList.remove("is-hidden");
+ if (registerButton) registerButton.classList.remove("is-hidden");
+ if (logoutButton) logoutButton.classList.add("is-hidden");
+ }
+ }
+
+ async function refreshAuthStatus() {
+ try {
+ var response = await fetch("/api/auth/me", { method: "GET" });
+ if (!response.ok) {
+ setAuthStatus("");
+ return;
+ }
+ var payload = await response.json();
+ if (payload && payload.authenticated && payload.email) {
+ setAuthStatus(payload.email);
+ } else {
+ setAuthStatus("");
+ }
+ } catch (error) {
+ setAuthStatus("");
+ }
+ }
+
+ function collectBrowserData() {
+ return {
+ userAgent: navigator.userAgent || "",
+ language: navigator.language || "",
+ languages: navigator.languages || [],
+ platform: navigator.platform || "",
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
+ screen: {
+ width: window.screen && window.screen.width ? window.screen.width : 0,
+ height: window.screen && window.screen.height ? window.screen.height : 0
+ }
+ };
+ }
+
+ if (subscribeForm) {
+ subscribeForm.addEventListener("submit", async function (event) {
+ event.preventDefault();
+ if (!subscribeEmailInput) return;
+
+ var email = subscribeEmailInput.value.trim();
+ if (!email || !subscribeEmailInput.checkValidity()) {
+ setSubscribeError(getActiveTranslation("subscribeInvalidEmail"));
+ return;
+ }
+
+ if (subscribeError) {
+ subscribeError.classList.add("is-hidden");
+ subscribeError.textContent = "";
+ }
+
+ if (subscribeSubmitButton) subscribeSubmitButton.disabled = true;
+
+ try {
+ var response = await fetch("/api/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ email: email,
+ browserData: collectBrowserData()
+ })
+ });
+
+ if (response.ok || response.status === 409) {
+ setSubscribeState(true);
+ subscribeForm.reset();
+ if (subscribeFeedback) {
+ subscribeFeedback.textContent = getActiveTranslation("subscribeThanks");
+ }
+ } else if (response.status === 400) {
+ setSubscribeError(getActiveTranslation("subscribeInvalidEmail"));
+ } else {
+ setSubscribeError(getActiveTranslation("subscribeError"));
+ }
+ } catch (error) {
+ setSubscribeError(getActiveTranslation("subscribeError"));
+ } finally {
+ if (subscribeSubmitButton) subscribeSubmitButton.disabled = false;
+ }
+ });
+ }
+
+ if (logoutButton) {
+ logoutButton.addEventListener("click", async function () {
+ try {
+ await fetch("/api/auth/logout", { method: "POST" });
+ } catch (error) {
+ }
+ setAuthStatus("");
+ });
+ }
+
+ refreshAuthStatus();
+
+ document.addEventListener("keydown", function (event) {
+ if (event.key === "Escape") {
+ for (var i = 0; i < modals.length; i++) {
+ closeModal(modals[i]);
+ }
+ }
+ });
+})();
diff --git a/static/js/simple_i18n.js b/static/js/simple_i18n.js
new file mode 100644
index 0000000..de1d7a8
--- /dev/null
+++ b/static/js/simple_i18n.js
@@ -0,0 +1,98 @@
+(function () {
+ var supported = ["en", "it", "fr", "de", "es"];
+ var fallback = "en";
+ var aliases = {
+ "en-us": "en",
+ "de-ch": "de",
+ "fr-ch": "fr"
+ };
+
+ function normalizeLanguage(lang) {
+ if (!lang) return fallback;
+ var normalized = String(lang).toLowerCase().replace("_", "-");
+ if (aliases[normalized]) return aliases[normalized];
+ var base = normalized.split("-")[0];
+ if (supported.indexOf(base) !== -1) return base;
+ return fallback;
+ }
+
+ function getInitialLanguage() {
+ var params = new URLSearchParams(window.location.search);
+ var queryLang = params.get("lang");
+ if (queryLang) return normalizeLanguage(queryLang);
+
+ var saved = window.localStorage.getItem("preferredLang");
+ if (saved) return normalizeLanguage(saved);
+
+ var browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || fallback;
+ return normalizeLanguage(browserLang);
+ }
+
+ window.initPageI18n = function (translations) {
+ var currentLanguage = getInitialLanguage();
+
+ function t(key) {
+ var active = translations[currentLanguage] || translations[fallback] || {};
+ if (Object.prototype.hasOwnProperty.call(active, key)) return active[key];
+ return key;
+ }
+
+ function applyLanguage(lang) {
+ currentLanguage = normalizeLanguage(lang);
+ var active = translations[currentLanguage] || translations[fallback] || {};
+
+ document.documentElement.lang = currentLanguage;
+ if (active.pageTitle) document.title = active.pageTitle;
+
+ var nodes = document.querySelectorAll("[data-i18n]");
+ for (var i = 0; i < nodes.length; i++) {
+ var key = nodes[i].getAttribute("data-i18n");
+ if (Object.prototype.hasOwnProperty.call(active, key)) nodes[i].textContent = active[key];
+ }
+
+ var placeholders = document.querySelectorAll("[data-i18n-placeholder]");
+ for (var j = 0; j < placeholders.length; j++) {
+ var pKey = placeholders[j].getAttribute("data-i18n-placeholder");
+ if (Object.prototype.hasOwnProperty.call(active, pKey)) {
+ placeholders[j].setAttribute("placeholder", active[pKey]);
+ }
+ }
+
+ var selector = document.getElementById("language-select");
+ if (selector) selector.value = currentLanguage;
+
+ var links = document.querySelectorAll("a[data-lang-link]");
+ for (var k = 0; k < links.length; k++) {
+ var href = links[k].getAttribute("href");
+ if (!href || href.indexOf("javascript:") === 0 || href.indexOf("#") === 0) continue;
+ try {
+ var linkUrl = new URL(href, window.location.origin);
+ linkUrl.searchParams.set("lang", currentLanguage);
+ links[k].setAttribute("href", linkUrl.pathname + linkUrl.search + linkUrl.hash);
+ } catch (e) {}
+ }
+
+ var url = new URL(window.location.href);
+ url.searchParams.set("lang", currentLanguage);
+ window.history.replaceState({}, "", url.toString());
+ }
+
+ var selector = document.getElementById("language-select");
+ if (selector) {
+ selector.addEventListener("change", function (event) {
+ var chosen = normalizeLanguage(event.target.value);
+ window.localStorage.setItem("preferredLang", chosen);
+ applyLanguage(chosen);
+ });
+ }
+
+ applyLanguage(currentLanguage);
+
+ window.pageT = t;
+ return {
+ t: t,
+ getLanguage: function () { return currentLanguage; },
+ setLanguage: applyLanguage
+ };
+ };
+})();
diff --git a/templates/forgot_password.html b/templates/forgot_password.html
new file mode 100644
index 0000000..ed209c6
--- /dev/null
+++ b/templates/forgot_password.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+ Bag Exchange | Forgot Password
+
+
+
+
+
+
+
+ English
+ Italiano
+ Français
+ Deutsch
+ Español
+
+
+
+ Recover your password
+ Enter your email and we will send you a password reset link if your account exists.
+
+
+
+
+ Back to homepage
+
+
+
+
+
+
+
diff --git a/templates/howtowork.html b/templates/howtowork.html
new file mode 100644
index 0000000..57f6132
--- /dev/null
+++ b/templates/howtowork.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Bag Exchange | How To Work
+
+
+
+
+
+
+
+ How Bag Exchange works
+ Three simple steps to publish and exchange your bag.
+
+
+
+ 1. Create your bag gallery
+ Upload clear photos from multiple angles and include details about model, size, and materials.
+
+
+ 2. Describe your product
+ Write a transparent description with defects, usage history, and what makes the item special.
+
+
+ 3. Set exchange conditions
+ Define what you are looking for in exchange, shipping preferences, and availability for videochat.
+
+
+
+
+
Tip: complete profiles and accurate listings get better quality matches.
+
+
+
+
+
+
+
+
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..63f1944
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+ {{.InitialTitle}}
+
+
+
+
+
+
+
+
+
Give your bags a new life.
+
+ Swap bags and handbags in a simple, safe, and sustainable way.
+ Upload your item, find real matches, and refresh your style without buying new every time.
+
+
+
+
+ How it works in 3 steps
+
+ 1. List your bag with photos and condition
+ 2. Receive offers from people with similar taste
+ 3. Confirm the swap through chat and tracked shipping
+
+
+
+
+
+
+ Verified profiles only
+ We reduce risk with account verification, feedback, and transparent swap history.
+
+
+ Save and add value
+ A smart way to renew your wardrobe while avoiding waste and unnecessary spending.
+
+
+ Circular style
+ From daily bags to elegant clutches: every piece can find a new owner.
+
+
+
+
+
+
+
+
+
+
×
+
We are building Bag Exchange.
+
Enter your email to stay updated.
+
+
Thank you. We will keep you updated.
+
+
+
+
+
+
+
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..2e43b20
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+ Bag Exchange | Login
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/reset_password.html b/templates/reset_password.html
new file mode 100644
index 0000000..2b2e5a5
--- /dev/null
+++ b/templates/reset_password.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+ Bag Exchange | Reset Password
+
+
+
+
+
+
+
+ English
+ Italiano
+ Français
+ Deutsch
+ Español
+
+
+
+ Set a new password
+ Create your new password to recover access to your Bag Exchange account.
+
+
+
+
+ Back to login
+
+
+
+
+
+
+
diff --git a/templates/signup.html b/templates/signup.html
new file mode 100644
index 0000000..afffe04
--- /dev/null
+++ b/templates/signup.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+ Bag Exchange | Sign Up
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/transactionalMails/password_reset.html b/templates/transactionalMails/password_reset.html
new file mode 100644
index 0000000..8eab1df
--- /dev/null
+++ b/templates/transactionalMails/password_reset.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Reset your Bag Exchange password
+ We received a request to reset your password. Use the button below to set a new one.
+
+ Reset password
+
+ If the button does not work, copy and paste this link into your browser:
+ {{.ActionURL}}
+
+
+
+
+
diff --git a/templates/transactionalMails/verify_email.html b/templates/transactionalMails/verify_email.html
new file mode 100644
index 0000000..d99a180
--- /dev/null
+++ b/templates/transactionalMails/verify_email.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Verify your Bag Exchange email
+ Thanks for joining Bag Exchange. Please verify your email to activate your account and start exchanging.
+
+ Verify email
+
+ If the button does not work, copy and paste this link into your browser:
+ {{.ActionURL}}
+
+
+
+
+