additional fetaure buildout for footer and footer links
This commit is contained in:
@@ -63,6 +63,7 @@ func main() {
|
|||||||
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
statusHandler := handlers.NewStatusHandler(checkpointModel)
|
||||||
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
|
stripeHandler := handlers.NewStripeHandler(userModel, alertRuleModel, cfg)
|
||||||
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
accountHandler := handlers.NewAccountHandler(userModel, addressModel, cfg)
|
||||||
|
supportHandler := handlers.NewSupportHandler(cfg)
|
||||||
|
|
||||||
authenticate := middleware.Authenticate(userModel)
|
authenticate := middleware.Authenticate(userModel)
|
||||||
requireSub := middleware.RequireSubscription(userModel)
|
requireSub := middleware.RequireSubscription(userModel)
|
||||||
@@ -101,6 +102,10 @@ func main() {
|
|||||||
mux.Handle("GET "+b+"/user/account",
|
mux.Handle("GET "+b+"/user/account",
|
||||||
authenticate(http.HandlerFunc(accountHandler.GetAccount)))
|
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
|
// Authenticated + subscribed routes — addresses
|
||||||
mux.Handle("POST "+b+"/addresses",
|
mux.Handle("POST "+b+"/addresses",
|
||||||
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
authAndSub(http.HandlerFunc(addressHandler.Create)))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//loads environment-based configuration.
|
// loads environment-based configuration.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,65 +9,67 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultPort = 3001
|
defaultPort = 3001
|
||||||
defaultDBPort = 5432
|
defaultDBPort = 5432
|
||||||
defaultPollIntervalMS = 60000
|
defaultPollIntervalMS = 60000
|
||||||
minPollIntervalMS = 1000
|
minPollIntervalMS = 1000
|
||||||
defaultDigestIntervalHours = 24
|
defaultDigestIntervalHours = 24
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
APIBasePath string
|
APIBasePath string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
DBHost string
|
DBHost string
|
||||||
DBPort int
|
DBPort int
|
||||||
DBUser string
|
DBUser string
|
||||||
DBPassword string
|
DBPassword string
|
||||||
DBName string
|
DBName string
|
||||||
FirebaseProjectID string
|
FirebaseProjectID string
|
||||||
EthRPCURL string
|
EthRPCURL string
|
||||||
PollIntervalMS int
|
PollIntervalMS int
|
||||||
NodeEnv string
|
NodeEnv string
|
||||||
ResendAPIKey string
|
ResendAPIKey string
|
||||||
EmailFrom string
|
EmailFrom string
|
||||||
DigestIntervalHours int
|
SupportInboxEmail string
|
||||||
StripeSecretKey string
|
DigestIntervalHours int
|
||||||
StripeWebhookSecret string
|
StripeSecretKey string
|
||||||
StripePriceIDPremium string
|
StripeWebhookSecret string
|
||||||
StripePriceIDPro string
|
StripePriceIDPremium string
|
||||||
StripePriceIDPremiumAnnual string
|
StripePriceIDPro string
|
||||||
StripePriceIDProAnnual string
|
StripePriceIDPremiumAnnual string
|
||||||
StripePublishableKey string
|
StripePriceIDProAnnual string
|
||||||
FrontendURL string
|
StripePublishableKey string
|
||||||
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and returns a Config.
|
// Load reads configuration from environment variables and returns a Config.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: getEnvInt("PORT", defaultPort),
|
Port: getEnvInt("PORT", defaultPort),
|
||||||
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
APIBasePath: getEnv("API_BASE_PATH", "/v1"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
DBPort: getEnvInt("DB_PORT", defaultDBPort),
|
||||||
DBUser: os.Getenv("DB_USER"),
|
DBUser: os.Getenv("DB_USER"),
|
||||||
DBPassword: os.Getenv("DB_PASSWORD"),
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||||
DBName: os.Getenv("DB_NAME"),
|
DBName: os.Getenv("DB_NAME"),
|
||||||
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
|
||||||
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
EthRPCURL: os.Getenv("ETH_RPC_URL"),
|
||||||
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
PollIntervalMS: getEnvInt("POLL_INTERVAL_MS", defaultPollIntervalMS),
|
||||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||||
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
ResendAPIKey: os.Getenv("RESEND_API_KEY"),
|
||||||
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
EmailFrom: getEnv("EMAIL_FROM", "Koin Ping <alerts@koinping.com>"),
|
||||||
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
SupportInboxEmail: getEnv("SUPPORT_INBOX_EMAIL", "sj@sjdev.co"),
|
||||||
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
DigestIntervalHours: getEnvInt("DIGEST_INTERVAL_HOURS", defaultDigestIntervalHours),
|
||||||
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
|
||||||
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||||
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
StripePriceIDPremium: os.Getenv("STRIPE_PRICE_ID_PREMIUM"),
|
||||||
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
|
StripePriceIDPro: os.Getenv("STRIPE_PRICE_ID_PRO"),
|
||||||
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
|
StripePriceIDPremiumAnnual: os.Getenv("STRIPE_PRICE_ID_PREMIUM_ANNUAL"),
|
||||||
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
StripePriceIDProAnnual: os.Getenv("STRIPE_PRICE_ID_PRO_ANNUAL"),
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
StripePublishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||||
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PollIntervalMS < minPollIntervalMS {
|
if cfg.PollIntervalMS < minPollIntervalMS {
|
||||||
|
|||||||
111
backend/internal/handlers/support.go
Normal file
111
backend/internal/handlers/support.go
Normal 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"})
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -156,3 +158,57 @@ func alertTypeLabel(alertType string) string {
|
|||||||
return "Alert"
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import AlertHistory from "./pages/alertHistory/AlertHistory";
|
|||||||
import Account from "./pages/user_account/Account";
|
import Account from "./pages/user_account/Account";
|
||||||
import Terms from "./pages/terms/Terms";
|
import Terms from "./pages/terms/Terms";
|
||||||
import Privacy from "./pages/privacy/Privacy";
|
import Privacy from "./pages/privacy/Privacy";
|
||||||
|
import Support from "./pages/support/Support";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { currentUser, isSubscribed } = useAuth();
|
const { currentUser, isSubscribed } = useAuth();
|
||||||
@@ -38,6 +39,7 @@ export default function App() {
|
|||||||
<Route path="/account" element={<><Navbar /><Account /></>} />
|
<Route path="/account" element={<><Navbar /><Account /></>} />
|
||||||
<Route path="/terms" element={<><Navbar /><Terms /></>} />
|
<Route path="/terms" element={<><Navbar /><Terms /></>} />
|
||||||
<Route path="/privacy" element={<><Navbar /><Privacy /></>} />
|
<Route path="/privacy" element={<><Navbar /><Privacy /></>} />
|
||||||
|
<Route path="/support" element={<><Navbar /><Support /></>} />
|
||||||
<Route path="*" element={<Navigate to="/subscribe" />} />
|
<Route path="*" element={<Navigate to="/subscribe" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,6 +62,7 @@ export default function App() {
|
|||||||
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
|
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
|
||||||
<Route path="/terms" element={<Terms />} />
|
<Route path="/terms" element={<Terms />} />
|
||||||
<Route path="/privacy" element={<Privacy />} />
|
<Route path="/privacy" element={<Privacy />} />
|
||||||
|
<Route path="/support" element={<Support />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
frontend/src/api/support.js
Normal file
23
frontend/src/api/support.js
Normal 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();
|
||||||
|
}
|
||||||
@@ -27,14 +27,14 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
Privacy
|
Privacy
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href="mailto:support@koinp.ing"
|
to="/support"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="footer__link"
|
className="footer__link"
|
||||||
>
|
>
|
||||||
Contact Support
|
Contact Support
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
31
frontend/src/pages/support/Support.css
Normal file
31
frontend/src/pages/support/Support.css
Normal 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;
|
||||||
|
}
|
||||||
121
frontend/src/pages/support/Support.jsx
Normal file
121
frontend/src/pages/support/Support.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,3 +22,8 @@
|
|||||||
.tos-para:last-child {
|
.tos-para:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tos-para strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Terms() {
|
|||||||
TO ACCESS OR USE THE WEB SITE OR APPS.
|
TO ACCESS OR USE THE WEB SITE OR APPS.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
you consent to receiving communication, in connection with your
|
||||||
Membership subscription. This may include communication about your
|
Membership subscription. This may include communication about your
|
||||||
account, features, and services via e-email, push notification, phone,
|
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.
|
charges applied by your cell phone carrier may apply.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
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;
|
least 18 years of age. The Websites may be accessible worldwide;
|
||||||
however, the Websites are intended for use only in the USA and Canada.
|
however, the Websites are intended for use only in the USA and Canada.
|
||||||
@@ -48,7 +48,7 @@ export default function Terms() {
|
|||||||
Websites.
|
Websites.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
code, and related functionality appearing, featured or otherwise
|
||||||
displayed on the Websites are owned by Koin Ping, its affiliates, and
|
displayed on the Websites are owned by Koin Ping, its affiliates, and
|
||||||
their respective licensors or other third parties and protected under
|
their respective licensors or other third parties and protected under
|
||||||
@@ -56,7 +56,7 @@ export default function Terms() {
|
|||||||
countries sand international treaty provisions.
|
countries sand international treaty provisions.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
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
|
these Terms of Service and Use and the instructions provided by us on
|
||||||
the Web Site. The materials provided on the Web Site, including,
|
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.
|
publicity and communications regulations and statutes.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
warrant that you will not use the Websites for any purpose that is
|
||||||
unlawful or prohibited by these terms, conditions and notices. You may
|
unlawful or prohibited by these terms, conditions and notices. You may
|
||||||
not use the Websites in any way that could damage, disable, overburden
|
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.
|
available or provided for through the Web Site.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
be suspended or terminated immediately upon receipt of any notice which
|
||||||
alleges that you have used the Websites in violation of these Terms of
|
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
|
Use and/or for any purpose that violates any local, state, federal or
|
||||||
@@ -102,7 +102,7 @@ export default function Terms() {
|
|||||||
protect our rights.
|
protect our rights.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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
|
THE WEB SITE, THE SUITABILITY OF THE INFORMATION CONTAINED ON OR
|
||||||
RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
|
RECEIVED THROUGH THE WEB SITE, OR ANY SERVICES OR PRODUCTS RECEIVED
|
||||||
THROUGH THE WEB SITE. ALL INFORMATION AND USE OF THE WEB SITE ARE
|
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.
|
REPRESENTATIONS OR STATEMENTS OTHER THAN IN THIS AGREEMENT.
|
||||||
</p>
|
</p>
|
||||||
<p className="tos-para">
|
<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,
|
LIABLE FOR ANY DAMAGES, INCLUDING, WITHOUT LIMITATION, DIRECT,
|
||||||
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
|
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES OR LOST
|
||||||
PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE
|
PROFITS THAT RESULT FROM, OR ARISE OUT OF OR IN CONNECTION WITH THE USE
|
||||||
|
|||||||
Reference in New Issue
Block a user