feat(web): extract static assets and routes for clarity

Refactored web server to move route registration into a dedicated method
and extracted static CSS into its own file for better maintainability.
Updated HTML template to use external stylesheet and improved JS code
style consistency. This enhances separation of concerns and prepares
for easier future asset management.
This commit is contained in:
Ruidy 2025-09-27 23:01:04 +02:00
parent f0a6b128a0
commit 9814dc5e30
No known key found for this signature in database
GPG key ID: 705C24D202990805
5 changed files with 122 additions and 102 deletions

11
internal/web/routes.go Normal file
View file

@ -0,0 +1,11 @@
package web
import (
"net/http"
)
func (h *Handler) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/checkout", h.createCheckoutSession)
mux.Handle("GET /", http.HandlerFunc(h.renderCheckoutPage))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(h.fs))))
}

View file

@ -23,6 +23,7 @@ type Handler struct {
cfg config.Config cfg config.Config
checkout checkoutService checkout checkoutService
page *template.Template page *template.Template
fs fs.FS
} }
// NewServer constructs the root HTTP handler, wiring Stripe-backed endpoints as they are implemented. // NewServer constructs the root HTTP handler, wiring Stripe-backed endpoints as they are implemented.
@ -35,12 +36,10 @@ func NewServer(cfg config.Config) http.Handler {
panic(fmt.Errorf("failed to load static assets: %w", err)) panic(fmt.Errorf("failed to load static assets: %w", err))
} }
h := &Handler{cfg: cfg, checkout: checkoutSvc, page: tmpl} h := &Handler{cfg: cfg, checkout: checkoutSvc, page: tmpl, fs: staticFS}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/checkout", h.createCheckoutSession) h.registerRoutes(mux)
mux.Handle("GET /", http.HandlerFunc(h.renderCheckoutPage))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
return mux return mux
} }

View file

@ -1,8 +1,8 @@
(() => { (() => {
const form = document.getElementById('checkout-form'); const form = document.getElementById("checkout-form");
const button = document.getElementById('checkout-button'); const button = document.getElementById("checkout-button");
const qtyInput = document.getElementById('quantity'); const qtyInput = document.getElementById("quantity");
const message = document.getElementById('message'); const message = document.getElementById("message");
if (!form || !button || !qtyInput) { if (!form || !button || !qtyInput) {
return; return;
@ -13,46 +13,46 @@
return; return;
} }
message.textContent = text; message.textContent = text;
message.style.color = isError ? '#dc2626' : '#16a34a'; message.style.color = isError ? "#dc2626" : "#16a34a";
}; };
form.addEventListener('submit', async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const quantity = Number.parseInt(qtyInput.value, 10); const quantity = Number.parseInt(qtyInput.value, 10);
if (!Number.isFinite(quantity) || quantity <= 0) { if (!Number.isFinite(quantity) || quantity <= 0) {
setMessage('Enter a quantity of at least 1.'); setMessage("Enter a quantity of at least 1.");
qtyInput.focus(); qtyInput.focus();
return; return;
} }
try { try {
button.disabled = true; button.disabled = true;
setMessage('Contacting Stripe…', false); setMessage("Contacting Stripe…", false);
const response = await fetch('/api/checkout', { const response = await fetch("/api/checkout", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ quantity }), body: JSON.stringify({ quantity }),
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(errorText || 'Checkout request failed.'); throw new Error(errorText || "Checkout request failed.");
} }
const data = await response.json(); const data = await response.json();
if (!data || !data.url) { if (!data || !data.url) {
throw new Error('Checkout response missing redirect URL.'); throw new Error("Checkout response missing redirect URL.");
} }
setMessage('Redirecting to Stripe…', false); setMessage("Redirecting to Stripe…", false);
window.location.href = data.url; window.location.href = data.url;
} catch (err) { } catch (err) {
console.error('Checkout failed', err); console.error("Checkout failed", err);
setMessage('Unable to start checkout. Please try again.'); setMessage("Unable to start checkout. Please try again.");
button.disabled = false; button.disabled = false;
} }
}); });

