ghostguild-org/app/pages/join.vue
Jennie Robinson Faber 59d6e97787
Some checks failed
Test / vitest (push) Failing after 7m23s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
Member/Ecology revamp.
2026-04-14 09:25:09 +01:00

1091 lines
29 KiB
Vue

<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?.contributionTier || "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>
<!-- HOW MEMBERSHIP WORKS -->
<ParchmentInset>
<h2>How membership works</h2>
<ul>
<li>
Full access to the knowledge commons, events, Slack community, and
peer support
</li>
<li>One member, one vote in all decisions</li>
<li>Your circle is where you are in your journey, not rank</li>
<li>
Your contribution is what you can afford ($0--50+/month, separate
from your circle)
</li>
<li>
Higher contributions create solidarity spots for those who need them
</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>
<!-- CONTRIBUTION + SIGN UP (two columns) -->
<div v-if="currentStep === 1" class="join-two-col">
<!-- Left: Monthly Contribution -->
<div class="join-col">
<div class="section-label" style="margin-bottom: 12px">
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">$5</span> I can contribute</li>
<li>
<span class="tier-amt">$15</span> I can sustain the community
(suggested)
</li>
<li><span class="tier-amt">$30</span> I can support others too</li>
<li>
<span class="tier-amt">$50</span> I want to sponsor multiple
members
</li>
</ul>
<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" for="join-contribution"
>Monthly Contribution</label
>
<select
id="join-contribution"
v-model="form.contributionTier"
class="form-select"
>
<option value="0">$0/mo -- I need support right now</option>
<option value="5">$5/mo -- I can contribute</option>
<option value="15">
$15/mo -- I can sustain the community (suggested)
</option>
<option value="30">$30/mo -- I can support others too</option>
<option value="50">
$50/mo -- I want to sponsor multiple members
</option>
</select>
</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">
By joining you agree to our
<NuxtLink to="/guidelines">community guidelines</NuxtLink>. 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>
<!-- Step 2: Payment -->
<div v-if="currentStep === 2" class="form-section">
<h2>Payment Information</h2>
<p class="form-intro">
You're signing up for the {{ selectedTier.label }} plan -- ${{
selectedTier.amount
}}
CAD / month
</p>
<!-- Error Message -->
<div v-if="errorMessage" class="error-box">
{{ errorMessage }}
</div>
<DashedBox :hoverable="false">
<p class="payment-instruction">
Click "Complete Payment" below to open the secure payment modal and
verify your payment method.
</p>
</DashedBox>
<div class="button-row" style="margin-top: 24px">
<button class="btn" :disabled="isSubmitting" @click="goBack">
Back
</button>
<button
class="form-submit"
:disabled="isSubmitting"
@click="processPayment"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else>Complete Payment</span>
</button>
</div>
</div>
<!-- Step 3: Confirmation -->
<div v-if="currentStep === 3" class="form-section">
<h2>Welcome to Ghost Guild!</h2>
<div v-if="successMessage" class="success-box">
{{ successMessage }}
</div>
<DashedBox :hoverable="false">
<div class="section-label" style="margin-bottom: 12px">
Membership Details
</div>
<dl class="details-list">
<div class="details-row">
<dt>Name</dt>
<dd>{{ form.name }}</dd>
</div>
<div class="details-row">
<dt>Email</dt>
<dd>{{ form.email }}</dd>
</div>
<div class="details-row">
<dt>Circle</dt>
<dd class="capitalize">{{ form.circle }}</dd>
</div>
<div class="details-row">
<dt>Contribution</dt>
<dd>{{ selectedTier.label }}</dd>
</div>
<div v-if="customerCode" class="details-row">
<dt>Member ID</dt>
<dd>{{ customerCode }}</dd>
</div>
</dl>
</DashedBox>
<p class="form-note" style="margin-top: 20px">
We've sent a confirmation email to {{ form.email }} with your
membership details.
</p>
<DashedBox :hoverable="false" style="margin-top: 16px">
<p class="redirect-note">
You will be automatically redirected to your dashboard in a few
seconds...
</p>
</DashedBox>
<div class="button-row" style="margin-top: 24px">
<NuxtLink to="/member/dashboard" class="form-submit"
>Go to Dashboard Now</NuxtLink
>
<button class="btn" @click="resetForm">
Register Another Member
</button>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from "vue";
import { getCircleOptions } from "~/config/circles";
import {
getContributionOptions,
requiresPayment,
getContributionTierByValue,
} 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",
contributionTier: "15",
billingAddress: {
street: "",
city: "",
province: "",
postalCode: "",
country: "CA",
},
});
// UI state
const isSubmitting = ref(false);
const currentStep = ref(1); // 1: Info, 2: Billing (paid only), 3: Payment, 4: Confirmation
const errorMessage = ref("");
const successMessage = ref("");
// 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();
// Contribution options from central config
const contributionOptions = getContributionOptions();
// Initialize composables
const {
initializeHelcimPay,
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
// Form validation
const isFormValid = computed(() => {
return form.name && form.email && form.circle && form.contributionTier;
});
// Check if payment is required
const needsPayment = computed(() => {
return requiresPayment(form.contributionTier);
});
// Get selected tier info
const selectedTier = computed(() => {
return getContributionTierByValue(form.contributionTier);
});
// Step 1: Create customer
const handleSubmit = async () => {
if (isSubmitting.value || !isFormValid.value) return;
isSubmitting.value = true;
errorMessage.value = "";
try {
// Create customer in Helcim
const response = await $fetch("/api/helcim/customer", {
method: "POST",
body: {
name: form.name,
email: form.email,
circle: form.circle,
contributionTier: form.contributionTier,
billingAddress: form.billingAddress,
},
});
if (response.success) {
customerId.value = response.customerId;
customerCode.value = response.customerCode;
// Token is now set as httpOnly cookie by the server
// No need to manually set cookie on client side
// Move to next step
if (needsPayment.value) {
currentStep.value = 2;
// Initialize HelcimPay.js session for card verification
await initializeHelcimPay(customerId.value, customerCode.value, 0);
} else {
// For free tier, create subscription directly
await createSubscription();
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
// Automatically redirect to welcome page after a short delay
setTimeout(() => {
navigateTo("/welcome");
}, 3000); // 3 second delay to show success message
}
}
} catch (error) {
console.error("Error creating customer:", error);
errorMessage.value =
error.data?.message || "Failed to create account. Please try again.";
} finally {
isSubmitting.value = false;
}
};
// Step 2: Process payment
const processPayment = async () => {
if (isSubmitting.value) return;
isSubmitting.value = true;
errorMessage.value = "";
try {
// Verify payment through HelcimPay.js
const paymentResult = await verifyPayment();
if (paymentResult.success) {
paymentToken.value = paymentResult.cardToken;
// Verify payment on server
const verifyResult = await $fetch("/api/helcim/verify-payment", {
method: "POST",
body: {
cardToken: paymentResult.cardToken,
customerId: customerId.value,
},
});
// Create subscription (don't let subscription errors prevent form progression)
const subscriptionResult = await createSubscription(
paymentResult.cardToken,
);
if (!subscriptionResult || !subscriptionResult.success) {
console.warn(
"Subscription creation failed but payment succeeded:",
subscriptionResult?.error,
);
// Still progress to success page since payment worked
currentStep.value = 3;
successMessage.value =
"Payment successful! Subscription setup may need manual completion.";
}
}
} catch (error) {
console.error("Payment process error:", error);
errorMessage.value =
error.message || "Payment verification failed. Please try again.";
} 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,
contributionTier: form.contributionTier,
cardToken: cardToken,
},
});
if (response.success) {
subscriptionData.value = response.subscription;
currentStep.value = 3;
successMessage.value = "Your membership is active.";
// Check member status to ensure user is properly authenticated
await checkMemberStatus();
// Automatically redirect to welcome page after a short delay
setTimeout(() => {
navigateTo("/welcome");
}, 3000); // 3 second delay to show success message
} 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",
};
}
};
// Go back to previous step
const goBack = () => {
if (currentStep.value > 1) {
currentStep.value--;
errorMessage.value = "";
}
};
// Reset form
const resetForm = () => {
currentStep.value = 1;
customerId.value = null;
customerCode.value = null;
subscriptionData.value = null;
paymentToken.value = null;
errorMessage.value = "";
successMessage.value = "";
form.email = "";
form.name = "";
form.circle = "community";
form.contributionTier = "15";
};
// 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-dim);
opacity: 0.5;
}
.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;
}
/* ---- 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);
}
/* ---- 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);
}
/* ---- 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;
}
/* ---- DETAILS LIST (confirmation) ---- */
.details-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.details-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
}
.details-row dt {
color: var(--text-faint);
}
.details-row dd {
color: var(--text-bright);
font-weight: 500;
}
/* ---- 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>