ghostguild-org/app/pages/join.vue
Jennie Robinson Faber 208638e374 feat(launch): security and correctness fixes for 2026-05-01 launch
Day-of-launch deep-dive audit and remediation. 11 issues fixed across
security, correctness, and reliability. Tests: 698 → 758 passing
(+60), 0 failing, 2 skipped.

CRITICAL (security)

Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead
useHelcim.js deleted. Production token MUST BE ROTATED post-deploy
(was previously exposed in window.__NUXT__ payload).

Fix #2 — /api/helcim/customer gated with origin check + per-IP/email
rate limit + magic-link email verification (replaces unauthenticated
setAuthCookie). Adds payment-bridge token for paid-tier signup so
users can complete Helcim checkout before email verify. New utils:
server/utils/{magicLink,rateLimit}.js. UX: signup success copy now
prompts user to check email.

Fix #3 — /api/events/[id]/payment deleted (dead code with unauth
member-spoof bypass — processHelcimPayment was a permanent stub).
Removes processHelcimPayment export and eventPaymentSchema.

Fix #4 — /api/helcim/initialize-payment re-derives ticket amount
server-side via calculateTicketPrice and calculateSeriesTicketPrice.
Adds new series_ticket metadata type (was being shoved through
event_ticket with seriesId in metadata.eventId).

Fix #5 — /api/helcim/customer upgrades existing status:guest members
in place rather than rejecting with 409. Lowercases email at lookup;
preserves _id so prior event registrations stay linked.

HIGH (correctness / reliability)

Fix #6 — Daily reconciliation cron via Netlify scheduled function
(@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs,
server/api/internal/reconcile-payments.post.js. Shared-secret auth
via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff
on Helcim transactions API.

Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist
endpoints) to dodge legacy location validators.

Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest
Member when caller is unauthenticated, mirrors event-ticket flow
byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and
client auth refresh on signedIn:true response.

Fix #9 — /api/members/cancel-subscription leaves status active per
ratified bylaws (was pending_payment). Adds lastCancelledAt audit
field on Member model. Indirectly fixes false-positive
detectStuckPendingPayment admin alert for cancelled members.

Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema
(verifyMagicLinkSchema, max 2000 chars).

Fix #11 — 8 vitest cases for cancel-subscription handler (was
uncovered).

Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and
docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md.
LAUNCH_READINESS.md updated with new test count, 3 deploy-time
tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify
Netlify scheduled function), and Fixed-2026-04-25 fix log.
2026-04-25 18:42:36 +01:00

