Compare commits
9 Commits
update-sub
...
update-REA
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d68ce40e7 | ||
|
|
ae256eea91 | ||
|
|
7bad2ab562 | ||
|
|
54b7c92e83 | ||
|
|
39c6957e2f | ||
|
|
4f44af5180 | ||
|
|
a341741ac6 | ||
|
|
c387798be7 | ||
|
|
cb476c38d7 |
@@ -31,3 +31,15 @@ Backend startup quickstart:
|
||||
make poller — Builds and runs the poller.
|
||||
make poller-dev — Runs the poller with auto-reload.
|
||||
|
||||
## Production: `make build` + restart service
|
||||
|
||||
On the server, from this directory:
|
||||
|
||||
- **`make build`** — writes fresh **`bin/api`** and **`bin/poller`** (see the Makefile).
|
||||
- **`make build-api`** — API only; **`make build-poller`** — poller only.
|
||||
|
||||
Building alone does **not** reload a process that is already listening (e.g. on port 8080). After deploy:
|
||||
|
||||
1. Ensure **`systemctl`** (or your supervisor) **`ExecStart`** runs the binary you just built (e.g. **`.../backend/bin/api`**) or copy `bin/api` to that path.
|
||||
2. **`sudo systemctl restart <your-api-service>`** (and the poller service if you rebuilt it).
|
||||
3. Optional check: **`readlink -f /proc/<pid>/exe`** should show your **`bin/api`** path without **`(deleted)`** — if you see `(deleted)`, an old process is still running the previous binary from memory.
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
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"
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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";
|
||||
import Support from "./pages/support/Support";
|
||||
|
||||
export default function App() {
|
||||
const { currentUser, isSubscribed } = useAuth();
|
||||
@@ -27,18 +31,27 @@ export default function App() {
|
||||
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<div className="app-layout__main">
|
||||
<Routes>
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<Route path="/subscribe/return/:sessionId" element={<CheckoutReturn />} />
|
||||
<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>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="app-layout">
|
||||
<Navbar />
|
||||
<div className="app-layout__main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Addresses />} />
|
||||
<Route path="/addresses" element={<Addresses />} />
|
||||
@@ -47,8 +60,13 @@ export default function App() {
|
||||
<Route path="/account" element={<Account />} />
|
||||
<Route path="/subscribe" element={<Subscribe />} />
|
||||
<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>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || "/v1";
|
||||
const DEFAULT_API_BASE = "/v1";
|
||||
|
||||
/**
|
||||
* API origin for fetch(): root-relative prefix (e.g. /v1) or absolute URL (https://host/v1).
|
||||
* Root-relative values are forced to start with "/" so requests work from any SPA route
|
||||
* (e.g. /support); otherwise "v1/support" would resolve under the current path and 404.
|
||||
*/
|
||||
function normalizeApiBase(raw) {
|
||||
const s = String(raw ?? "").trim();
|
||||
const base = s || DEFAULT_API_BASE;
|
||||
|
||||
if (/^https?:\/\//i.test(base)) {
|
||||
return base.replace(/\/+$/, "") || DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
const path = base.startsWith("/") ? base : `/${base}`;
|
||||
return path.replace(/\/+$/, "") || DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
export const API_BASE = normalizeApiBase(import.meta.env.VITE_API_BASE);
|
||||
|
||||
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 (HTTP ${res.status}). The server did not return JSON — check that POST ${API_BASE}/support is proxied to your Go API (e.g. nginx location /v1/).`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
36
frontend/src/components/Footer.css
Normal file
36
frontend/src/components/Footer.css
Normal file
@@ -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;
|
||||
}
|
||||
41
frontend/src/components/Footer.jsx
Normal file
41
frontend/src/components/Footer.jsx
Normal file
@@ -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 (
|
||||
<footer className="footer">
|
||||
<div className="footer__inner">
|
||||
<span className="footer__copyright">© 2026 sjDev.co</span>
|
||||
<Link
|
||||
to="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
>
|
||||
Terms
|
||||
</Link>
|
||||
<Link
|
||||
to="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
>
|
||||
Privacy
|
||||
</Link>
|
||||
<Link
|
||||
to="/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
>
|
||||
Contact Support
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,12 @@ const navLinks = [
|
||||
|
||||
export default function Navbar() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const authData = useAuth();
|
||||
const location = useLocation();
|
||||
const [isNavPanelOpen, setIsNavPanelOpen] = useState(false);
|
||||
|
||||
if (!currentUser) return null;
|
||||
console.log(authData)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
frontend/src/pages/privacy/Privacy.css
Normal file
24
frontend/src/pages/privacy/Privacy.css
Normal file
@@ -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;
|
||||
}
|
||||
30
frontend/src/pages/privacy/Privacy.jsx
Normal file
30
frontend/src/pages/privacy/Privacy.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import "./Privacy.css";
|
||||
|
||||
export default function Privacy() {
|
||||
return (
|
||||
<div className="privacy-container">
|
||||
<h1>Privacy</h1>
|
||||
<p className="privacy-para">
|
||||
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.
|
||||
</p>
|
||||
<p className="privacy-para">
|
||||
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.
|
||||
</p>
|
||||
<p className="privacy-para">
|
||||
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.
|
||||
</p>
|
||||
<p className="privacy-para">
|
||||
If you would like to remove your data, or if you have any questions
|
||||
about how your data is used, please contact us.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/pages/terms/Terms.css
Normal file
29
frontend/src/pages/terms/Terms.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.tos-para strong {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
141
frontend/src/pages/terms/Terms.jsx
Normal file
141
frontend/src/pages/terms/Terms.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import "./Terms.css";
|
||||
|
||||
export default function Terms() {
|
||||
return (
|
||||
<div className="tos-container">
|
||||
<h1>Terms of Service And Use</h1>
|
||||
<p className="tos-para">
|
||||
YOU SHOULD READ THESE TERMS OF SERVICE AND USE BEFORE USING THIS WEB
|
||||
SITE.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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,
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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.
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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
|
||||
the copyright, trademark and other laws of the United States and other
|
||||
countries sand international treaty provisions.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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,
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
<p className="tos-para">
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user