code created by GPT-5.3-Codex

This commit is contained in:
Fuzzy Book
2026-02-15 17:09:19 +01:00
parent c30f8cd020
commit 48c0bec24e
20 changed files with 2771 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
data/subscribers.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,23 @@
<!--
To: devdiversity@prada.ch
Subject: Bag Exchange - Password reset
GeneratedAtUTC: 2026-02-14T20:14:43Z
-->
<!doctype html>
<html>
<body style="margin:0;padding:24px;background:#f5ede1;font-family:Arial,sans-serif;color:#202126;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;margin:0 auto;background:#fff9f0;border:1px solid #dfcfb7;border-radius:12px;">
<tr>
<td style="padding:24px;">
<h1 style="margin:0 0 12px;font-size:22px;">Reset your Bag Exchange password</h1>
<p style="margin:0 0 14px;line-height:1.5;">We received a request to reset your password. Use the button below to set a new one.</p>
<p style="margin:20px 0;">
<a href="http://localhost:6081/reset-password?token=U9SX3EIH5_vfUZ8vwo2L0vDHw1YRhriMQ5SuHSqfLrg" style="display:inline-block;background:#4f5e3d;color:#ffffff;text-decoration:none;padding:12px 16px;border-radius:10px;font-weight:700;">Reset password</a>
</p>
<p style="margin:0 0 8px;line-height:1.5;">If the button does not work, copy and paste this link into your browser:</p>
<p style="margin:0;word-break:break-all;"><a href="http://localhost:6081/reset-password?token=U9SX3EIH5_vfUZ8vwo2L0vDHw1YRhriMQ5SuHSqfLrg">http://localhost:6081/reset-password?token=U9SX3EIH5_vfUZ8vwo2L0vDHw1YRhriMQ5SuHSqfLrg</a></p>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,23 @@
<!--
To: fabio@prada.ch
Subject: Bag Exchange - Verify your email
GeneratedAtUTC: 2026-02-14T20:24:35Z
-->
<!doctype html>
<html>
<body style="margin:0;padding:24px;background:#f5ede1;font-family:Arial,sans-serif;color:#202126;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;margin:0 auto;background:#fff9f0;border:1px solid #dfcfb7;border-radius:12px;">
<tr>
<td style="padding:24px;">
<h1 style="margin:0 0 12px;font-size:22px;">Verify your Bag Exchange email</h1>
<p style="margin:0 0 14px;line-height:1.5;">Thanks for joining Bag Exchange. Please verify your email to activate your account and start exchanging.</p>
<p style="margin:20px 0;">
<a href="https://www.prada.ch/auth/verify-email?token=2xWEhEQH1Y26x05hE0eGKXBoKElbSZg6hZVUVFCU5OA" style="display:inline-block;background:#e0795a;color:#ffffff;text-decoration:none;padding:12px 16px;border-radius:10px;font-weight:700;">Verify email</a>
</p>
<p style="margin:0 0 8px;line-height:1.5;">If the button does not work, copy and paste this link into your browser:</p>
<p style="margin:0;word-break:break-all;"><a href="https://www.prada.ch/auth/verify-email?token=2xWEhEQH1Y26x05hE0eGKXBoKElbSZg6hZVUVFCU5OA">https://www.prada.ch/auth/verify-email?token=2xWEhEQH1Y26x05hE0eGKXBoKElbSZg6hZVUVFCU5OA</a></p>
</td>
</tr>
</table>
</body>
</html>

47
docs/todo-auth.md Normal file
View File

@@ -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.

18
docs/video.txt Normal file
View File

@@ -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.

30
go.mod Normal file
View File

@@ -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
)

63
go.sum Normal file
View File

@@ -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=

909
main.go Normal file
View File

@@ -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" +
"To: " + recipientEmail + "\n" +
"Subject: " + subject + "\n" +
"GeneratedAtUTC: " + time.Now().UTC().Format(time.RFC3339) + "\n" +
"-->\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
}

351
static/css/main.css Normal file
View File

@@ -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; }
}

572
static/js/i18n.js Normal file
View File

@@ -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]);
}
}
});
})();

98
static/js/simple_i18n.js Normal file
View File

@@ -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
};
};
})();

View File