1130 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<!-- HERO -->
<div class="hero">
<h1>Join Ghost Guild</h1>
<p>
Resources, events, and a community of people figuring it out. Everyone
gets everything. Pay what you can.
</p>
</div>
<!-- Already a member -->
<template v-if="isAuthenticated">
<div class="full-section">
<h2>You're already a member</h2>
<p class="section-intro">
Welcome back, {{ memberData?.name || "member" }}. You're part of Ghost
Guild in the
<span class="capitalize">{{
memberData?.circle || "community"
}}</span>
circle.
</p>
<div class="member-info-grid">
<DashedBox :hoverable="false">
<div class="section-label">Circle</div>
<div class="info-value capitalize">
{{ memberData?.circle || "Community" }}
</div>
</DashedBox>
<DashedBox :hoverable="false">
<div class="section-label">Contribution</div>
<div class="info-value">
${{ memberData?.contributionAmount ?? 0 }} CAD/month
</div>
</DashedBox>
</div>
<div class="button-row">
<NuxtLink to="/member/dashboard" class="form-submit"
>Go to Dashboard</NuxtLink
>
<NuxtLink to="/member/profile" class="btn">Edit Profile</NuxtLink>
</div>
</div>
<ParchmentInset>
<h2>Want to change your circle or contribution?</h2>
<p>
You can update your circle and adjust your monthly contribution at any
time from your profile settings.
</p>
<NuxtLink to="/member/profile" class="parchment-link"
>Update Membership Settings</NuxtLink
>
</ParchmentInset>
</template>
<!-- Not authenticated: show full join page -->
<template v-else>
<!-- CONTRIBUTION + SIGN UP (two columns) -->
<div class="join-two-col">
<!-- Left: Monthly Contribution -->
<div class="join-col">
<div class="section-label" style="margin-bottom: 12px">
{{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
</div>
<h2>Pay what you can</h2>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">{{ formatContributionAmount(5) }}</span> I can contribute</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
(suggested)
</li>
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
<li>
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
members
</li>
</ul>
<p class="charity-note">
Baby Ghosts Studio Development Fund is a registered Canadian charity.
Members who file Canadian taxes can claim their contributions.
We'll help you set up tax receipts once you've joined.
</p>
<p class="solidarity-note">
Pay what you can. If you can pay more, you're making room for
someone who can't.
</p>
<p class="circle-not-sure">
Not sure where you fit? Start with Community. You can always move
later.
</p>
</div>
<!-- Right: Become a member -->
<div class="join-col">
<h2>Become a member</h2>
<p class="form-intro">
You'll get a magic link to confirm your email. No passwords.
</p>
<!-- Error Message -->
<div v-if="errorMessage" class="error-box">
{{ errorMessage }}
</div>
<form @submit.prevent="handleSubmit">
<div class="form-stack">
<div class="form-group">
<label class="form-label" for="join-name">Full Name</label>
<input
id="join-name"
v-model="form.name"
class="form-input"
type="text"
placeholder="Your name"
required
>
</div>
<div class="form-group">
<label class="form-label" for="join-email">Email Address</label>
<input
id="join-email"
v-model="form.email"
class="form-input"
type="email"
placeholder="you@example.com"
required
>
</div>
<div class="form-group">
<label class="form-label">Circle</label>
<div class="circle-radios">
<div class="circle-radio community">
<input
id="circle-community"
v-model="form.circle"
type="radio"
name="circle"
value="community"
>
<label for="circle-community">
<span
class="circle-label-name"
style="color: var(--c-community)"
>Community</span
>
<span class="circle-label-desc">Exploring</span>
</label>
</div>
<div class="circle-radio founder">
<input
id="circle-founder"
v-model="form.circle"
type="radio"
name="circle"
value="founder"
>
<label for="circle-founder">
<span
class="circle-label-name"
style="color: var(--c-founder)"
>Founder</span
>
<span class="circle-label-desc">Building</span>
</label>
</div>
<div class="circle-radio practitioner">
<input
id="circle-practitioner"
v-model="form.circle"
type="radio"
name="circle"
value="practitioner"
>
<label for="circle-practitioner">
<span
class="circle-label-name"
style="color: var(--c-practitioner)"
>Practitioner</span
>
<span class="circle-label-desc">Practicing</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Billing Cadence</label>
<div class="cadence-radios">
<div class="circle-radio">
<input
id="cadence-monthly"
v-model="cadence"
type="radio"
name="cadence"
value="monthly"
>
<label for="cadence-monthly">
<span class="circle-label-name">Per Month</span>
</label>
</div>
<div class="circle-radio">
<input
id="cadence-annual"
v-model="cadence"
type="radio"
name="cadence"
value="annual"
>
<label for="cadence-annual">
<span class="circle-label-name">Per Year</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="join-contribution">
Monthly Contribution
</label>
<div class="contribution-input-row">
<span class="contribution-currency">$</span>
<input
id="join-contribution"
v-model.number="form.contributionAmount"
type="number"
min="0"
step="1"
inputmode="numeric"
class="contribution-input"
>
</div>
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
<button
v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount"
type="button"
class="contribution-preset-chip"
@click="form.contributionAmount = preset.amount"
>
${{ preset.amount }}
</button>
</div>
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
</div>
<div v-if="form.contributionAmount > 0" class="form-group">
<div class="billing-summary">
<p class="billing-summary-line">
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month &times; 12)</span>.
</p>
<p class="billing-summary-line">
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width">
<label class="checkbox-label">
<input
v-model="form.agreedToGuidelines"
type="checkbox"
>
<span>
I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank"
>Community Guidelines</NuxtLink
>.
</span>
</label>
</div>
<div class="form-group">
<button
class="form-submit"
type="submit"
:disabled="!isFormValid || isSubmitting"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else-if="needsPayment">Continue to Payment</span>
<span v-else>Become a Member</span>
</button>
</div>
</div>
<p class="form-note">
You can change your circle or contribution at any time from your
dashboard. Payment is handled securely through
<a href="https://www.helcim.com" target="_blank" rel="noopener"
>Helcim</a
>.
</p>
</form>
</div>
</div>
<!-- HOW MEMBERSHIP WORKS -->
<ParchmentInset>
<h2>How membership works</h2>
<ul>
<li>Full access to the knowledge commons, Slack, and peer support</li>
<li>Free access to all Ghost Guild events</li>
<li>Equal access for every member, regardless of contribution</li>
<li>Your circle reflects where you are, not rank</li>
<li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li>
<li>Higher contributions create solidarity spots for others</li>
</ul>
</ParchmentInset>
<!-- THREE CIRCLES -->
<div class="content-row">
<div class="content-block">
<div class="section-label" style="color: var(--c-community)">
Community
</div>
<h2>Exploring</h2>
<p>
For game workers curious about cooperatives and people exploring
alternative work models. You might be a solo developer, a student, a
researcher, or just someone who heard about this and wants to know
more. Start here.
</p>
</div>
<div class="content-block">
<div class="section-label" style="color: var(--c-founder)">
Founder
</div>
<h2>Building</h2>
<p>
For people actively building cooperative studios. You have a team,
or you are forming one. You are working through governance, legal
structure, revenue sharing, and all the hard parts. You want
structured support and peers doing the same thing.
</p>
</div>
<div class="content-block">
<div class="section-label" style="color: var(--c-practitioner)">
Practitioner
</div>
<h2>Practicing</h2>
<p>
For those already running cooperative studios or with deep
experience in cooperative practice. You are here to teach, advise,
mentor, and help shape the program itself. Alumni.
</p>
</div>
</div>
</template>
<!-- Flow overlay: covers the page from form submit through redirect.
Lives outside v-if/v-else so it survives the auth state flip that
fires after checkMemberStatus() at the end of createSubscription. -->
<SignupFlowOverlay
:state="flowState"
:summary="flowSummary"
:error-message="errorMessage"
@close="closeFlowOverlay"
/>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { getCircleOptions } from "~/config/circles";
import {
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
// Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
// Check authentication status on mount
onMounted(async () => {
await checkMemberStatus();
});
// Form state
const form = reactive({
email: "",
name: "",
circle: "community",
contributionAmount: 15,
agreedToGuidelines: false,
billingAddress: {
street: "",
city: "",
province: "",
postalCode: "",
country: "CA",
},
});
// UI state
const isSubmitting = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const cadence = ref("monthly"); // 'monthly' | 'annual'
// Flow overlay state — drives the post-submit full-viewport UI.
// 'idle' = overlay hidden; user is editing the form.
// 'creating-customer' | 'opening-payment' | 'processing-payment'
// | 'creating-subscription' = progress states, overlay shows a spinner + label.
// 'success' = overlay shows confirmation, auto-redirect is queued.
// 'error' = overlay shows error + Retry/Back buttons.
const flowState = ref("idle");
// Helcim state
const customerId = ref(null);
const customerCode = ref(null);
const subscriptionData = ref(null);
const paymentToken = ref(null);
// Circle options from central config
const circleOptions = getCircleOptions();
const formatContributionAmount = (amount) => {
if (!amount || amount === 0) return "$0";
const display = cadence.value === "annual" ? amount * 12 : amount;
const suffix = cadence.value === "annual" ? "/yr" : "/mo";
return `$${display}${suffix}`;
};
// Initialize composables
const {
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
// Form validation
const isFormValid = computed(() => {
return (
form.name &&
form.email &&
form.circle &&
Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
});
// Check if payment is required
const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount);
});
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
const firstCharge = computed(() => {
const amount = form.contributionAmount || 0;
return cadence.value === "annual" ? amount * 12 : amount;
});
const flowSummary = computed(() => ({
name: form.name,
email: form.email,
circle: form.circle,
contribution: formatContributionAmount(form.contributionAmount),
}));
const handleSubmit = async () => {
if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true;
errorMessage.value = "";
flowState.value = "creating-customer";
try {
// Create customer
const response = await $fetch("/api/helcim/customer", {
method: "POST",
body: {
name: form.name,
email: form.email,
circle: form.circle,
contributionAmount: form.contributionAmount,
agreedToGuidelines: form.agreedToGuidelines,
billingAddress: form.billingAddress,
},
});
if (!response.success) {
throw new Error("Failed to create account.");
}
customerId.value = response.customerId;
customerCode.value = response.customerCode;
// Free tier: no Helcim modal, go straight to subscription.
if (!needsPayment.value) {
flowState.value = "creating-subscription";
await createSubscription();
return;
}
// Paid tier: initialize HelcimPay session, then auto-open modal.
flowState.value = "opening-payment";
await initializeHelcimPay(customerId.value, customerCode.value, 0);
const paymentResult = await verifyPayment();
if (!paymentResult?.success) {
throw new Error("Payment was not completed.");
}
paymentToken.value = paymentResult.cardToken;
flowState.value = "processing-payment";
await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
});
flowState.value = "creating-subscription";
const subscriptionResult = await createSubscription(
paymentResult.cardToken,
);
if (!subscriptionResult || subscriptionResult.success === false) {
// Payment succeeded but subscription couldn't be created.
// Keep overlay in success state; admin follow-up will reconcile.
successMessage.value =
"Payment successful. Subscription setup may need manual completion.";
flowState.value = "success";
}
} catch (error) {
console.error("Join flow error:", error);
errorMessage.value =
error.data?.message ||
error.message ||
"Something went wrong. Please try again.";
flowState.value = "error";
} finally {
isSubmitting.value = false;
}
};
// Create subscription
const createSubscription = async (cardToken = null) => {
try {
const response = await $fetch("/api/helcim/subscription", {
method: "POST",
body: {
customerId: customerId.value,
customerCode: customerCode.value,
contributionAmount: form.contributionAmount,
cadence: cadence.value,
cardToken: cardToken,
},
});
if (response.success) {
subscriptionData.value = response.subscription;
flowState.value = "success";
successMessage.value = "Your membership is active.";
// Sign-in cookie is now issued by the email-verify magic link
// (see /api/helcim/customer). Don't auto-navigate to a gated page —
// the success state instructs the user to check their inbox.
} else {
throw new Error("Subscription creation failed - response not successful");
}
} catch (error) {
console.error("Subscription creation error:", error);
console.error("Error details:", {
message: error.message,
statusCode: error.statusCode,
statusMessage: error.statusMessage,
data: error.data,
});
console.error(
"Subscription creation completely failed, but payment was successful",
);
// Don't throw error - let the calling function handle progression
return {
success: false,
error:
error.data?.message || error.message || "Failed to create subscription",
};
}
};
const closeFlowOverlay = () => {
flowState.value = "idle";
errorMessage.value = "";
};
// Cleanup on unmount
onUnmounted(() => {
cleanupHelcimPay();
});
</script>
<style scoped>
/* ---- HERO ---- */
.hero {
padding: 48px 32px;
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: "Brygada 1918", serif;
font-size: 36px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
letter-spacing: -0.01em;
margin-bottom: 16px;
max-width: 540px;
}
.hero p {
color: var(--text-dim);
max-width: 460px;
line-height: 1.7;
}
/* ---- PARCHMENT LIST STYLES ---- */
:deep(.parchment-inset ul) {
list-style: none;
max-width: 560px;
padding: 0;
}
:deep(.parchment-inset ul li) {
font-size: 13px;
color: var(--parch-text-dim);
line-height: 1.75;
padding: 4px 0;
padding-left: 16px;
position: relative;
}
:deep(.parchment-inset ul li::before) {
content: "";
position: absolute;
left: 0;
color: var(--candle-faint);
font-size: 14px;
line-height: 1.4;
}
.parchment-link {
color: var(--candle-faint);
font-size: 12px;
}
.parchment-link:hover {
color: var(--candle-dim);
}
/* ---- CONTENT ROW (three circles) ---- */
.content-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
border-bottom: 1px dashed var(--border);
}
.content-block {
padding: 24px 28px;
border-right: 1px dashed var(--border);
min-width: 0;
overflow-wrap: break-word;
align-self: stretch;
}
.content-block:last-child {
border-right: none;
}
.content-block h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 8px;
}
.content-block p {
color: var(--text-dim);
font-size: 12px;
line-height: 1.65;
}
.circle-not-sure {
font-size: 11px;
color: var(--text-faint);
margin-top: 10px;
line-height: 1.6;
}
/* ---- TWO-COLUMN JOIN LAYOUT ---- */
.join-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px dashed var(--border);
}
.join-col {
padding: 32px;
}
.join-col:first-child {
border-right: 1px dashed var(--border);
}
.join-col h2 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 16px;
}
/* ---- FULL-WIDTH SECTION ---- */
.full-section {
padding: 32px;
border-bottom: 1px dashed var(--border);
}
.full-section h2 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 16px;
}
.section-intro {
font-size: 13px;
color: var(--text-dim);
line-height: 1.65;
margin-bottom: 20px;
}
/* ---- TIER LIST (matches about page) ---- */
.tier-list {
list-style: none;
padding: 0;
}
.tier-list li {
padding: 5px 0;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px dashed var(--border);
display: flex;
gap: 12px;
}
.tier-list li:last-child {
border-bottom: none;
}
.tier-amt {
color: var(--text-bright);
font-weight: 600;
min-width: 36px;
}
.solidarity-note {
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
margin-top: 16px;
}
.charity-note {
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
margin-top: 16px;
}
/* ---- FORM SECTION ---- */
.form-section {
padding: 32px;
border-bottom: 1px dashed var(--border);
}
.form-section h2 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 4px;
}
.form-intro {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 24px;
line-height: 1.65;
}
.form-stack {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 600px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
}
.form-input {
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-bright);
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 10px 14px;
transition: border-color 0.2s;
outline: none;
width: 100%;
}
.form-input:focus {
border-color: var(--candle-dim);
border-style: solid;
}
.form-input::placeholder {
color: var(--text-faint);
}
/* ---- CADENCE RADIOS ---- */
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
.contribution-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.contribution-currency {
font-weight: 600;
}
.contribution-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
outline: none;
border-color: var(--candle);
}
.contribution-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.contribution-preset-chip {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
.contribution-preset-chip:hover {
border-style: solid;
border-color: var(--candle);
}
.contribution-guidance {
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--ink-soft, currentColor);
}
/* ---- BILLING SUMMARY ---- */
.billing-summary {
padding: 12px 16px;
border: 1px dashed var(--border);
background: var(--surface);
}
.billing-summary-line {
font-size: 13px;
color: var(--text);
line-height: 1.5;
margin: 0;
}
.billing-summary-line + .billing-summary-line {
margin-top: 4px;
}
.billing-summary-line strong {
color: var(--text-bright);
font-weight: 600;
}
/* ---- CIRCLE RADIOS ---- */
.circle-radios {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.circle-radio {
position: relative;
}
.circle-radio input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.circle-radio label {
display: block;
border: 1px dashed var(--border);
padding: 14px 12px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.circle-radio label:hover {
border-color: var(--candle-faint);
}
.circle-radio input:checked + label {
border-style: solid;
}
.circle-radio input:checked + label .circle-label-name {
color: var(--text-bright);
}
.circle-radio.community input:checked + label {
border-color: var(--c-community);
}
.circle-radio.founder input:checked + label {
border-color: var(--c-founder);
}
.circle-radio.practitioner input:checked + label {
border-color: var(--c-practitioner);
}
.circle-label-name {
font-size: 12px;
color: var(--text-dim);
display: block;
margin-bottom: 2px;
}
.circle-label-desc {
font-size: 10px;
color: var(--text-faint);
}
/* ---- CONTRIBUTION SELECT ---- */
.form-select {
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-bright);
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 10px 14px;
transition: border-color 0.2s;
outline: none;
width: 100%;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a7e6a' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
cursor: pointer;
}
.form-select:focus {
border-color: var(--candle-dim);
border-style: solid;
}
.form-select option {
background: var(--surface);
color: var(--text-bright);
}
/* ---- SUBMIT BUTTON ---- */
.form-submit {
display: inline-block;
background: var(--parch);
color: var(--parch-accent);
font-family: "Commit Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
border: 1px solid var(--parch);
padding: 12px 28px;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
text-align: center;
}
.form-submit:hover {
background: var(--parch-hover);
border-color: var(--parch-hover);
color: var(--parch-text);
text-decoration: none;
}
.form-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---- FORM NOTE ---- */
.form-note {
font-size: 11px;
color: var(--text-faint);
line-height: 1.6;
margin-top: 16px;
max-width: 460px;
}
.form-note a,
.form-note :deep(a) {
color: var(--candle-dim);
}
/* ---- CHECKBOX ---- */
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.checkbox-label input {
margin-top: 3px;
flex-shrink: 0;
}
.checkbox-label a,
.checkbox-label :deep(a) {
color: var(--candle);
}
/* ---- ERROR & SUCCESS BOXES ---- */
.error-box {
border: 1px dashed var(--ember);
color: var(--ember);
padding: 12px 16px;
font-size: 12px;
margin-bottom: 20px;
max-width: 600px;
}
.success-box {
border: 1px dashed var(--green, var(--candle));
color: var(--green, var(--candle));
padding: 12px 16px;
font-size: 12px;
margin-bottom: 20px;
max-width: 600px;
}
/* ---- PAYMENT INSTRUCTION ---- */
.payment-instruction {
font-size: 13px;
color: var(--text-dim);
line-height: 1.65;
}
/* ---- REDIRECT NOTE ---- */
.redirect-note {
font-size: 12px;
color: var(--text-dim);
text-align: center;
}
/* ---- BUTTON ROW ---- */
.button-row {
display: flex;
gap: 12px;
align-items: center;
margin-top: 20px;
}
/* ---- MEMBER INFO GRID ---- */
.member-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
max-width: 500px;
margin-bottom: 8px;
}
.info-value {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin-top: 4px;
}
/* ---- UTILITY ---- */
.capitalize {
text-transform: capitalize;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.content-row {
grid-template-columns: 1fr;
}
.content-block {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.content-block:last-child {
border-bottom: none;
}
.join-two-col {
grid-template-columns: 1fr;
}
.join-col:first-child {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.circle-radios {
grid-template-columns: 1fr;
}
.member-info-grid {
grid-template-columns: 1fr;
}
.hero {
padding: 32px 20px;
}
.hero h1 {
font-size: 28px;
}
.full-section,
.form-section {
padding: 24px 20px;
}
.content-block {
padding: 20px;
}
}
@media (max-width: 480px) {
.button-row {
flex-direction: column;
align-items: stretch;
}
}
</style>