Migrate frontend to Vuetify/Vuexy + add real WHMCS product data
- Migrate all frontend from plain JS/Tailwind to TypeScript/Vuetify 3 (Vuexy design system) - Replace placeholder plans with 25 real products scraped from WHMCS: 9 VPS plans ($4.20-$30/mo), 8 dedicated servers ($44.39-$107.99/mo), 4 web hosting plans ($2.39-$15.99/mo), 4 MySQL hosting plans ($6-$30/mo) - Fix Pricing page: correct field mapping (service_type, price), display feature values instead of keys, proper price formatting - Update all marketing pages (Home, Products, VPS, Dedicated, Web Hosting) with real specs, pricing, and features from production WHMCS - Add 38 Vuexy @core SCSS override files for component styling - Create 4 layouts (Account, Admin, Auth, Marketing) with Vuetify - Add AppTextField/AppSelect/AppTextarea wrapper components - Purple primary theme (#7367F0), dark mode default - 52 tests passing, build clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
199
website/resources/ts/Pages/Checkout/Show.vue
Normal file
199
website/resources/ts/Pages/Checkout/Show.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useForm, Link } from '@inertiajs/vue3'
|
||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||
import type { Plan, PaymentMethod } from '@/types'
|
||||
|
||||
interface Props {
|
||||
plan: Plan
|
||||
paymentMethods: PaymentMethod[]
|
||||
intent: Record<string, unknown>
|
||||
stripeKey: string
|
||||
}
|
||||
|
||||
defineOptions({ layout: AccountLayout })
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const selectedGateway = ref('stripe')
|
||||
const selectedPaymentMethod = ref(props.paymentMethods?.[0]?.id || '')
|
||||
const couponCode = ref('')
|
||||
const couponApplied = ref(false)
|
||||
const couponDiscount = ref(0)
|
||||
const couponError = ref('')
|
||||
|
||||
const total = computed(() => {
|
||||
const price = parseFloat(props.plan.price)
|
||||
return Math.max(0, price - couponDiscount.value).toFixed(2)
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
gateway: 'stripe',
|
||||
payment_method_id: props.paymentMethods?.[0]?.id || '',
|
||||
coupon_code: '',
|
||||
})
|
||||
|
||||
const applyCoupon = async (): Promise<void> => {
|
||||
couponError.value = ''
|
||||
couponApplied.value = false
|
||||
|
||||
try {
|
||||
const response = await fetch('/checkout/apply-coupon', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: couponCode.value,
|
||||
plan_id: props.plan.id,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.valid) {
|
||||
couponApplied.value = true
|
||||
couponDiscount.value = data.discount
|
||||
} else {
|
||||
couponError.value = data.message || 'Invalid coupon.'
|
||||
}
|
||||
} catch {
|
||||
couponError.value = 'Failed to validate coupon.'
|
||||
}
|
||||
}
|
||||
|
||||
const submit = (): void => {
|
||||
form.gateway = selectedGateway.value
|
||||
form.payment_method_id = selectedPaymentMethod.value
|
||||
form.coupon_code = couponApplied.value ? couponCode.value : ''
|
||||
|
||||
form.post(`/checkout/${props.plan.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<Link href="/plans" class="text-primary text-body-2 text-decoration-none">← Back to Plans</Link>
|
||||
</div>
|
||||
|
||||
<div class="text-h4 font-weight-bold mb-6">Checkout</div>
|
||||
|
||||
<VRow>
|
||||
<!-- Order Summary -->
|
||||
<VCol cols="12" lg="4" order="2" order-lg="1">
|
||||
<VCard>
|
||||
<VCardTitle>Order Summary</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex justify-space-between text-body-2 mb-2">
|
||||
<span class="text-medium-emphasis">{{ plan.name }}</span>
|
||||
<span>${{ parseFloat(plan.price).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between text-body-2 text-medium-emphasis mb-2">
|
||||
<span>Billing Cycle</span>
|
||||
<span class="text-capitalize">{{ plan.billing_cycle }}</span>
|
||||
</div>
|
||||
<div v-if="couponApplied" class="d-flex justify-space-between text-body-2 text-success mb-2">
|
||||
<span>Discount</span>
|
||||
<span>-${{ couponDiscount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<VDivider class="my-3" />
|
||||
<div class="d-flex justify-space-between font-weight-bold">
|
||||
<span>Total</span>
|
||||
<span>${{ total }}/{{ plan.billing_cycle }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
|
||||
<!-- Checkout Form -->
|
||||
<VCol cols="12" lg="8" order="1" order-lg="2">
|
||||
<VForm @submit.prevent="submit">
|
||||
<!-- Payment Gateway -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle>Payment Method</VCardTitle>
|
||||
<VCardText>
|
||||
<VRadioGroup v-model="selectedGateway" hide-details>
|
||||
<VRadio label="Credit / Debit Card (Stripe)" value="stripe" />
|
||||
<VRadio label="PayPal" value="paypal" />
|
||||
</VRadioGroup>
|
||||
|
||||
<!-- Saved Payment Methods (Stripe) -->
|
||||
<div v-if="selectedGateway === 'stripe' && paymentMethods.length > 0" class="mt-4">
|
||||
<AppSelect
|
||||
v-model="selectedPaymentMethod"
|
||||
label="Select Card"
|
||||
:items="paymentMethods.map(pm => ({
|
||||
title: `${pm.brand} ending in ${pm.last_four} (${pm.exp_month}/${pm.exp_year})${pm.is_default ? ' - Default' : ''}`,
|
||||
value: pm.id,
|
||||
}))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedGateway === 'stripe' && paymentMethods.length === 0" class="mt-4">
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
You have no saved payment methods.
|
||||
<Link href="/billing" class="text-primary text-decoration-none">Add one first</Link>.
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Coupon -->
|
||||
<VCard class="mb-6">
|
||||
<VCardTitle>Coupon Code</VCardTitle>
|
||||
<VCardText>
|
||||
<div class="d-flex ga-3">
|
||||
<AppTextField
|
||||
v-model="couponCode"
|
||||
placeholder="Enter coupon code"
|
||||
:disabled="couponApplied"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="flex-grow-1"
|
||||
/>
|
||||
<VBtn
|
||||
variant="outlined"
|
||||
:disabled="!couponCode || couponApplied"
|
||||
@click="applyCoupon"
|
||||
>
|
||||
{{ couponApplied ? 'Applied' : 'Apply' }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div v-if="couponError" class="text-body-2 text-error mt-2">{{ couponError }}</div>
|
||||
<div v-if="couponApplied" class="text-body-2 text-success mt-2">Coupon applied successfully!</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Errors -->
|
||||
<VAlert
|
||||
v-if="form.errors && Object.keys(form.errors).length"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-6"
|
||||
>
|
||||
<ul class="ps-4">
|
||||
<li v-for="(error, field) in form.errors" :key="field">{{ error }}</li>
|
||||
</ul>
|
||||
</VAlert>
|
||||
|
||||
<!-- Submit -->
|
||||
<VBtn
|
||||
type="submit"
|
||||
block
|
||||
size="large"
|
||||
:loading="form.processing"
|
||||
:disabled="form.processing || (selectedGateway === 'stripe' && !selectedPaymentMethod)"
|
||||
>
|
||||
<span v-if="form.processing">Processing...</span>
|
||||
<span v-else>Subscribe for ${{ total }}/{{ plan.billing_cycle }}</span>
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user