@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bag Exchange | Forgot Password</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
<main class="mx-auto grid min-h-screen w-full max-w-2xl place-items-center px-4 py-8">
<section class="w-full max-w-xl rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm">
<div class="mb-4 flex justify-end">
<select id="language-select" class="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-sm">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
</div>
<h1 class="mb-2 text-3xl font-bold tracking-tight" data-i18n="title">Recover your password</h1>
<p class="mb-4 text-sm text-zinc-700" id="intro-text" data-i18n="subtitle">Enter your email and we will send you a password reset link if your account exists.</p>
<form class="grid gap-3" id="forgot-form">
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="email" type="email" required data-i18n-placeholder="emailPlaceholder" placeholder="Your email" />
<button class="rounded-xl bg-orange-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-60" id="submit-btn" type="submit" data-i18n="submit">Send reset link</button>
</form>
<div class="mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed" id="feedback"></div>
<a class="mt-4 inline-block text-sm text-emerald-900 underline decoration-1 underline-offset-2" href="/" data-lang-link data-i18n="homeLink">Back to homepage</a>
</section>
</main>
<script src="/static/js/simple_i18n.js"></script>
<script>
var i18n = window.initPageI18n({
en: { pageTitle: "Bag Exchange | Forgot Password", title: "Recover your password", subtitle: "Enter your email and we will send you a password reset link if your account exists.", emailPlaceholder: "Your email", submit: "Send reset link", homeLink: "Back to homepage", msgInvalid: "Enter a valid email address.", msgOk: "If the account exists, a reset link has been sent.", msgError: "Unable to process request. Try again." },
it: { pageTitle: "Bag Exchange | Recupero Password", title: "Recupera la tua password", subtitle: "Inserisci la tua email e invieremo un link di reset se l'account esiste.", emailPlaceholder: "La tua email", submit: "Invia link di reset", homeLink: "Torna alla home", msgInvalid: "Inserisci un indirizzo email valido.", msgOk: "Se l'account esiste, e stato inviato un link di reset.", msgError: "Impossibile elaborare la richiesta. Riprova." },
fr: { pageTitle: "Bag Exchange | Recuperation du mot de passe", title: "Recuperer votre mot de passe", subtitle: "Entrez votre email et nous enverrons un lien de reinitialisation si le compte existe.", emailPlaceholder: "Votre email", submit: "Envoyer le lien", homeLink: "Retour a l'accueil", msgInvalid: "Entrez une adresse email valide.", msgOk: "Si le compte existe, un lien de reinitialisation a ete envoye.", msgError: "Impossible de traiter la demande. Veuillez reessayer." },
de: { pageTitle: "Bag Exchange | Passwort wiederherstellen", title: "Passwort wiederherstellen", subtitle: "Gib deine E-Mail ein und wir senden einen Reset-Link, falls das Konto existiert.", emailPlaceholder: "Deine E-Mail", submit: "Reset-Link senden", homeLink: "Zur Startseite", msgInvalid: "Bitte eine gueltige E-Mail-Adresse eingeben.", msgOk: "Falls das Konto existiert, wurde ein Reset-Link gesendet.", msgError: "Anfrage konnte nicht verarbeitet werden. Bitte erneut versuchen." },
es: { pageTitle: "Bag Exchange | Recuperar contrasena", title: "Recupera tu contrasena", subtitle: "Introduce tu email y enviaremos un enlace de restablecimiento si la cuenta existe.", emailPlaceholder: "Tu email", submit: "Enviar enlace", homeLink: "Volver al inicio", msgInvalid: "Introduce un correo electronico valido.", msgOk: "Si la cuenta existe, se ha enviado un enlace de restablecimiento.", msgError: "No se pudo procesar la solicitud. Intentalo de nuevo." }
});
(function () {
var form = document.getElementById("forgot-form");
var emailInput = document.getElementById("email");
var submitButton = document.getElementById("submit-btn");
var feedback = document.getElementById("feedback");
var introText = document.getElementById("intro-text");
function showMessage(kind, text) {
feedback.className = "mt-4 rounded-xl px-3 py-2 text-sm leading-relaxed";
if (kind === "error") {
feedback.classList.add("bg-red-100", "text-red-800");
} else {
feedback.classList.add("bg-emerald-100", "text-emerald-900");
}
feedback.textContent = text;
}
form.addEventListener("submit", async function (event) {
event.preventDefault();
var email = emailInput.value.trim();
if (!email || !emailInput.checkValidity()) {
showMessage("error", i18n.t("msgInvalid"));
return;
}
submitButton.disabled = true;
feedback.className = "mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed";
feedback.textContent = "";
try {
var response = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email })
});
if (response.ok) {
showMessage("ok", i18n.t("msgOk"));
form.reset();
form.classList.add("hidden");
if (introText) introText.classList.add("hidden");
} else {
showMessage("error", i18n.t("msgError"));
}
} catch (err) {
showMessage("error", i18n.t("msgError"));
} finally {
submitButton.disabled = false;
}
});
})();
</script>
</body>
</html>

