diff --git a/internal/config/config.go b/internal/config/config.go
index d4b906f..bf453c1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,6 +19,7 @@ type Config struct {
SMTPPort string
SMTPUser string
SMTPPass string
+ AdminEmail string
}
func LoadConfig() (*Config, error) {
@@ -36,6 +37,7 @@ func LoadConfig() (*Config, error) {
SMTPPort: getEnv("SMTP_PORT", ""),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPass: getEnv("SMTP_PASSWORD", ""),
+ AdminEmail: os.Getenv("ADMIN_EMAIL"),
}
if cfg.SMTPHost == "" {
diff --git a/internal/database/database.go b/internal/database/database.go
index c04e53a..3e8adbe 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -58,6 +58,14 @@ func (db *DB) InitDB(ctx context.Context) error {
body TEXT NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
+ `CREATE TABLE IF NOT EXISTS contact_messages (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ message TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )`,
}
for _, query := range queries {
@@ -155,6 +163,28 @@ func (db *DB) GetNewsletter(
return &n, nil
}
+func (db *DB) AddContactMessage(
+ ctx context.Context,
+ name, email, subject, message string,
+) error {
+ query := `
+ INSERT INTO contact_messages (name, email, subject, message, created_at)
+ VALUES ($1, $2, $3, $4, $5)
+ `
+
+ _, err := db.pool.Exec(
+ ctx,
+ query,
+ name,
+ email,
+ subject,
+ message,
+ time.Now(),
+ )
+
+ return err
+}
+
func (db *DB) Close(ctx context.Context) {
db.pool.Close()
}
\ No newline at end of file
diff --git a/internal/email/sender.go b/internal/email/sender.go
index 04b902f..24540a9 100644
--- a/internal/email/sender.go
+++ b/internal/email/sender.go
@@ -3,10 +3,10 @@ package email
import (
"crypto/tls"
"fmt"
- "net"
+ "html"
"net/smtp"
"strconv"
- "time"
+ "strings"
"landing/internal/config"
)
@@ -23,13 +23,6 @@ func (s *Sender) SendConfirmationEmail(
email string,
unsubscribeLink string,
) error {
- // Parse SMTP port from env
- port, err := strconv.Atoi(s.cfg.SMTPPort)
- if err != nil {
- return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
- }
-
- // Build email message
subject := "Thanks for subscribing!"
htmlBody := fmt.Sprintf(`
@@ -41,88 +34,143 @@ func (s *Sender) SendConfirmationEmail(
`, unsubscribeLink)
+ return s.sendEmail(email, subject, htmlBody)
+}
+
+func (s *Sender) SendContactConfirmation(email, name string) error {
+ subject := "We received your message - RideAware"
+ htmlBody := fmt.Sprintf(`
+
+
+ Thank you for reaching out, %s!
+ We've received your message and will get back to you as soon as possible.
+ In the meantime, feel free to check out more about RideAware on our website.
+ Best regards, The RideAware Team
+
+
+ `, html.EscapeString(name))
+
+ return s.sendEmail(email, subject, htmlBody)
+}
+
+func (s *Sender) SendContactNotification(
+ adminEmail, name, email, subject, message string,
+) error {
+ emailSubject := fmt.Sprintf("New contact message from %s", name)
+ htmlBody := fmt.Sprintf(`
+
+
+ New Contact Message
+ From: %s (%s)
+ Subject: %s
+ Message:
+ %s
+
+
+ `,
+ html.EscapeString(name),
+ html.EscapeString(email),
+ html.EscapeString(subject),
+ strings.ReplaceAll(html.EscapeString(message), "\n", " "),
+ )
+
+ return s.sendEmail(adminEmail, emailSubject, htmlBody)
+}
+
+func (s *Sender) sendEmail(toEmail, subject, htmlBody string) error {
+ port, err := strconv.Atoi(s.cfg.SMTPPort)
+ if err != nil {
+ return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
+ }
+
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
s.cfg.SMTPUser,
- email,
+ toEmail,
subject,
htmlBody,
)
+ addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
+
+ // Port 465 uses direct SSL/TLS
+ return s.sendEmailSSL(addr, toEmail, message)
+}
+
+func (s *Sender) sendEmailSSL(addr, toEmail, message string) error {
// Create TLS config
tlsConfig := &tls.Config{
ServerName: s.cfg.SMTPHost,
}
- // Send email using smtp.SendMail
- addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
- auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost)
-
- // Use a custom dialer with timeout
- conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
+ // Try to dial with TLS
+ conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
- return fmt.Errorf("failed to connect to SMTP server: %w", err)
+ return fmt.Errorf("failed to dial TLS to %s: %w", addr, err)
}
defer conn.Close()
+ // Create SMTP client
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
- // Start TLS
- if err := client.StartTLS(tlsConfig); err != nil {
- return fmt.Errorf("failed to start TLS: %w", err)
- }
-
// Authenticate
+ auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost)
if err := client.Auth(auth); err != nil {
- return fmt.Errorf("failed to authenticate: %w", err)
+ return fmt.Errorf("failed to authenticate with %s: %w", s.cfg.SMTPUser, err)
}
- // Set recipient and send
+ // Set sender
if err := client.Mail(s.cfg.SMTPUser); err != nil {
- return fmt.Errorf("failed to set mail from: %w", err)
+ return fmt.Errorf("failed to set mail from %s: %w", s.cfg.SMTPUser, err)
}
- if err := client.Rcpt(email); err != nil {
- return fmt.Errorf("failed to set mail to: %w", err)
+ // Set recipient
+ if err := client.Rcpt(toEmail); err != nil {
+ return fmt.Errorf("failed to set mail to %s: %w", toEmail, err)
}
+ // Get data writer
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
+ // Write message
if _, err := wc.Write([]byte(message)); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
- if err := client.Quit(); err != nil {
- return fmt.Errorf("failed to quit SMTP: %w", err)
- }
+ // Quit - ignore quit errors since email was already queued
+ _ = client.Quit()
return nil
}
-// TestConnection tests SMTP connection without sending email
func (s *Sender) TestConnection() error {
port, err := strconv.Atoi(s.cfg.SMTPPort)
if err != nil {
return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
}
- // Test TCP connection
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
- conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
+
+ // Test TLS connection
+ tlsConfig := &tls.Config{
+ ServerName: s.cfg.SMTPHost,
+ }
+
+ conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
- return fmt.Errorf("TCP connection failed to %s: %w", addr, err)
+ return fmt.Errorf("failed to dial TLS to %s: %w", addr, err)
}
defer conn.Close()
- // Test SMTP connection
+ // Test SMTP client creation
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index a451d37..2a5c087 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -156,6 +156,8 @@ func (h *Handler) Start(host, port string) error {
mux.HandleFunc("/unsubscribe", h.unsubscribeHandler)
mux.HandleFunc("/newsletters", h.newslettersHandler)
mux.HandleFunc("/newsletter/", h.newsletterDetailHandler)
+ mux.HandleFunc("/contact", h.contactHandler)
+ mux.HandleFunc("/about", h.aboutHandler)
// Wrap with logging middleware
handler := h.loggingMiddleware(mux)
@@ -331,6 +333,178 @@ func (h *Handler) newsletterDetailHandler(
tmpl.ExecuteTemplate(w, "base.html", newsletter)
}
+func (h *Handler) contactHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet && r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if r.Method == http.MethodGet {
+ tmpl, err := template.ParseFiles(
+ h.getTemplatePath("base.html"),
+ h.getTemplatePath("contact.html"),
+ )
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ data := map[string]interface{}{
+ "IsContact": true,
+ }
+
+ tmpl.ExecuteTemplate(w, "base.html", data)
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ h.handleContactSubmission(w, r)
+ }
+}
+
+func (h *Handler) handleContactSubmission(w http.ResponseWriter, r *http.Request) {
+ // Parse form data
+ if err := r.ParseForm(); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "Failed to parse form data",
+ })
+ return
+ }
+
+ // Extract form fields
+ name := strings.TrimSpace(r.FormValue("name"))
+ email := strings.TrimSpace(r.FormValue("email"))
+ subject := strings.TrimSpace(r.FormValue("subject"))
+ message := strings.TrimSpace(r.FormValue("message"))
+ subscribe := r.FormValue("subscribe") == "on"
+
+ // Validate required fields
+ if name == "" || email == "" || subject == "" || message == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "All fields are required",
+ })
+ return
+ }
+
+ // Validate email format (basic validation)
+ if !strings.Contains(email, "@") || !strings.Contains(email, ".") {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "Invalid email address",
+ })
+ return
+ }
+
+ // Validate message length (prevent spam)
+ if len(message) < 10 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "Message must be at least 10 characters",
+ })
+ return
+ }
+
+ if len(message) > 5000 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "Message must be less than 5000 characters",
+ })
+ return
+ }
+
+ // If subscribe checkbox is checked, add to subscribers
+ if subscribe {
+ if err := h.db.AddSubscriber(r.Context(), email); err != nil {
+ // Log but don't fail the contact submission
+ h.logger.Printf(
+ "ℹ Subscriber %s already exists or failed to add: %v",
+ email,
+ err,
+ )
+ } else {
+ h.logger.Printf("✓ New subscriber added: %s", email)
+ }
+ }
+
+ // Send confirmation email to the user
+ if err := h.email.SendContactConfirmation(email, name); err != nil {
+ h.logger.Printf(
+ "❌ Failed to send contact confirmation to %s: %v",
+ email,
+ err,
+ )
+ } else {
+ h.logger.Printf("✓ Contact confirmation email sent to %s", email)
+ }
+
+ // Send notification email to admin
+ adminEmail := h.cfg.AdminEmail
+ if adminEmail != "" {
+ if err := h.email.SendContactNotification(
+ adminEmail,
+ name,
+ email,
+ subject,
+ message,
+ ); err != nil {
+ h.logger.Printf(
+ "❌ Failed to send contact notification to admin: %v",
+ err,
+ )
+ } else {
+ h.logger.Printf("✓ Contact notification sent to admin: %s", adminEmail)
+ }
+ }
+
+ // Save contact message to database (optional - add to your DB interface)
+ if err := h.db.AddContactMessage(r.Context(), name, email, subject, message); err != nil {
+ h.logger.Printf(
+ "⚠ Failed to save contact message: %v",
+ err,
+ )
+ }
+
+ h.logger.Printf("✓ Contact form submitted by %s (%s)", name, email)
+
+ // Return success response
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]string{
+ "message": "Thank you for your message. We'll get back to you soon!",
+ })
+}
+
+func (h *Handler) aboutHandler(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("about.html"),
+ )
+ if err != nil {
+ http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError)
+ h.logger.Printf("❌ Template parse error: %v", err)
+ return
+ }
+
+ data := map[string]interface{}{
+ "IsAbout": true,
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ tmpl.ExecuteTemplate(w, "base.html", data)
+}
+
func getBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
diff --git a/static/css/styles.css b/static/css/styles.css
index 860492e..a05c93e 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -23,7 +23,7 @@
);
--shadow: 0 10px 30px rgba(30, 78, 156, 0.1);
--shadow-hover: 0 20px 40px rgba(30, 78, 156, 0.15);
- --navbar-height: 64px; /* consistent fixed navbar height */
+ --navbar-height: 64px;
}
html {
@@ -53,9 +53,7 @@ body {
transition: all 0.3s ease;
border-bottom: 1px solid rgba(2, 6, 23, 0.04);
min-height: var(--navbar-height);
-
- /* Lock navbar typography (prevents subtle shifts) */
- font-size: 1rem; /* 16px baseline */
+ font-size: 1rem;
line-height: 1.2;
letter-spacing: 0;
}
@@ -67,11 +65,11 @@ body {
display: flex;
justify-content: space-between;
align-items: center;
- position: relative; /* for mobile dropdown positioning */
+ position: relative;
}
.logo {
- font-size: 1.5rem; /* consistent logo size */
+ font-size: 1.5rem;
font-weight: 700;
color: var(--text-dark);
text-decoration: none;
@@ -128,7 +126,6 @@ body {
width: 100%;
}
-/* Hamburger toggle (hidden on desktop) */
.nav-toggle {
display: none;
flex-direction: column;
@@ -167,8 +164,6 @@ body {
align-items: center;
position: relative;
overflow: hidden;
-
- /* Ensure content clears the fixed navbar on all screens */
padding-top: calc(var(--navbar-height) + 16px);
}
@@ -268,7 +263,6 @@ body {
box-shadow: var(--shadow-hover);
}
-/* Countdown (if used) */
.countdown {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -340,50 +334,74 @@ body {
.app-interface {
color: #fff;
text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 2rem 1.5rem;
+ width: 100%;
}
.app-brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
- margin-bottom: 0.5rem;
+ margin-bottom: 1.5rem;
}
.app-brand-icon {
width: 32px;
height: 32px;
display: block;
- border-radius: 6px; /* optional */
+ border-radius: 6px;
}
.app-logo {
- font-size: 2rem;
+ font-size: 1.8rem;
font-weight: 700;
- margin-bottom: 0;
+ margin: 0;
+ letter-spacing: -0.5px;
+ color: #ffffff;
}
-.stats-grid {
+.screen .stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
- gap: 1rem;
- margin-top: 2rem;
+ gap: 0.8rem;
+ margin-top: 1.5rem;
+ width: 100%;
}
-.stat-card {
- background: rgba(255, 255, 255, 0.1);
- padding: 1rem;
- border-radius: 15px;
- text-align: center;
+.screen .stat-card {
+ background: rgba(0, 0, 0, 0.3) !important;
+ padding: 1.2rem 0.8rem !important;
+ border-radius: 16px !important;
+ text-align: center !important;
+ backdrop-filter: blur(15px) !important;
+ border: 1px solid rgba(255, 255, 255, 0.15) !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2) !important;
}
-.stat-number {
- font-size: 1.5rem;
- font-weight: 700;
+.screen .stat-number {
+ font-size: 1.8rem !important;
+ font-weight: 900 !important;
+ color: #ffffff !important;
+ margin-bottom: 0.4rem !important;
+ display: block !important;
+ text-shadow: none !important;
+ line-height: 1.1 !important;
+ letter-spacing: -0.5px !important;
}
-.stat-label {
- font-size: 0.75rem;
- opacity: 0.8;
+.screen .stat-label {
+ font-size: 0.7rem !important;
+ font-weight: 600 !important;
+ text-transform: uppercase !important;
+ letter-spacing: 0.8px !important;
+ color: #ffffff !important;
+ display: block !important;
+ opacity: 1 !important;
}
/* =======================
@@ -409,8 +427,8 @@ body {
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
- background-clip: text;
- color: transparent;
+ background-clip: text;
+ color: transparent;
}
.section-header p {
@@ -502,15 +520,13 @@ body {
/* =======================
Newsletter Pages (List & Detail)
======================= */
-
-/* Page header banner */
.page-header {
background: linear-gradient(
180deg,
rgba(30, 78, 156, 0.06),
rgba(0, 212, 255, 0.06)
);
- padding: 8rem 0 3rem; /* account for fixed navbar */
+ padding: 8rem 0 3rem;
border-bottom: 1px solid rgba(30, 78, 156, 0.08);
}
@@ -547,21 +563,18 @@ body {
font-size: 1.05rem;
}
-/* Main content container */
.main-content {
max-width: 1100px;
margin: 0 auto;
padding: 2rem 2rem 4rem;
}
-/* Grid of newsletter cards */
.newsletters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
-/* Individual card */
.newsletter-card {
background: #fff;
border: 1px solid rgba(30, 78, 156, 0.08);
@@ -609,7 +622,6 @@ body {
color: var(--secondary);
}
-/* Meta/date and excerpt */
.newsletter-date {
display: flex;
align-items: center;
@@ -625,7 +637,6 @@ body {
margin-bottom: 0.75rem;
}
-/* Read more button */
.read-more-btn {
display: inline-flex;
align-items: center;
@@ -638,10 +649,9 @@ body {
font-weight: 600;
}
-/* Detail page nav back link */
.back-navigation {
max-width: 1100px;
- margin: 6rem auto 0; /* space for fixed navbar */
+ margin: 6rem auto 0;
padding: 0 2rem;
}
@@ -655,7 +665,6 @@ body {
text-decoration: underline;
}
-/* Detail header, meta, tags */
.newsletter-header h1 {
margin-top: 0.5rem;
}
@@ -690,7 +699,6 @@ body {
border-radius: 999px;
}
-/* Detail content */
.newsletter-content {
margin-top: 1.25rem;
background: #fff;
@@ -724,7 +732,6 @@ body {
color: var(--text-dark);
}
-/* Actions */
.newsletter-actions {
display: flex;
flex-wrap: wrap;
@@ -764,6 +771,649 @@ body {
padding: 2rem 0;
}
+/* =======================
+ Contact & About Page
+ ======================= */
+.contact-about-page {
+ min-height: 100vh;
+ background: #fff;
+ padding-top: var(--navbar-height);
+}
+
+.hero-section {
+ background: var(--gradient);
+ padding: 6rem 2rem;
+ text-align: center;
+ color: #fff;
+}
+
+.hero-section h1 {
+ font-size: clamp(2rem, 4vw, 3rem);
+ font-weight: 700;
+ margin-bottom: 1rem;
+}
+
+.hero-section p {
+ font-size: 1.125rem;
+ opacity: 0.95;
+}
+
+.about-content {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 4rem 2rem;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 3rem;
+ align-items: start;
+}
+
+.about-text h2 {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--text-dark);
+ margin-bottom: 1rem;
+}
+
+.about-text p {
+ font-size: 1.05rem;
+ color: var(--text-light);
+ line-height: 1.8;
+ margin-bottom: 1.5rem;
+}
+
+.about-text ul {
+ list-style: none;
+ margin-bottom: 1.5rem;
+}
+
+.about-text li {
+ padding: 0.75rem 0 0.75rem 2rem;
+ position: relative;
+ color: var(--text-dark);
+}
+
+.about-text li::before {
+ content: '✓';
+ position: absolute;
+ left: 0;
+ color: var(--secondary);
+ font-weight: bold;
+ font-size: 1.2rem;
+}
+
+.contact-form {
+ background: #fff;
+ border: 1px solid rgba(30, 78, 156, 0.08);
+ border-radius: 16px;
+ padding: 2rem;
+ box-shadow: var(--shadow);
+}
+
+.contact-form h2 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-dark);
+ margin-bottom: 1.5rem;
+}
+
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-group label {
+ display: block;
+ font-weight: 600;
+ color: var(--text-dark);
+ margin-bottom: 0.5rem;
+}
+
+.form-group label .required {
+ color: #ef4444;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid rgba(30, 78, 156, 0.1);
+ border-radius: 8px;
+ font-family: inherit;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--secondary);
+ box-shadow: 0 0 0 3px rgba(51, 124, 242, 0.1);
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 120px;
+}
+
+.form-group small {
+ display: block;
+ color: var(--text-light);
+ margin-top: 0.3rem;
+ font-size: 0.9rem;
+}
+
+.form-group input[type='checkbox'],
+.form-group input[type='radio'] {
+ width: auto;
+ margin-right: 0.5rem;
+}
+
+.checkbox-group,
+.radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.checkbox-group label,
+.radio-group label {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0;
+ font-weight: 400;
+}
+
+.newsletter-opt-in {
+ margin: 2rem 0 2rem 0;
+ padding: 1.5rem;
+ background: linear-gradient(135deg, rgba(51, 124, 242, 0.08), rgba(0, 212, 255, 0.08));
+ border-radius: 12px;
+ border: 1px solid rgba(51, 124, 242, 0.15);
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ cursor: pointer;
+ margin: 0;
+}
+
+.checkbox-input {
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+ accent-color: var(--secondary);
+}
+
+.checkbox-text {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--text-dark);
+ font-weight: 500;
+ line-height: 1.4;
+}
+
+.checkbox-text i {
+ color: var(--secondary);
+ font-size: 0.9rem;
+}
+
+.form-submit {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ background: var(--gradient);
+ color: #fff;
+ padding: 1rem 2.5rem;
+ border: none;
+ border-radius: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-size: 1rem;
+}
+
+.form-submit:hover {
+ transform: translateY(-3px);
+ box-shadow: var(--shadow-hover);
+}
+
+.form-submit i {
+ font-size: 1.1rem;
+}
+
+.form-success {
+ background: #ecfdf5;
+ border: 1px solid #86efac;
+ color: #166534;
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 1.5rem;
+ display: none;
+}
+
+.form-success.show {
+ display: block;
+}
+
+.contact-info {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 2rem;
+ max-width: 1100px;
+ margin: 4rem auto;
+ padding: 0 2rem;
+}
+
+.info-card {
+ background: #fff;
+ border: 1px solid rgba(30, 78, 156, 0.08);
+ border-radius: 16px;
+ padding: 2rem;
+ text-align: center;
+ box-shadow: var(--shadow);
+ transition: all 0.3s ease;
+}
+
+.info-card:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--shadow-hover);
+}
+
+.info-card-icon {
+ width: 60px;
+ height: 60px;
+ background: var(--gradient);
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 1.5rem;
+ margin: 0 auto 1rem;
+}
+
+.info-card h3 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-dark);
+ margin-bottom: 0.5rem;
+}
+
+.info-card p {
+ color: var(--text-light);
+}
+
+.info-card a {
+ color: var(--secondary);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.info-card a:hover {
+ text-decoration: underline;
+}
+
+.team-section {
+ background: var(--bg-light);
+ padding: 4rem 2rem;
+}
+
+.team-container {
+ max-width: 1100px;
+ margin: 0 auto;
+}
+
+.team-header {
+ text-align: center;
+ margin-bottom: 3rem;
+}
+
+.team-header h2 {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--text-dark);
+ margin-bottom: 0.5rem;
+}
+
+.team-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 2rem;
+}
+
+.team-member {
+ background: #fff;
+ border-radius: 16px;
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ transition: all 0.3s ease;
+ text-align: center;
+}
+
+.team-member:hover {
+ transform: translateY(-8px);
+ box-shadow: var(--shadow-hover);
+}
+
+.team-member-image {
+ width: 100%;
+ height: 250px;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 4rem;
+ color: #fff;
+}
+
+.team-member-info {
+ padding: 1.5rem;
+}
+
+.team-member h3 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-dark);
+ margin-bottom: 0.25rem;
+}
+
+.team-member p {
+ color: var(--secondary);
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+}
+
+.team-member .bio {
+ color: var(--text-light);
+ font-size: 0.95rem;
+}
+
+.values-section {
+ padding: 4rem 2rem;
+ background: #fff;
+}
+
+.values-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 2rem;
+ max-width: 1100px;
+ margin: 3rem auto 0;
+}
+
+.value-card {
+ background: #fff;
+ border: 1px solid rgba(30, 78, 156, 0.08);
+ border-radius: 16px;
+ padding: 2rem;
+ text-align: center;
+ box-shadow: var(--shadow);
+ transition: all 0.3s ease;
+}
+
+.value-card:hover {
+ transform: translateY(-8px);
+ box-shadow: var(--shadow-hover);
+}
+
+.value-icon {
+ width: 60px;
+ height: 60px;
+ background: var(--gradient);
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 1.5rem;
+ margin: 0 auto 1rem;
+}
+
+.value-card h3 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-dark);
+ margin-bottom: 0.75rem;
+}
+
+.value-card p {
+ color: var(--text-light);
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+.stats-section {
+ background: linear-gradient(
+ 180deg,
+ rgba(30, 78, 156, 0.06),
+ rgba(0, 212, 255, 0.06)
+ );
+ padding: 4rem 2rem;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 2rem;
+ max-width: 1100px;
+ margin: 3rem auto 0;
+}
+
+.stat-box {
+ text-align: center;
+ padding: 2rem;
+ background: #fff;
+ border-radius: 16px;
+ box-shadow: var(--shadow);
+}
+
+.stat-number {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: #1e4e9c;
+ margin-bottom: 0.5rem;
+}
+
+.stat-label {
+ color: var(--text-light);
+ font-weight: 600;
+}
+
+.faq-section {
+ padding: 4rem 2rem;
+ background: #fff;
+}
+
+.faq-container {
+ max-width: 800px;
+ margin: 3rem auto 0;
+}
+
+.faq-item {
+ border: 1px solid rgba(30, 78, 156, 0.08);
+ border-radius: 12px;
+ margin-bottom: 1rem;
+ overflow: hidden;
+ background: #fff;
+ box-shadow: var(--shadow);
+ transition: all 0.3s ease;
+}
+
+.faq-item.open {
+ box-shadow: var(--shadow-hover);
+}
+
+.faq-question {
+ padding: 1.5rem;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: background 0.3s ease;
+}
+
+.faq-question:hover {
+ background: var(--bg-light);
+}
+
+.faq-question h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ color: var(--text-dark);
+}
+
+.faq-question i {
+ color: var(--secondary);
+ transition: transform 0.3s ease;
+ font-size: 1rem;
+}
+
+.faq-item.open .faq-question i {
+ transform: rotate(180deg);
+}
+
+.faq-answer {
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.3s ease;
+ background: var(--bg-light);
+}
+
+.faq-item.open .faq-answer {
+ max-height: 500px;
+}
+
+.faq-answer p {
+ padding: 0 1.5rem 1.5rem;
+ color: var(--text-dark);
+ line-height: 1.7;
+}
+
+.about-image {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* =======================
+ Article layout for newsletter detail
+ ======================= */
+.article-wrap {
+ max-width: 1200px;
+ margin: 6rem auto 3rem;
+ padding: 0 2rem;
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: 2rem;
+}
+
+.article-aside {
+ position: sticky;
+ top: 84px;
+ align-self: start;
+}
+
+.article-meta {
+ background: #fff;
+ border: 1px solid rgba(30, 78, 156, 0.08);
+ border-radius: 16px;
+ padding: 1rem;
+ box-shadow: var(--shadow);
+ margin-top: 0.75rem;
+}
+
+.article-title {
+ font-size: 1.1rem;
+ margin: 0 0 0.5rem 0;
+}
+
+.meta-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--text-light);
+ font-size: 0.95rem;
+ margin: 0.25rem 0;
+}
+
+.article-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.5rem;
+}
+
+.article-tags .tag {
+ font-size: 0.8rem;
+ padding: 0.25rem 0.5rem;
+ background: rgba(51, 124, 242, 0.08);
+ color: var(--secondary);
+ border: 1px solid rgba(51, 124, 242, 0.2);
+ border-radius: 999px;
+}
+
+.toc {
+ margin-top: 1rem;
+ background: #fff;
+ border: 1px solid rgba(30, 78, 156, 0.08);
+ border-radius: 16px;
+ padding: 0.75rem 0.75rem 0.75rem 1rem;
+ box-shadow: var(--shadow);
+}
+
+.toc-title {
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+ color: var(--text-dark);
+}
+
+#toc-list {
+ list-style: none;
+ padding-left: 0;
+}
+
+#toc-list li {
+ margin: 0.25rem 0;
+}
+
+#toc-list a {
+ text-decoration: none;
+ color: var(--text-dark);
+ font-size: 0.95rem;
+}
+
+#toc-list a:hover {
+ color: var(--secondary);
+}
+
+.toc-h3 {
+ margin-left: 0.75rem;
+ opacity: 0.9;
+}
+
+.article-main .article-hero {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.article-hero .newsletter-icon {
+ width: 44px;
+ height: 44px;
+ border-radius: 12px;
+ background: var(--gradient);
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
/* =======================
Responsive
======================= */
@@ -791,17 +1441,16 @@ body {
padding: 0 1rem;
}
- /* Show hamburger, convert links to dropdown */
.nav-toggle {
display: inline-flex;
}
.nav-links {
position: absolute;
- top: 64px; /* below navbar */
+ top: 64px;
left: 16px;
right: 16px;
- display: none; /* hidden until opened */
+ display: none;
flex-direction: column;
gap: 0;
list-style: none;
@@ -832,13 +1481,11 @@ body {
background: #f8fafc;
}
- /* Remove desktop underline animation on mobile */
.nav-links a::after {
display: none;
content: none;
}
- /* Animate hamburger to X when active */
.nav-toggle.active .bar:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
@@ -849,7 +1496,6 @@ body {
transform: translateY(-7px) rotate(-45deg);
}
- /* Hero and phone scaling + ensure hero clears navbar */
.hero {
padding-top: calc(var(--navbar-height) + 12px);
}
@@ -894,25 +1540,24 @@ body {
margin-bottom: 0;
}
- .stats-grid {
+ .screen .stats-grid {
gap: 0.6rem;
margin-top: 1.2rem;
}
- .stat-card {
- padding: 0.6rem;
+ .screen .stat-card {
+ padding: 0.8rem 0.6rem;
border-radius: 12px;
}
- .stat-number {
- font-size: 1.1rem;
+ .screen .stat-number {
+ font-size: 1.3rem;
}
- .stat-label {
+ .screen .stat-label {
font-size: 0.65rem;
}
- /* Newsletter pages spacing */
.page-header {
padding: 7rem 0 2rem;
}
@@ -920,6 +1565,80 @@ body {
.main-content {
padding: 1.25rem 1rem 3rem;
}
+
+ .about-content {
+ grid-template-columns: 1fr;
+ }
+
+ .hero-section {
+ padding: 4rem 2rem;
+ }
+
+ .contact-form {
+ padding: 1.5rem;
+ }
+
+ .team-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .values-section {
+ padding: 2rem 1rem;
+ }
+
+ .stats-section {
+ padding: 2rem 1rem;
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+ }
+
+ .stat-box {
+ padding: 1.5rem;
+ }
+
+ .stat-number {
+ font-size: 1.8rem;
+ }
+
+ .faq-section {
+ padding: 2rem 1rem;
+ }
+
+ .faq-question {
+ padding: 1rem;
+ }
+
+ .faq-question h3 {
+ font-size: 1rem;
+ }
+
+ .faq-answer p {
+ padding: 0 1rem 1rem;
+ font-size: 0.95rem;
+ }
+
+ .cta-section {
+ margin: 2rem 1rem !important;
+ padding: 2.5rem 1rem !important;
+ }
+
+ .cta-section h2 {
+ font-size: 1.5rem;
+ }
+
+ .cta-section p {
+ font-size: 0.95rem;
+ }
+
+ .article-wrap {
+ grid-template-columns: 1fr;
+ }
+ .article-aside {
+ position: static;
+ }
}
/* Small phones */
@@ -954,21 +1673,21 @@ body {
font-size: 1.25rem;
}
- .stats-grid {
+ .screen .stats-grid {
gap: 0.5rem;
margin-top: 1rem;
}
- .stat-card {
+ .screen .stat-card {
padding: 0.55rem;
border-radius: 10px;
}
- .stat-number {
+ .screen .stat-number {
font-size: 1rem;
}
- .stat-label {
+ .screen .stat-label {
font-size: 0.6rem;
}
}
@@ -996,21 +1715,21 @@ body {
font-size: 1.15rem;
}
- .stats-grid {
+ .screen .stats-grid {
gap: 0.45rem;
margin-top: 0.9rem;
}
- .stat-card {
+ .screen .stat-card {
padding: 0.5rem;
border-radius: 9px;
}
- .stat-number {
+ .screen .stat-number {
font-size: 0.95rem;
}
- .stat-label {
+ .screen .stat-label {
font-size: 0.58rem;
}
}
@@ -1026,8 +1745,6 @@ main,
/* =======================
Hard Guards for Navbar
======================= */
-
-/* Ensure navbar logo colors are always visible on white background */
.navbar .logo {
color: var(--text-dark) !important;
}
@@ -1035,126 +1752,10 @@ main,
color: var(--accent) !important;
}
-/* Prevent content typography from leaking into the navbar */
.navbar,
.navbar * {
text-transform: none;
letter-spacing: normal;
line-height: normal;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
-}
-
-/* =======================
- Article layout for newsletter detail
- ======================= */
-.article-wrap {
- max-width: 1200px;
- margin: 6rem auto 3rem;
- padding: 0 2rem;
- display: grid;
- grid-template-columns: 280px 1fr;
- gap: 2rem;
-}
-
-.article-aside {
- position: sticky;
- top: 84px; /* below fixed navbar */
- align-self: start;
-}
-
-.article-meta {
- background: #fff;
- border: 1px solid rgba(30, 78, 156, 0.08);
- border-radius: 16px;
- padding: 1rem;
- box-shadow: var(--shadow);
- margin-top: 0.75rem;
-}
-
-.article-title {
- font-size: 1.1rem;
- margin: 0 0 0.5rem 0;
-}
-
-.meta-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--text-light);
- font-size: 0.95rem;
- margin: 0.25rem 0;
-}
-
-.article-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
- margin-top: 0.5rem;
-}
-.article-tags .tag {
- font-size: 0.8rem;
- padding: 0.25rem 0.5rem;
- background: rgba(51, 124, 242, 0.08);
- color: var(--secondary);
- border: 1px solid rgba(51, 124, 242, 0.2);
- border-radius: 999px;
-}
-
-.toc {
- margin-top: 1rem;
- background: #fff;
- border: 1px solid rgba(30, 78, 156, 0.08);
- border-radius: 16px;
- padding: 0.75rem 0.75rem 0.75rem 1rem;
- box-shadow: var(--shadow);
-}
-.toc-title {
- font-weight: 700;
- margin-bottom: 0.5rem;
- color: var(--text-dark);
-}
-#toc-list {
- list-style: none;
- padding-left: 0;
-}
-#toc-list li {
- margin: 0.25rem 0;
-}
-#toc-list a {
- text-decoration: none;
- color: var(--text-dark);
- font-size: 0.95rem;
-}
-#toc-list a:hover {
- color: var(--secondary);
-}
-.toc-h3 {
- margin-left: 0.75rem;
- opacity: 0.9;
-}
-
-.article-main .article-hero {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- margin-bottom: 0.75rem;
-}
-.article-hero .newsletter-icon {
- width: 44px;
- height: 44px;
- border-radius: 12px;
- background: var(--gradient);
- color: #fff;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-
-@media (max-width: 992px) {
- .article-wrap {
- grid-template-columns: 1fr;
- }
- .article-aside {
- position: static;
- }
}
\ No newline at end of file
diff --git a/templates/about.html b/templates/about.html
new file mode 100644
index 0000000..49ce8ae
--- /dev/null
+++ b/templates/about.html
@@ -0,0 +1,330 @@
+{{define "about"}}
+
+
+
+
About RideAware
+
Smart cycling training for every level
+
+
+
+
+
Our Mission
+
+ RideAware is dedicated to making cycling training accessible,
+ effective, and enjoyable for cyclists of all levels. We provide
+ intelligent training plans, real-time analytics, and community support
+ to help you achieve your cycling goals.
+
+
+ Every ride counts. We believe smart training combined with technology
+ can unlock your full potential as a cyclist.
+
+
+ AI-powered adaptive training plans
+ Real-time performance analytics
+ Expert coaching and guidance
+ Community-driven motivation
+ Seamless device integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Passion
+
+ We're cyclists ourselves. We understand the dedication it takes
+ to improve and achieve your goals.
+
+
+
+
+
+
+
+
Intelligence
+
+ Our AI-driven platform learns from your performance to deliver
+ personalized training that actually works.
+
+
+
+
+
+
+
+
Community
+
+ Cycling is better together. Connect with other riders, share
+ achievements, and push each other forward.
+
+
+
+
+
+
+
+
Transparency
+
+ See all your data clearly. We believe in giving you the insights
+ you need to understand your progress.
+
+
+
+
+
+
+
+
Innovation
+
+ Technology should enhance your cycling, not complicate it.
+ We're constantly improving to serve you better.
+
+
+
+
+
+
+
+
Excellence
+
+ Whether you're training for a race or personal satisfaction,
+ we help you reach peak performance.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Blake Ridgway
+
Founder & CEO
+
+ Building the future of cycling training with scalable infrastructure
+ and performant systems. Passionate about Infrastructure-as-Code,
+ cloud networking, and creating observable platforms that ship faster
+ with confidence.
+
+
+
+
+
+
+
+
+
+
Cycling Experts
+
Training Advisors
+
+ Professional cyclists and coaches ensuring our training plans
+ are effective and science-based.
+
+
+
+
+
+
+
+
+
+
You
+
Community
+
+ Every rider using RideAware is part of our team. Your feedback
+ shapes our future.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
When is RideAware launching?
+
+
+
+
+ We're launching Q4 2026! Sign up for our newsletter to get
+ early access and exclusive launch day bonuses.
+
+
+
+
+
+
+
How much will it cost?
+
+
+
+
+ Pricing details coming soon. We're committed to making RideAware
+ accessible to cyclists at all price points.
+
+
+
+
+
+
+
What devices does RideAware support?
+
+
+
+
+ RideAware works on iOS, Android, web, and integrates with all
+ major fitness trackers and cycling computers (Garmin, Wahoo, etc.).
+
+
+
+
+
+
+
Is my data private?
+
+
+
+
+ Yes. Your training data is yours alone. We'll never sell or share
+ your personal information with third parties.
+
+
+
+
+
+
+
Can I import my current training data?
+
+
+
+
+ Yes! RideAware will integrate with Strava, TrainingPeaks, and other
+ platforms so you can bring all your history with you.
+
+
+
+
+
+
+
+
Ready to Elevate Your Cycling?
+
+ Join the waitlist and be first to know when we launch
+
+
+ Join the Waitlist
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file
diff --git a/templates/base.html b/templates/base.html
index 92c8523..e3e0f9f 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1,136 +1,145 @@
-
-
-
- {{block "title" .}}RideAware{{end}}
+
+
+
+ {{block "title" .}}RideAware{{end}}
-
-
-
-
+
+
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
- {{block "extra_head" .}}{{end}}
-
-
-
-
-
-
-
+ {{block "extra_head" .}}{{end}}
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
- {{block "content" .}}{{end}}
+
+
+
+
+
+
+
-
+ {{block "content" .}}
+
+ {{if .IsContact}}
+ {{template "contact" .}}
+ {{else if .IsAbout}}
+ {{template "about" .}}
+ {{else if .IsHome}}
+ {{template "index" .}}
+ {{else}}
+ {{template "newsletters" .}}
+ {{end}}
+
+ {{end}}
-
-
+
- {{block "extra_scripts" .}}
-
- function closeMenu() {
- btn.classList.remove("active");
- btn.setAttribute("aria-expanded", "false");
- menu.classList.remove("open");
+ {{block "extra_scripts" .}}
+
- {{end}}
-
+ });
+ })();
+
+ {{end}}
+
\ No newline at end of file
diff --git a/templates/contact.html b/templates/contact.html
new file mode 100644
index 0000000..d923b8b
--- /dev/null
+++ b/templates/contact.html
@@ -0,0 +1,171 @@
+{{define "contact"}}
+
+
+
+
Get in Touch
+
We'd love to hear from you. Send us a message!
+
+
+
+
+
Let's Connect
+
+ Have a question about RideAware? Want to collaborate?
+ Reach out and let us know how we can help.
+
+
+ Fast response times
+ Friendly support team
+ Multiple contact options
+ Always here to help
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
\ No newline at end of file