additional fetaure buildout for footer and footer links

This commit is contained in:
KS Jannette
2026-05-12 18:31:41 -04:00
parent c387798be7
commit a341741ac6
11 changed files with 419 additions and 62 deletions

View File

@@ -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)))

View File

@@ -1,4 +1,4 @@
//loads environment-based configuration.
// loads environment-based configuration.
package config
import (
@@ -31,6 +31,7 @@ type Config struct {
NodeEnv string
ResendAPIKey string
EmailFrom string
SupportInboxEmail string
DigestIntervalHours int
StripeSecretKey string
StripeWebhookSecret string
@@ -59,6 +60,7 @@ func Load() (*Config, error) {
NodeEnv: getEnv("NODE_ENV", "development"),
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
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"),

View File

@@ -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"})
}

View File

@@ -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 := `<pre style="font-family:ui-sans-serif,system-ui,sans-serif;white-space:pre-wrap;">` +
escaped + `</pre>`
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
}

View File

@@ -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() {
<Route path="/account" element={<><Navbar /><Account /></>} />
<Route path="/terms" element={<><Navbar /><Terms /></>} />
<Route path="/privacy" element={<><Navbar /><Privacy /></>} />
<Route path="/support" element={<><Navbar /><Support /></>} />
<Route path="*" element={<Navigate to="/subscribe" />} />
</Routes>
</div>
@@ -60,6 +62,7 @@ export default function App() {
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/support" element={<Support />} />
<Route path="*" element={<Navigate to="/addresses" />} />
</Routes>
</div>

View File

@@ -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();
}

View File

@@ -27,14 +27,14 @@ export default function Footer() {
>
Privacy
</Link>
<a
href="mailto:support@koinp.ing"
<Link
to="/support"
target="_blank"
rel="noopener noreferrer"
className="footer__link"
>
Contact Support
</a>
</Link>
</div>
</footer>
);

View File

@@ -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;
}

View File

@@ -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 (
<div className="support-page page">
<h1 className="support-page__title">Contact support</h1>
<p className="support-page__intro">
Describe your issue below. We will reply to the email address you
provide.
</p>
{error && <div className="alert alert--error support-page__alert">{error}</div>}
{success && (
<div className="alert alert--success support-page__alert">
Your message was sent. Thank you.
</div>
)}
<form className="support-form" onSubmit={handleSubmit} noValidate>
<Input
label="Email"
type="email"
value={email}
onChange={setEmail}
autoComplete="email"
required
/>
<label className="form-field support-form__description">
<div className="input__label">Description of Issue</div>
<textarea
className="form-control support-form__textarea"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={8}
maxLength={DESCRIPTION_MAX}
placeholder="Letters, numbers, and spaces only"
aria-required="true"
/>
</label>
<Button type="submit" disabled={sending} className="support-form__submit">
{sending ? "Sending…" : "Send"}
</Button>
</form>
</div>
);
}

View File

@@ -22,3 +22,8 @@
.tos-para:last-child {
margin-bottom: 0;
}
.tos-para strong {
font-weight: 700;
color: var(--color-text);
}

View File

@@ -28,7 +28,7 @@ export default function Terms() {
TO ACCESS OR USE THE WEB SITE OR APPS.
</p>
<p className="tos-para">
Communications. You agree that by providing your contact information,
<strong>Communications.</strong> 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.
</p>
<p className="tos-para">
User Eligibility. The Website should only be accessed and used by
<strong>User Eligibility.</strong> 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.
</p>
<p className="tos-para">
Intellectual property rights. The Web Site and the information, computer
<strong>Intellectual property rights.</strong> 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.
</p>
<p className="tos-para">
Limited license. Koin Ping grants to you a limited, non-exclusive,
<strong>Limited license.</strong> 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.
</p>
<p className="tos-para">
Restrictions on Use. As a condition of your use of the Websites, you
<strong>Restrictions on Use.</strong> 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.
</p>
<p className="tos-para">
Revocation of privileges. You agree that your use of the Websites may
<strong>Revocation of privileges.</strong> 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.
</p>
<p className="tos-para">
No Warranties. Koin Ping MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT
<strong>No Warranties.</strong> 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.
</p>
<p className="tos-para">
Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL Koin Ping BE
<strong>Limitation of Liability.</strong> 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