58
templates/howtowork.html Normal file
View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bag Exchange | How To Work</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
<main class="mx-auto w-full max-w-4xl px-4 py-10">
<div class="mb-6 flex items-center justify-between gap-4">
<a href="/" data-lang-link class="inline-block text-sm text-emerald-900 underline decoration-1 underline-offset-2" data-i18n="backHome">Back to homepage</a>
<select id="language-select" class="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-sm">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
</div>
<section class="rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm">
<h1 class="text-3xl font-bold tracking-tight" data-i18n="title">How Bag Exchange works</h1>
<p class="mt-2 text-sm text-zinc-700" data-i18n="subtitle">Three simple steps to publish and exchange your bag.</p>
<ol class="mt-6 grid gap-4">
<li class="rounded-xl border border-zinc-300/70 bg-white/70 p-4">
<h2 class="text-lg font-semibold" data-i18n="step1Title">1. Create your bag gallery</h2>
<p class="mt-1 text-sm leading-relaxed text-zinc-700" data-i18n="step1Desc">Upload clear photos from multiple angles and include details about model, size, and materials.</p>
</li>
<li class="rounded-xl border border-zinc-300/70 bg-white/70 p-4">
<h2 class="text-lg font-semibold" data-i18n="step2Title">2. Describe your product</h2>
<p class="mt-1 text-sm leading-relaxed text-zinc-700" data-i18n="step2Desc">Write a transparent description with defects, usage history, and what makes the item special.</p>
</li>
<li class="rounded-xl border border-zinc-300/70 bg-white/70 p-4">
<h2 class="text-lg font-semibold" data-i18n="step3Title">3. Set exchange conditions</h2>
<p class="mt-1 text-sm leading-relaxed text-zinc-700" data-i18n="step3Desc">Define what you are looking for in exchange, shipping preferences, and availability for videochat.</p>
</li>
</ol>
<div class="mt-6 rounded-xl border border-emerald-900/15 bg-emerald-50 p-4">
<p class="text-sm text-emerald-900" data-i18n="tip">Tip: complete profiles and accurate listings get better quality matches.</p>
</div>
</section>
</main>
<script src="/static/js/simple_i18n.js"></script>
<script>
window.initPageI18n({
en: { pageTitle: "Bag Exchange | How To Work", backHome: "Back to homepage", title: "How Bag Exchange works", subtitle: "Three simple steps to publish and exchange your bag.", step1Title: "1. Create your bag gallery", step1Desc: "Upload clear photos from multiple angles and include details about model, size, and materials.", step2Title: "2. Describe your product", step2Desc: "Write a transparent description with defects, usage history, and what makes the item special.", step3Title: "3. Set exchange conditions", step3Desc: "Define what you are looking for in exchange, shipping preferences, and availability for videochat.", tip: "Tip: complete profiles and accurate listings get better quality matches." },
it: { pageTitle: "Bag Exchange | Come Funziona", backHome: "Torna alla home", title: "Come funziona Bag Exchange", subtitle: "Tre passaggi semplici per pubblicare e scambiare la tua borsa.", step1Title: "1. Crea la galleria della tua borsa", step1Desc: "Carica foto chiare da diverse angolazioni e aggiungi dettagli su modello, dimensioni e materiali.", step2Title: "2. Descrivi il prodotto", step2Desc: "Scrivi una descrizione trasparente con difetti, stato d'uso e caratteristiche distintive.", step3Title: "3. Definisci le condizioni di scambio", step3Desc: "Specifica cosa cerchi in cambio, preferenze di spedizione e disponibilita per videochat.", tip: "Suggerimento: profili completi e annunci accurati ottengono match migliori." },
fr: { pageTitle: "Bag Exchange | Comment ca marche", backHome: "Retour a l'accueil", title: "Comment fonctionne Bag Exchange", subtitle: "Trois etapes simples pour publier et echanger votre sac.", step1Title: "1. Creez la galerie de votre sac", step1Desc: "Ajoutez des photos claires sous plusieurs angles et des details sur le modele, la taille et les materiaux.", step2Title: "2. Decrivez le produit", step2Desc: "Redigez une description transparente avec les defauts, l'etat d'usage et les points forts.", step3Title: "3. Definissez les conditions d'echange", step3Desc: "Indiquez ce que vous recherchez, les preferences de livraison et la disponibilite pour videochat.", tip: "Astuce : des profils complets et des annonces precises obtiennent de meilleurs matchs." },
de: { pageTitle: "Bag Exchange | So funktioniert es", backHome: "Zur Startseite", title: "So funktioniert Bag Exchange", subtitle: "Drei einfache Schritte, um deine Tasche zu veroeffentlichen und zu tauschen.", step1Title: "1. Erstelle deine Taschen-Galerie", step1Desc: "Lade klare Fotos aus mehreren Winkeln hoch und fuege Details zu Modell, Groesse und Material hinzu.", step2Title: "2. Beschreibe das Produkt", step2Desc: "Schreibe eine transparente Beschreibung mit Maengeln, Nutzungszustand und besonderen Merkmalen.", step3Title: "3. Lege Tauschbedingungen fest", step3Desc: "Definiere, was du im Tausch suchst, Versandpraeferenzen und Verfuegbarkeit fuer Videochat.", tip: "Tipp: Vollstaendige Profile und praezise Anzeigen liefern bessere Matches." },
es: { pageTitle: "Bag Exchange | Como funciona", backHome: "Volver al inicio", title: "Como funciona Bag Exchange", subtitle: "Tres pasos simples para publicar e intercambiar tu bolso.", step1Title: "1. Crea la galeria de tu bolso", step1Desc: "Sube fotos claras desde varios angulos e incluye detalles del modelo, tamano y materiales.", step2Title: "2. Describe el producto", step2Desc: "Escribe una descripcion transparente con defectos, estado de uso y puntos destacados.", step3Title: "3. Define las condiciones de intercambio", step3Desc: "Indica que buscas a cambio, preferencias de envio y disponibilidad para videochat.", tip: "Consejo: perfiles completos y anuncios precisos consiguen mejores coincidencias." }
});
</script>
</body>
</html>

102
templates/index.html Normal file
View File

