From c387798be72dc01f391e427f061e94d5da6ed4ad Mon Sep 17 00:00:00 2001
From: KS Jannette
Date: Tue, 12 May 2026 18:17:02 -0400
Subject: [PATCH 1/2] added TOS and Privacy content
---
frontend/src/App.jsx | 49 ++++++---
frontend/src/components/Footer.css | 36 +++++++
frontend/src/components/Footer.jsx | 41 +++++++
frontend/src/index.css | 12 +++
frontend/src/pages/privacy/Privacy.css | 24 +++++
frontend/src/pages/privacy/Privacy.jsx | 30 ++++++
frontend/src/pages/terms/Terms.css | 24 +++++
frontend/src/pages/terms/Terms.jsx | 141 +++++++++++++++++++++++++
8 files changed, 340 insertions(+), 17 deletions(-)
create mode 100644 frontend/src/components/Footer.css
create mode 100644 frontend/src/components/Footer.jsx
create mode 100644 frontend/src/pages/privacy/Privacy.css
create mode 100644 frontend/src/pages/privacy/Privacy.jsx
create mode 100644 frontend/src/pages/terms/Terms.css
create mode 100644 frontend/src/pages/terms/Terms.jsx
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 014f142..362c2e6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./contexts/AuthContext";
import Navbar from "./components/Navbar";
+import Footer from "./components/Footer";
import Login from "./pages/login/Login";
import Signup from "./pages/Signup";
import Subscribe from "./pages/subscribe/Subscribe";
@@ -9,6 +10,8 @@ import Addresses from "./pages/addresses/Addresses";
import Alerts from "./pages/alerts/Alerts";
import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
+import Terms from "./pages/terms/Terms";
+import Privacy from "./pages/privacy/Privacy";
export default function App() {
const { currentUser, isSubscribed } = useAuth();
@@ -27,28 +30,40 @@ export default function App() {
if (!isSubscribed) {
return (
-
- } />
- } />
- >} />
- } />
-
+
+
+
+ } />
+ } />
+ >} />
+ >} />
+ >} />
+ } />
+
+
+
+
);
}
return (
-
+
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
);
}
diff --git a/frontend/src/components/Footer.css b/frontend/src/components/Footer.css
new file mode 100644
index 0000000..2189184
--- /dev/null
+++ b/frontend/src/components/Footer.css
@@ -0,0 +1,36 @@
+.footer {
+ flex-shrink: 0;
+ margin-top: auto;
+ padding: 0.75rem 1rem;
+ background-color: var(--color-bg);
+ border-top: 1px solid var(--color-border-light);
+}
+
+.footer__inner {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem 1rem;
+ max-width: 1400px;
+ margin: 0 auto;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ color: var(--color-text-dimmed);
+}
+
+.footer__copyright {
+ color: var(--color-text-dimmed);
+}
+
+.footer__link {
+ font-size: 1.265em;
+ color: inherit;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.footer__link:hover {
+ color: var(--color-primary);
+ text-decoration: underline;
+}
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx
new file mode 100644
index 0000000..a260e41
--- /dev/null
+++ b/frontend/src/components/Footer.jsx
@@ -0,0 +1,41 @@
+import { Link } from "react-router-dom";
+import { useAuth } from "../contexts/AuthContext";
+import "./Footer.css";
+
+export default function Footer() {
+ const { currentUser } = useAuth();
+
+ if (!currentUser) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index befdf10..fa44484 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -78,6 +78,18 @@ button {
font-weight: 200;
}
+/* ── App shell (navbar + main + footer) ───────────────────── */
+
+.app-layout {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-layout__main {
+ flex: 1 0 auto;
+}
+
/* ── Page Layouts ─────────────────────────────────────────── */
.page {
diff --git a/frontend/src/pages/privacy/Privacy.css b/frontend/src/pages/privacy/Privacy.css
new file mode 100644
index 0000000..a76e1e5
--- /dev/null
+++ b/frontend/src/pages/privacy/Privacy.css
@@ -0,0 +1,24 @@
+.privacy-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ color: var(--color-text);
+}
+
+.privacy-container h1 {
+ font-size: 2rem;
+ font-weight: 200;
+ margin-bottom: 1.25rem;
+}
+
+.privacy-para {
+ margin-bottom: 1.25rem;
+ font-size: 1.35rem;
+ font-weight: 200;
+ line-height: 1.65;
+ color: var(--color-text-muted);
+}
+
+.privacy-para:last-child {
+ margin-bottom: 0;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/privacy/Privacy.jsx b/frontend/src/pages/privacy/Privacy.jsx
new file mode 100644
index 0000000..518fd8e
--- /dev/null
+++ b/frontend/src/pages/privacy/Privacy.jsx
@@ -0,0 +1,30 @@
+import "./Privacy.css";
+
+export default function Privacy() {
+ return (
+
+
Privacy
+
+ Koin Ping is committed to protecting the privacy of our users. Koin Ping
+ does not sell or rent user information and we do not share user
+ information without prior consent except as compelled by law.
+
+
+ Koin Ping collects only enough information from users to enable the
+ functioning of this website and its services. It will never collect
+ extra information about you and uses no tracking, cookies or logging
+ with personal information.
+
+
+ The only information ever stored is your chosen email address and
+ contact information that is provided upon signup. Koin Ping is located
+ within the United States, and will process and store your information in
+ the United States.
+
+
+ If you would like to remove your data, or if you have any questions
+ about how your data is used, please contact us.
+
+
+ );
+}
diff --git a/frontend/src/pages/terms/Terms.css b/frontend/src/pages/terms/Terms.css
new file mode 100644
index 0000000..b586b09
--- /dev/null
+++ b/frontend/src/pages/terms/Terms.css
@@ -0,0 +1,24 @@
+.tos-container {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ color: var(--color-text);
+}
+
+.tos-container h1 {
+ font-size: 2rem;
+ font-weight: 200;
+ margin-bottom: 1.25rem;
+}
+
+.tos-para {
+ margin-bottom: 1.25rem;
+ font-size: 1.35rem;
+ font-weight: 200;
+ line-height: 1.65;
+ color: var(--color-text-muted);
+}
+
+.tos-para:last-child {
+ margin-bottom: 0;
+}
diff --git a/frontend/src/pages/terms/Terms.jsx b/frontend/src/pages/terms/Terms.jsx
new file mode 100644
index 0000000..bb31b7a
--- /dev/null
+++ b/frontend/src/pages/terms/Terms.jsx
@@ -0,0 +1,141 @@
+import "./Terms.css";
+
+export default function Terms() {
+ return (
+
+
Terms of Service And Use
+
+ YOU SHOULD READ THESE TERMS OF SERVICE AND USE BEFORE USING THIS WEB
+ SITE.
+
+
+ By using the Website, you acknoweldge that you have read this, and
+ that you understand it. The Website which is located at the domain
+ Koin Ping.com and Koin Ping.ai ("Koin Ping") and/or any mobile
+ apps available for download (collectively, the "Web Site,"
+ "Websites" and "Apps") are governed by these Terms
+ of Service and Use (the "Agreement," "Terms of
+ Service", or "TOS"). Any reference herein to the
+ Website, Websites, and/or Apps is intended, and shall be construed, to
+ pertain to one, all, or any of them, indivuidually and collectively.
+
+
+ By using the Web Site and Apps, you ("you" or "User")
+ signify your acceptance of this Agreement and your acknowledgement that
+ all information that you provide, directly or indirectly, through the Web
+ Sites and App will be managed in accordance with the Privacy Notice. IF
+ YOU DO NOT ACCEPT THESE TERMS OF SERVICE AND USE, YOU ARE NOT AUTHORIZED
+ TO ACCESS OR USE THE WEB SITE OR APPS.
+
+
+ Communications. You agree that by providing your contact information,
+ you consent to receiving communication, in connection with your
+ Membership subscription. This may include communication about your
+ account, features, and services via e-email, push notification, phone,
+ or text message (including by an automatic telephone dialing system
+ and/or with an artificial or pre-recorded voice) at any of the phone
+ numbers provided by you or on your behalf. Standard text messaging
+ charges applied by your cell phone carrier may apply.
+
+
+ User Eligibility. The Website should only be accessed and used by
+ individuals who agree to be bound by these Terms of Use and who are at
+ least 18 years of age. The Websites may be accessible worldwide;
+ however, the Websites are intended for use only in the USA and Canada.
+ If you access/use the Websites from outside the USA or Canada, you do
+ so at your own risk and are responsible for complying with the laws
+ and regulations of the territory from which you access/use the
+ Websites.
+
+
+ Intellectual property rights. The Web Site and the information, computer
+ code, and related functionality appearing, featured or otherwise
+ displayed on the Websites are owned by Koin Ping, its affiliates, and
+ their respective licensors or other third parties and protected under
+ the copyright, trademark and other laws of the United States and other
+ countries sand international treaty provisions.
+
+
+ Limited license. Koin Ping grants to you a limited, non-exclusive,
+ non-transferable license to use the Web Site in strict accordance with
+ these Terms of Service and Use and the instructions provided by us on
+ the Web Site. The materials provided on the Web Site, including,
+ without limitation, the Information, computer code, and related
+ functionality, are for your personal use only. Except as may be
+ explicitly permitted through the Websites, you may not copy, modify,
+ upload, republish, distribute, display, post, license, create
+ derivative works from, or transmit anything you obtain from the Web
+ Sites, including anything you download from the Websites, unless you
+ first obtain our written consent.
+
+
+ Any rights not expressly granted herein are reserved to Koin Ping and
+ its affiliates. You may not remove, obscure, or otherwise deface
+ proprietary notices appearing on the Web Site, or any content or
+ Information. Any unauthorized use of the Websites or its contents may
+ violate copyright laws, trademark laws, the laws of privacy and
+ publicity and communications regulations and statutes.
+
+
+ Restrictions on Use. As a condition of your use of the Websites, you
+ warrant that you will not use the Websites for any purpose that is
+ unlawful or prohibited by these terms, conditions and notices. You may
+ not use the Websites in any way that could damage, disable, overburden
+ or impair the Websites or interfere with any other party's use and
+ enjoyment of the Websites. You may not obtain or attempt to obtain any
+ materials or information through any means not intentionally made
+ available or provided for through the Web Site.
+
+
+ Revocation of privileges. You agree that your use of the Websites may
+ be suspended or terminated immediately upon receipt of any notice which
+ alleges that you have used the Websites in violation of these Terms of
+ Use and/or for any purpose that violates any local, state, federal or
+ law of the USA or other jurisdictions, including, but not limited to,
+ the posting of information that may violate third party rights, may
+ defame a third party, may be obscene or pornographic, may harass or
+ assault others, or may violate any laws, rules or regulations,
+ including, hacking or other criminal regulations. You understand that
+ actions in violation of these Terms of Use may subject you to serious
+ civil and criminal legal penalties and Koin Ping reserves the right to
+ pursue penalties and other remedies to the fullest extent of the law to
+ protect our rights.
+
+
+ No Warranties. Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
+ THE WEB SITE, THE SUITABILITY OF THE INFORMATION CONTAINED ON OR
+ RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
+ THROUGH THE WEB SITE. ALL INFORMATION AND USE OF THE WEB SITE ARE
+ PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. Koin Ping HEREBY
+ EXPRESSLY DISCLAIMS ALL WARRANTIES WITH REGARD TO THE Websites, THE
+ INFORMATION CONTAINED ON OR RECEIVED THROUGH USE OF THE Websites AND
+ ANY SERVICES OR PRODUCTS RECEIVED THROUGH THE Websites, INCLUDING ALL
+ EXPRESS, STATUTORY AND IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS
+ FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. Koin Ping DOES
+ NOT WARRANT THAT THE CONTENTS OR ANY INFORMATION RECEIVED THROUGH THE
+ Websites ARE ACCURATE, RELIABLE OR CORRECT; THAT THE Websites WILL BE
+ AVAILABLE AT ANY PARTICULAR TIME OR LOCATION; THAT ANY DEFECTS OR
+ ERRORS WILL BE CORRECTED; OR THAT THE CONTENTS OR ANY INFORMATION
+ RECEIVED THROUGH THE Websites ARE FREE OF VIRUSES OR OTHER DESTRUCTIVE
+ OR HARMFUL COMPONENTS. YOUR USE OF THE Websites IS SOLELY AT YOUR OWN
+ RISK. USER EXPRESSLY AGREES THAT IT HAS RELIED ON NO WARRANTIES,
+ REPRESENTATIONS OR STATEMENTS OTHER THAN IN THIS AGREEMENT.
+
+
+ Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
+ LIABLE FOR ANY DAMAGES, INCLUDING, WITHOUT LIMITATION, DIRECT,
+ INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
+ PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE
+ OF, OR INABILITY TO USE THE Websites, THE INFORMATION CONTAINED ON OR
+ RECEIVED THROUGH USE OF THE Websites, OR ANY SERVICES OR PRODUCTS
+ RECEIVED THROUGH THE Websites. THIS LIMITATION APPLIES WHETHER THE
+ ALLEGED LIABILITY IS BASED ON CONTRACT, TORT, NEGLIGENCE, STRICT
+ LIABILITY OR ANY OTHER BASIS, EVEN IF Koin Ping HAS BEEN ADVISED OF THE
+ POSSIBILITY OF SUCH DAMAGES. BECAUSE SOME JURISDICTIONS DO NOT ALLOW THE
+ EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, Koin
+ Ping's LIABILITY IN SUCH JURISDICTIONS SHALL BE LIMITED TO THE
+ MAXIMUM EXTENT PERMITTED BY THE LAW OF YOUR JURISDICTION.
+
+
+ );
+}
From a341741ac6926c1be06f9f40416121ee944bb94d Mon Sep 17 00:00:00 2001
From: KS Jannette
Date: Tue, 12 May 2026 18:31:41 -0400
Subject: [PATCH 2/2] additional fetaure buildout for footer and footer links
---
backend/cmd/api/main.go | 5 +
backend/internal/config/config.go | 104 ++++++++++----------
backend/internal/handlers/support.go | 111 ++++++++++++++++++++++
backend/internal/notifications/email.go | 56 +++++++++++
frontend/src/App.jsx | 3 +
frontend/src/api/support.js | 23 +++++
frontend/src/components/Footer.jsx | 6 +-
frontend/src/pages/support/Support.css | 31 ++++++
frontend/src/pages/support/Support.jsx | 121 ++++++++++++++++++++++++
frontend/src/pages/terms/Terms.css | 5 +
frontend/src/pages/terms/Terms.jsx | 16 ++--
11 files changed, 419 insertions(+), 62 deletions(-)
create mode 100644 backend/internal/handlers/support.go
create mode 100644 frontend/src/api/support.js
create mode 100644 frontend/src/pages/support/Support.css
create mode 100644 frontend/src/pages/support/Support.jsx
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
index 1af3cc1..99246ee 100644
--- a/backend/cmd/api/main.go
+++ b/backend/cmd/api/main.go
@@ -63,6 +63,7 @@ func main() {
statusHandler := handlers.NewStatusHandler(checkpointModel)
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
+ supportHandler := handlers.NewSupportHandler(cfg)
authenticate := middleware.Authenticate(userModel)
requireSub := middleware.RequireSubscription(userModel)
@@ -101,6 +102,10 @@ func main() {
mux.Handle("GET "+b+"/user/account",
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
+ // Support (auth required; avoids anonymous spam)
+ mux.Handle("POST "+b+"/support",
+ authenticate(http.HandlerFunc(supportHandler.Submit)))
+
// Authenticated + subscribed routes — addresses
mux.Handle("POST "+b+"/addresses",
authAndSub(http.HandlerFunc(addressHandler.Create)))
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index d066b11..7969085 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -1,4 +1,4 @@
-//loads environment-based configuration.
+// loads environment-based configuration.
package config
import (
@@ -9,65 +9,67 @@ import (
)
const (
- defaultPort = 3001
- defaultDBPort = 5432
- defaultPollIntervalMS = 60000
- minPollIntervalMS = 1000
+ defaultPort = 3001
+ defaultDBPort = 5432
+ defaultPollIntervalMS = 60000
+ minPollIntervalMS = 1000
defaultDigestIntervalHours = 24
)
type Config struct {
- Port int
- APIBasePath string
- DatabaseURL string
- DBHost string
- DBPort int
- DBUser string
- DBPassword string
- DBName string
- FirebaseProjectID string
- EthRPCURL string
- PollIntervalMS int
- NodeEnv string
- ResendAPIKey string
- EmailFrom string
- DigestIntervalHours int
- StripeSecretKey string
- StripeWebhookSecret string
- StripePriceIDPremium string
- StripePriceIDPro string
- StripePriceIDPremiumAnnual string
- StripePriceIDProAnnual string
- StripePublishableKey string
- FrontendURL string
+ Port int
+ APIBasePath string
+ DatabaseURL string
+ DBHost string
+ DBPort int
+ DBUser string
+ DBPassword string
+ DBName string
+ FirebaseProjectID string
+ EthRPCURL string
+ PollIntervalMS int
+ NodeEnv string
+ ResendAPIKey string
+ EmailFrom string
+ SupportInboxEmail string
+ DigestIntervalHours int
+ StripeSecretKey string
+ StripeWebhookSecret string
+ StripePriceIDPremium string
+ StripePriceIDPro string
+ StripePriceIDPremiumAnnual string
+ StripePriceIDProAnnual string
+ StripePublishableKey string
+ FrontendURL string
}
// Load reads configuration from environment variables and returns a Config.
func Load() (*Config, error) {
cfg := &Config{
- Port: getEnvInt("PORT", defaultPort),
- APIBasePath: getEnv("API_BASE_PATH", "/v1"),
- DatabaseURL: os.Getenv("DATABASE_URL"),
- DBHost: getEnv("DB_HOST", "localhost"),
- DBPort: getEnvInt("DB_PORT", defaultDBPort),
- DBUser: os.Getenv("DB_USER"),
- DBPassword: os.Getenv("DB_PASSWORD"),
- DBName: os.Getenv("DB_NAME"),
- FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
- EthRPCURL: os.Getenv("ETH_RPC_URL"),
- PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
- NodeEnv: getEnv("NODE_ENV", "development"),
- ResendAPIKey: os.Getenv("RESEND_API_KEY"),
- EmailFrom: getEnv("EMAIL_FROM", "Koin Ping "),
- DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
- StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
- StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
- StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
- StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
- StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
- StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
- StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
- FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
+ Port: getEnvInt("PORT", defaultPort),
+ APIBasePath: getEnv("API_BASE_PATH", "/v1"),
+ DatabaseURL: os.Getenv("DATABASE_URL"),
+ DBHost: getEnv("DB_HOST", "localhost"),
+ DBPort: getEnvInt("DB_PORT", defaultDBPort),
+ DBUser: os.Getenv("DB_USER"),
+ DBPassword: os.Getenv("DB_PASSWORD"),
+ DBName: os.Getenv("DB_NAME"),
+ FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
+ EthRPCURL: os.Getenv("ETH_RPC_URL"),
+ PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
+ NodeEnv: getEnv("NODE_ENV", "development"),
+ ResendAPIKey: os.Getenv("RESEND_API_KEY"),
+ EmailFrom: getEnv("EMAIL_FROM", "Koin Ping "),
+ SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
+ DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
+ StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
+ StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
+ StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
+ StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
+ StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
+ StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
+ StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
+ FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
}
if cfg.PollIntervalMS < minPollIntervalMS {
diff --git a/backend/internal/handlers/support.go b/backend/internal/handlers/support.go
new file mode 100644
index 0000000..7edc413
--- /dev/null
+++ b/backend/internal/handlers/support.go
@@ -0,0 +1,111 @@
+package handlers
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "net/mail"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/kjannette/koin-ping/backend/internal/config"
+ "github.com/kjannette/koin-ping/backend/internal/notifications"
+)
+
+const (
+ maxSupportDescriptionLen = 8000
+ maxSupportEmailLen = 320
+)
+
+var descriptionAllowedRE = regexp.MustCompile(`^[a-zA-Z0-9\s]+$`)
+
+type supportRequestBody struct {
+ Email string `json:"email"`
+ Description string `json:"description"`
+}
+
+// SupportHandler accepts authenticated support form submissions and emails the inbox.
+type SupportHandler struct {
+ cfg *config.Config
+}
+
+func NewSupportHandler(cfg *config.Config) *SupportHandler {
+ return &SupportHandler{cfg: cfg}
+}
+
+// Submit handles POST /support.
+func (h *SupportHandler) Submit(w http.ResponseWriter, r *http.Request) {
+ var body supportRequestBody
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusBadRequest, "INVALID_JSON", "Request body must be JSON")
+ return
+ }
+
+ email := strings.TrimSpace(body.Email)
+ description := strings.TrimSpace(body.Description)
+
+ if len(email) > maxSupportEmailLen {
+ writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Email is too long")
+ return
+ }
+
+ parsed, err := mail.ParseAddress(email)
+ if err != nil || parsed.Address == "" {
+ writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Invalid email address")
+ return
+ }
+
+ canonicalEmail := parsed.Address
+
+ if description == "" {
+ writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is required")
+ return
+ }
+
+ if len(description) > maxSupportDescriptionLen {
+ writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION", "Description is too long")
+ return
+ }
+
+ if !descriptionAllowedRE.MatchString(description) {
+ writeError(w, http.StatusBadRequest, "INVALID_DESCRIPTION",
+ "Description may only contain letters, numbers, and whitespace")
+ return
+ }
+
+ subject := time.Now().UTC().Format(time.RFC3339) + " - new support issue - koinp.ing"
+ plainBody := "Contact email: " + canonicalEmail + "\n\nIssue description:\n" + description
+
+ // Local dev: skip Resend when SUPPORT_DEV_SKIP_EMAIL=1 (see backend logs for payload).
+ if strings.EqualFold(h.cfg.NodeEnv, "development") && os.Getenv("SUPPORT_DEV_SKIP_EMAIL") == "1" {
+ log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL: skipping Resend; to=%s subject=%s", h.cfg.SupportInboxEmail, subject)
+ log.Printf("[dev] SUPPORT_DEV_SKIP_EMAIL body:\n%s", plainBody)
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+ return
+ }
+
+ if h.cfg.ResendAPIKey == "" {
+ writeError(w, http.StatusServiceUnavailable, "EMAIL_UNAVAILABLE", "Support email is not configured")
+ return
+ }
+
+ if err := notifications.SendSupportEmail(
+ h.cfg.ResendAPIKey,
+ h.cfg.EmailFrom,
+ h.cfg.SupportInboxEmail,
+ subject,
+ plainBody,
+ ); err != nil {
+ log.Printf("support Submit: send email: %v", err)
+ msg := "Could not send email. Confirm RESEND_API_KEY and that EMAIL_FROM uses a domain verified in Resend."
+ if strings.EqualFold(h.cfg.NodeEnv, "development") {
+ msg = "Email send failed (development): " + err.Error()
+ }
+ writeError(w, http.StatusInternalServerError, "SEND_FAILED", msg)
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
diff --git a/backend/internal/notifications/email.go b/backend/internal/notifications/email.go
index 9bfc19a..6a5b25d 100644
--- a/backend/internal/notifications/email.go
+++ b/backend/internal/notifications/email.go
@@ -5,6 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
+ "html"
+ "io"
"log"
"net/http"
"time"
@@ -156,3 +158,57 @@ func alertTypeLabel(alertType string) string {
return "Alert"
}
}
+
+type resendSupportPayload struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Subject string `json:"subject"`
+ Text string `json:"text"`
+ HTML string `json:"html"`
+}
+
+// SendSupportEmail delivers a plain-text support request via Resend (HTML copy is escaped).
+func SendSupportEmail(apiKey, fromAddress, toAddress, subject, plainBody string) error {
+ if apiKey == "" {
+ return fmt.Errorf("RESEND_API_KEY not set") //nolint:err113
+ }
+
+ escaped := html.EscapeString(plainBody)
+ htmlBody := `` +
+ escaped + `
`
+
+ payload := resendSupportPayload{
+ From: fromAddress,
+ To: toAddress,
+ Subject: subject,
+ Text: plainBody,
+ HTML: htmlBody,
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshal support email payload: %w", err)
+ }
+
+ req, err := http.NewRequest(http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("create support email request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+
+ resp, err := emailHTTPClient.Do(req)
+ if err != nil {
+ log.Printf("Failed to send support email: %v", err)
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ log.Printf("Resend support email failed: HTTP %d body: %s", resp.StatusCode, string(snippet))
+ return fmt.Errorf("resend API failed: HTTP %d: %s", resp.StatusCode, string(snippet)) //nolint:err113
+ }
+
+ return nil
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 362c2e6..79b7a80 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -12,6 +12,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
import Account from "./pages/user_account/Account";
import Terms from "./pages/terms/Terms";
import Privacy from "./pages/privacy/Privacy";
+import Support from "./pages/support/Support";
export default function App() {
const { currentUser, isSubscribed } = useAuth();
@@ -38,6 +39,7 @@ export default function App() {
>} />
>} />
>} />
+ >} />
} />
@@ -60,6 +62,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/api/support.js b/frontend/src/api/support.js
new file mode 100644
index 0000000..cfe03e7
--- /dev/null
+++ b/frontend/src/api/support.js
@@ -0,0 +1,23 @@
+import { getAuthHeaders } from "./authHeaders";
+import { API_BASE } from "./config";
+
+export async function submitSupportRequest({ email, description }) {
+ const headers = await getAuthHeaders();
+ const res = await fetch(`${API_BASE}/support`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ email, description }),
+ });
+ if (!res.ok) {
+ let message = "Failed to send support request";
+ try {
+ const data = await res.json();
+ if (data.message) message = data.message;
+ else if (data.error && res.status) message = `${data.error} (${res.status})`;
+ } catch {
+ message = `Request failed (${res.status}). Is the API running on port 3001?`;
+ }
+ throw new Error(message);
+ }
+ return res.json();
+}
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx
index a260e41..be7249e 100644
--- a/frontend/src/components/Footer.jsx
+++ b/frontend/src/components/Footer.jsx
@@ -27,14 +27,14 @@ export default function Footer() {
>
Privacy
-
Contact Support
-
+
);
diff --git a/frontend/src/pages/support/Support.css b/frontend/src/pages/support/Support.css
new file mode 100644
index 0000000..5196d53
--- /dev/null
+++ b/frontend/src/pages/support/Support.css
@@ -0,0 +1,31 @@
+.support-page__title {
+ font-size: 2rem;
+ font-weight: 200;
+ margin-bottom: 0.75rem;
+}
+
+.support-page__intro {
+ font-size: 1.1rem;
+ font-weight: 200;
+ color: var(--color-text-muted);
+ margin-bottom: 1.5rem;
+ line-height: 1.5;
+}
+
+.support-page__alert {
+ margin-bottom: 1rem;
+}
+
+.support-form__description {
+ display: block;
+}
+
+.support-form__textarea {
+ min-height: 10rem;
+ resize: vertical;
+ margin-top: 0.25rem;
+}
+
+.support-form__submit {
+ margin-top: 0.5rem;
+}
diff --git a/frontend/src/pages/support/Support.jsx b/frontend/src/pages/support/Support.jsx
new file mode 100644
index 0000000..479c9ba
--- /dev/null
+++ b/frontend/src/pages/support/Support.jsx
@@ -0,0 +1,121 @@
+import { useEffect, useState } from "react";
+import { useAuth } from "../../contexts/AuthContext";
+import Input from "../../components/Input";
+import Button from "../../components/Button";
+import { submitSupportRequest } from "../../api/support";
+import "./Support.css";
+
+const EMAIL_MAX = 320;
+const DESCRIPTION_MAX = 8000;
+const DESCRIPTION_PATTERN = /^[a-zA-Z0-9\s]+$/;
+
+function validateEmail(value) {
+ const v = value.trim();
+ if (!v) return "Email is required.";
+ if (v.length > EMAIL_MAX) return "Email is too long.";
+ const ok =
+ /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/.test(
+ v,
+ );
+ if (!ok) return "Enter a valid email address.";
+ return null;
+}
+
+function validateDescription(value) {
+ const t = value.trim();
+ if (!t) return "Description of issue is required.";
+ if (t.length > DESCRIPTION_MAX) return "Description is too long.";
+ if (!DESCRIPTION_PATTERN.test(t)) {
+ return "Use only letters, numbers, and spaces (no special characters).";
+ }
+ return null;
+}
+
+export default function Support() {
+ const { currentUser } = useAuth();
+ const [email, setEmail] = useState("");
+ const [description, setDescription] = useState("");
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(false);
+ const [sending, setSending] = useState(false);
+
+ useEffect(() => {
+ if (currentUser?.email) {
+ setEmail(currentUser.email);
+ }
+ }, [currentUser?.email]);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError(null);
+ setSuccess(false);
+
+ const emailErr = validateEmail(email);
+ if (emailErr) {
+ setError(emailErr);
+ return;
+ }
+ const descErr = validateDescription(description);
+ if (descErr) {
+ setError(descErr);
+ return;
+ }
+
+ setSending(true);
+ try {
+ await submitSupportRequest({
+ email: email.trim(),
+ description: description.trim(),
+ });
+ setSuccess(true);
+ setDescription("");
+ } catch (err) {
+ setError(err.message || "Something went wrong.");
+ } finally {
+ setSending(false);
+ }
+ }
+
+ return (
+
+
Contact support
+
+ Describe your issue below. We will reply to the email address you
+ provide.
+
+
+ {error &&
{error}
}
+ {success && (
+
+ Your message was sent. Thank you.
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/terms/Terms.css b/frontend/src/pages/terms/Terms.css
index b586b09..07cf348 100644
--- a/frontend/src/pages/terms/Terms.css
+++ b/frontend/src/pages/terms/Terms.css
@@ -22,3 +22,8 @@
.tos-para:last-child {
margin-bottom: 0;
}
+
+.tos-para strong {
+ font-weight: 700;
+ color: var(--color-text);
+}
diff --git a/frontend/src/pages/terms/Terms.jsx b/frontend/src/pages/terms/Terms.jsx
index bb31b7a..a4a52d7 100644
--- a/frontend/src/pages/terms/Terms.jsx
+++ b/frontend/src/pages/terms/Terms.jsx
@@ -28,7 +28,7 @@ export default function Terms() {
TO ACCESS OR USE THE WEB SITE OR APPS.
- Communications. You agree that by providing your contact information,
+ Communications. You agree that by providing your contact information,
you consent to receiving communication, in connection with your
Membership subscription. This may include communication about your
account, features, and services via e-email, push notification, phone,
@@ -38,7 +38,7 @@ export default function Terms() {
charges applied by your cell phone carrier may apply.
- User Eligibility. The Website should only be accessed and used by
+ User Eligibility. The Website should only be accessed and used by
individuals who agree to be bound by these Terms of Use and who are at
least 18 years of age. The Websites may be accessible worldwide;
however, the Websites are intended for use only in the USA and Canada.
@@ -48,7 +48,7 @@ export default function Terms() {
Websites.
- Intellectual property rights. The Web Site and the information, computer
+ Intellectual property rights. The Web Site and the information, computer
code, and related functionality appearing, featured or otherwise
displayed on the Websites are owned by Koin Ping, its affiliates, and
their respective licensors or other third parties and protected under
@@ -56,7 +56,7 @@ export default function Terms() {
countries sand international treaty provisions.
- Limited license. Koin Ping grants to you a limited, non-exclusive,
+ Limited license. Koin Ping grants to you a limited, non-exclusive,
non-transferable license to use the Web Site in strict accordance with
these Terms of Service and Use and the instructions provided by us on
the Web Site. The materials provided on the Web Site, including,
@@ -77,7 +77,7 @@ export default function Terms() {
publicity and communications regulations and statutes.
- Restrictions on Use. As a condition of your use of the Websites, you
+ Restrictions on Use. As a condition of your use of the Websites, you
warrant that you will not use the Websites for any purpose that is
unlawful or prohibited by these terms, conditions and notices. You may
not use the Websites in any way that could damage, disable, overburden
@@ -87,7 +87,7 @@ export default function Terms() {
available or provided for through the Web Site.
- Revocation of privileges. You agree that your use of the Websites may
+ Revocation of privileges. You agree that your use of the Websites may
be suspended or terminated immediately upon receipt of any notice which
alleges that you have used the Websites in violation of these Terms of
Use and/or for any purpose that violates any local, state, federal or
@@ -102,7 +102,7 @@ export default function Terms() {
protect our rights.
- No Warranties. Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
+ No Warranties. Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
THE WEB SITE, THE SUITABILITY OF THE INFORMATION CONTAINED ON OR
RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
THROUGH THE WEB SITE. ALL INFORMATION AND USE OF THE WEB SITE ARE
@@ -122,7 +122,7 @@ export default function Terms() {
REPRESENTATIONS OR STATEMENTS OTHER THAN IN THIS AGREEMENT.
- Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
+ Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
LIABLE FOR ANY DAMAGES, INCLUDING, WITHOUT LIMITATION, DIRECT,
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE