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

View File

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

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

View File

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

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

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 { .tos-para:last-child {
margin-bottom: 0; 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. 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