@@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.InitialTitle}}</title>
<link rel="stylesheet" href="/static/css/main.css?v={{.AssetVersion}}" />
</head>
<body>
<div class="container">
<header>
<div>
<div class="logo">{{.Brand}}</div>
<div class="auth-row">
<p class="auth-status is-hidden" id="auth-status"></p>
<a class="btn btn-secondary auth-logout" id="login-btn" href="/login" data-i18n="ctaLogin">Login</a>
<a class="btn btn-secondary auth-logout" id="register-btn" href="/signup" data-i18n="ctaRegister">Create account</a>
<button class="btn btn-secondary auth-logout is-hidden" id="logout-btn" type="button" data-i18n="ctaLogout">Logout</button>
</div>
</div>
<div class="header-actions">
<div class="lang-wrap">
<div class="lang-flags" aria-hidden="true">
<span title="English">🇬🇧</span>
<span title="Italiano">🇮🇹</span>
<span title="Français">🇫🇷</span>
<span title="Deutsch">🇩🇪</span>
<span title="Español">🇪🇸</span>
</div>
<select id="language-select" aria-label="Language selector">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
</div>
<div class="badge" data-i18n="badge">community beta</div>
</div>
</header>
<section class="hero">
<div>
<h1 data-i18n="heroTitle">Give your bags a new life.</h1>
<p data-i18n="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.
</p>
<div class="actions">
<button class="btn btn-primary" id="start-swapping-btn" type="button" data-i18n="ctaPrimary">Start swapping</button>
<a class="btn btn-secondary" id="how-it-works-btn" href="/howtowork" data-i18n="ctaSecondary">See how it works</a>
</div>
</div>
<aside class="showcase">
<h2 data-i18n="howTitle">How it works in 3 steps</h2>
<ul>
<li data-i18n="step1">1. List your bag with photos and condition</li>
<li data-i18n="step2">2. Receive offers from people with similar taste</li>
<li data-i18n="step3">3. Confirm the swap through chat and tracked shipping</li>
</ul>
</aside>
</section>
<section class="grid">
<article class="card">
<h3 data-i18n="card1Title">Verified profiles only</h3>
<p data-i18n="card1Desc">We reduce risk with account verification, feedback, and transparent swap history.</p>
</article>
<article class="card">
<h3 data-i18n="card2Title">Save and add value</h3>
<p data-i18n="card2Desc">A smart way to renew your wardrobe while avoiding waste and unnecessary spending.</p>
</article>
<article class="card">
<h3 data-i18n="card3Title">Circular style</h3>
<p data-i18n="card3Desc">From daily bags to elegant clutches: every piece can find a new owner.</p>
</article>
</section>
<section class="community-note">
<h3 data-i18n="communityTitle">Videochat and grow your profile</h3>
<p data-i18n="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.</p>
</section>
<footer data-i18n="footer">{{.FooterText}}</footer>
</div>
<div class="modal-backdrop" id="subscribe-modal" aria-hidden="true">
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="subscribe-title">
<button class="modal-close" id="subscribe-close-btn" type="button" aria-label="Close">×</button>
<h3 id="subscribe-title" data-i18n="subscribeTitle">We are building Bag Exchange.</h3>
<p class="modal-desc" data-i18n="subscribeDesc">Enter your email to stay updated.</p>
<form class="subscribe-form" id="subscribe-form">
<input class="form-input" id="subscribe-email" type="email" required data-i18n-placeholder="subscribePlaceholder" placeholder="Your email" />
<button class="btn btn-primary" id="subscribe-submit-btn" type="submit" data-i18n="subscribeCta">Keep me updated</button>
</form>
<p class="subscribe-feedback is-hidden" id="subscribe-feedback" data-i18n="subscribeThanks">Thank you. We will keep you updated.</p>
<p class="subscribe-error is-hidden" id="subscribe-error"></p>
</div>
</div>
<script src="/static/js/i18n.js?v={{.AssetVersion}}"></script>
</body>
</html>

104
templates/login.html Normal file
View File

