feat: added lots of work to landing page
This commit is contained in:
@@ -19,6 +19,7 @@ type Config struct {
|
|||||||
SMTPPort string
|
SMTPPort string
|
||||||
SMTPUser string
|
SMTPUser string
|
||||||
SMTPPass string
|
SMTPPass string
|
||||||
|
AdminEmail string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
func LoadConfig() (*Config, error) {
|
||||||
@@ -36,6 +37,7 @@ func LoadConfig() (*Config, error) {
|
|||||||
SMTPPort: getEnv("SMTP_PORT", ""),
|
SMTPPort: getEnv("SMTP_PORT", ""),
|
||||||
SMTPUser: getEnv("SMTP_USER", ""),
|
SMTPUser: getEnv("SMTP_USER", ""),
|
||||||
SMTPPass: getEnv("SMTP_PASSWORD", ""),
|
SMTPPass: getEnv("SMTP_PASSWORD", ""),
|
||||||
|
AdminEmail: os.Getenv("ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.SMTPHost == "" {
|
if cfg.SMTPHost == "" {
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ func (db *DB) InitDB(ctx context.Context) error {
|
|||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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 {
|
for _, query := range queries {
|
||||||
@@ -155,6 +163,28 @@ func (db *DB) GetNewsletter(
|
|||||||
return &n, nil
|
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) {
|
func (db *DB) Close(ctx context.Context) {
|
||||||
db.pool.Close()
|
db.pool.Close()
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,10 @@ package email
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"html"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"landing/internal/config"
|
"landing/internal/config"
|
||||||
)
|
)
|
||||||
@@ -23,13 +23,6 @@ func (s *Sender) SendConfirmationEmail(
|
|||||||
email string,
|
email string,
|
||||||
unsubscribeLink string,
|
unsubscribeLink string,
|
||||||
) error {
|
) 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!"
|
subject := "Thanks for subscribing!"
|
||||||
htmlBody := fmt.Sprintf(`
|
htmlBody := fmt.Sprintf(`
|
||||||
<html>
|
<html>
|
||||||
@@ -41,88 +34,143 @@ func (s *Sender) SendConfirmationEmail(
|
|||||||
</html>
|
</html>
|
||||||
`, unsubscribeLink)
|
`, unsubscribeLink)
|
||||||
|
|
||||||
|
return s.sendEmail(email, subject, htmlBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) SendContactConfirmation(email, name string) error {
|
||||||
|
subject := "We received your message - RideAware"
|
||||||
|
htmlBody := fmt.Sprintf(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Thank you for reaching out, %s!</h2>
|
||||||
|
<p>We've received your message and will get back to you as soon as possible.</p>
|
||||||
|
<p>In the meantime, feel free to check out more about RideAware on our website.</p>
|
||||||
|
<p>Best regards,<br>The RideAware Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, 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(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h3>New Contact Message</h3>
|
||||||
|
<p><strong>From:</strong> %s (%s)</p>
|
||||||
|
<p><strong>Subject:</strong> %s</p>
|
||||||
|
<h4>Message:</h4>
|
||||||
|
<p>%s</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
html.EscapeString(name),
|
||||||
|
html.EscapeString(email),
|
||||||
|
html.EscapeString(subject),
|
||||||
|
strings.ReplaceAll(html.EscapeString(message), "\n", "<br>"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
message := fmt.Sprintf(
|
||||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s",
|
||||||
s.cfg.SMTPUser,
|
s.cfg.SMTPUser,
|
||||||
email,
|
toEmail,
|
||||||
subject,
|
subject,
|
||||||
htmlBody,
|
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
|
// Create TLS config
|
||||||
tlsConfig := &tls.Config{
|
tlsConfig := &tls.Config{
|
||||||
ServerName: s.cfg.SMTPHost,
|
ServerName: s.cfg.SMTPHost,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send email using smtp.SendMail
|
// Try to dial with TLS
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
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)
|
|
||||||
if err != nil {
|
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()
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Create SMTP client
|
||||||
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
// Start TLS
|
|
||||||
if err := client.StartTLS(tlsConfig); err != nil {
|
|
||||||
return fmt.Errorf("failed to start TLS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
|
auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost)
|
||||||
if err := client.Auth(auth); err != nil {
|
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 {
|
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 {
|
// Set recipient
|
||||||
return fmt.Errorf("failed to set mail to: %w", err)
|
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()
|
wc, err := client.Data()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get data writer: %w", err)
|
return fmt.Errorf("failed to get data writer: %w", err)
|
||||||
}
|
}
|
||||||
defer wc.Close()
|
defer wc.Close()
|
||||||
|
|
||||||
|
// Write message
|
||||||
if _, err := wc.Write([]byte(message)); err != nil {
|
if _, err := wc.Write([]byte(message)); err != nil {
|
||||||
return fmt.Errorf("failed to write message: %w", err)
|
return fmt.Errorf("failed to write message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.Quit(); err != nil {
|
// Quit - ignore quit errors since email was already queued
|
||||||
return fmt.Errorf("failed to quit SMTP: %w", err)
|
_ = client.Quit()
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnection tests SMTP connection without sending email
|
|
||||||
func (s *Sender) TestConnection() error {
|
func (s *Sender) TestConnection() error {
|
||||||
port, err := strconv.Atoi(s.cfg.SMTPPort)
|
port, err := strconv.Atoi(s.cfg.SMTPPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
|
return fmt.Errorf("invalid SMTP port '%s': %w", s.cfg.SMTPPort, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test TCP connection
|
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.SMTPHost, port)
|
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 {
|
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()
|
defer conn.Close()
|
||||||
|
|
||||||
// Test SMTP connection
|
// Test SMTP client creation
|
||||||
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ func (h *Handler) Start(host, port string) error {
|
|||||||
mux.HandleFunc("/unsubscribe", h.unsubscribeHandler)
|
mux.HandleFunc("/unsubscribe", h.unsubscribeHandler)
|
||||||
mux.HandleFunc("/newsletters", h.newslettersHandler)
|
mux.HandleFunc("/newsletters", h.newslettersHandler)
|
||||||
mux.HandleFunc("/newsletter/", h.newsletterDetailHandler)
|
mux.HandleFunc("/newsletter/", h.newsletterDetailHandler)
|
||||||
|
mux.HandleFunc("/contact", h.contactHandler)
|
||||||
|
mux.HandleFunc("/about", h.aboutHandler)
|
||||||
|
|
||||||
// Wrap with logging middleware
|
// Wrap with logging middleware
|
||||||
handler := h.loggingMiddleware(mux)
|
handler := h.loggingMiddleware(mux)
|
||||||
@@ -331,6 +333,178 @@ func (h *Handler) newsletterDetailHandler(
|
|||||||
tmpl.ExecuteTemplate(w, "base.html", newsletter)
|
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 {
|
func getBaseURL(r *http.Request) string {
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
330
templates/about.html
Normal file
330
templates/about.html
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
{{define "about"}}
|
||||||
|
|
||||||
|
<div class="contact-about-page">
|
||||||
|
<div class="hero-section">
|
||||||
|
<h1>About RideAware</h1>
|
||||||
|
<p>Smart cycling training for every level</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-text">
|
||||||
|
<h2>Our Mission</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Every ride counts. We believe smart training combined with technology
|
||||||
|
can unlock your full potential as a cyclist.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>AI-powered adaptive training plans</li>
|
||||||
|
<li>Real-time performance analytics</li>
|
||||||
|
<li>Expert coaching and guidance</li>
|
||||||
|
<li>Community-driven motivation</li>
|
||||||
|
<li>Seamless device integration</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-image">
|
||||||
|
<div style="
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
background: var(--gradient);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 4rem;
|
||||||
|
">
|
||||||
|
<i class="fas fa-bicycle"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="values-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Our Values</h2>
|
||||||
|
<p>What drives our mission</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="values-grid">
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Passion</h3>
|
||||||
|
<p>
|
||||||
|
We're cyclists ourselves. We understand the dedication it takes
|
||||||
|
to improve and achieve your goals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-brain"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Intelligence</h3>
|
||||||
|
<p>
|
||||||
|
Our AI-driven platform learns from your performance to deliver
|
||||||
|
personalized training that actually works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Community</h3>
|
||||||
|
<p>
|
||||||
|
Cycling is better together. Connect with other riders, share
|
||||||
|
achievements, and push each other forward.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Transparency</h3>
|
||||||
|
<p>
|
||||||
|
See all your data clearly. We believe in giving you the insights
|
||||||
|
you need to understand your progress.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-lightbulb"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Innovation</h3>
|
||||||
|
<p>
|
||||||
|
Technology should enhance your cycling, not complicate it.
|
||||||
|
We're constantly improving to serve you better.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="value-card">
|
||||||
|
<div class="value-icon">
|
||||||
|
<i class="fas fa-medal"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Excellence</h3>
|
||||||
|
<p>
|
||||||
|
Whether you're training for a race or personal satisfaction,
|
||||||
|
we help you reach peak performance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-section">
|
||||||
|
<div class="team-container">
|
||||||
|
<div class="team-header">
|
||||||
|
<h2>Meet Our Team</h2>
|
||||||
|
<p>Cyclists and engineers building the future of training</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-grid">
|
||||||
|
<div class="team-member">
|
||||||
|
<div class="team-member-image">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="team-member-info">
|
||||||
|
<h3>Blake Ridgway</h3>
|
||||||
|
<p>Founder & CEO</p>
|
||||||
|
<div class="bio">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-member">
|
||||||
|
<div class="team-member-image">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="team-member-info">
|
||||||
|
<h3>Cycling Experts</h3>
|
||||||
|
<p>Training Advisors</p>
|
||||||
|
<div class="bio">
|
||||||
|
Professional cyclists and coaches ensuring our training plans
|
||||||
|
are effective and science-based.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-member">
|
||||||
|
<div class="team-member-image">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="team-member-info">
|
||||||
|
<h3>You</h3>
|
||||||
|
<p>Community</p>
|
||||||
|
<div class="bio">
|
||||||
|
Every rider using RideAware is part of our team. Your feedback
|
||||||
|
shapes our future.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>By The Numbers</h2>
|
||||||
|
<p>Growth and impact</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-number">Coming</div>
|
||||||
|
<div class="stat-label">Q4 2026</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-number">∞</div>
|
||||||
|
<div class="stat-label">Potential</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-number">100%</div>
|
||||||
|
<div class="stat-label">Passion</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-number">You</div>
|
||||||
|
<div class="stat-label">In Control</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Frequently Asked Questions</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-container">
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-question">
|
||||||
|
<h3>When is RideAware launching?</h3>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>
|
||||||
|
We're launching Q4 2026! Sign up for our newsletter to get
|
||||||
|
early access and exclusive launch day bonuses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-question">
|
||||||
|
<h3>How much will it cost?</h3>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>
|
||||||
|
Pricing details coming soon. We're committed to making RideAware
|
||||||
|
accessible to cyclists at all price points.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-question">
|
||||||
|
<h3>What devices does RideAware support?</h3>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>
|
||||||
|
RideAware works on iOS, Android, web, and integrates with all
|
||||||
|
major fitness trackers and cycling computers (Garmin, Wahoo, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-question">
|
||||||
|
<h3>Is my data private?</h3>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>
|
||||||
|
Yes. Your training data is yours alone. We'll never sell or share
|
||||||
|
your personal information with third parties.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-item">
|
||||||
|
<div class="faq-question">
|
||||||
|
<h3>Can I import my current training data?</h3>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="faq-answer">
|
||||||
|
<p>
|
||||||
|
Yes! RideAware will integrate with Strava, TrainingPeaks, and other
|
||||||
|
platforms so you can bring all your history with you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-section" style="
|
||||||
|
background: var(--gradient);
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 4rem 2rem;
|
||||||
|
">
|
||||||
|
<h2>Ready to Elevate Your Cycling?</h2>
|
||||||
|
<p style="opacity: 0.9; margin: 1rem 0 2rem;">
|
||||||
|
Join the waitlist and be first to know when we launch
|
||||||
|
</p>
|
||||||
|
<a href="/" class="action-btn primary" style="
|
||||||
|
background: white;
|
||||||
|
color: var(--primary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
">
|
||||||
|
Join the Waitlist
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// FAQ accordion toggle
|
||||||
|
document.querySelectorAll('.faq-question').forEach((question) => {
|
||||||
|
question.addEventListener('click', () => {
|
||||||
|
const item = question.parentElement;
|
||||||
|
const answer = item.querySelector('.faq-answer');
|
||||||
|
const isOpen = item.classList.contains('open');
|
||||||
|
|
||||||
|
// Close all other items
|
||||||
|
document.querySelectorAll('.faq-item').forEach((faq) => {
|
||||||
|
faq.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle current item
|
||||||
|
if (!isOpen) {
|
||||||
|
item.classList.add('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
@@ -1,136 +1,145 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<title>{{block "title" .}}RideAware{{end}}</title>
|
<title>{{block "title" .}}RideAware{{end}}</title>
|
||||||
|
|
||||||
<!-- Icons/Fonts -->
|
<!-- Icons/Fonts -->
|
||||||
<link
|
<link
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Core CSS -->
|
<!-- Core CSS -->
|
||||||
<link
|
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||||
rel="stylesheet"
|
|
||||||
href="/static/css/styles.css"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
type="image/png"
|
type="image/png"
|
||||||
sizes="32x32"
|
sizes="32x32"
|
||||||
href="/static/assets/32x32.png"
|
href="/static/assets/32x32.png"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="alternate icon"
|
rel="alternate icon"
|
||||||
type="image/png"
|
type="image/png"
|
||||||
sizes="32x32"
|
sizes="32x32"
|
||||||
href="/static/assets/32x32.png"
|
href="/static/assets/32x32.png"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-icon"
|
rel="apple-touch-icon"
|
||||||
sizes="180x180"
|
sizes="180x180"
|
||||||
href="/static/assets/apple-touch-icon.png"
|
href="/static/assets/apple-touch-icon.png"
|
||||||
/>
|
/>
|
||||||
<link
|
<link rel="manifest" href="/static/assets/site.webmanifest" />
|
||||||
rel="manifest"
|
<meta name="theme-color" content="#0f172a" />
|
||||||
href="/static/assets/site.webmanifest"
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#0f172a" />
|
|
||||||
|
|
||||||
{{block "extra_head" .}}{{end}}
|
{{block "extra_head" .}}{{end}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<a
|
<a href="/" class="logo" aria-label="RideAware home">
|
||||||
href="/"
|
<img
|
||||||
class="logo"
|
src="/static/assets/logo.png"
|
||||||
aria-label="RideAware home"
|
alt="RideAware"
|
||||||
>
|
class="logo-img"
|
||||||
<img
|
width="140"
|
||||||
src="/static/assets/logo.png"
|
height="28"
|
||||||
alt="RideAware"
|
decoding="async"
|
||||||
class="logo-img"
|
fetchpriority="high"
|
||||||
width="140"
|
/>
|
||||||
height="28"
|
</a>
|
||||||
decoding="async"
|
|
||||||
fetchpriority="high"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
<ul class="nav-links" id="primary-nav">
|
||||||
class="nav-toggle"
|
<li><a href="/#features">Features</a></li>
|
||||||
id="nav-toggle"
|
<li><a href="/newsletters">Newsletters</a></li>
|
||||||
aria-label="Toggle navigation"
|
<li><a href="/about">About</a></li>
|
||||||
aria-controls="primary-nav"
|
<li><a href="/contact">Contact</a></li>
|
||||||
aria-expanded="false"
|
</ul>
|
||||||
>
|
|
||||||
<span class="bar"></span>
|
|
||||||
<span class="bar"></span>
|
|
||||||
<span class="bar"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{{block "content" .}}{{end}}
|
<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>
|
||||||
|
|
||||||
<footer class="footer">
|
{{block "content" .}}
|
||||||
<p>© 2025 RideAware. All rights reserved.</p>
|
|
||||||
</footer>
|
{{if .IsContact}}
|
||||||
|
{{template "contact" .}}
|
||||||
|
{{else if .IsAbout}}
|
||||||
|
{{template "about" .}}
|
||||||
|
{{else if .IsHome}}
|
||||||
|
{{template "index" .}}
|
||||||
|
{{else}}
|
||||||
|
{{template "newsletters" .}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Core JS -->
|
<footer class="footer">
|
||||||
<script
|
<p>© 2025 RideAware. All rights reserved.</p>
|
||||||
defer
|
</footer>
|
||||||
src="https://cdn.statically.io/gl/rideaware/landing/06d19988c7df53636277f945f9ed853bda76471b/static/js/main.min.js"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
></script>
|
|
||||||
|
|
||||||
{{block "extra_scripts" .}}
|
<!-- Core JS -->
|
||||||
<script>
|
<script
|
||||||
(function () {
|
defer
|
||||||
const btn = document.getElementById("nav-toggle");
|
src="https://cdn.statically.io/gl/rideaware/landing/06d19988c7df53636277f945f9ed853bda76471b/static/js/main.min.js"
|
||||||
const menu = document.getElementById("primary-nav");
|
crossorigin="anonymous"
|
||||||
if (!btn || !menu) return;
|
></script>
|
||||||
|
|
||||||
function closeMenu() {
|
{{block "extra_scripts" .}}
|
||||||
btn.classList.remove("active");
|
<script>
|
||||||
btn.setAttribute("aria-expanded", "false");
|
(function () {
|
||||||
menu.classList.remove("open");
|
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();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
btn.addEventListener("click", () => {
|
})();
|
||||||
const open = btn.classList.toggle("active");
|
</script>
|
||||||
btn.setAttribute("aria-expanded", String(open));
|
{{end}}
|
||||||
menu.classList.toggle("open", open);
|
</body>
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
</html>
|
||||||
171
templates/contact.html
Normal file
171
templates/contact.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
{{define "contact"}}
|
||||||
|
|
||||||
|
<div class="contact-about-page">
|
||||||
|
<div class="hero-section">
|
||||||
|
<h1>Get in Touch</h1>
|
||||||
|
<p>We'd love to hear from you. Send us a message!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="about-text">
|
||||||
|
<h2>Let's Connect</h2>
|
||||||
|
<p>
|
||||||
|
Have a question about RideAware? Want to collaborate?
|
||||||
|
Reach out and let us know how we can help.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Fast response times</li>
|
||||||
|
<li>Friendly support team</li>
|
||||||
|
<li>Multiple contact options</li>
|
||||||
|
<li>Always here to help</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="contact-form" id="contactForm">
|
||||||
|
<div class="form-success" id="successMessage">
|
||||||
|
<strong>Thank you!</strong> We've received your message
|
||||||
|
and will get back to you soon.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Send us a message</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">
|
||||||
|
Full Name <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">
|
||||||
|
Email Address <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
<small>We'll respond to this email address</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subject">Subject <span class="required">*</span></label>
|
||||||
|
<select id="subject" name="subject" required>
|
||||||
|
<option value="">-- Select a subject --</option>
|
||||||
|
<option value="general">General Inquiry</option>
|
||||||
|
<option value="support">Support</option>
|
||||||
|
<option value="partnership">Partnership</option>
|
||||||
|
<option value="feedback">Feedback</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="message">
|
||||||
|
Message <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
placeholder="Your message here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="newsletter-opt-in">
|
||||||
|
<label for="subscribe" class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="subscribe"
|
||||||
|
name="subscribe"
|
||||||
|
class="checkbox-input"
|
||||||
|
/>
|
||||||
|
<span class="checkbox-text">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
Subscribe to our newsletter for training tips and updates
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="form-submit">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-icon">
|
||||||
|
<i class="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Email</h3>
|
||||||
|
<p>
|
||||||
|
<a href="mailto:hello@rideaware.com"
|
||||||
|
>hello@rideaware.com</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-icon">
|
||||||
|
<i class="fas fa-map-marker-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Address</h3>
|
||||||
|
<p>1909 W Owen K Garriott Rd<br />Enid, OK 73703</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('contactForm').addEventListener(
|
||||||
|
'submit',
|
||||||
|
async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
console.log('Form submitted');
|
||||||
|
console.log('Form data:', Object.fromEntries(formData));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Response data:', data);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
document.getElementById('successMessage')
|
||||||
|
.classList.add('show');
|
||||||
|
form.reset();
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('successMessage')
|
||||||
|
.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Failed to send message'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to send message: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user