83
web/static/main.css Normal file
View file

@ -0,0 +1,83 @@
:root {
color-scheme: light dark;
font-family:
"Inter",
system-ui,
-apple-system,
sans-serif;
line-height: 1.5;
}
body {
margin: 0;
display: flex;
min-height: 100vh;
background: radial-gradient(circle at top, #fdfbfb, #ebedee);
justify-content: center;
align-items: center;
padding: 2rem;
}
.card {
background: rgba(255, 255, 255, 0.95);
border-radius: 18px;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12);
padding: 2.5rem;
max-width: 420px;
width: 100%;
}
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #0f172a;
}
p {
margin: 0.5rem 0 1.5rem;
color: #475569;
}
.price {
font-size: 1.5rem;
font-weight: 600;
color: #2563eb;
margin-bottom: 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
label {
font-weight: 600;
color: #1e293b;
}
input[type="number"] {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid #cbd5f5;
font-size: 1rem;
}
button {
background: linear-gradient(135deg, #2563eb, #7c3aed);
border: none;
border-radius: 12px;
color: #fff;
font-size: 1rem;
font-weight: 600;
padding: 0.9rem 1.2rem;
cursor: pointer;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
button:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
}
button[disabled] {
opacity: 0.65;
cursor: wait;
}
#message {
min-height: 1.5rem;
color: #dc2626;
font-size: 0.95rem;
}

View file

@ -4,93 +4,20 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PayIt Checkout</title> <title>PayIt Checkout</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" /> <link
<style> rel="stylesheet"
:root { href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
color-scheme: light dark; />
font-family: "Inter", system-ui, -apple-system, sans-serif; <link rel="stylesheet" href="/static/main.css" />
line-height: 1.5;
}
body {
margin: 0;
display: flex;
min-height: 100vh;
background: radial-gradient(circle at top, #fdfbfb, #ebedee);
justify-content: center;
align-items: center;
padding: 2rem;
}
.card {
background: rgba(255, 255, 255, 0.95);
border-radius: 18px;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12);
padding: 2.5rem;
max-width: 420px;
width: 100%;
}
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #0f172a;
}
p {
margin: 0.5rem 0 1.5rem;
color: #475569;
}
.price {
font-size: 1.5rem;
font-weight: 600;
color: #2563eb;
margin-bottom: 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
label {
font-weight: 600;
color: #1e293b;
}
input[type="number"] {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid #cbd5f5;
font-size: 1rem;
}
button {
background: linear-gradient(135deg, #2563eb, #7c3aed);
border: none;
border-radius: 12px;
color: #fff;
font-size: 1rem;
font-weight: 600;
padding: 0.9rem 1.2rem;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
button:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
}
button[disabled] {
opacity: 0.65;
cursor: wait;
}
#message {
min-height: 1.5rem;
color: #dc2626;
font-size: 0.95rem;
}
</style>
</head> </head>
<body> <body>
<main class="card"> <main class="card">
<h1>{{ .ProductName }}</h1> <h1>{{ .ProductName }}</h1>
<p>{{ .ProductDescription }}</p> <p>{{ .ProductDescription }}</p>
<div class="price">{{ .PriceDisplay }} <span class="currency">{{ .Currency }}</span></div> <div class="price">
<form id="checkout-form"> {{ .PriceDisplay }} <span class="currency">{{ .Currency }}</span>
</div>
<form id="checkout-form" action="/api/checkout">
<label for="quantity">Quantity</label> <label for="quantity">Quantity</label>
<input id="quantity" name="quantity" type="number" value="1" min="1" /> <input id="quantity" name="quantity" type="number" value="1" min="1" />
<button id="checkout-button" type="submit">Buy now</button> <button id="checkout-button" type="submit">Buy now</button>