ghostguild-org/app/pages/join.vue
Jennie Robinson Faber 039a6802e3 fix(e2e): repair failing suite — a11y fixes and stale assertions
Three product a11y defects: drop role="radiogroup" from the /join PWYC
<ul> (it stripped the list role; native radios already group), use
--parch-text on the active contribution chip (was --text-bright, 1.17:1
on --parch), and label the New tag pool USelect on event create.

Three stale tests: real event-type filter labels, updated location
placeholder, and click the label instead of the hidden 0×0 radio.
2026-05-24 00:43:54 +01:00

1080 lines
27 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>
<!-- 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">
{{ formatContribution(memberData?.contributionAmount ?? 0, memberData?.billingCadence) }} CAD
</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: new layout -->
<template v-else>
<!-- HERO (split, matches about.vue) -->
<div class="join-hero">
<div class="join-hero-left">
<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>
<div class="join-hero-right">
<div class="section-label">What you get</div>
<ul>
<li>Full access to the knowledge commons</li>
<li>Free entry to all events and workshops</li>
<li>Slack invite in the next monthly onboarding wave</li>
<li>Equal access regardless of contribution</li>
<li>Pick your circle anytime after signup</li>
</ul>
</div>
</div>
<!-- FORM + ASIDE -->
<div class="join-grid">
<div class="join-main">
<div class="join-main-inner">
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
<form @submit.prevent="handleSubmit">
<!-- About you -->
<div class="form-block">
<h2>About you</h2>
<p class="form-block-intro">
We'll send a magic link to confirm your email. No passwords.
</p>
<div class="identity-grid">
<div class="field-row">
<label for="join-name">Name</label>
<input
id="join-name"
v-model="form.name"
class="form-input"
type="text"
required
@blur="validateName"
@input="fieldErrors.name && (fieldErrors.name = '')"
>
<p v-if="fieldErrors.name" class="field-error">
{{ fieldErrors.name }}
</p>
</div>
<div class="field-row">
<label for="join-email">Email</label>
<input
id="join-email"
v-model="form.email"
class="form-input"
type="email"
placeholder="you@example.com"
required
@blur="validateEmail"
@input="fieldErrors.email && (fieldErrors.email = '')"
>
<p v-if="fieldErrors.email" class="field-error">
{{ fieldErrors.email }}
</p>
</div>
</div>
</div>
<!-- Pay what you can -->
<div class="form-block">
<div class="pwyc-header">
<h2>Pay what you can</h2>
<div
class="cadence-toggle"
role="group"
aria-label="Billing cadence"
>
<button
type="button"
data-testid="cadence-monthly"
:class="{ active: cadence === 'monthly' }"
@click="onCadenceChange('monthly')"
>
Monthly
</button>
<button
type="button"
data-testid="cadence-annual"
:class="{ active: cadence === 'annual' }"
@click="onCadenceChange('annual')"
>
Annual
</button>
</div>
</div>
<p class="form-block-intro">
Equal access for everyone. Pick what fits &mdash; these aren't
tiers.
</p>
<ul
class="pwyc-list"
aria-label="Contribution amount"
>
<li
v-for="preset in presetRows"
:key="preset.baseAmount"
:class="[
'pwyc-row',
{ selected: isPresetSelected(preset) },
]"
>
<input
:id="`pwyc-${preset.baseAmount}`"
v-model.number="form.contributionAmount"
class="pwyc-radio"
type="radio"
name="pwyc-amount"
:value="preset.cadenceAmount"
@change="customAmount = ''"
>
<label
:for="`pwyc-${preset.baseAmount}`"
class="pwyc-row-content"
>
<span class="pwyc-amt">${{ preset.cadenceAmount }}</span>
<span class="pwyc-label">{{ preset.label }}</span>
<span v-if="preset.suggested" class="pwyc-tag"
>Suggested</span
>
</label>
</li>
<li :class="['pwyc-row', 'pwyc-custom', { selected: isCustomSelected }]">
<div class="pwyc-custom-input-wrap">
<span class="currency">$</span>
<input
v-model="customAmount"
class="pwyc-custom-input"
type="number"
min="0"
placeholder="100"
aria-label="Custom contribution amount"
@blur="commitCustomAmount"
>
</div>
<span class="pwyc-label"
>A different amount works for me</span
>
</li>
</ul>
</div>
<!-- Submit row -->
<div class="submit-row">
<label class="agreement-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>
<button
class="submit-btn"
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>
</form>
</div>
</div>
<aside class="join-aside">
<div class="join-aside-inner">
<div class="section-label">About the money</div>
<ul class="trust-list">
<li>
<strong>Pay what you can.</strong> Equal access for everyone, no
matter the amount. If you can pay more, you're making room for
someone who can't.
</li>
<li>
<strong>Canadian charity.</strong> Baby Ghosts Studio
Development Fund is a registered Canadian charity. We'll help
you set up tax receipts after you join.
</li>
<li>
<strong>Secure payment.</strong> Card entry handled by Helcim
&mdash; we never see your card details.
</li>
<li>
<strong>Change anytime.</strong> Adjust your contribution or
cancel from your dashboard.
</li>
</ul>
</div>
</aside>
</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 {
requiresPayment,
formatContribution,
CONTRIBUTION_PRESETS,
} from "~/config/contributions";
useSiteMeta({
title: "Join",
description:
"Join Ghost Guild — a membership community for game developers exploring cooperative models. Everyone gets everything. Pay what you can, $0 to $50 per month.",
});
// Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
onMounted(async () => {
await checkMemberStatus();
});
// Form state — circle stays "community" and is submitted to the API unchanged.
// Picker is removed from the template; selection moves to a post-signup prompt.
const form = reactive({
email: "",
name: "",
circle: "community",
contributionAmount: 15,
agreedToGuidelines: false,
billingAddress: {
street: "",
city: "",
province: "",
postalCode: "",
country: "CA",
},
});
const isSubmitting = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const cadence = ref("monthly"); // 'monthly' | 'annual'
const customAmount = ref(""); // string; the raw <input type=number> value
const fieldErrors = reactive({ name: "", email: "" });
const validateName = () => {
fieldErrors.name = form.name.trim() ? "" : "Please enter your name.";
};
const validateEmail = () => {
const value = form.email.trim();
if (!value) {
fieldErrors.email = "";
return;
}
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
fieldErrors.email = ok ? "" : "Please enter a valid email address.";
};
const flowState = ref("idle");
const customerId = ref(null);
const customerCode = ref(null);
const subscriptionData = ref(null);
const paymentToken = ref(null);
// Preset rows with cadence-adjusted display amounts.
const presetRows = computed(() =>
CONTRIBUTION_PRESETS.map((p) => ({
baseAmount: p.amount,
cadenceAmount: cadence.value === "annual" ? p.amount * 12 : p.amount,
label: p.label,
suggested: p.amount === 15,
})),
);
const isCustomSelected = computed(() => customAmount.value !== "");
const isPresetSelected = (preset) =>
!isCustomSelected.value && form.contributionAmount === preset.cadenceAmount;
const onCadenceChange = (newCadence) => {
if (newCadence === cadence.value) return;
const next =
newCadence === "annual"
? form.contributionAmount * 12
: Math.floor(form.contributionAmount / 12);
form.contributionAmount = next;
if (customAmount.value !== "") {
customAmount.value = String(next);
}
cadence.value = newCadence;
};
// On blur of the custom-amount input: commit to form.contributionAmount.
// If the typed value matches a preset, snap the radio selection to that
// preset row and clear the custom input.
const commitCustomAmount = () => {
const raw = customAmount.value;
if (raw === "" || raw === null || raw === undefined) return;
const n = Math.max(0, Math.floor(Number(raw)));
if (!Number.isFinite(n)) {
customAmount.value = "";
return;
}
const matched = presetRows.value.find((p) => p.cadenceAmount === n);
if (matched) {
form.contributionAmount = matched.cadenceAmount;
customAmount.value = "";
} else {
form.contributionAmount = n;
}
};
const {
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
const isFormValid = computed(() => {
return (
form.name &&
form.email &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
});
const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount);
});
const flowSummary = computed(() => {
const amount = form.contributionAmount || 0;
return {
name: form.name,
email: form.email,
circle: form.circle,
contribution: amount > 0 ? formatContribution(amount, cadence.value) : "$0",
};
});
const handleSubmit = async () => {
if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true;
errorMessage.value = "";
flowState.value = "creating-customer";
try {
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;
if (!needsPayment.value) {
flowState.value = "creating-subscription";
await createSubscription();
return;
}
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) {
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;
}
};
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.";
} 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",
);
return {
success: false,
error:
error.data?.message || error.message || "Failed to create subscription",
};
}
};
const closeFlowOverlay = () => {
flowState.value = "idle";
errorMessage.value = "";
};
onUnmounted(() => {
cleanupHelcimPay();
});
</script>
<style scoped>
/* ---- HERO (split, matches about.vue) ---- */
.join-hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
align-items: stretch;
border-bottom: 1px dashed var(--border);
}
.join-hero-left {
padding: 32px 32px 28px;
border-right: 1px dashed var(--border);
align-self: stretch;
}
.join-hero-left h1 {
font-family: "Brygada 1918", serif;
font-size: 28px;
font-weight: 600;
color: var(--text-bright);
line-height: 1.15;
letter-spacing: -0.01em;
margin: 0 0 12px;
}
.join-hero-left p {
color: var(--text-dim);
line-height: 1.7;
font-size: 13px;
margin: 0;
max-width: 460px;
}
.join-hero-right {
padding: 32px;
align-self: stretch;
}
.join-hero-right .section-label {
margin-bottom: 10px;
}
.join-hero-right ul {
list-style: none;
padding: 0;
margin: 0;
}
.join-hero-right li {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
padding: 2px 0 2px 14px;
position: relative;
}
.join-hero-right li::before {
content: "";
position: absolute;
left: 0;
color: var(--candle-faint);
}
/* ---- TWO-COL GRID ---- */
.join-grid {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: stretch;
border-bottom: 1px dashed var(--border);
}
.join-main {
border-right: 1px dashed var(--border);
padding: 32px;
min-width: 0;
}
.join-aside {
padding: 32px;
min-width: 0;
position: relative;
}
.join-main-inner {
max-width: 560px;
}
.join-aside-inner {
max-width: 460px;
}
/* ---- SECTION LABEL (hero + aside + auth'd branch DashedBox) ---- */
.section-label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-bottom: 8px;
}
/* ---- FORM BLOCKS ---- */
.form-block {
margin-bottom: 32px;
}
.form-block:last-of-type {
margin-bottom: 0;
}
.form-block h2 {
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 4px;
}
.form-block-intro {
font-size: 12px;
color: var(--text-dim);
margin: 0 0 14px;
line-height: 1.6;
}
/* ---- IDENTITY GRID ---- */
.identity-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-row label {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
}
.form-input {
background: var(--input-bg);
border: 1px dashed var(--border);
color: var(--text-bright);
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 10px 14px;
width: 100%;
outline: none;
transition: border-color 0.15s;
}
.form-input:focus {
border-color: var(--candle);
border-style: solid;
}
.form-input::placeholder {
color: var(--text-faint);
}
.field-error {
font-size: 11px;
color: var(--ember);
margin: 0;
}
/* ---- PWYC ---- */
.pwyc-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
gap: 12px;
}
.cadence-toggle {
display: inline-flex;
border: 1px dashed var(--border);
}
.cadence-toggle button {
background: transparent;
border: none;
padding: 5px 12px;
font-family: "Commit Mono", monospace;
font-size: 10px;
color: var(--text-faint);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.cadence-toggle button.active {
background: var(--parch);
color: var(--parch-text);
}
.pwyc-list {
list-style: none;
padding: 0;
margin: 0;
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.pwyc-row {
position: relative;
border-bottom: 1px dashed var(--border);
transition: background 0.12s;
}
.pwyc-row:last-of-type {
border-bottom: none;
}
.pwyc-row:hover {
background: var(--surface);
}
.pwyc-row.selected {
background: var(--input-bg);
}
.pwyc-row.selected::before {
content: "";
position: absolute;
left: 12px;
color: var(--candle);
font-size: 14px;
top: 50%;
transform: translateY(-50%);
line-height: 1;
}
.pwyc-radio {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.pwyc-radio:focus-visible + .pwyc-row-content {
outline: 1px solid var(--candle);
outline-offset: -2px;
}
.pwyc-row-content {
display: flex;
align-items: baseline;
gap: 12px;
padding: 8px 14px 8px 28px;
cursor: pointer;
}
.pwyc-amt {
font-size: 13px;
font-weight: 600;
color: var(--text-bright);
min-width: 36px;
flex-shrink: 0;
}
.pwyc-row.selected .pwyc-amt {
color: var(--candle);
}
.pwyc-label {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
flex: 1;
}
.pwyc-row.selected .pwyc-label {
color: var(--text-bright);
}
.pwyc-tag {
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--candle);
border: 1px dashed var(--candle-faint);
padding: 1px 6px;
flex-shrink: 0;
}
/* Custom row */
.pwyc-custom {
display: flex;
align-items: baseline;
gap: 12px;
padding: 6px 14px 6px 28px;
cursor: default;
}
.pwyc-custom:hover {
background: transparent;
}
.pwyc-custom.selected {
background: var(--input-bg);
}
.pwyc-custom-input-wrap {
display: flex;
align-items: center;
border: 1px dashed var(--border);
background: var(--input-bg);
height: 26px;
flex-shrink: 0;
}
.pwyc-custom-input-wrap .currency {
padding: 0 2px 0 6px;
color: var(--text-faint);
font-weight: 600;
font-size: 12px;
}
.pwyc-custom-input {
border: none;
background: transparent;
font-family: "Commit Mono", monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-bright);
padding: 0 6px 0 0;
width: 56px;
outline: none;
-moz-appearance: textfield;
}
.pwyc-custom-input::-webkit-outer-spin-button,
.pwyc-custom-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ---- SUBMIT ROW ---- */
.submit-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 20px;
flex-wrap: wrap;
}
.agreement-label {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 12px;
color: var(--text);
line-height: 1.55;
cursor: pointer;
flex: 1;
min-width: 240px;
}
.agreement-label input {
margin-top: 3px;
flex-shrink: 0;
}
.agreement-label :deep(a) {
color: var(--candle);
text-decoration: underline;
}
.submit-btn {
display: inline-block;
background: var(--parch);
color: var(--parch-accent);
font-family: "Commit Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
border: 1px solid var(--parch);
padding: 12px 24px;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.submit-btn:hover {
background: var(--parch-hover);
color: var(--parch-text);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---- TRUST LIST (aside) ---- */
.trust-list {
list-style: none;
padding: 0;
margin: 0;
}
.trust-list li {
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
padding: 0 0 14px 14px;
position: relative;
}
.trust-list li:last-child {
padding-bottom: 0;
}
.trust-list li::before {
content: "";
position: absolute;
left: 0;
top: 0;
color: var(--candle-faint);
}
.trust-list li :deep(strong) {
color: var(--text-bright);
font-weight: 600;
}
/* ---- ERROR BOX ---- */
.error-box {
border: 1px dashed var(--ember);
color: var(--ember);
padding: 12px 16px;
font-size: 12px;
margin-bottom: 20px;
max-width: 560px;
}
/* ---- AUTH'D MEMBER BRANCH ---- */
.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;
}
.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;
}
.button-row {
display: flex;
gap: 12px;
align-items: center;
margin-top: 20px;
}
.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;
}
.parchment-link {
color: var(--candle-faint);
font-size: 12px;
}
.parchment-link:hover {
color: var(--candle-dim);
}
.capitalize {
text-transform: capitalize;
}
/* ---- PARCHMENT LIST (only used in auth'd branch ParchmentInset) ---- */
: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;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.join-grid {
grid-template-columns: 1fr;
}
.join-main {
border-right: none;
border-bottom: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.join-hero {
grid-template-columns: 1fr;
}
.join-hero-left {
border-right: none;
border-bottom: 1px dashed var(--border);
padding: 24px 20px 20px;
}
.join-hero-right {
padding: 24px 20px;
}
.join-hero-left h1 {
font-size: 26px;
}
.identity-grid {
grid-template-columns: 1fr;
}
.join-main,
.join-aside {
padding: 24px 20px;
}
.full-section {
padding: 24px 20px;
}
.member-info-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.submit-row {
flex-direction: column;
align-items: stretch;
}
.submit-btn {
width: 100%;
}
.button-row {
flex-direction: column;
align-items: stretch;
}
}
</style>