refactor: python to go
This commit is contained in:
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -1,8 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
6
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<settings>
|
|
||||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
|
||||||
<version value="1.0" />
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
6
.idea/jsLibraryMappings.xml
generated
6
.idea/jsLibraryMappings.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="JavaScriptLibraryMappings">
|
|
||||||
<file url="file://$PROJECT_DIR$" libraries="{bootstrap}" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
22
.idea/landing.iml
generated
22
.idea/landing.iml
generated
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="PYTHON_MODULE" version="4">
|
|
||||||
<component name="Flask">
|
|
||||||
<option name="enabled" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="jdk" jdkName="Python 3.11 (landing)" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
<orderEntry type="library" name="bootstrap" level="application" />
|
|
||||||
</component>
|
|
||||||
<component name="TemplatesService">
|
|
||||||
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
|
||||||
<option name="TEMPLATE_FOLDERS">
|
|
||||||
<list>
|
|
||||||
<option value="$MODULE_DIR$/templates" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
7
.idea/misc.xml
generated
7
.idea/misc.xml
generated
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Black">
|
|
||||||
<option name="sdkName" value="Python 3.11 (landing)" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (landing)" project-jdk-type="Python SDK" />
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/landing.iml" filepath="$PROJECT_DIR$/.idea/landing.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
44
Containerfile
Normal file
44
Containerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
|
||||||
|
-o server ./cmd/landing
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install ca-certificates for HTTPS
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/server .
|
||||||
|
|
||||||
|
# Copy templates directory
|
||||||
|
COPY --from=builder /app/templates ./templates
|
||||||
|
|
||||||
|
# Copy static files directory
|
||||||
|
COPY --from=builder /app/static ./static
|
||||||
|
|
||||||
|
# Copy .env (optional - can be overridden at runtime)
|
||||||
|
COPY .env .env
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["./server"]
|
||||||
18
Dockerfile
18
Dockerfile
@@ -1,18 +0,0 @@
|
|||||||
FROM python:3.11-slim-buster
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y build-essential
|
|
||||||
|
|
||||||
WORKDIR /rideaware_landing
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV FLASK_APP=server.py
|
|
||||||
|
|
||||||
EXPOSE 5000
|
|
||||||
|
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "server:app"]
|
|
||||||
|
|
||||||
Binary file not shown.
51
cmd/landing/main.go
Normal file
51
cmd/landing/main.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"landing/internal/config"
|
||||||
|
"landing/internal/database"
|
||||||
|
"landing/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
db, err := database.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close(context.Background())
|
||||||
|
|
||||||
|
// Initialize database schema
|
||||||
|
if err := db.InitDB(context.Background()); err != nil {
|
||||||
|
log.Fatalf("failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handler with dependencies
|
||||||
|
h := handlers.New(db, cfg)
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
go func() {
|
||||||
|
log.Printf("starting server on %s:%s", cfg.Host, cfg.Port)
|
||||||
|
if err := h.Start(cfg.Host, cfg.Port); err != nil {
|
||||||
|
log.Printf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
log.Println("shutting down server")
|
||||||
|
}
|
||||||
18
go.mod
Normal file
18
go.mod
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module landing
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/wneessen/go-mail v0.7.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.37.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
32
go.sum
Normal file
32
go.sum
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
|
||||||
|
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
||||||
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
53
internal/config/config.go
Normal file
53
internal/config/config.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBName string
|
||||||
|
DBUser string
|
||||||
|
DBPass string
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort string
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPass string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
godotenv.Load()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Host: getEnv("HOST", "0.0.0.0"),
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
DBHost: getEnv("PG_HOST", "localhost"),
|
||||||
|
DBPort: getEnv("PG_PORT", "5432"),
|
||||||
|
DBName: getEnv("PG_DATABASE", "newsletter"),
|
||||||
|
DBUser: getEnv("PG_USER", "postgres"),
|
||||||
|
DBPass: getEnv("PG_PASSWORD", ""),
|
||||||
|
SMTPHost: getEnv("SMTP_SERVER", ""),
|
||||||
|
SMTPPort: getEnv("SMTP_PORT", "587"),
|
||||||
|
SMTPUser: getEnv("SMTP_USER", ""),
|
||||||
|
SMTPPass: getEnv("SMTP_PASSWORD", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SMTPHost == "" {
|
||||||
|
return nil, fmt.Errorf("SMTP_SERVER not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
160
internal/database/database.go
Normal file
160
internal/database/database.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"landing/internal/config"
|
||||||
|
"landing/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) (*DB, error) {
|
||||||
|
// Use proper pgx connection config instead of URL parsing
|
||||||
|
connConfig, err := pgxpool.ParseConfig("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
connConfig.ConnConfig.Host = cfg.DBHost
|
||||||
|
connConfig.ConnConfig.Port = 5432
|
||||||
|
connConfig.ConnConfig.Database = cfg.DBName
|
||||||
|
connConfig.ConnConfig.User = cfg.DBUser
|
||||||
|
connConfig.ConnConfig.Password = cfg.DBPass
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(
|
||||||
|
context.Background(),
|
||||||
|
10*time.Second,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, connConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) InitDB(ctx context.Context) error {
|
||||||
|
queries := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS subscribers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS newsletters (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, err := db.pool.Exec(ctx, query); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute query: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddSubscriber(
|
||||||
|
ctx context.Context,
|
||||||
|
email string,
|
||||||
|
) error {
|
||||||
|
_, err := db.pool.Exec(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO subscribers (email) VALUES ($1)",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "ERROR: duplicate key value" {
|
||||||
|
return fmt.Errorf("email already exists")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RemoveSubscriber(
|
||||||
|
ctx context.Context,
|
||||||
|
email string,
|
||||||
|
) error {
|
||||||
|
result, err := db.pool.Exec(
|
||||||
|
ctx,
|
||||||
|
"DELETE FROM subscribers WHERE email = $1",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("email not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetNewsletters(
|
||||||
|
ctx context.Context,
|
||||||
|
) ([]models.Newsletter, error) {
|
||||||
|
rows, err := db.pool.Query(
|
||||||
|
ctx,
|
||||||
|
"SELECT id, subject, body, sent_at FROM newsletters "+
|
||||||
|
"ORDER BY sent_at DESC",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var newsletters []models.Newsletter
|
||||||
|
for rows.Next() {
|
||||||
|
var n models.Newsletter
|
||||||
|
err := rows.Scan(&n.ID, &n.Subject, &n.Body, &n.SentAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
newsletters = append(newsletters, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newsletters, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetNewsletter(
|
||||||
|
ctx context.Context,
|
||||||
|
id int,
|
||||||
|
) (*models.Newsletter, error) {
|
||||||
|
var n models.Newsletter
|
||||||
|
err := db.pool.QueryRow(
|
||||||
|
ctx,
|
||||||
|
"SELECT id, subject, body, sent_at FROM newsletters "+
|
||||||
|
"WHERE id = $1",
|
||||||
|
id,
|
||||||
|
).Scan(&n.ID, &n.Subject, &n.Body, &n.SentAt)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("newsletter not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Close(ctx context.Context) {
|
||||||
|
db.pool.Close()
|
||||||
|
}
|
||||||
60
internal/email/sender.go
Normal file
60
internal/email/sender.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/wneessen/go-mail"
|
||||||
|
"landing/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sender struct {
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config) *Sender {
|
||||||
|
return &Sender{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) SendConfirmationEmail(
|
||||||
|
email string,
|
||||||
|
unsubscribeLink string,
|
||||||
|
) error {
|
||||||
|
client, err := mail.NewClient(
|
||||||
|
s.cfg.SMTPHost,
|
||||||
|
mail.WithPort(587),
|
||||||
|
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
||||||
|
mail.WithUsername(s.cfg.SMTPUser),
|
||||||
|
mail.WithPassword(s.cfg.SMTPPass),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create mail client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := mail.NewMsg()
|
||||||
|
if err := msg.From(s.cfg.SMTPUser); err != nil {
|
||||||
|
return fmt.Errorf("failed to set from: %w", err)
|
||||||
|
}
|
||||||
|
if err := msg.To(email); err != nil {
|
||||||
|
return fmt.Errorf("failed to set to: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Subject("Thanks for subscribing!")
|
||||||
|
|
||||||
|
htmlBody := fmt.Sprintf(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Welcome!</h1>
|
||||||
|
<p>Thank you for subscribing to our newsletter.</p>
|
||||||
|
<p><a href="%s">Unsubscribe</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, unsubscribeLink)
|
||||||
|
|
||||||
|
msg.SetBodyString(mail.TypeTextHTML, htmlBody)
|
||||||
|
|
||||||
|
if err := client.DialAndSend(msg); err != nil {
|
||||||
|
return fmt.Errorf("failed to send email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
310
internal/handlers/handlers.go
Normal file
310
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"landing/internal/config"
|
||||||
|
"landing/internal/database"
|
||||||
|
"landing/internal/email"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
email *email.Sender
|
||||||
|
templatesPath string
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
templatesPath := "templates"
|
||||||
|
if _, err := os.Stat(templatesPath); os.IsNotExist(err) {
|
||||||
|
templatesPath = "./templates"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handler{
|
||||||
|
db: db,
|
||||||
|
cfg: cfg,
|
||||||
|
email: email.New(cfg),
|
||||||
|
templatesPath: templatesPath,
|
||||||
|
logger: log.New(os.Stdout, "", log.LstdFlags),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware logs HTTP requests
|
||||||
|
func (h *Handler) loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Create a custom response writer to capture status code
|
||||||
|
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
|
||||||
|
// Call the next handler
|
||||||
|
next.ServeHTTP(wrapped, r)
|
||||||
|
|
||||||
|
// Log the request
|
||||||
|
duration := time.Since(start)
|
||||||
|
statusColor := getStatusColor(wrapped.statusCode)
|
||||||
|
methodColor := getMethodColor(r.Method)
|
||||||
|
|
||||||
|
h.logger.Printf(
|
||||||
|
"%s %s %s %s %s %d %s",
|
||||||
|
methodColor+r.Method+"\033[0m",
|
||||||
|
r.RequestURI,
|
||||||
|
statusColor+fmt.Sprintf("%d", wrapped.statusCode)+"\033[0m",
|
||||||
|
duration.String(),
|
||||||
|
r.RemoteAddr,
|
||||||
|
wrapped.contentLength,
|
||||||
|
r.UserAgent(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
contentLength int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(code int) {
|
||||||
|
rw.statusCode = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||||
|
rw.contentLength = len(b)
|
||||||
|
return rw.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color codes for terminal output
|
||||||
|
func getStatusColor(statusCode int) string {
|
||||||
|
switch {
|
||||||
|
case statusCode >= 200 && statusCode < 300:
|
||||||
|
return "\033[32m" // Green
|
||||||
|
case statusCode >= 300 && statusCode < 400:
|
||||||
|
return "\033[36m" // Cyan
|
||||||
|
case statusCode >= 400 && statusCode < 500:
|
||||||
|
return "\033[33m" // Yellow
|
||||||
|
case statusCode >= 500:
|
||||||
|
return "\033[31m" // Red
|
||||||
|
default:
|
||||||
|
return "\033[37m" // White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMethodColor(method string) string {
|
||||||
|
switch method {
|
||||||
|
case "GET":
|
||||||
|
return "\033[34m" // Blue
|
||||||
|
case "POST":
|
||||||
|
return "\033[32m" // Green
|
||||||
|
case "PUT":
|
||||||
|
return "\033[33m" // Yellow
|
||||||
|
case "DELETE":
|
||||||
|
return "\033[31m" // Red
|
||||||
|
default:
|
||||||
|
return "\033[37m" // White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Start(host, port string) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
|
||||||
|
|
||||||
|
mux.HandleFunc("/", h.indexHandler)
|
||||||
|
mux.HandleFunc("/subscribe", h.subscribeHandler)
|
||||||
|
mux.HandleFunc("/unsubscribe", h.unsubscribeHandler)
|
||||||
|
mux.HandleFunc("/newsletters", h.newslettersHandler)
|
||||||
|
mux.HandleFunc("/newsletter/", h.newsletterDetailHandler)
|
||||||
|
|
||||||
|
// Wrap with logging middleware
|
||||||
|
handler := h.loggingMiddleware(mux)
|
||||||
|
|
||||||
|
h.logger.Printf("\033[36m▶ Starting server on http://%s:%s\033[0m", host, port)
|
||||||
|
|
||||||
|
return http.ListenAndServe(host+":"+port, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getTemplatePath(name string) string {
|
||||||
|
return filepath.Join(h.templatesPath, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) indexHandler(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(
|
||||||
|
h.getTemplatePath("base.html"),
|
||||||
|
h.getTemplatePath("index.html"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"IsHome": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl.ExecuteTemplate(w, "base.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) subscribeHandler(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Email == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": "Email is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.AddSubscriber(r.Context(), req.Email); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"error": "Email already exists",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribeLink := fmt.Sprintf(
|
||||||
|
"%s/unsubscribe?email=%s",
|
||||||
|
getBaseURL(r),
|
||||||
|
req.Email,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := h.email.SendConfirmationEmail(
|
||||||
|
req.Email,
|
||||||
|
unsubscribeLink,
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Printf("❌ Failed to send confirmation email to %s: %v", req.Email, err)
|
||||||
|
} else {
|
||||||
|
h.logger.Printf("✓ Confirmation email sent to %s", req.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"message": "Email has been added",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) unsubscribeHandler(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
|
email := r.URL.Query().Get("email")
|
||||||
|
if email == "" {
|
||||||
|
http.Error(w, "No email specified", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.RemoveSubscriber(r.Context(), email); err != nil {
|
||||||
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Email %s was not found or already unsubscribed",
|
||||||
|
email,
|
||||||
|
),
|
||||||
|
http.StatusBadRequest,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Printf("✓ Unsubscribed %s", email)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
fmt.Fprintf(w, "The email %s has been unsubscribed.", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) newslettersHandler(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
|
newsletters, err := h.db.GetNewsletters(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to fetch newsletters", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(
|
||||||
|
h.getTemplatePath("base.html"),
|
||||||
|
h.getTemplatePath("newsletters.html"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl.ExecuteTemplate(w, "base.html", newsletters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) newsletterDetailHandler(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
) {
|
||||||
|
idStr := r.URL.Path[len("/newsletter/"):]
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid newsletter ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newsletter, err := h.db.GetNewsletter(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Newsletter not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(
|
||||||
|
h.getTemplatePath("base.html"),
|
||||||
|
h.getTemplatePath("newsletter_detail.html"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl.ExecuteTemplate(w, "base.html", newsletter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBaseURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||||
|
}
|
||||||
15
internal/models/models.go
Normal file
15
internal/models/models.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Subscriber struct {
|
||||||
|
ID int
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Newsletter struct {
|
||||||
|
ID int
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
SentAt time.Time
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
gunicorn
|
|
||||||
flask
|
|
||||||
python-dotenv
|
|
||||||
psycopg2-binary
|
|
||||||
BIN
static/assets/32x32.png
Normal file
BIN
static/assets/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/assets/apple-touch-icon.png
Normal file
BIN
static/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
static/assets/logo.png
Normal file
BIN
static/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
// Countdown timer
|
// Countdown timer
|
||||||
const targetDate = new Date("2025-01-31T00:00:00Z"); // Set your launch date
|
const targetDate = new Date("2025-12-31T00:00:00Z");
|
||||||
|
|
||||||
function updateCountdown() {
|
function updateCountdown() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const difference = targetDate - now;
|
const difference = targetDate - now;
|
||||||
|
|
||||||
if (difference < 0) {
|
if (difference < 0) {
|
||||||
document.getElementById("countdown").innerHTML = "<p>We're Live!</p>";
|
document.getElementById("countdown").innerHTML = "<p style='color: white; font-size: 1.5rem;'>We're Live!</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,3 +22,4 @@ function updateCountdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInterval(updateCountdown, 1000);
|
setInterval(updateCountdown, 1000);
|
||||||
|
updateCountdown(); // Run immediately
|
||||||
|
|||||||
@@ -1,34 +1,175 @@
|
|||||||
document.getElementById("notify-button").addEventListener("click", async () => {
|
(() => {
|
||||||
const emailInput = document.getElementById("email-input");
|
'use strict';
|
||||||
const email = emailInput.value.trim();
|
|
||||||
|
|
||||||
if (email) {
|
const navbar = document.querySelector('.navbar');
|
||||||
try {
|
const featureCards = document.querySelectorAll('.feature-card');
|
||||||
const response = await fetch("/subscribe", {
|
const newsletterCards = document.querySelectorAll('.newsletter-card');
|
||||||
method: "POST",
|
const progressBar = document.querySelector('.reading-progress');
|
||||||
headers: { "Content-Type": "application/json" },
|
const emailInput = document.getElementById('email-input');
|
||||||
body: JSON.stringify({ email }),
|
const notifyBtn = document.getElementById('notify-button');
|
||||||
|
const emailRE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
(e) => {
|
||||||
|
const a = e.target.closest('a[href^="#"]');
|
||||||
|
if (!a) return;
|
||||||
|
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (!href || href === '#') return;
|
||||||
|
|
||||||
|
const target = document.querySelector(href);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries, obs) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('is-visible');
|
||||||
|
obs.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
featureCards.forEach((card) => {
|
||||||
|
card.classList.add('will-animate');
|
||||||
|
observer.observe(card);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
featureCards.forEach((card) => card.classList.add('is-visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
document
|
||||||
|
.querySelectorAll('.newsletter-header, .newsletter-content')
|
||||||
|
.forEach((el, i) => {
|
||||||
|
el.style.transitionDelay = `${i * 0.2}s`;
|
||||||
|
el.classList.add('is-visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if response is OK, then parse JSON
|
newsletterCards.forEach((card, i) => {
|
||||||
const result = await response.json();
|
card.style.transitionDelay = `${i * 0.1}s`;
|
||||||
console.log("Server response:", result);
|
card.classList.add('is-visible');
|
||||||
alert(result.message || result.error);
|
});
|
||||||
} catch (error) {
|
});
|
||||||
console.error("Error during subscribe fetch:", error);
|
|
||||||
alert("There was an error during subscription. Please try again later.");
|
|
||||||
}
|
|
||||||
emailInput.value = ""; // Clear input field
|
|
||||||
} else {
|
|
||||||
alert("Please enter a valid email.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('scroll', function() {
|
let lastY = 0;
|
||||||
var footerHeight = document.querySelector('footer').offsetHeight;
|
let ticking = false;
|
||||||
if (window.scrollY + window.innerHeight >= document.body.offsetHeight - footerHeight) {
|
|
||||||
document.querySelector('footer').style.display = 'block';
|
function onScroll() {
|
||||||
} else {
|
lastY = window.scrollY || window.pageYOffset;
|
||||||
document.querySelector('footer').style.display = 'none';
|
if (!ticking) {
|
||||||
|
requestAnimationFrame(updateOnScroll);
|
||||||
|
ticking = true;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function updateOnScroll() {
|
||||||
|
if (navbar) {
|
||||||
|
navbar.classList.toggle('navbar--scrolled', lastY > 50);
|
||||||
|
navbar.classList.toggle('navbar--deeper', lastY > 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBar) {
|
||||||
|
const max = document.body.scrollHeight - window.innerHeight;
|
||||||
|
const progress = max > 0 ? Math.min(Math.max(lastY / max, 0), 1) : 0;
|
||||||
|
progressBar.style.width = `${progress * 100}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
updateOnScroll(); // initial state
|
||||||
|
|
||||||
|
if (notifyBtn && emailInput) {
|
||||||
|
let inFlight = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
notifyBtn.addEventListener('click', async () => {
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
if (!emailRE.test(email)) {
|
||||||
|
alert('Please enter a valid email address.');
|
||||||
|
emailInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inFlight) return;
|
||||||
|
|
||||||
|
inFlight = true;
|
||||||
|
notifyBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = 'Thank you for subscribing!';
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
message = data.message || message;
|
||||||
|
} else {
|
||||||
|
message = "Thanks! We'll notify you when we launch.";
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
emailInput.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Subscribe error:', err);
|
||||||
|
alert("Thanks! We'll notify you when we launch.");
|
||||||
|
emailInput.value = '';
|
||||||
|
} finally {
|
||||||
|
notifyBtn.disabled = false;
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => controller.abort(), {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.shareNewsletter = async function shareNewsletter() {
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: document.title,
|
||||||
|
text: 'Check out this newsletter from RideAware',
|
||||||
|
url: location.href,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('navigator.share error/cancel:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(location.href);
|
||||||
|
alert('Newsletter URL copied to clipboard!');
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = document.createElement('input');
|
||||||
|
tmp.value = location.href;
|
||||||
|
document.body.appendChild(tmp);
|
||||||
|
tmp.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(tmp);
|
||||||
|
alert('Newsletter URL copied to clipboard!');
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
175
static/js/main.min.js
vendored
Normal file
175
static/js/main.min.js
vendored
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const navbar = document.querySelector('.navbar');
|
||||||
|
const featureCards = document.querySelectorAll('.feature-card');
|
||||||
|
const newsletterCards = document.querySelectorAll('.newsletter-card');
|
||||||
|
const progressBar = document.querySelector('.reading-progress');
|
||||||
|
const emailInput = document.getElementById('email-input');
|
||||||
|
const notifyBtn = document.getElementById('notify-button');
|
||||||
|
const emailRE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'click',
|
||||||
|
(e) => {
|
||||||
|
const a = e.target.closest('a[href^="#"]');
|
||||||
|
if (!a) return;
|
||||||
|
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (!href || href === '#') return;
|
||||||
|
|
||||||
|
const target = document.querySelector(href);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries, obs) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('is-visible');
|
||||||
|
obs.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
featureCards.forEach((card) => {
|
||||||
|
card.classList.add('will-animate');
|
||||||
|
observer.observe(card);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
featureCards.forEach((card) => card.classList.add('is-visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
document
|
||||||
|
.querySelectorAll('.newsletter-header, .newsletter-content')
|
||||||
|
.forEach((el, i) => {
|
||||||
|
el.style.transitionDelay = `${i * 0.2}s`;
|
||||||
|
el.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
newsletterCards.forEach((card, i) => {
|
||||||
|
card.style.transitionDelay = `${i * 0.1}s`;
|
||||||
|
card.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastY = 0;
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
lastY = window.scrollY || window.pageYOffset;
|
||||||
|
if (!ticking) {
|
||||||
|
requestAnimationFrame(updateOnScroll);
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOnScroll() {
|
||||||
|
if (navbar) {
|
||||||
|
navbar.classList.toggle('navbar--scrolled', lastY > 50);
|
||||||
|
navbar.classList.toggle('navbar--deeper', lastY > 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBar) {
|
||||||
|
const max = document.body.scrollHeight - window.innerHeight;
|
||||||
|
const progress = max > 0 ? Math.min(Math.max(lastY / max, 0), 1) : 0;
|
||||||
|
progressBar.style.width = `${progress * 100}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ticking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
updateOnScroll(); // initial state
|
||||||
|
|
||||||
|
if (notifyBtn && emailInput) {
|
||||||
|
let inFlight = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
notifyBtn.addEventListener('click', async () => {
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
if (!emailRE.test(email)) {
|
||||||
|
alert('Please enter a valid email address.');
|
||||||
|
emailInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inFlight) return;
|
||||||
|
|
||||||
|
inFlight = true;
|
||||||
|
notifyBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = 'Thank you for subscribing!';
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
message = data.message || message;
|
||||||
|
} else {
|
||||||
|
message = "Thanks! We'll notify you when we launch.";
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
emailInput.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Subscribe error:', err);
|
||||||
|
alert("Thanks! We'll notify you when we launch.");
|
||||||
|
emailInput.value = '';
|
||||||
|
} finally {
|
||||||
|
notifyBtn.disabled = false;
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => controller.abort(), {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.shareNewsletter = async function shareNewsletter() {
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: document.title,
|
||||||
|
text: 'Check out this newsletter from RideAware',
|
||||||
|
url: location.href,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('navigator.share error/cancel:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(location.href);
|
||||||
|
alert('Newsletter URL copied to clipboard!');
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmp = document.createElement('input');
|
||||||
|
tmp.value = location.href;
|
||||||
|
document.body.appendChild(tmp);
|
||||||
|
tmp.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(tmp);
|
||||||
|
alert('Newsletter URL copied to clipboard!');
|
||||||
|
};
|
||||||
|
})();
|
||||||
136
templates/base.html
Normal file
136
templates/base.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<title>{{block "title" .}}RideAware{{end}}</title>
|
||||||
|
|
||||||
|
<!-- Icons/Fonts -->
|
||||||
|
<link
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Core CSS -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/static/css/styles.css"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Favicons -->
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/static/assets/32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="alternate icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/static/assets/32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/static/assets/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="manifest"
|
||||||
|
href="/static/assets/site.webmanifest"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
|
||||||
|
{{block "extra_head" .}}{{end}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="nav-container">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="logo"
|
||||||
|
aria-label="RideAware home"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/static/assets/logo.png"
|
||||||
|
alt="RideAware"
|
||||||
|
class="logo-img"
|
||||||
|
width="140"
|
||||||
|
height="28"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="nav-toggle"
|
||||||
|
id="nav-toggle"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
aria-controls="primary-nav"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2025 RideAware. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Core JS -->
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="https://cdn.statically.io/gl/rideaware/landing/06d19988c7df53636277f945f9ed853bda76471b/static/js/main.min.js"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
{{block "extra_scripts" .}}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const btn = document.getElementById("nav-toggle");
|
||||||
|
const menu = document.getElementById("primary-nav");
|
||||||
|
if (!btn || !menu) return;
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
btn.classList.remove("active");
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
menu.classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const open = btn.classList.toggle("active");
|
||||||
|
btn.setAttribute("aria-expanded", String(open));
|
||||||
|
menu.classList.toggle("open", open);
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addEventListener("click", (e) => {
|
||||||
|
if (e.target.tagName === "A") closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!menu.contains(e.target) && !btn.contains(e.target)) {
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,35 +1,343 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<title>Thanks for Subscribing!</title>
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<title>Welcome to RideAware!</title>
|
||||||
<style>
|
<style>
|
||||||
|
html,
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
margin: 0 !important;
|
||||||
line-height: 1.6;
|
padding: 0 !important;
|
||||||
color: #333;
|
height: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
mso-table-lspace: 0pt !important;
|
||||||
|
mso-table-rspace: 0pt !important;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.bg {
|
||||||
|
background-color: #f8fafc;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 20px auto;
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: #1e4e9c;
|
||||||
|
background-image: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%);
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.logo-text {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.logo-accent {
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 6px 0 6px 0;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.main-message {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
.features-wrap {
|
||||||
|
padding: 0 24px 24px 24px;
|
||||||
|
}
|
||||||
|
.features {
|
||||||
|
border: 1px solid rgba(30, 78, 156, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #ffffff;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
}
|
||||||
a {
|
.features-title {
|
||||||
color: #007bff;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
color: #1e4e9c;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.feature-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(2, 6, 23, 0.05);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.feature-title {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
}
|
||||||
|
.feature-desc {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.cta {
|
||||||
|
padding: 8px 24px 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cta-btn {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
display: inline-block;
|
||||||
|
background: #1e4e9c;
|
||||||
|
background-image: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%);
|
||||||
|
color: #ffffff !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
a:hover {
|
.social {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
padding: 20px 24px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 24px 24px 24px;
|
||||||
|
}
|
||||||
|
.social-title {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.social-link {
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
line-height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 6px;
|
||||||
|
background-image: linear-gradient(135deg, #1e4e9c 0%, #337cf2 100%);
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.footer p {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.unsubscribe {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.unsubscribe a {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.unsubscribe a:hover {
|
||||||
|
color: #00d4ff !important;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.header {
|
||||||
|
padding: 32px 18px !important;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 24px 18px !important;
|
||||||
|
}
|
||||||
|
.features {
|
||||||
|
padding: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg">
|
||||||
<div class="container">
|
<center role="article" aria-roledescription="email">
|
||||||
<h2>Thanks for subscribing to RideAware newsletter!</h2>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<p>We're excited to share our journey with you.</p>
|
<tr>
|
||||||
<p>If you ever wish to unsubscribe, please click <a href="{{ unsubscribe_link }}">here</a>.</p>
|
<td align="center">
|
||||||
|
<table role="presentation" class="container" width="600" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td class="header">
|
||||||
|
<div class="logo-text">Ride<span class="logo-accent">Aware</span></div>
|
||||||
|
<div style="font-size: 36px; line-height: 1; margin-bottom: 10px;" aria-hidden="true">🎉</div>
|
||||||
|
<div class="header-title">Welcome Aboard!</div>
|
||||||
|
<div class="subtitle">You're now part of the RideAware community</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td class="content">
|
||||||
|
<p class="main-message">Thanks for subscribing to the RideAware newsletter!</p>
|
||||||
|
<p class="description">
|
||||||
|
We're thrilled to have you with us. Expect training tips, performance insights,
|
||||||
|
product news, and community highlights—delivered straight to your inbox.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<tr>
|
||||||
|
<td class="features-wrap">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" class="features">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom: 10px;">
|
||||||
|
<div class="features-title">What to expect from us</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td class="feature-item">
|
||||||
|
<div class="feature-title">Training Tips</div>
|
||||||
|
<div class="feature-desc">Actionable advice to improve your performance.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feature-item">
|
||||||
|
<div class="feature-title">Performance Insights</div>
|
||||||
|
<div class="feature-desc">Data-driven analysis for smarter rides.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feature-item">
|
||||||
|
<div class="feature-title">Feature Updates</div>
|
||||||
|
<div class="feature-desc">Be first to know about new releases.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="feature-item" style="margin-bottom: 0;">
|
||||||
|
<div class="feature-title">Community Stories</div>
|
||||||
|
<div class="feature-desc">Inspiring journeys from fellow cyclists.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<tr>
|
||||||
|
<td class="cta" align="center">
|
||||||
|
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color:#1a1a1a; font-size:15px; margin: 0 0 12px 0;">
|
||||||
|
Ready to start your journey with RideAware?
|
||||||
|
</p>
|
||||||
|
<a href="https://rideaware.org" target="_blank" class="cta-btn">Explore RideAware →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Social -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="social">
|
||||||
|
<div class="social-title">Stay Connected</div>
|
||||||
|
<a href="https://twitter.com" class="social-link" title="Twitter" aria-label="Twitter">T</a>
|
||||||
|
<a href="https://facebook.com" class="social-link" title="Facebook" aria-label="Facebook">f</a>
|
||||||
|
<a href="https://instagram.com" class="social-link" title="Instagram" aria-label="Instagram">IG</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td class="footer">
|
||||||
|
<p><strong>RideAware Team</strong></p>
|
||||||
|
<p>Empowering cyclists, one ride at a time</p>
|
||||||
|
<div class="unsubscribe">
|
||||||
|
<p style="margin: 0;">
|
||||||
|
<a href="{{.UnsubscribeLink}}">Unsubscribe</a>
|
||||||
|
|
|
||||||
|
<a href="mailto:support@rideaware.com">Contact Support</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 12px; color: #9ca3af;">
|
||||||
|
© 2025 RideAware. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 11px; color: #9ca3af;">
|
||||||
|
You received this email because you subscribed to RideAware updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,95 +1,214 @@
|
|||||||
<!DOCTYPE html>
|
{{define "title"}}RideAware - Smart Cycling Training Platform{{end}}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{{define "content"}}
|
||||||
<meta charset="UTF-8">
|
<!-- Hero Section -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<section class="hero">
|
||||||
<title>RideAware</title>
|
<div class="hero-container">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav>
|
|
||||||
<a href="/">
|
|
||||||
<span>Ride</span><span style="color: #1e4e9c;">Aware</span>
|
|
||||||
</a>
|
|
||||||
<a href="/newsletters">Newsletters</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<section class="hero-section-1">
|
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-text">
|
<h1>Elevate Your Cycling Journey</h1>
|
||||||
<img src="{{ url_for('static', filename='assets/RideAwareLogo.svg') }}" alt="RideAware Logo">
|
<p class="subtitle">
|
||||||
</div>
|
The ultimate smart training platform for cyclists who demand
|
||||||
</section>
|
excellence in every ride.
|
||||||
<section class="hero-section-2">
|
</p>
|
||||||
<h2>Get notified when we’re launching</h2>
|
|
||||||
<p>Sign up to receive updates and special offers as we prepare to launch.</p>
|
|
||||||
|
|
||||||
<div class="subscription">
|
<div class="cta-section">
|
||||||
<input id="email-input" type="email" placeholder="Enter your email" required />
|
<h3>Coming soon!</h3>
|
||||||
<button id="notify-button">Notify Me</button>
|
<p>Join us while waiting for launch</p>
|
||||||
|
|
||||||
|
<div class="email-form">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="email-input"
|
||||||
|
id="email-input"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button class="notify-btn" id="notify-button">Notify Me</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-visual">
|
||||||
|
<div class="phone-mockup">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="app-interface">
|
||||||
|
<div class="app-brand">
|
||||||
|
<img
|
||||||
|
src="/static/assets/32x32.png"
|
||||||
|
alt="RideAware icon"
|
||||||
|
class="app-brand-icon"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<div class="app-logo">RideAware</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
<div class="stats-grid">
|
||||||
<section class="hero-section-3">
|
<div class="stat-card">
|
||||||
<h2 class="hero-sec2-header">Features</h2>
|
<div class="stat-number">24.5</div>
|
||||||
<div class="feature-cards">
|
<div class="stat-label">KM/H AVG</div>
|
||||||
<div class="feature-card">
|
</div>
|
||||||
<h3>Workout Planning</h3>
|
<div class="stat-card">
|
||||||
<ul>
|
<div class="stat-number">45</div>
|
||||||
<li><b>Customizable Training Plans:</b> Allow users to create customized training plans based on their goals and fitness level.</li>
|
<div class="stat-label">MINUTES</div>
|
||||||
<li><b>Workout Scheduling:</b> Provide a feature to schedule workouts and set reminders.</li>
|
</div>
|
||||||
<li><b>Goal Setting:</b> Allow users to set and track their fitness goals.</li>
|
<div class="stat-card">
|
||||||
</ul>
|
<div class="stat-number">285</div>
|
||||||
|
<div class="stat-label">CALORIES</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number">18.2</div>
|
||||||
|
<div class="stat-label">DISTANCE</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
|
||||||
<h3>Workout Tracking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>Workout Logging:</b> Allow users to log their workouts, including exercises, sets, reps, and weight.</li>
|
|
||||||
<li><b>Data Analysis:</b> Provide tools to analyze user data, including charts, graphs, and statistics.</li>
|
|
||||||
<li><b>Progress Tracking:</b> Allow users to track their progress over time.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
|
||||||
<h3>Training and Coaching</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>Coaching and Guidance:</b> Provide coaching and guidance to help users achieve their fitness goals.</li>
|
|
||||||
<li><b>Virtual Training Rides:</b> Offer immersive virtual training rides to boost users' cycling performance.</li>
|
|
||||||
<li><b>Structured Workouts:</b> Offer structured workouts to help users improve their fitness and performance.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
|
||||||
<h3>Nutrition and Recovery</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>Nutrition Planning:</b> Provide tools to help users plan and track their nutrition.</li>
|
|
||||||
<li><b>Recovery Planning:</b> Offer resources and tools to help users plan and track their recovery.</li>
|
|
||||||
<li><b>Injury Prevention and Management:</b> Provide resources and tools to help users prevent and manage injuries.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
|
||||||
<h3>Social and Community</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>Social Sharing:</b> Allow users to share their workouts and progress on social media.</li>
|
|
||||||
<li><b>Community Forum:</b> Create a community forum where users can connect with each other and share their experiences.</li>
|
|
||||||
<li><b>Leaderboards:</b> Provide leaderboards to encourage competition and motivation.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
|
||||||
<h3>Integration and Data</h3>
|
|
||||||
<ul>
|
|
||||||
<li><b>Integration with Wearable Devices:</b> Integrate with wearable devices to track user activity and health metrics.</li>
|
|
||||||
<li><b>Integration with Music Services:</b> Integrate with music services to provide a more engaging workout experience.</li>
|
|
||||||
<li><b>Data Import/Export:</b> Allow users to import and export their data to other platforms.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
<footer class="normal-footer">
|
<!-- Features Section -->
|
||||||
Copyright © 2025 RideAware. All rights reserved.
|
{{if .IsHome}}
|
||||||
</footer>
|
<section class="features" id="features">
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<div class="section-header">
|
||||||
</body>
|
<h2>Powerful Features for Every Cyclist</h2>
|
||||||
</html>
|
<p>
|
||||||
|
From beginners to professionals, RideAware provides comprehensive
|
||||||
|
tools to optimize your training and performance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features-container">
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Smart Training Plans</h3>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<strong>AI-Powered Planning:</strong> Customized training plans
|
||||||
|
based on your goals and fitness level
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Adaptive Scheduling:</strong> Smart workout scheduling
|
||||||
|
with automated reminders
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Goal Tracking:</strong> Set and monitor your cycling
|
||||||
|
objectives in real-time
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Advanced Analytics</h3>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<strong>Detailed Logging:</strong> Track exercises, sets, reps,
|
||||||
|
and performance metrics
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Data Visualization:</strong> Interactive charts, graphs,
|
||||||
|
and progress statistics
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Progress Insights:</strong> Monitor your improvement
|
||||||
|
over time with AI analysis
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-bicycle"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Virtual Training</h3>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<strong>Expert Coaching:</strong> Professional guidance to
|
||||||
|
achieve your cycling goals
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Immersive Rides:</strong> Virtual training experiences
|
||||||
|
to boost performance
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Structured Workouts:</strong> Designed programs for
|
||||||
|
fitness and performance gains
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Health & Recovery</h3>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<strong>Nutrition Tracking:</strong> Plan and monitor your
|
||||||
|
dietary intake for optimal performance
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Recovery Optimization:</strong> Tools and resources for
|
||||||
|
effective rest and recovery
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Injury Prevention:</strong> Proactive measures to
|
||||||
|
prevent and manage injuries
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Community & Social</h3>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<strong>Social Sharing:</strong> Share achievements and progress
|
||||||
|
on social platforms
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Active Community:</strong> Connect with fellow cyclists
|
||||||
|
and share experiences
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Competitive Leaderboards:</strong> Challenge yourself
|
||||||
|
against the community
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Smart Integration</h3>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li>
|
||||||
|
<strong>Wearable Sync:</strong> Connect with fitness trackers
|
||||||
|
and smart devices
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Music Integration:</strong> Seamlessly sync with your
|
||||||
|
favorite music services
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Data Portability:</strong> Easy import/export to other
|
||||||
|
cycling platforms
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -1,32 +1,96 @@
|
|||||||
<!DOCTYPE html>
|
{{define "title"}}RideAware - {{.Subject}}{{end}}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{{define "content"}}
|
||||||
<meta charset="UTF-8" />
|
<div class="article-wrap">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<aside class="article-aside">
|
||||||
<title>RideAware - Newsletter Detail</title>
|
<a href="/newsletters" class="back-link">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}" />
|
<i class="fas fa-arrow-left"></i>
|
||||||
</head>
|
Back to Newsletters
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<nav>
|
|
||||||
<a href="/">
|
|
||||||
<span>Ride</span><span style="color: #1e4e9c;">Aware</span>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/newsletters">Newsletters</a>
|
|
||||||
|
<div class="article-meta">
|
||||||
|
<h2 class="article-title">
|
||||||
|
{{.Subject}}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="meta-row">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>{{.SentAt.Format "January 2, 2006 at 3:04 PM"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="toc">
|
||||||
|
<div class="toc-title">On this page</div>
|
||||||
|
<ol id="toc-list"></ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="article-main">
|
||||||
|
<header class="article-hero">
|
||||||
|
<div class="newsletter-icon">
|
||||||
|
<i class="fas fa-envelope-open-text"></i>
|
||||||
|
</div>
|
||||||
|
<h1>{{.Subject}}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<article class="newsletter-content" id="article">
|
||||||
<a href="/newsletters" class="back-link">← Back to Newsletters</a>
|
{{.Body}}
|
||||||
<h1>{{ newsletter.subject }}</h1>
|
</article>
|
||||||
<div class="newsletter-content">
|
|
||||||
{{ newsletter.body | safe }}
|
<div class="newsletter-actions">
|
||||||
|
<a href="/newsletters" class="action-btn primary">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
View All Newsletters
|
||||||
|
</a>
|
||||||
|
<button onclick="window.print()" class="action-btn secondary">
|
||||||
|
<i class="fas fa-print"></i>
|
||||||
|
Print
|
||||||
|
</button>
|
||||||
|
<button onclick="shareNewsletter()" class="action-btn secondary">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<footer class="normal-footer">
|
{{define "extra_scripts"}}
|
||||||
<p>© 2025 RideAware. All rights reserved.</p>
|
<script>
|
||||||
</footer>
|
function shareNewsletter() {
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
if (navigator.share) {
|
||||||
</body>
|
navigator
|
||||||
</html>
|
.share({ title: document.title, url: location.href })
|
||||||
|
.catch(() => {});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(location.href);
|
||||||
|
alert('Link copied to clipboard!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build TOC from h2/h3 inside the article
|
||||||
|
(function buildTOC() {
|
||||||
|
const article = document.getElementById('article');
|
||||||
|
if (!article) return;
|
||||||
|
|
||||||
|
const headings = article.querySelectorAll('h2, h3');
|
||||||
|
const list = document.getElementById('toc-list');
|
||||||
|
if (!headings.length || !list) return;
|
||||||
|
|
||||||
|
headings.forEach((h, idx) => {
|
||||||
|
const id = h.id || `h-${idx}`;
|
||||||
|
h.id = id;
|
||||||
|
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = h.tagName === 'H2' ? 'toc-h2' : 'toc-h3';
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `#${id}`;
|
||||||
|
a.textContent = h.textContent;
|
||||||
|
|
||||||
|
li.appendChild(a);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -1,40 +1,72 @@
|
|||||||
<!DOCTYPE html>
|
{{define "title"}}RideAware - Newsletters{{end}}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>RideAware - Newsletters</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
|
|
||||||
</head>
|
{{define "content"}}
|
||||||
<body>
|
<section class="page-header">
|
||||||
<header>
|
<div class="page-header-content">
|
||||||
<nav>
|
<div class="header-icon">
|
||||||
<a href="/">
|
<i class="fas fa-newspaper"></i>
|
||||||
<span>Ride</span><span style="color: #1e4e9c;">Aware</span>
|
|
||||||
</a>
|
|
||||||
<a href="/newsletters" class="active">Newsletters</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main class="newsletter-main">
|
|
||||||
<h1>RideAware Newsletters</h1>
|
|
||||||
{% if newsletters %}
|
|
||||||
{% for nl in newsletters %}
|
|
||||||
<div class="newsletter">
|
|
||||||
<h2>
|
|
||||||
<a href="/newsletter/{{ nl['id'] }}">{{ nl['subject'] }}</a>
|
|
||||||
</h2>
|
|
||||||
<p class="newsletter-time">Sent on: {{ nl['sent_at'] }}</p>
|
|
||||||
<a href="/newsletter/{{ nl['id'] }}" class="read-more">Read More</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
<h1>RideAware Newsletters</h1>
|
||||||
{% else %}
|
<p>
|
||||||
<p>No newsletters to display yet.</p>
|
Stay updated with the latest cycling tips, training insights, and
|
||||||
{% endif %}
|
product updates from our team.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
{{if .}}
|
||||||
|
<div class="newsletters-grid">
|
||||||
|
{{range .}}
|
||||||
|
<article class="newsletter-card">
|
||||||
|
<div class="newsletter-header">
|
||||||
|
<div class="newsletter-icon">
|
||||||
|
<i class="fas fa-envelope-open-text"></i>
|
||||||
|
</div>
|
||||||
|
<div class="newsletter-info">
|
||||||
|
<h2>
|
||||||
|
<a href="/newsletter/{{.ID}}">
|
||||||
|
{{.Subject}}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="newsletter-date">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
<span>Sent on: {{.SentAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="newsletter-excerpt">
|
||||||
|
Get the latest updates on cycling training, performance tips,
|
||||||
|
and RideAware features in this newsletter edition.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/newsletter/{{.ID}}"
|
||||||
|
class="read-more-btn"
|
||||||
|
>
|
||||||
|
Read Full Newsletter
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
</div>
|
||||||
|
<h3>No Newsletters Yet</h3>
|
||||||
|
<p>
|
||||||
|
We're working on some amazing content for you. Subscribe to be the
|
||||||
|
first to know when we publish our newsletters!
|
||||||
|
</p>
|
||||||
|
<a href="/" class="subscribe-prompt">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
Subscribe for Updates
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
<footer class="fixed-footer">
|
{{end}}
|
||||||
<p>© 2025 RideAware. All rights reserved.</p>
|
|
||||||
</footer>
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user