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:
Claude Dev
2026-02-09 10:16:41 -05:00
parent 0fe4e4ab42
commit ec8f0272ec
141 changed files with 9592 additions and 2440 deletions

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
import type { Subscription } from '@/types'
interface Props {
subscriptions: Subscription[]
}
defineOptions({ layout: AccountLayout })
defineProps<Props>()
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div class="text-h4 font-weight-bold">Subscriptions</div>
<Link href="/plans" class="text-decoration-none">
<VBtn>Browse Plans</VBtn>
</Link>
</div>
<VCard v-if="subscriptions.length === 0">
<VCardText class="text-center py-12">
<div class="text-medium-emphasis mb-4">You don't have any subscriptions yet.</div>
<Link href="/plans" class="text-primary text-body-2 font-weight-medium text-decoration-none">Browse Available Plans</Link>
</VCardText>
</VCard>
<div v-else class="d-flex flex-column ga-4">
<VCard
v-for="subscription in subscriptions"
:key="subscription.id"
>
<VCardText>
<div class="d-flex align-center justify-space-between">
<div>
<div class="text-h6 font-weight-bold">
{{ subscription.plan?.name || subscription.type }}
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ subscription.gateway || 'stripe' }} &middot;
<span v-if="subscription.current_period_end">
Renews {{ new Date(subscription.current_period_end).toLocaleDateString() }}
</span>
</div>
</div>
<div class="d-flex align-center ga-3">
<VChip
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
size="small"
class="text-capitalize"
>
{{ subscription.stripe_status }}
</VChip>
<Link
:href="`/subscriptions/${subscription.id}`"
class="text-primary text-body-2 font-weight-medium text-decoration-none"
>
Manage
</Link>
</div>
</div>
<div v-if="subscription.plan" class="text-body-2 text-medium-emphasis mt-3">
{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}
</div>
<div v-if="subscription.ends_at" class="text-body-2 text-error mt-2">
Cancels on {{ new Date(subscription.ends_at).toLocaleDateString() }}
</div>
</VCardText>
</VCard>
</div>
</div>
</template>

View File

@@ -0,0 +1,173 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useForm, Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveSubscriptionStatusColor, formatPrice } from '@/utils/resolvers'
import type { Subscription, Plan } from '@/types'
interface Props {
subscription: Subscription
availablePlans: Plan[]
}
defineOptions({ layout: AccountLayout })
const props = defineProps<Props>()
const cancelImmediately = ref(false)
const cancelForm = useForm({
immediately: false,
})
const swapForm = useForm({
plan_id: '',
})
const cancelSubscription = (): void => {
cancelForm.immediately = cancelImmediately.value
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`)
}
const resumeSubscription = (): void => {
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`)
}
const swapPlan = (): void => {
swapForm.post(`/subscriptions/${props.subscription.id}/swap`)
}
</script>
<template>
<div>
<div class="mb-4">
<Link href="/subscriptions" class="text-primary text-body-2 text-decoration-none">&larr; Back to Subscriptions</Link>
</div>
<div class="text-h4 font-weight-bold mb-6">Subscription Details</div>
<VRow>
<!-- Subscription Info -->
<VCol cols="12" lg="8">
<VCard class="mb-6">
<VCardText>
<div class="d-flex align-center justify-space-between mb-4">
<div class="text-h6 font-weight-bold">
{{ subscription.plan?.name || subscription.type }}
</div>
<VChip
:color="resolveSubscriptionStatusColor(subscription.stripe_status)"
size="small"
class="text-capitalize"
>
{{ subscription.stripe_status }}
</VChip>
</div>
<VRow>
<VCol cols="6">
<div class="text-body-2 text-medium-emphasis">Gateway</div>
<div class="text-body-1 text-capitalize mt-1">{{ subscription.gateway || 'stripe' }}</div>
</VCol>
<VCol v-if="subscription.plan" cols="6">
<div class="text-body-2 text-medium-emphasis">Price</div>
<div class="text-body-1 mt-1">{{ formatPrice(subscription.plan.price, subscription.plan.billing_cycle) }}</div>
</VCol>
<VCol v-if="subscription.current_period_start" cols="6">
<div class="text-body-2 text-medium-emphasis">Current Period Start</div>
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</div>
</VCol>
<VCol v-if="subscription.current_period_end" cols="6">
<div class="text-body-2 text-medium-emphasis">Current Period End</div>
<div class="text-body-1 mt-1">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</div>
</VCol>
<VCol v-if="subscription.ends_at" cols="6">
<div class="text-body-2 text-medium-emphasis">Cancels On</div>
<div class="text-body-1 text-error mt-1">{{ new Date(subscription.ends_at).toLocaleDateString() }}</div>
</VCol>
<VCol cols="6">
<div class="text-body-2 text-medium-emphasis">Created</div>
<div class="text-body-1 mt-1">{{ new Date(subscription.created_at).toLocaleDateString() }}</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Change Plan -->
<VCard v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'">
<VCardTitle>Change Plan</VCardTitle>
<VCardText>
<VForm @submit.prevent="swapPlan">
<VRadioGroup v-model="swapForm.plan_id" class="mb-4">
<VRadio
v-for="plan in availablePlans"
:key="plan.id"
:value="String(plan.id)"
>
<template #label>
<div class="d-flex justify-space-between w-100">
<span>{{ plan.name }}</span>
<span class="text-medium-emphasis">{{ formatPrice(plan.price, plan.billing_cycle) }}</span>
</div>
</template>
</VRadio>
</VRadioGroup>
<VBtn
type="submit"
:loading="swapForm.processing"
:disabled="!swapForm.plan_id || swapForm.processing"
>
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
</VBtn>
</VForm>
</VCardText>
</VCard>
</VCol>
<!-- Actions Sidebar -->
<VCol cols="12" lg="4">
<!-- Cancel -->
<VCard v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="mb-6">
<VCardTitle>Cancel Subscription</VCardTitle>
<VCardText>
<VCheckbox
v-model="cancelImmediately"
label="Cancel immediately (no grace period)"
hide-details
class="mb-4"
/>
<VBtn
color="error"
block
:loading="cancelForm.processing"
:disabled="cancelForm.processing"
@click="cancelSubscription"
>
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
</VBtn>
</VCardText>
</VCard>
<!-- Resume -->
<VCard v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'">
<VCardTitle>Resume Subscription</VCardTitle>
<VCardText>
<div class="text-body-2 text-medium-emphasis mb-3">
Your subscription is set to cancel. You can resume it before it expires.
</div>
<VBtn
color="success"
block
@click="resumeSubscription"
>
Resume Subscription
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</template>