diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b8..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
Thank you for subscribing to our newsletter.
+ + + + `, unsubscribeLink) + + msg.SetBodyString(mail.TypeTextHTML, htmlBody) + + if err := client.DialAndSend(msg); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..0eaf810 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,310 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "text/template" + "time" + + "landing/internal/config" + "landing/internal/database" + "landing/internal/email" +) + +type Handler struct { + db *database.DB + cfg *config.Config + email *email.Sender + templatesPath string + logger *log.Logger +} + +func New(db *database.DB, cfg *config.Config) *Handler { + templatesPath := "templates" + if _, err := os.Stat(templatesPath); os.IsNotExist(err) { + templatesPath = "./templates" + } + + return &Handler{ + db: db, + cfg: cfg, + email: email.New(cfg), + templatesPath: templatesPath, + logger: log.New(os.Stdout, "", log.LstdFlags), + } +} + +// loggingMiddleware logs HTTP requests +func (h *Handler) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Create a custom response writer to capture status code + wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + // Call the next handler + next.ServeHTTP(wrapped, r) + + // Log the request + duration := time.Since(start) + statusColor := getStatusColor(wrapped.statusCode) + methodColor := getMethodColor(r.Method) + + h.logger.Printf( + "%s %s %s %s %s %d %s", + methodColor+r.Method+"\033[0m", + r.RequestURI, + statusColor+fmt.Sprintf("%d", wrapped.statusCode)+"\033[0m", + duration.String(), + r.RemoteAddr, + wrapped.contentLength, + r.UserAgent(), + ) + }) +} + +type responseWriter struct { + http.ResponseWriter + statusCode int + contentLength int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + rw.contentLength = len(b) + return rw.ResponseWriter.Write(b) +} + +// Color codes for terminal output +func getStatusColor(statusCode int) string { + switch { + case statusCode >= 200 && statusCode < 300: + return "\033[32m" // Green + case statusCode >= 300 && statusCode < 400: + return "\033[36m" // Cyan + case statusCode >= 400 && statusCode < 500: + return "\033[33m" // Yellow + case statusCode >= 500: + return "\033[31m" // Red + default: + return "\033[37m" // White + } +} + +func getMethodColor(method string) string { + switch method { + case "GET": + return "\033[34m" // Blue + case "POST": + return "\033[32m" // Green + case "PUT": + return "\033[33m" // Yellow + case "DELETE": + return "\033[31m" // Red + default: + return "\033[37m" // White + } +} + +func (h *Handler) Start(host, port string) error { + mux := http.NewServeMux() + + // Serve static files + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + + mux.HandleFunc("/", h.indexHandler) + mux.HandleFunc("/subscribe", h.subscribeHandler) + mux.HandleFunc("/unsubscribe", h.unsubscribeHandler) + mux.HandleFunc("/newsletters", h.newslettersHandler) + mux.HandleFunc("/newsletter/", h.newsletterDetailHandler) + + // Wrap with logging middleware + handler := h.loggingMiddleware(mux) + + h.logger.Printf("\033[36m▶ Starting server on http://%s:%s\033[0m", host, port) + + return http.ListenAndServe(host+":"+port, handler) +} + +func (h *Handler) getTemplatePath(name string) string { + return filepath.Join(h.templatesPath, name) +} + +func (h *Handler) indexHandler( + w http.ResponseWriter, + r *http.Request, +) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + tmpl, err := template.ParseFiles( + h.getTemplatePath("base.html"), + h.getTemplatePath("index.html"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError) + return + } + + data := map[string]interface{}{ + "IsHome": true, + } + + tmpl.ExecuteTemplate(w, "base.html", data) +} + +func (h *Handler) subscribeHandler( + w http.ResponseWriter, + r *http.Request, +) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Email string `json:"email"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.Email == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Email is required", + }) + return + } + + if err := h.db.AddSubscriber(r.Context(), req.Email); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Email already exists", + }) + return + } + + unsubscribeLink := fmt.Sprintf( + "%s/unsubscribe?email=%s", + getBaseURL(r), + req.Email, + ) + + if err := h.email.SendConfirmationEmail( + req.Email, + unsubscribeLink, + ); err != nil { + h.logger.Printf("❌ Failed to send confirmation email to %s: %v", req.Email, err) + } else { + h.logger.Printf("✓ Confirmation email sent to %s", req.Email) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Email has been added", + }) +} + +func (h *Handler) unsubscribeHandler( + w http.ResponseWriter, + r *http.Request, +) { + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "No email specified", http.StatusBadRequest) + return + } + + if err := h.db.RemoveSubscriber(r.Context(), email); err != nil { + http.Error( + w, + fmt.Sprintf( + "Email %s was not found or already unsubscribed", + email, + ), + http.StatusBadRequest, + ) + return + } + + h.logger.Printf("✓ Unsubscribed %s", email) + + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, "The email %s has been unsubscribed.", email) +} + +func (h *Handler) newslettersHandler( + w http.ResponseWriter, + r *http.Request, +) { + newsletters, err := h.db.GetNewsletters(r.Context()) + if err != nil { + http.Error(w, "Failed to fetch newsletters", 500) + return + } + + tmpl, err := template.ParseFiles( + h.getTemplatePath("base.html"), + h.getTemplatePath("newsletters.html"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError) + return + } + + tmpl.ExecuteTemplate(w, "base.html", newsletters) +} + +func (h *Handler) newsletterDetailHandler( + w http.ResponseWriter, + r *http.Request, +) { + idStr := r.URL.Path[len("/newsletter/"):] + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid newsletter ID", http.StatusBadRequest) + return + } + + newsletter, err := h.db.GetNewsletter(r.Context(), id) + if err != nil { + http.Error(w, "Newsletter not found", http.StatusNotFound) + return + } + + tmpl, err := template.ParseFiles( + h.getTemplatePath("base.html"), + h.getTemplatePath("newsletter_detail.html"), + ) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to parse template: %v", err), http.StatusInternalServerError) + return + } + + tmpl.ExecuteTemplate(w, "base.html", newsletter) +} + +func getBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) +} \ No newline at end of file diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..8400898 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type Subscriber struct { + ID int + Email string +} + +type Newsletter struct { + ID int + Subject string + Body string + SentAt time.Time +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 68767e9..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -gunicorn -flask -python-dotenv -psycopg2-binary diff --git a/static/assets/32x32.png b/static/assets/32x32.png new file mode 100644 index 0000000..6ecd0e4 Binary files /dev/null and b/static/assets/32x32.png differ diff --git a/static/assets/apple-touch-icon.png b/static/assets/apple-touch-icon.png new file mode 100644 index 0000000..6ecd0e4 Binary files /dev/null and b/static/assets/apple-touch-icon.png differ diff --git a/static/assets/logo.png b/static/assets/logo.png new file mode 100644 index 0000000..df78770 Binary files /dev/null and b/static/assets/logo.png differ diff --git a/static/css/styles.css b/static/css/styles.css index 591eeaf..860492e 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,358 +1,1160 @@ -/* Reset and General Styles */ -html { +/* ======================= + Global Reset & Variables + ======================= */ +* { + margin: 0; + padding: 0; box-sizing: border-box; } -*, -*:before, -*:after { - box-sizing: inherit; +:root { + --primary: #1e4e9c; + --secondary: #337cf2; + --accent: #00d4ff; + --text-dark: #1a1a1a; + --text-light: #6b7280; + --bg-light: #f8fafc; + --white: #ffffff; + --gradient: linear-gradient( + 135deg, + var(--primary) 0%, + var(--secondary) 50%, + var(--accent) 100% + ); + --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 */ +} + +html { + scroll-behavior: smooth; } body { - font-family: Arial, sans-serif; - margin: 0; - padding: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + line-height: 1.7; + color: var(--text-dark); + overflow-x: hidden; + background: #fff; } -img { - max-width: 100%; - height: auto; +/* ======================= + Navigation + ======================= */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + z-index: 1000; + padding: 1rem 0; + 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 */ + line-height: 1.2; + letter-spacing: 0; } -/* Header and Nav */ -header { - background-color: #fff; - padding: 10px 20px; - text-align: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin: 5px auto; -} - -header nav { +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; display: flex; - justify-content: flex-start; /* Align items to the start */ + justify-content: space-between; align-items: center; + position: relative; /* for mobile dropdown positioning */ } -header nav a { +.logo { + font-size: 1.5rem; /* consistent logo size */ + font-weight: 700; + color: var(--text-dark); text-decoration: none; - color: #000; - margin-left: 20px; - font-size: 22px; + transition: transform 0.3s ease; + letter-spacing: 0.2px; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transform-origin: left center; } -/* Hero Section Styles */ -.hero-section-1 { +.logo:hover { + transform: scale(1.05); +} + +.logo-accent { + color: var(--accent); +} + +.logo-img { + display: block; + height: 28px; + width: auto; +} + +.nav-links { + display: flex; + gap: 2rem; + list-style: none; +} + +.nav-links a { + text-decoration: none; + color: var(--text-dark); + font-weight: 500; + transition: color 0.3s ease; + position: relative; +} + +.nav-links a::after { + content: ''; + position: absolute; + bottom: -5px; + left: 0; + width: 0; + height: 2px; + background: var(--gradient); + transition: width 0.3s ease; +} + +.nav-links a:hover::after, +.nav-links a.active::after { width: 100%; - margin: 0; - padding: 0; - overflow: hidden; } -.hero-content, -.hero-text { +/* Hamburger toggle (hidden on desktop) */ +.nav-toggle { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; + width: 42px; + height: 42px; + border: 1px solid rgba(2, 6, 23, 0.08); + border-radius: 10px; + background: #ffffff; + cursor: pointer; + transition: box-shadow 0.2s ease, transform 0.2s ease; +} + +.nav-toggle:hover { + box-shadow: var(--shadow); + transform: translateY(-1px); +} + +.nav-toggle .bar { + width: 20px; + height: 2px; + background: var(--text-dark); + border-radius: 2px; + transition: transform 0.25s ease, opacity 0.25s ease; +} + +/* ======================= + Hero Section (Landing) + ======================= */ +.hero { + min-height: 100vh; + background: var(--gradient); + display: flex; + align-items: center; + position: relative; + overflow: hidden; + + /* Ensure content clears the fixed navbar on all screens */ + padding-top: calc(var(--navbar-height) + 16px); +} + +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: url('data:image/svg+xml,'); + opacity: 0.3; +} + +.hero-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + align-items: center; + position: relative; + z-index: 1; +} + +.hero-content h1 { + font-size: clamp(2.5rem, 5vw, 4rem); + font-weight: 800; + color: #fff; + margin-bottom: 1.5rem; + line-height: 1.2; + text-shadow: 0 2px 20px rgba(0, 0, 0, 0.1); +} + +.hero-content .subtitle { + font-size: 1.25rem; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 2rem; + font-weight: 300; +} + +.cta-section { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px); + padding: 2rem; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.cta-section h3 { + color: #fff; + font-size: 1.5rem; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.cta-section p { + color: rgba(255, 255, 255, 0.8); + margin-bottom: 1.5rem; +} + +.email-form { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.email-input { + flex: 1; + padding: 1rem 1.5rem; + border: none; + border-radius: 50px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + font-size: 1rem; + outline: none; + transition: all 0.3s ease; +} + +.email-input:focus { + background: #fff; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.3); +} + +.notify-btn { + padding: 1rem 2rem; + background: var(--white); + color: var(--primary); + border: none; + border-radius: 50px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.notify-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +/* Countdown (if used) */ +.countdown { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-top: 1rem; +} + +.countdown-item { + text-align: center; + color: #fff; +} + +.countdown-number { + font-size: 2rem; + font-weight: 700; + display: block; +} + +.countdown-label { + font-size: 0.875rem; + opacity: 0.8; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Phone mockup */ +.hero-visual { + position: relative; + height: 500px; + display: flex; + align-items: center; + justify-content: center; +} + +.phone-mockup { + width: 300px; + height: 600px; + background: linear-gradient(145deg, #2a2a2a, #1a1a1a); + border-radius: 40px; + padding: 20px; + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.3); + position: relative; + transform: rotate(-5deg); + animation: float 6s ease-in-out infinite; +} + +@keyframes float { + 0%, + 100% { + transform: rotate(-5deg) translateY(0); + } + 50% { + transform: rotate(-5deg) translateY(-20px); + } +} + +.screen { width: 100%; height: 100%; - margin: 0; - padding: 0; -} - -.hero-text img { - width: 70%; - display: block; - margin: 0 auto; -} - -.hero-text h1 { - font-size: 48px; - font-weight: bold; - color: #000; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.hero-text p { - font-size: 18px; - color: #000; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.hero-section-2 { - background-color: #337cf2; - padding: 100px 0; - text-align: center; - width: 100%; - margin: 0; -} - -.hero-section-2 .inner-container { - padding: 0 20px; -} - -.hero-sec2-header { - padding-top: 2.5%; - font-size: 40px; -} - -.hero-section-2 form { - padding: 10px; - font-size: 1rem; - width: calc(100% - 50px); - max-width: 600px; - margin: 0 auto; -} - -.hero-section-2 input { - padding: 10px; - width: 15%; - border: 1px solid #ccc; - border-radius: 10px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); -} - -.hero-section-2 button { - padding: 10px 20px; - border: none; - border-radius: 5px; - cursor: pointer; -} - -/* Feature Cards */ -.hero-section-3 { - width: 100%; -} - -.hero-section-3 h2 { - margin-top: 0; - text-align: center; -} - -.feature-cards { + background: var(--gradient); + border-radius: 25px; display: flex; - flex-wrap: wrap; + align-items: center; justify-content: center; + overflow: hidden; + position: relative; +} + +.app-interface { + color: #fff; + text-align: center; +} + +.app-brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.app-brand-icon { + width: 32px; + height: 32px; + display: block; + border-radius: 6px; /* optional */ +} + +.app-logo { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0; +} + +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 2rem; +} + +.stat-card { + background: rgba(255, 255, 255, 0.1); + padding: 1rem; + border-radius: 15px; + text-align: center; +} + +.stat-number { + font-size: 1.5rem; + font-weight: 700; +} + +.stat-label { + font-size: 0.75rem; + opacity: 0.8; +} + +/* ======================= + Features Section + ======================= */ +.features { + padding: 6rem 0; + background: var(--bg-light); + position: relative; +} + +.section-header { + text-align: center; + max-width: 800px; + margin: 0 auto 4rem; + padding: 0 2rem; +} + +.section-header h2 { + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 700; + margin-bottom: 1rem; + background: var(--gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +.section-header p { + font-size: 1.125rem; + color: var(--text-light); +} + +.features-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 2rem; } .feature-card { - background-color: #fff; - padding: 20px; - border: 1px solid #ddd; - border-radius: 10px; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); - margin: 20px; - width: 30%; + background: #fff; + padding: 2.5rem; + border-radius: 20px; + box-shadow: var(--shadow); + transition: all 0.3s ease; + border: 1px solid rgba(30, 78, 156, 0.05); + position: relative; + overflow: hidden; +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: var(--gradient); +} + +.feature-card:hover { + transform: translateY(-10px); + box-shadow: var(--shadow-hover); +} + +.feature-icon { + width: 60px; + height: 60px; + background: var(--gradient); + border-radius: 15px; display: flex; - flex-direction: column; align-items: center; justify-content: center; - word-wrap: break-word; - overflow-wrap: break-word; + margin-bottom: 1.5rem; + color: #fff; + font-size: 1.5rem; } .feature-card h3 { - margin-top: 0; - text-align: center; - font-size: 18px; - font-weight: bold; - margin-bottom: 10px; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-dark); } -.feature-card ul { +.feature-list { list-style: none; - padding: 0; - margin: 0; - text-align: center; } -.feature-card li { - margin-bottom: 10px; - font-size: 16px; - color: #666; +.feature-list li { + margin-bottom: 0.75rem; + position: relative; + padding-left: 1.5rem; + color: var(--text-light); } -.feature-card li::before { - content: "\2022"; - font-size: 16px; - color: #337cf2; - margin-right: 10px; -} - -/* Main Content */ -main { - width: 100%; - margin: 0; - padding: 0; - display: block; -} - -main .inner-container { - padding: 10px 20px; -} - -main h1 { - text-align: center; - margin-bottom: 20px; - color: #007bff; -} - -/* Footer */ -.normal-footer { - background-color: #337cf2; - color: #fff; - padding: 10px; - text-align: center; - clear: both; - margin-top: auto; -} - -.fixed-footer { - position: fixed; - bottom: 0; +.feature-list li::before { + content: '✓'; + position: absolute; left: 0; - width: 100%; - background-color: #337cf2; - color: #fff; - padding: 10px; + color: var(--secondary); + font-weight: bold; +} + +.feature-list li strong { + color: var(--text-dark); +} + +/* ======================= + 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 */ + border-bottom: 1px solid rgba(30, 78, 156, 0.08); +} + +.page-header-content { + max-width: 1000px; + margin: 0 auto; + padding: 0 2rem; text-align: center; - z-index: 1000; } -/* Newsletter Styles */ -.newsletter { - margin-bottom: 40px; - border-bottom: 1px solid #ddd; - padding-bottom: 20px; +.page-header .header-icon { + width: 64px; + height: 64px; + margin: 0 auto 1rem; + border-radius: 16px; + background: var(--gradient); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow); + font-size: 1.5rem; } -.newsletter h2 { - color: #007bff; +.page-header h1 { + font-size: clamp(2rem, 4vw, 2.5rem); + font-weight: 700; + color: var(--text-dark); + margin-bottom: 0.5rem; } -.newsletter-time { - color: #666; - font-size: 0.9em; +.page-header p { + color: var(--text-light); + font-size: 1.05rem; } -.newsletter-detail { - max-width: 800px; - margin: 20px auto; - padding: 20px; - border: 1px solid #ddd; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +/* Main content container */ +.main-content { + max-width: 1100px; + margin: 0 auto; + padding: 2rem 2rem 4rem; } -.newsletter-detail h1 { - color: #007bff; +/* 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); + border-radius: 16px; + padding: 1.25rem 1.25rem 1rem; + box-shadow: var(--shadow); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.newsletter-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-hover); +} + +.newsletter-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.newsletter-icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--gradient); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; +} + +.newsletter-info h2 { + font-size: 1.1rem; + margin: 0; +} + +.newsletter-info a { + color: var(--text-dark); + text-decoration: none; +} + +.newsletter-info a:hover { + color: var(--secondary); +} + +/* Meta/date and excerpt */ +.newsletter-date { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-light); + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.newsletter-excerpt { + color: var(--text-dark); + opacity: 0.9; + margin-bottom: 0.75rem; +} + +/* Read more button */ +.read-more-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: #fff; + background: var(--gradient); + padding: 0.55rem 0.9rem; + border-radius: 999px; + text-decoration: none; + font-weight: 600; +} + +/* Detail page nav back link */ +.back-navigation { + max-width: 1100px; + margin: 6rem auto 0; /* space for fixed navbar */ + padding: 0 2rem; } .back-link { - margin-top: 20px; - display: inline-block; - color: #007bff; + color: var(--secondary); text-decoration: none; + font-weight: 600; } .back-link:hover { text-decoration: underline; } -.cards-container { +/* Detail header, meta, tags */ +.newsletter-header h1 { + margin-top: 0.5rem; +} + +.newsletter-meta { display: flex; flex-wrap: wrap; - justify-content: center; - gap: 20px; + gap: 0.75rem 1.25rem; + margin-top: 0.5rem; + color: var(--text-light); } -.newsletter-content { - width: 100%; /* Fixed to 100% */ +.newsletter-meta .meta-item { + display: inline-flex; + align-items: center; + gap: 0.4rem; } -.newsletter-card { - background: #fff; - border: 1px solid #ddd; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - width: 300px; +.newsletter-tags { display: flex; - flex-direction: column; - padding: 20px; - transition: transform 0.15s ease-in-out; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; } -.newsletter-card:hover { - transform: translateY(-5px); +.newsletter-tags .tag { + font-size: 0.85rem; + padding: 0.3rem 0.6rem; + background: rgba(51, 124, 242, 0.1); + color: var(--secondary); + border: 1px solid rgba(51, 124, 242, 0.2); + border-radius: 999px; } -.newsletter-card h2 { - font-size: 1.5em; - color: #007bff; - margin-bottom: 10px; +/* Detail content */ +.newsletter-content { + margin-top: 1.25rem; + background: #fff; + border: 1px solid rgba(30, 78, 156, 0.08); + border-radius: 16px; + padding: 1.5rem; + box-shadow: var(--shadow); } -.newsletter-card p.newsletter-time { - font-size: 0.8em; +.newsletter-content h2, +.newsletter-content h3 { + margin-top: 1rem; + margin-bottom: 0.5rem; } -.newsletter-main { - max-width: 1200px; - margin: 0 auto; - padding: 20px; +.newsletter-content p, +.newsletter-content li { + color: var(--text-dark); } -/* Media Queries */ +.newsletter-content ul { + padding-left: 1.25rem; +} + +.newsletter-content blockquote { + margin: 1rem 0; + padding: 1rem 1.25rem; + background: rgba(0, 212, 255, 0.08); + border-left: 4px solid var(--accent); + border-radius: 8px; + color: var(--text-dark); +} + +/* Actions */ +.newsletter-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-radius: 10px; + padding: 0.6rem 1rem; + cursor: pointer; + border: none; + text-decoration: none; + font-weight: 600; +} + +.action-btn.primary { + background: var(--gradient); + color: #fff; +} + +.action-btn.secondary { + background: #f1f5f9; + color: var(--text-dark); +} + +/* ======================= + Footer + ======================= */ +.footer { + background: var(--text-dark); + color: #fff; + text-align: center; + padding: 2rem 0; +} + +/* ======================= + Responsive + ======================= */ + +/* Tablet adjustments */ +@media (max-width: 1024px) { + .hero-container { + gap: 3rem; + } + .hero-visual { + height: 460px; + } + .phone-mockup { + width: 280px; + height: 560px; + } + .app-logo { + font-size: 1.8rem; + } +} + +/* Mobile layout and hamburger menu */ @media (max-width: 768px) { - .hero-content { + .nav-container { + padding: 0 1rem; + } + + /* Show hamburger, convert links to dropdown */ + .nav-toggle { + display: inline-flex; + } + + .nav-links { + position: absolute; + top: 64px; /* below navbar */ + left: 16px; + right: 16px; + display: none; /* hidden until opened */ flex-direction: column; - padding: 0; + gap: 0; + list-style: none; + background: #ffffff; + border: 1px solid rgba(2, 6, 23, 0.08); + border-radius: 12px; + box-shadow: var(--shadow); + padding: 8px; + z-index: 1001; } - .hero-text { + .nav-links.open { + display: flex; + } + + .nav-links li { width: 100%; - padding: 10px; } - .hero-section-2 input { - width: 80%; + .nav-links a { + display: block; + width: 100%; + padding: 12px 14px; + border-radius: 8px; } - .feature-card { - width: 90%; + .nav-links a:hover { + background: #f8fafc; } - .hero-text h1 { - font-size: 2.5em; + /* Remove desktop underline animation on mobile */ + .nav-links a::after { + display: none; + content: none; } - .hero-section-2 { - padding: 50px 0; + /* Animate hamburger to X when active */ + .nav-toggle.active .bar:nth-child(1) { + transform: translateY(7px) rotate(45deg); + } + .nav-toggle.active .bar:nth-child(2) { + opacity: 0; + } + .nav-toggle.active .bar:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); } - .hero-sec2-header { - font-size: 32px; + /* Hero and phone scaling + ensure hero clears navbar */ + .hero { + padding-top: calc(var(--navbar-height) + 12px); } - .hero-section-2 form { - width: calc(100% - 40px); - padding: 10px; + .hero-container { + grid-template-columns: 1fr; + gap: 2rem; + text-align: center; + } + + .hero-visual { + order: -1; + height: 360px; + margin-top: 4px; + } + + .phone-mockup { + width: 230px; + height: 460px; + padding: 16px; + border-radius: 36px; + transform: rotate(-5deg) translateY(0); + } + + .screen { + border-radius: 22px; + } + + .app-brand { + gap: 0.4rem; + margin-bottom: 0.35rem; + } + + .app-brand-icon { + width: 26px; + height: 26px; + border-radius: 5px; + } + + .app-logo { + font-size: 1.4rem; + margin-bottom: 0; + } + + .stats-grid { + gap: 0.6rem; + margin-top: 1.2rem; + } + + .stat-card { + padding: 0.6rem; + border-radius: 12px; + } + + .stat-number { + font-size: 1.1rem; + } + + .stat-label { + font-size: 0.65rem; + } + + /* Newsletter pages spacing */ + .page-header { + padding: 7rem 0 2rem; + } + + .main-content { + padding: 1.25rem 1rem 3rem; } } -@media (max-width: 480px) { - .hero-text h1 { - font-size: 2em; +/* Small phones */ +@media (max-width: 420px) { + .hero { + padding-top: calc(var(--navbar-height) + 6px); } - .hero-section-2 input { - width: 90%; + .hero-visual { + height: 320px; } - .feature-card { - width: 100%; - margin: 10px 0; + .phone-mockup { + width: 200px; + height: 400px; + padding: 14px; + border-radius: 34px; + transform: rotate(-5deg) scale(0.96); + transform-origin: center top; } - .hero-section-1 { - padding: 50px 0; + .screen { + border-radius: 20px; + } + + .app-brand-icon { + width: 24px; + height: 24px; + } + + .app-logo { + font-size: 1.25rem; + } + + .stats-grid { + gap: 0.5rem; + margin-top: 1rem; + } + + .stat-card { + padding: 0.55rem; + border-radius: 10px; + } + + .stat-number { + font-size: 1rem; + } + + .stat-label { + font-size: 0.6rem; } } + +/* Very narrow devices */ +@media (max-width: 360px) { + .hero-visual { + height: 290px; + } + + .phone-mockup { + width: 184px; + height: 368px; + padding: 12px; + border-radius: 30px; + transform: rotate(-5deg) scale(0.94); + } + + .app-brand-icon { + width: 22px; + height: 22px; + } + + .app-logo { + font-size: 1.15rem; + } + + .stats-grid { + gap: 0.45rem; + margin-top: 0.9rem; + } + + .stat-card { + padding: 0.5rem; + border-radius: 9px; + } + + .stat-number { + font-size: 0.95rem; + } + + .stat-label { + font-size: 0.58rem; + } +} + +/* Anchor scroll offset so anchors are not hidden under fixed navbar */ +main, +.section, +.page-header, +#features { + scroll-margin-top: calc(var(--navbar-height) + 12px); +} + +/* ======================= + Hard Guards for Navbar + ======================= */ + +/* Ensure navbar logo colors are always visible on white background */ +.navbar .logo { + color: var(--text-dark) !important; +} +.navbar .logo .logo-accent { + 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/static/js/countdown.js b/static/js/countdown.js index bf7a2ea..57409f7 100644 --- a/static/js/countdown.js +++ b/static/js/countdown.js @@ -1,20 +1,20 @@ // Countdown timer -const targetDate = new Date("2025-01-31T00:00:00Z"); // Set your launch date +const targetDate = new Date("2025-12-31T00:00:00Z"); function updateCountdown() { const now = new Date(); const difference = targetDate - now; - + if (difference < 0) { - document.getElementById("countdown").innerHTML = "We're Live!
"; + document.getElementById("countdown").innerHTML = "We're Live!
"; return; } - + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); const minutes = Math.floor((difference / (1000 * 60)) % 60); const seconds = Math.floor((difference / 1000) % 60); - + document.getElementById("days").textContent = days.toString().padStart(2, "0"); document.getElementById("hours").textContent = hours.toString().padStart(2, "0"); document.getElementById("minutes").textContent = minutes.toString().padStart(2, "0"); @@ -22,3 +22,4 @@ function updateCountdown() { } setInterval(updateCountdown, 1000); +updateCountdown(); // Run immediately diff --git a/static/js/main.js b/static/js/main.js index 06f9172..db5c8fd 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -1,34 +1,175 @@ -document.getElementById("notify-button").addEventListener("click", async () => { - const emailInput = document.getElementById("email-input"); - const email = emailInput.value.trim(); +(() => { + 'use strict'; - if (email) { - try { - const response = await fetch("/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), + const navbar = document.querySelector('.navbar'); + const featureCards = document.querySelectorAll('.feature-card'); + const newsletterCards = document.querySelectorAll('.newsletter-card'); + const progressBar = document.querySelector('.reading-progress'); + const emailInput = document.getElementById('email-input'); + const notifyBtn = document.getElementById('notify-button'); + const emailRE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + document.addEventListener( + 'click', + (e) => { + const a = e.target.closest('a[href^="#"]'); + if (!a) return; + + const href = a.getAttribute('href'); + if (!href || href === '#') return; + + const target = document.querySelector(href); + if (!target) return; + + e.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + { passive: false } + ); + + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver( + (entries, obs) => { + for (const entry of entries) { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + obs.unobserve(entry.target); + } + } + }, + { threshold: 0.1, rootMargin: '0px 0px -50px 0px' } + ); + + featureCards.forEach((card) => { + card.classList.add('will-animate'); + observer.observe(card); + }); + } else { + featureCards.forEach((card) => card.classList.add('is-visible')); + } + + window.addEventListener('load', () => { + document + .querySelectorAll('.newsletter-header, .newsletter-content') + .forEach((el, i) => { + el.style.transitionDelay = `${i * 0.2}s`; + el.classList.add('is-visible'); }); - // Check if response is OK, then parse JSON - const result = await response.json(); - console.log("Server response:", result); - alert(result.message || result.error); - } catch (error) { - console.error("Error during subscribe fetch:", error); - alert("There was an error during subscription. Please try again later."); - } - emailInput.value = ""; // Clear input field - } else { - alert("Please enter a valid email."); - } -}); + newsletterCards.forEach((card, i) => { + card.style.transitionDelay = `${i * 0.1}s`; + card.classList.add('is-visible'); + }); + }); -window.addEventListener('scroll', function() { - var footerHeight = document.querySelector('footer').offsetHeight; - if (window.scrollY + window.innerHeight >= document.body.offsetHeight - footerHeight) { - document.querySelector('footer').style.display = 'block'; + let lastY = 0; + let ticking = false; + + function onScroll() { + lastY = window.scrollY || window.pageYOffset; + if (!ticking) { + requestAnimationFrame(updateOnScroll); + ticking = true; + } + } + + function updateOnScroll() { + if (navbar) { + navbar.classList.toggle('navbar--scrolled', lastY > 50); + navbar.classList.toggle('navbar--deeper', lastY > 100); + } + + if (progressBar) { + const max = document.body.scrollHeight - window.innerHeight; + const progress = max > 0 ? Math.min(Math.max(lastY / max, 0), 1) : 0; + progressBar.style.width = `${progress * 100}%`; + } + + ticking = false; + } + + window.addEventListener('scroll', onScroll, { passive: true }); + updateOnScroll(); // initial state + + if (notifyBtn && emailInput) { + let inFlight = false; + const controller = new AbortController(); + + notifyBtn.addEventListener('click', async () => { + const email = emailInput.value.trim(); + if (!emailRE.test(email)) { + alert('Please enter a valid email address.'); + emailInput.focus(); + return; + } + if (inFlight) return; + + inFlight = true; + notifyBtn.disabled = true; + + try { + const res = await fetch('/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + signal: controller.signal, + }); + + let message = 'Thank you for subscribing!'; + if (res.ok) { + const data = await res.json().catch(() => ({})); + message = data.message || message; } else { - document.querySelector('footer').style.display = 'none'; + message = "Thanks! We'll notify you when we launch."; } -}); + + alert(message); + emailInput.value = ''; + } catch (err) { + console.error('Subscribe error:', err); + alert("Thanks! We'll notify you when we launch."); + emailInput.value = ''; + } finally { + notifyBtn.disabled = false; + inFlight = false; + } + }); + + window.addEventListener('beforeunload', () => controller.abort(), { + passive: true, + }); + } + + window.shareNewsletter = async function shareNewsletter() { + try { + if (navigator.share) { + await navigator.share({ + title: document.title, + text: 'Check out this newsletter from RideAware', + url: location.href, + }); + return; + } + } catch (err) { + console.warn('navigator.share error/cancel:', err); + } + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(location.href); + alert('Newsletter URL copied to clipboard!'); + return; + } catch { + /* fall through */ + } + } + + const tmp = document.createElement('input'); + tmp.value = location.href; + document.body.appendChild(tmp); + tmp.select(); + document.execCommand('copy'); + document.body.removeChild(tmp); + alert('Newsletter URL copied to clipboard!'); + }; +})(); diff --git a/static/js/main.min.js b/static/js/main.min.js new file mode 100644 index 0000000..10e868d --- /dev/null +++ b/static/js/main.min.js @@ -0,0 +1,175 @@ +(() => { + 'use strict'; + + const navbar = document.querySelector('.navbar'); + const featureCards = document.querySelectorAll('.feature-card'); + const newsletterCards = document.querySelectorAll('.newsletter-card'); + const progressBar = document.querySelector('.reading-progress'); + const emailInput = document.getElementById('email-input'); + const notifyBtn = document.getElementById('notify-button'); + const emailRE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + document.addEventListener( + 'click', + (e) => { + const a = e.target.closest('a[href^="#"]'); + if (!a) return; + + const href = a.getAttribute('href'); + if (!href || href === '#') return; + + const target = document.querySelector(href); + if (!target) return; + + e.preventDefault(); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + { passive: false } + ); + + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver( + (entries, obs) => { + for (const entry of entries) { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + obs.unobserve(entry.target); + } + } + }, + { threshold: 0.1, rootMargin: '0px 0px -50px 0px' } + ); + + featureCards.forEach((card) => { + card.classList.add('will-animate'); + observer.observe(card); + }); + } else { + featureCards.forEach((card) => card.classList.add('is-visible')); + } + + window.addEventListener('load', () => { + document + .querySelectorAll('.newsletter-header, .newsletter-content') + .forEach((el, i) => { + el.style.transitionDelay = `${i * 0.2}s`; + el.classList.add('is-visible'); + }); + + newsletterCards.forEach((card, i) => { + card.style.transitionDelay = `${i * 0.1}s`; + card.classList.add('is-visible'); + }); + }); + + let lastY = 0; + let ticking = false; + + function onScroll() { + lastY = window.scrollY || window.pageYOffset; + if (!ticking) { + requestAnimationFrame(updateOnScroll); + ticking = true; + } + } + + function updateOnScroll() { + if (navbar) { + navbar.classList.toggle('navbar--scrolled', lastY > 50); + navbar.classList.toggle('navbar--deeper', lastY > 100); + } + + if (progressBar) { + const max = document.body.scrollHeight - window.innerHeight; + const progress = max > 0 ? Math.min(Math.max(lastY / max, 0), 1) : 0; + progressBar.style.width = `${progress * 100}%`; + } + + ticking = false; + } + + window.addEventListener('scroll', onScroll, { passive: true }); + updateOnScroll(); // initial state + + if (notifyBtn && emailInput) { + let inFlight = false; + const controller = new AbortController(); + + notifyBtn.addEventListener('click', async () => { + const email = emailInput.value.trim(); + if (!emailRE.test(email)) { + alert('Please enter a valid email address.'); + emailInput.focus(); + return; + } + if (inFlight) return; + + inFlight = true; + notifyBtn.disabled = true; + + try { + const res = await fetch('/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + signal: controller.signal, + }); + + let message = 'Thank you for subscribing!'; + if (res.ok) { + const data = await res.json().catch(() => ({})); + message = data.message || message; + } else { + message = "Thanks! We'll notify you when we launch."; + } + + alert(message); + emailInput.value = ''; + } catch (err) { + console.error('Subscribe error:', err); + alert("Thanks! We'll notify you when we launch."); + emailInput.value = ''; + } finally { + notifyBtn.disabled = false; + inFlight = false; + } + }); + + window.addEventListener('beforeunload', () => controller.abort(), { + passive: true, + }); + } + + window.shareNewsletter = async function shareNewsletter() { + try { + if (navigator.share) { + await navigator.share({ + title: document.title, + text: 'Check out this newsletter from RideAware', + url: location.href, + }); + return; + } + } catch (err) { + console.warn('navigator.share error/cancel:', err); + } + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(location.href); + alert('Newsletter URL copied to clipboard!'); + return; + } catch { + /* fall through */ + } + } + + const tmp = document.createElement('input'); + tmp.value = location.href; + document.body.appendChild(tmp); + tmp.select(); + document.execCommand('copy'); + document.body.removeChild(tmp); + alert('Newsletter URL copied to clipboard!'); + }; + })(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..92c8523 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,136 @@ + + + + + +We're excited to share our journey with you.
-If you ever wish to unsubscribe, please click here.
-
+
|
+