code created by GPT-5.3-Codex
This commit is contained in:
BIN
data/subscribers.db
Normal file
BIN
data/subscribers.db
Normal file
Binary file not shown.
23
devEmails/20260214_201443_bag_exchange___password_reset.html
Normal file
23
devEmails/20260214_201443_bag_exchange___password_reset.html
Normal 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>
|
||||||
@@ -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
47
docs/todo-auth.md
Normal 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
18
docs/video.txt
Normal 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
30
go.mod
Normal 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
63
go.sum
Normal 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
909
main.go
Normal 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
351
static/css/main.css
Normal 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
572
static/js/i18n.js
Normal 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
98
static/js/simple_i18n.js
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
99
templates/forgot_password.html
Normal file
99
templates/forgot_password.html
Normal 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
58
templates/howtowork.html
Normal 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
102
templates/index.html
Normal 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
104
templates/login.html
Normal 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>
|
||||||
118
templates/reset_password.html
Normal file
118
templates/reset_password.html
Normal 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
120
templates/signup.html
Normal 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>
|
||||||
18
templates/transactionalMails/password_reset.html
Normal file
18
templates/transactionalMails/password_reset.html
Normal 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>
|
||||||
18
templates/transactionalMails/verify_email.html
Normal file
18
templates/transactionalMails/verify_email.html
Normal 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>
|
||||||
Reference in New Issue
Block a user