From ac1d18f3a3ea10c3a6a936baa5a29f41559d4e1a Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Wed, 19 Nov 2025 09:03:29 -0600 Subject: [PATCH] feat: added lots of work to landing page --- internal/config/config.go | 2 + internal/database/database.go | 30 ++ internal/email/sender.go | 118 +++-- internal/handlers/handlers.go | 174 ++++++ static/css/styles.css | 965 +++++++++++++++++++++++++++------- templates/about.html | 330 ++++++++++++ templates/base.html | 253 ++++----- templates/contact.html | 171 ++++++ 8 files changed, 1704 insertions(+), 339 deletions(-) create mode 100644 templates/about.html create mode 100644 templates/contact.html 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
  • +
+
+ +
+
+ +
+
+
+ +
+
+

Our Values

+

What drives our mission

+
+ +
+
+
+ +
+

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

+
+
+
+ +
+
+
+

Meet Our Team

+

Cyclists and engineers building the future of training

+
+ +
+
+
+ +
+
+

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

By The Numbers

+

Growth and impact

+
+ +
+
+
Coming
+
Q4 2026
+
+ +
+
+
Potential
+
+ +
+
100%
+
Passion
+
+ +
+
You
+
In Control
+
+
+
+ +
+
+

Frequently Asked Questions

+
+ +
+
+
+

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 "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
  • +
+
+ +
+
+ Thank you! We've received your message + and will get back to you soon. +
+ +

Send us a message

+ +
+ + +
+ +
+ + + We'll respond to this email address +
+ +
+ + +
+ +
+ + +
+ + + + +
+
+ +
+
+
+ +
+

Email

+

+ hello@rideaware.com +

+
+ +
+
+ +
+

Address

+

1909 W Owen K Garriott Rd
Enid, OK 73703

+
+
+
+ + + +{{end}} \ No newline at end of file