@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bag Exchange | Login</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
<main class="mx-auto grid min-h-screen w-full max-w-2xl place-items-center px-4 py-8">
<section class="w-full max-w-xl rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm">
<div class="mb-4 flex justify-end">
<select id="language-select" class="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-sm">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
</div>
<h1 class="mb-2 text-3xl font-bold tracking-tight" data-i18n="title">Login</h1>
<p class="mb-4 text-sm text-zinc-700" data-i18n="subtitle">Access your Bag Exchange account.</p>
<form class="grid gap-3" id="login-form">
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="email" type="email" required data-i18n-placeholder="emailPlaceholder" placeholder="Your email" />
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="password" type="password" required data-i18n-placeholder="passwordPlaceholder" placeholder="Password" />
<button class="rounded-xl bg-orange-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-60" id="submit-btn" type="submit" data-i18n="submit">Log in</button>
</form>
<p class="mt-3 text-sm"><a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/forgot-password" data-lang-link data-i18n="forgotLink">Forgot your password?</a></p>
<p class="mt-1 text-sm"><a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/signup" data-lang-link data-i18n="signupLink">Create an account</a></p>
<p class="mt-1 text-sm"><a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/" data-lang-link data-i18n="homeLink">Back to homepage</a></p>
<div class="mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed" id="feedback"></div>
</section>
</main>
<script src="/static/js/simple_i18n.js"></script>
<script>
var i18n = window.initPageI18n({
en: { pageTitle: "Bag Exchange | Login", title: "Login", subtitle: "Access your Bag Exchange account.", emailPlaceholder: "Your email", passwordPlaceholder: "Password", submit: "Log in", forgotLink: "Forgot your password?", signupLink: "Create an account", homeLink: "Back to homepage", msgInvalid: "Invalid email or password.", msgNotVerified: "Email not verified. Check your inbox or request a new verification email.", msgError: "Unable to log in. Try again." },
it: { pageTitle: "Bag Exchange | Accesso", title: "Accedi", subtitle: "Accedi al tuo account Bag Exchange.", emailPlaceholder: "La tua email", passwordPlaceholder: "Password", submit: "Accedi", forgotLink: "Hai dimenticato la password?", signupLink: "Crea un account", homeLink: "Torna alla home", msgInvalid: "Email o password non validi.", msgNotVerified: "Email non verificata. Controlla la tua casella o richiedi un nuovo link di verifica.", msgError: "Impossibile effettuare il login. Riprova." },
fr: { pageTitle: "Bag Exchange | Connexion", title: "Connexion", subtitle: "Accedez a votre compte Bag Exchange.", emailPlaceholder: "Votre email", passwordPlaceholder: "Mot de passe", submit: "Connexion", forgotLink: "Mot de passe oublie ?", signupLink: "Creer un compte", homeLink: "Retour a l'accueil", msgInvalid: "Email ou mot de passe invalide.", msgNotVerified: "Email non verifie. Verifiez votre boite de reception ou demandez un nouveau lien.", msgError: "Connexion impossible. Veuillez reessayer." },
de: { pageTitle: "Bag Exchange | Login", title: "Anmelden", subtitle: "Melde dich bei deinem Bag Exchange Konto an.", emailPlaceholder: "Deine E-Mail", passwordPlaceholder: "Passwort", submit: "Anmelden", forgotLink: "Passwort vergessen?", signupLink: "Konto erstellen", homeLink: "Zur Startseite", msgInvalid: "Ungueltige E-Mail oder Passwort.", msgNotVerified: "E-Mail nicht verifiziert. Bitte pruefe dein Postfach oder fordere einen neuen Link an.", msgError: "Anmeldung nicht moeglich. Bitte erneut versuchen." },
es: { pageTitle: "Bag Exchange | Inicio de sesion", title: "Iniciar sesion", subtitle: "Accede a tu cuenta de Bag Exchange.", emailPlaceholder: "Tu email", passwordPlaceholder: "Contrasena", submit: "Iniciar sesion", forgotLink: "Has olvidado tu contrasena?", signupLink: "Crear una cuenta", homeLink: "Volver al inicio", msgInvalid: "Email o contrasena no validos.", msgNotVerified: "Email no verificado. Revisa tu bandeja o solicita un nuevo enlace.", msgError: "No se pudo iniciar sesion. Intentalo de nuevo." }
});
(function () {
var form = document.getElementById("login-form");
var emailInput = document.getElementById("email");
var passwordInput = document.getElementById("password");
var submitButton = document.getElementById("submit-btn");
var feedback = document.getElementById("feedback");
function showMessage(kind, text) {
feedback.className = "mt-4 rounded-xl px-3 py-2 text-sm leading-relaxed";
if (kind === "error") {
feedback.classList.add("bg-red-100", "text-red-800");
} else {
feedback.classList.add("bg-emerald-100", "text-emerald-900");
}
feedback.textContent = text;
}
form.addEventListener("submit", async function (event) {
event.preventDefault();
var email = emailInput.value.trim();
var password = passwordInput.value;
if (!email || !emailInput.checkValidity() || !password) {
showMessage("error", i18n.t("msgInvalid"));
return;
}
submitButton.disabled = true;
feedback.className = "mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed";
feedback.textContent = "";
try {
var response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email, password: password })
});
var payload = {};
try { payload = await response.json(); } catch (e) {}
if (response.ok) {
window.location.href = "/?lang=" + encodeURIComponent(i18n.getLanguage());
} else if (payload.error === "email_not_verified") {
showMessage("error", i18n.t("msgNotVerified"));
} else {
showMessage("error", i18n.t("msgInvalid"));
}
} catch (err) {
showMessage("error", i18n.t("msgError"));
} finally {
submitButton.disabled = false;
}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bag Exchange | Reset Password</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
<main class="mx-auto grid min-h-screen w-full max-w-2xl place-items-center px-4 py-8">
<section class="w-full max-w-xl rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm">
<div class="mb-4 flex justify-end">
<select id="language-select" class="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-sm">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
</div>
<h1 class="mb-2 text-3xl font-bold tracking-tight" data-i18n="title">Set a new password</h1>
<p class="mb-4 text-sm text-zinc-700" data-i18n="subtitle">Create your new password to recover access to your Bag Exchange account.</p>
<form class="grid gap-3" id="reset-form">
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="password" type="password" minlength="8" required data-i18n-placeholder="passwordPlaceholder" placeholder="New password" />
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="confirm-password" type="password" minlength="8" required data-i18n-placeholder="confirmPlaceholder" placeholder="Confirm new password" />
<button class="rounded-xl bg-orange-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-60" id="submit-btn" type="submit" data-i18n="submit">Reset password</button>
</form>
<div class="mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed" id="feedback"></div>
<a class="mt-4 inline-block text-sm text-emerald-900 underline decoration-1 underline-offset-2" href="/login" data-lang-link data-i18n="loginLink">Back to login</a>
</section>
</main>
<script src="/static/js/simple_i18n.js"></script>
<script>
var i18n = window.initPageI18n({
en: { pageTitle: "Bag Exchange | Reset Password", title: "Set a new password", subtitle: "Create your new password to recover access to your Bag Exchange account.", passwordPlaceholder: "New password", confirmPlaceholder: "Confirm new password", submit: "Reset password", loginLink: "Back to login", msgInvalidToken: "Invalid reset token. Open the link from your email again.", msgShort: "Use at least 8 characters.", msgMismatch: "Passwords do not match.", msgOk: "Password updated successfully. You can now log in.", msgExpired: "Reset link is invalid or expired.", msgWeak: "Use at least 8 characters with letters and numbers.", msgError: "Unable to reset password. Try again." },
it: { pageTitle: "Bag Exchange | Reimposta Password", title: "Imposta una nuova password", subtitle: "Crea una nuova password per recuperare l'accesso al tuo account Bag Exchange.", passwordPlaceholder: "Nuova password", confirmPlaceholder: "Conferma nuova password", submit: "Reimposta password", loginLink: "Torna al login", msgInvalidToken: "Token di reset non valido. Apri di nuovo il link ricevuto via email.", msgShort: "Usa almeno 8 caratteri.", msgMismatch: "Le password non coincidono.", msgOk: "Password aggiornata con successo. Ora puoi accedere.", msgExpired: "Il link di reset non e valido o e scaduto.", msgWeak: "Usa almeno 8 caratteri con lettere e numeri.", msgError: "Impossibile reimpostare la password. Riprova." },
fr: { pageTitle: "Bag Exchange | Reinitialiser le mot de passe", title: "Definir un nouveau mot de passe", subtitle: "Creez un nouveau mot de passe pour recuperer l'acces a votre compte Bag Exchange.", passwordPlaceholder: "Nouveau mot de passe", confirmPlaceholder: "Confirmer le nouveau mot de passe", submit: "Reinitialiser", loginLink: "Retour a la connexion", msgInvalidToken: "Jeton de reinitialisation invalide. Ouvrez a nouveau le lien recu par email.", msgShort: "Utilisez au moins 8 caracteres.", msgMismatch: "Les mots de passe ne correspondent pas.", msgOk: "Mot de passe mis a jour. Vous pouvez maintenant vous connecter.", msgExpired: "Le lien de reinitialisation est invalide ou expire.", msgWeak: "Utilisez au moins 8 caracteres avec lettres et chiffres.", msgError: "Impossible de reinitialiser le mot de passe. Veuillez reessayer." },
de: { pageTitle: "Bag Exchange | Passwort zuruecksetzen", title: "Neues Passwort festlegen", subtitle: "Lege ein neues Passwort fest, um wieder Zugriff auf dein Bag Exchange Konto zu erhalten.", passwordPlaceholder: "Neues Passwort", confirmPlaceholder: "Neues Passwort bestaetigen", submit: "Passwort zuruecksetzen", loginLink: "Zurueck zum Login", msgInvalidToken: "Ungueltiger Reset-Token. Oeffne den Link aus der E-Mail erneut.", msgShort: "Mindestens 8 Zeichen verwenden.", msgMismatch: "Die Passwoerter stimmen nicht ueberein.", msgOk: "Passwort erfolgreich aktualisiert. Du kannst dich jetzt anmelden.", msgExpired: "Reset-Link ist ungueltig oder abgelaufen.", msgWeak: "Mindestens 8 Zeichen mit Buchstaben und Zahlen verwenden.", msgError: "Passwort konnte nicht zurueckgesetzt werden. Bitte erneut versuchen." },
es: { pageTitle: "Bag Exchange | Restablecer contrasena", title: "Define una nueva contrasena", subtitle: "Crea una nueva contrasena para recuperar el acceso a tu cuenta Bag Exchange.", passwordPlaceholder: "Nueva contrasena", confirmPlaceholder: "Confirmar nueva contrasena", submit: "Restablecer contrasena", loginLink: "Volver al login", msgInvalidToken: "Token de restablecimiento no valido. Abre de nuevo el enlace del correo.", msgShort: "Usa al menos 8 caracteres.", msgMismatch: "Las contrasenas no coinciden.", msgOk: "Contrasena actualizada correctamente. Ya puedes iniciar sesion.", msgExpired: "El enlace de restablecimiento no es valido o ha caducado.", msgWeak: "Usa al menos 8 caracteres con letras y numeros.", msgError: "No se pudo restablecer la contrasena. Intentalo de nuevo." }
});
(function () {
var token = "{{.Token}}";
var form = document.getElementById("reset-form");
var passwordInput = document.getElementById("password");
var confirmInput = document.getElementById("confirm-password");
var submitButton = document.getElementById("submit-btn");
var feedback = document.getElementById("feedback");
function showMessage(kind, text) {
feedback.className = "mt-4 rounded-xl px-3 py-2 text-sm leading-relaxed";
if (kind === "error") {
feedback.classList.add("bg-red-100", "text-red-800");
} else {
feedback.classList.add("bg-emerald-100", "text-emerald-900");
}
feedback.textContent = text;
}
if (!token) {
showMessage("error", i18n.t("msgInvalidToken"));
submitButton.disabled = true;
}
form.addEventListener("submit", async function (event) {
event.preventDefault();
var password = passwordInput.value;
var confirmPassword = confirmInput.value;
if (!password || password.length < 8) {
showMessage("error", i18n.t("msgShort"));
return;
}
if (password !== confirmPassword) {
showMessage("error", i18n.t("msgMismatch"));
return;
}
submitButton.disabled = true;
feedback.className = "mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed";
feedback.textContent = "";
try {
var response = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: token, password: password, confirmPassword: confirmPassword })
});
var payload = {};
try { payload = await response.json(); } catch (e) { payload = {}; }
if (response.ok) {
form.classList.add("hidden");
showMessage("ok", i18n.t("msgOk"));
} else if (payload.error === "invalid_or_expired_token" || payload.error === "invalid_token") {
showMessage("error", i18n.t("msgExpired"));
} else if (payload.error === "password_too_weak") {
showMessage("error", i18n.t("msgWeak"));
} else if (payload.error === "password_mismatch") {
showMessage("error", i18n.t("msgMismatch"));
} else {
showMessage("error", i18n.t("msgError"));
}
} catch (err) {
showMessage("error", i18n.t("msgError"));
} finally {
submitButton.disabled = false;
}
});
})();
</script>
</body>
</html>

