Add GIVE_DOMAINS support with separate static directories
@@ -1,8 +1,9 @@
|
|||||||
|
- Never change directories (no `cd`)
|
||||||
- DO NOT add comments to code unless something is *incredibly* subtle
|
- DO NOT add comments to code unless something is *incredibly* subtle
|
||||||
- Use e.g. map[string]bool{} instead of make() wherever possible
|
- Use e.g. map[string]bool{} instead of make() wherever possible
|
||||||
- Use all-lowercase log messages
|
- Use all-lowercase log messages
|
||||||
- Prepend log messages with [ERROR] if applicable
|
- Prepend log messages with [ERROR] if applicable
|
||||||
- Don't mention claude in commit messages. Keep them to a single, short, descriptive sentence
|
- Don't mention claude in commit messages (no Co-Authored-By). Keep them to a single, short, descriptive sentence
|
||||||
- Always push after commiting
|
- Always push after commiting
|
||||||
- Use git add -A so you don't miss files when committing
|
- Use git add -A so you don't miss files when committing
|
||||||
- Never use go build -- use go run instead
|
- Never use go build -- use go run instead
|
||||||
|
|||||||
47
main.go
@@ -41,14 +41,33 @@ var events = map[string]eventInfo{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SiteMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SiteModeRSVP SiteMode = "rsvp"
|
||||||
|
SiteModeGive SiteMode = "give"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
templates *template.Template
|
modeTemplates = map[SiteMode]*template.Template{}
|
||||||
db *sql.DB
|
domainModes = map[string]SiteMode{}
|
||||||
|
db *sql.DB
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
templates = template.Must(template.New("").ParseGlob("static/*.html"))
|
for _, d := range strings.Split(os.Getenv("GIVE_DOMAINS"), ",") {
|
||||||
template.Must(templates.ParseGlob("static/*.js"))
|
d = strings.TrimSpace(d)
|
||||||
|
if d != "" {
|
||||||
|
domainModes[d] = SiteModeGive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mode := range []SiteMode{SiteModeRSVP, SiteModeGive} {
|
||||||
|
dir := "static/" + string(mode) + "/"
|
||||||
|
modeTemplates[mode] = template.Must(template.New("").ParseGlob("static/shared/*.html"))
|
||||||
|
template.Must(modeTemplates[mode].ParseGlob(dir + "*.html"))
|
||||||
|
template.Must(modeTemplates[mode].ParseGlob(dir + "*.js"))
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
db, err = sql.Open("postgres", os.Getenv("PGCONN"))
|
db, err = sql.Open("postgres", os.Getenv("PGCONN"))
|
||||||
@@ -111,9 +130,12 @@ func handleStatic(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimPrefix(path, "/")
|
name := strings.TrimPrefix(path, "/")
|
||||||
|
mode := getSiteMode(r)
|
||||||
|
tmpl := modeTemplates[mode]
|
||||||
|
staticDir := filepath.Join("static", string(mode))
|
||||||
|
|
||||||
if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".js") {
|
if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".js") {
|
||||||
t := templates.Lookup(name)
|
t := tmpl.Lookup(name)
|
||||||
if t == nil {
|
if t == nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -128,7 +150,7 @@ func handleStatic(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(name, ".") {
|
if !strings.Contains(name, ".") {
|
||||||
t := templates.Lookup(name + ".html")
|
t := tmpl.Lookup(name + ".html")
|
||||||
if t != nil {
|
if t != nil {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
t.Execute(w, templateData())
|
t.Execute(w, templateData())
|
||||||
@@ -136,7 +158,7 @@ func handleStatic(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http.ServeFile(w, r, filepath.Join("static", name))
|
http.ServeFile(w, r, filepath.Join(staticDir, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
func templateData() map[string]any {
|
func templateData() map[string]any {
|
||||||
@@ -155,6 +177,17 @@ func envMap() map[string]string {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getSiteMode(r *http.Request) SiteMode {
|
||||||
|
host := r.Host
|
||||||
|
if idx := strings.Index(host, ":"); idx != -1 {
|
||||||
|
host = host[:idx]
|
||||||
|
}
|
||||||
|
if mode, ok := domainModes[host]; ok {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return SiteModeRSVP
|
||||||
|
}
|
||||||
|
|
||||||
func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
|
func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
credential := r.FormValue("credential")
|
credential := r.FormValue("credential")
|
||||||
if credential == "" {
|
if credential == "" {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
203
static/give/afac26.html
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Donate - Applause for a Cause</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: var(--wa-font-sans);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
#main { width: 100%; max-width: 600px; }
|
||||||
|
.event-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.event-header img {
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.event-header p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.donation-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.donation-group wa-button::part(base) {
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.donation-group wa-button[data-value="custom"]::part(base) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.donation-group wa-input {
|
||||||
|
width: 80px;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
--wa-focus-ring-width: 0;
|
||||||
|
--wa-input-border-width: 0;
|
||||||
|
--wa-input-focus-ring-color: transparent;
|
||||||
|
--wa-input-border-color: transparent;
|
||||||
|
--wa-input-border-color-focus: transparent;
|
||||||
|
}
|
||||||
|
wa-input[type="number"]::part(input) {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.donation-group wa-input::part(base) {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.donation-group wa-input::part(base):focus-within {
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
wa-input[type="number"]::part(input)::-webkit-inner-spin-button,
|
||||||
|
wa-input[type="number"]::part(input)::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
wa-button.selected::part(base) {
|
||||||
|
background-color: #FEDE02;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.donation-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.thank-you {
|
||||||
|
background: var(--wa-color-success-95);
|
||||||
|
color: var(--wa-color-success-30);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.wa-dark .thank-you {
|
||||||
|
background: var(--wa-color-success-20);
|
||||||
|
color: var(--wa-color-success-90);
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: var(--wa-color-neutral-95);
|
||||||
|
color: var(--wa-color-neutral-20);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.wa-dark .info-box {
|
||||||
|
background: var(--wa-color-neutral-20);
|
||||||
|
color: var(--wa-color-neutral-90);
|
||||||
|
}
|
||||||
|
.donation-group wa-button {
|
||||||
|
width: calc(50% - 0.25rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="opacity: 0">
|
||||||
|
<div id="main">
|
||||||
|
<div id="thank-you" class="thank-you" style="display: none;"></div>
|
||||||
|
<wa-card>
|
||||||
|
<div class="event-header">
|
||||||
|
<img src="/afac26-logo.png" alt="Applause for a Cause">
|
||||||
|
<p>Saturday, February 7, 2026 · 6:30 PM</p>
|
||||||
|
<p>Helios Gym</p>
|
||||||
|
<p>597 Central Avenue, Sunnyvale, CA 94086</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-box">
|
||||||
|
<p style="margin: 0;">All donations go to <a href="https://svcommunityservices.org/" target="_blank">Sunnyvale Community Services</a>. Thank you for your generosity!</p>
|
||||||
|
</div>
|
||||||
|
<div class="donation-section">
|
||||||
|
<div style="font-weight: bold; margin-bottom: 1rem;">Select a donation amount:</div>
|
||||||
|
<div class="button-group donation-group" id="donation-group">
|
||||||
|
<wa-button variant="neutral" class="selected" data-value="25">$25</wa-button>
|
||||||
|
<wa-button variant="neutral" data-value="50">$50</wa-button>
|
||||||
|
<wa-button variant="neutral" data-value="custom"><span>Other</span><wa-input type="number" size="small" id="custom-amount" min="1" value="100"><span slot="start">$</span></wa-input></wa-button>
|
||||||
|
<wa-button variant="neutral" data-value="100">$100</wa-button>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 1.5rem;">
|
||||||
|
<wa-button variant="brand" id="donate-btn">Donate</wa-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</wa-card>
|
||||||
|
</div>
|
||||||
|
<script type="module">
|
||||||
|
import { api } from '/app.js';
|
||||||
|
|
||||||
|
const eventId = 'afac26';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const donatedParam = params.get('donated');
|
||||||
|
if (donatedParam) {
|
||||||
|
const el = document.getElementById('thank-you');
|
||||||
|
el.textContent = `Thank you for your $${parseFloat(donatedParam).toFixed(2)} donation!`;
|
||||||
|
el.style.display = 'block';
|
||||||
|
history.replaceState({}, '', location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedDonation = 25;
|
||||||
|
|
||||||
|
function setupButtonGroup(groupId, onChange) {
|
||||||
|
const group = document.getElementById(groupId);
|
||||||
|
group.querySelectorAll('wa-button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
if (e.target.tagName === 'WA-INPUT') return;
|
||||||
|
group.querySelectorAll('wa-button').forEach(b => b.classList.remove('selected'));
|
||||||
|
btn.classList.add('selected');
|
||||||
|
onChange(btn.dataset.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupButtonGroup('donation-group', val => {
|
||||||
|
selectedDonation = val === 'custom' ? 'custom' : parseInt(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('custom-amount').addEventListener('focus', () => {
|
||||||
|
const group = document.getElementById('donation-group');
|
||||||
|
group.querySelectorAll('wa-button').forEach(b => b.classList.remove('selected'));
|
||||||
|
group.querySelector('wa-button[data-value="custom"]').classList.add('selected');
|
||||||
|
selectedDonation = 'custom';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('donate-btn').addEventListener('click', async () => {
|
||||||
|
let donationCents = 0;
|
||||||
|
if (selectedDonation === 'custom') {
|
||||||
|
donationCents = (parseInt(document.getElementById('custom-amount').value) || 0) * 100;
|
||||||
|
} else {
|
||||||
|
donationCents = selectedDonation * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (donationCents <= 0) {
|
||||||
|
alert('Please select a donation amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await api('POST', `/api/donate/${eventId}`, { donationCents });
|
||||||
|
if (data.url) {
|
||||||
|
location.href = data.url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await customElements.whenDefined('wa-card');
|
||||||
|
document.body.style.opacity = 1;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
static/give/app.js
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../rsvp/app.js
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
46
static/give/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HCA Give</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: var(--wa-font-sans);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
#main { width: 100%; max-width: 600px; }
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.event-card img {
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.event-info p { margin: 0; opacity: 0.7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="opacity: 0">
|
||||||
|
<div id="main">
|
||||||
|
<h2>Events</h2>
|
||||||
|
{{template "event-afac26"}}
|
||||||
|
</div>
|
||||||
|
<script type="module">
|
||||||
|
import { init } from '/app.js';
|
||||||
|
|
||||||
|
await init(false);
|
||||||
|
|
||||||
|
await customElements.whenDefined('wa-card');
|
||||||
|
document.body.style.opacity = 1;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
static/rsvp/afac26-logo.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
@@ -186,9 +186,9 @@
|
|||||||
<wa-button variant="text" size="small" href="/" style="margin-top: 2rem; margin-left: 3px;">« Events</wa-button>
|
<wa-button variant="text" size="small" href="/" style="margin-top: 2rem; margin-left: 3px;">« Events</wa-button>
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { auth, logout, api } from '/app.js';
|
import { init, logout, api } from '/app.js';
|
||||||
|
|
||||||
await auth();
|
await init(true);
|
||||||
document.getElementById('main').style.display = 'block';
|
document.getElementById('main').style.display = 'block';
|
||||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||||
|
|
||||||
@@ -46,10 +46,12 @@ export async function api(method, path, body) {
|
|||||||
const opts = {
|
const opts = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + (profile?.token || ''),
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if (profile?.token) {
|
||||||
|
opts.headers['Authorization'] = 'Bearer ' + profile.token;
|
||||||
|
}
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
opts.body = JSON.stringify(body);
|
opts.body = JSON.stringify(body);
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,11 @@ const googleReady = new Promise((resolve) => {
|
|||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function auth() {
|
export async function init(requireAuth) {
|
||||||
|
if (!requireAuth) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let profile = getProfile();
|
let profile = getProfile();
|
||||||
if (profile) {
|
if (profile) {
|
||||||
bind(profile);
|
bind(profile);
|
||||||
BIN
static/rsvp/favicon.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
6
static/rsvp/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect x="10" y="25" width="80" height="50" rx="5" fill="#FEDE02"/>
|
||||||
|
<circle cx="10" cy="50" r="8" fill="#fff"/>
|
||||||
|
<circle cx="90" cy="50" r="8" fill="#fff"/>
|
||||||
|
<line x1="30" y1="25" x2="30" y2="75" stroke="#fff" stroke-width="2" stroke-dasharray="4,4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
@@ -59,20 +59,12 @@
|
|||||||
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
|
<wa-button variant="neutral" size="small" id="logout-btn">Switch User</wa-button>
|
||||||
</div>
|
</div>
|
||||||
<h2>Events</h2>
|
<h2>Events</h2>
|
||||||
<wa-card>
|
{{template "event-afac26"}}
|
||||||
<a href="/afac26" class="event-card">
|
|
||||||
<img src="/afac26-logo.png" alt="Applause for a Cause">
|
|
||||||
<div class="event-info">
|
|
||||||
<p>Saturday, February 7, 2026 · 6:30 PM</p>
|
|
||||||
<p>Helios Gym</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</wa-card>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { auth, logout } from '/app.js';
|
import { init, logout } from '/app.js';
|
||||||
|
|
||||||
await auth();
|
await init(true);
|
||||||
document.getElementById('main').style.display = 'block';
|
document.getElementById('main').style.display = 'block';
|
||||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||||
|
|
||||||
11
static/shared/events.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{define "event-afac26"}}
|
||||||
|
<wa-card>
|
||||||
|
<a href="/afac26" class="event-card">
|
||||||
|
<img src="/afac26-logo.png" alt="Applause for a Cause">
|
||||||
|
<div class="event-info">
|
||||||
|
<p>Saturday, February 7, 2026 · 6:30 PM</p>
|
||||||
|
<p>Helios Gym</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</wa-card>
|
||||||
|
{{end}}
|
||||||