ghostguild-org/app/pages/member/payment-setup.vue
Jennie Robinson Faber cd0d3f7167 feat(join): cadence selector with annual pricing (monthly×10)
Radio-pair cadence selector (Monthly / Annual) added to the join form,
reusing the existing .circle-radio styling. contributionItems computed
reactively; all tier labels and the left-column price list update on
toggle. cadence submitted with the subscription payload. payment-setup
hardcoded to monthly (annual upgrades go through /join).
2026-04-18 17:59:10 +01:00

188 lines
5.2 KiB
Vue

<template>
<PageShell>
<ClientOnly>
<PageHeader
title="Set Up Payment"
:subtitle="targetTier ? `Upgrading to $${targetTier}/month` : 'Payment setup'"
/>
<PageSection>
<div v-if="step === 'loading'" class="status-block">
<p>Preparing payment setup</p>
</div>
<div v-else-if="step === 'error'" class="status-block">
<div class="error-box">{{ errorMessage }}</div>
<div class="button-row">
<button class="btn" @click="initialize">Try again</button>
<NuxtLink to="/member/account" class="btn">Back to account</NuxtLink>
</div>
</div>
<div v-else-if="step === 'ready'" class="status-block">
<p>
To upgrade to <strong>${{ targetTier }}/month</strong>, we need a
payment method on file. Click below to open the secure payment
form we'll verify your card with a $0 authorization and then
activate your new tier.
</p>
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
<div class="button-row">
<button
class="btn btn-primary"
:disabled="isProcessing"
@click="openModal"
>
{{ isProcessing ? 'Processing' : 'Enter payment details' }}
</button>
<NuxtLink to="/member/account" class="btn">Cancel</NuxtLink>
</div>
</div>
<div v-else-if="step === 'success'" class="status-block">
<p>Payment setup complete. Redirecting to your account…</p>
</div>
</PageSection>
</ClientOnly>
</PageShell>
</template>
<script setup>
definePageMeta({ middleware: 'auth' });
const route = useRoute();
const router = useRouter();
const toast = useToast();
const { memberData, checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcim } = useHelcimPay();
const VALID_TIERS = ['5', '15', '30', '50'];
const VALID_CIRCLES = ['community', 'founder', 'practitioner'];
const targetTier = computed(() => {
const t = String(route.query.tier || '');
return VALID_TIERS.includes(t) ? t : null;
});
const targetCircle = computed(() => {
const c = String(route.query.circle || '');
return VALID_CIRCLES.includes(c) ? c : null;
});
const step = ref('loading'); // loading | ready | success | error
const errorMessage = ref('');
const isProcessing = ref(false);
const customerId = ref('');
const customerCode = ref('');
const initialize = async () => {
errorMessage.value = '';
step.value = 'loading';
if (!targetTier.value) {
errorMessage.value = 'Missing or invalid target tier.';
step.value = 'error';
return;
}
try {
const customer = await $fetch('/api/helcim/get-or-create-customer', {
method: 'POST',
});
customerId.value = customer.customerId;
customerCode.value = customer.customerCode;
await initializeHelcimPay(customerId.value, customerCode.value, 0);
step.value = 'ready';
} catch (err) {
console.error('Payment setup init failed:', err);
errorMessage.value =
err.data?.statusMessage || err.message || 'Failed to initialize payment.';
step.value = 'error';
}
};
const openModal = async () => {
if (isProcessing.value) return;
isProcessing.value = true;
errorMessage.value = '';
try {
const result = await verifyPayment();
if (!result?.success) throw new Error('Payment was not completed.');
await $fetch('/api/helcim/verify-payment', {
method: 'POST',
body: {
cardToken: result.cardToken,
customerId: customerId.value,
},
});
// Update circle first if it changed — update-contribution only touches tier.
if (targetCircle.value && targetCircle.value !== memberData.value?.circle) {
await $fetch('/api/members/update-circle', {
method: 'POST',
body: { circle: targetCircle.value },
});
}
await $fetch('/api/members/update-contribution', {
method: 'POST',
// cadence: annual upgrades go through /join; this page is monthly-only
body: { contributionTier: targetTier.value, cadence: 'monthly' },
});
await checkMemberStatus();
step.value = 'success';
toast.add({ title: 'Payment method saved', color: 'success' });
setTimeout(() => router.push('/member/account'), 1500);
} catch (err) {
console.error('Payment setup error:', err);
errorMessage.value =
err.data?.statusMessage || err.message || 'Payment setup failed.';
// Re-initialize Helcim session so the user can try again.
cleanupHelcim();
await initialize();
} finally {
isProcessing.value = false;
}
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupHelcim();
});
useHead({ title: 'Set Up Payment - Ghost Guild' });
</script>
<style scoped>
.status-block {
padding: 12px 0;
font-size: 13px;
line-height: 1.6;
color: var(--text);
}
.status-block p {
margin-bottom: 16px;
}
.error-box {
padding: 12px 14px;
border: 1px dashed var(--ember);
color: var(--ember);
font-size: 12px;
margin-bottom: 16px;
}
.button-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
</style>