120
templates/signup.html Normal file
View File

@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bag Exchange | Sign Up</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-stone-200 text-zinc-800">
<main class="mx-auto grid min-h-screen w-full max-w-2xl place-items-center px-4 py-8">
<section class="w-full max-w-xl rounded-2xl border border-zinc-300/70 bg-amber-50/80 p-6 shadow-2xl shadow-stone-700/10 backdrop-blur-sm">
<div class="mb-4 flex justify-end">
<select id="language-select" class="rounded-lg border border-zinc-300 bg-white px-2 py-1 text-sm">
<option value="en">English</option>
<option value="it">Italiano</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
</div>
<h1 class="mb-2 text-3xl font-bold tracking-tight" data-i18n="title">Create account</h1>
<p class="mb-4 text-sm text-zinc-700" data-i18n="subtitle">Join Bag Exchange and verify your email to activate access.</p>
<form class="grid gap-3" id="signup-form">
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="email" type="email" required data-i18n-placeholder="emailPlaceholder" placeholder="Your email" />
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="password" type="password" minlength="8" required data-i18n-placeholder="passwordPlaceholder" placeholder="Password" />
<input class="w-full rounded-xl border border-zinc-400/40 bg-white px-3 py-3 text-sm outline-none ring-orange-300 transition focus:ring-2" id="confirm-password" type="password" minlength="8" required data-i18n-placeholder="confirmPlaceholder" placeholder="Confirm password" />
<button class="rounded-xl bg-orange-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-60" id="submit-btn" type="submit" data-i18n="submit">Create account</button>
</form>
<p class="mt-3 text-sm"><a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/login" data-lang-link data-i18n="loginLink">Already have an account? Log in</a></p>
<p class="mt-1 text-sm"><a class="text-emerald-900 underline decoration-1 underline-offset-2" href="/" data-lang-link data-i18n="homeLink">Back to homepage</a></p>
<div class="mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed" id="feedback"></div>
</section>
</main>
<script src="/static/js/simple_i18n.js"></script>
<script>
var i18n = window.initPageI18n({
en: { pageTitle: "Bag Exchange | Sign Up", title: "Create account", subtitle: "Join Bag Exchange and verify your email to activate access.", emailPlaceholder: "Your email", passwordPlaceholder: "Password", confirmPlaceholder: "Confirm password", submit: "Create account", loginLink: "Already have an account? Log in", homeLink: "Back to homepage", msgInvalidEmail: "Please enter a valid email address.", msgMismatch: "Passwords do not match.", msgWeak: "Use at least 8 characters with letters and numbers.", msgExists: "This email is already registered.", msgGeneric: "Unable to create account. Try again.", msgSuccess: "Account created. Check your email to verify your account before login." },
it: { pageTitle: "Bag Exchange | Registrazione", title: "Crea account", subtitle: "Unisciti a Bag Exchange e verifica la tua email per attivare l'accesso.", emailPlaceholder: "La tua email", passwordPlaceholder: "Password", confirmPlaceholder: "Conferma password", submit: "Crea account", loginLink: "Hai gia un account? Accedi", homeLink: "Torna alla home", msgInvalidEmail: "Inserisci un indirizzo email valido.", msgMismatch: "Le password non coincidono.", msgWeak: "Usa almeno 8 caratteri con lettere e numeri.", msgExists: "Questa email e gia registrata.", msgGeneric: "Impossibile creare l'account. Riprova.", msgSuccess: "Account creato. Controlla la tua email per verificare l'account prima del login." },
fr: { pageTitle: "Bag Exchange | Inscription", title: "Creer un compte", subtitle: "Rejoignez Bag Exchange et verifiez votre email pour activer l'acces.", emailPlaceholder: "Votre email", passwordPlaceholder: "Mot de passe", confirmPlaceholder: "Confirmer le mot de passe", submit: "Creer un compte", loginLink: "Vous avez deja un compte ? Connectez-vous", homeLink: "Retour a l'accueil", msgInvalidEmail: "Veuillez entrer une adresse email valide.", msgMismatch: "Les mots de passe ne correspondent pas.", msgWeak: "Utilisez au moins 8 caracteres avec lettres et chiffres.", msgExists: "Cet email est deja enregistre.", msgGeneric: "Impossible de creer le compte. Veuillez reessayer.", msgSuccess: "Compte cree. Verifiez votre email avant de vous connecter." },
de: { pageTitle: "Bag Exchange | Registrierung", title: "Konto erstellen", subtitle: "Tritt Bag Exchange bei und bestaetige deine E-Mail, um den Zugang zu aktivieren.", emailPlaceholder: "Deine E-Mail", passwordPlaceholder: "Passwort", confirmPlaceholder: "Passwort bestaetigen", submit: "Konto erstellen", loginLink: "Schon ein Konto? Anmelden", homeLink: "Zur Startseite", msgInvalidEmail: "Bitte gib eine gueltige E-Mail-Adresse ein.", msgMismatch: "Die Passwoerter stimmen nicht ueberein.", msgWeak: "Mindestens 8 Zeichen mit Buchstaben und Zahlen verwenden.", msgExists: "Diese E-Mail ist bereits registriert.", msgGeneric: "Konto konnte nicht erstellt werden. Bitte erneut versuchen.", msgSuccess: "Konto erstellt. Bitte bestaetige deine E-Mail vor dem Login." },
es: { pageTitle: "Bag Exchange | Registro", title: "Crear cuenta", subtitle: "Unete a Bag Exchange y verifica tu email para activar el acceso.", emailPlaceholder: "Tu email", passwordPlaceholder: "Contrasena", confirmPlaceholder: "Confirmar contrasena", submit: "Crear cuenta", loginLink: "Ya tienes cuenta? Inicia sesion", homeLink: "Volver al inicio", msgInvalidEmail: "Introduce un correo electronico valido.", msgMismatch: "Las contrasenas no coinciden.", msgWeak: "Usa al menos 8 caracteres con letras y numeros.", msgExists: "Este email ya esta registrado.", msgGeneric: "No se pudo crear la cuenta. Intentalo de nuevo.", msgSuccess: "Cuenta creada. Revisa tu email para verificar la cuenta antes de iniciar sesion." }
});
(function () {
var form = document.getElementById("signup-form");
var emailInput = document.getElementById("email");
var passwordInput = document.getElementById("password");
var confirmInput = document.getElementById("confirm-password");
var submitButton = document.getElementById("submit-btn");
var feedback = document.getElementById("feedback");
function showMessage(kind, text) {
feedback.className = "mt-4 rounded-xl px-3 py-2 text-sm leading-relaxed";
if (kind === "error") {
feedback.classList.add("bg-red-100", "text-red-800");
} else {
feedback.classList.add("bg-emerald-100", "text-emerald-900");
}
feedback.textContent = text;
}
form.addEventListener("submit", async function (event) {
event.preventDefault();
var email = emailInput.value.trim();
var password = passwordInput.value;
var confirmPassword = confirmInput.value;
if (!email || !emailInput.checkValidity()) {
showMessage("error", i18n.t("msgInvalidEmail"));
return;
}
if (password !== confirmPassword) {
showMessage("error", i18n.t("msgMismatch"));
return;
}
if (password.length < 8) {
showMessage("error", i18n.t("msgWeak"));
return;
}
submitButton.disabled = true;
feedback.className = "mt-4 hidden rounded-xl px-3 py-2 text-sm leading-relaxed";
feedback.textContent = "";
try {
var response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email, password: password, confirmPassword: confirmPassword })
});
var payload = {};
try { payload = await response.json(); } catch (e) {}
if (response.ok) {
form.classList.add("hidden");
showMessage("ok", i18n.t("msgSuccess"));
} else if (payload.error === "email_exists") {
showMessage("error", i18n.t("msgExists"));
} else if (payload.error === "password_too_weak") {
showMessage("error", i18n.t("msgWeak"));
} else if (payload.error === "password_mismatch") {
showMessage("error", i18n.t("msgMismatch"));
} else {
showMessage("error", i18n.t("msgGeneric"));
}
} catch (err) {
showMessage("error", i18n.t("msgGeneric"));
} finally {
submitButton.disabled = false;
}
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html>
<body style="margin:0;padding:24px;background:#f5ede1;font-family:Arial,sans-serif;color:#202126;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;margin:0 auto;background:#fff9f0;border:1px solid #dfcfb7;border-radius:12px;">
<tr>
<td style="padding:24px;">
<h1 style="margin:0 0 12px;font-size:22px;">Reset your Bag Exchange password</h1>
<p style="margin:0 0 14px;line-height:1.5;">We received a request to reset your password. Use the button below to set a new one.</p>
<p style="margin:20px 0;">
<a href="{{.ActionURL}}" style="display:inline-block;background:#4f5e3d;color:#ffffff;text-decoration:none;padding:12px 16px;border-radius:10px;font-weight:700;">Reset password</a>
</p>
<p style="margin:0 0 8px;line-height:1.5;">If the button does not work, copy and paste this link into your browser:</p>
<p style="margin:0;word-break:break-all;"><a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html>
<body style="margin:0;padding:24px;background:#f5ede1;font-family:Arial,sans-serif;color:#202126;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;margin:0 auto;background:#fff9f0;border:1px solid #dfcfb7;border-radius:12px;">
<tr>
<td style="padding:24px;">
<h1 style="margin:0 0 12px;font-size:22px;">Verify your Bag Exchange email</h1>
<p style="margin:0 0 14px;line-height:1.5;">Thanks for joining Bag Exchange. Please verify your email to activate your account and start exchanging.</p>
<p style="margin:20px 0;">
<a href="{{.ActionURL}}" style="display:inline-block;background:#e0795a;color:#ffffff;text-decoration:none;padding:12px 16px;border-radius:10px;font-weight:700;">Verify email</a>
</p>
<p style="margin:0 0 8px;line-height:1.5;">If the button does not work, copy and paste this link into your browser:</p>
<p style="margin:0;word-break:break-all;"><a href="{{.ActionURL}}">{{.ActionURL}}</a></p>
</td>
</tr>
</table>
</body>
</html>