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

@@ -1,12 +0,0 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@source '../**/*.vue';
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,28 +0,0 @@
<script setup>
defineProps({
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: 'primary',
},
disabled: Boolean,
});
</script>
<template>
<button
:type="type"
:disabled="disabled"
:class="[
'px-4 py-2 text-sm font-medium rounded-md disabled:opacity-50',
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
variant === 'secondary' && 'bg-gray-800 text-gray-300 border border-gray-700 hover:bg-gray-700',
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700',
]"
>
<slot />
</button>
</template>

View File

@@ -1,12 +0,0 @@
<script setup>
defineProps({
title: String,
});
</script>
<template>
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h3 v-if="title" class="text-sm font-medium text-gray-400 mb-2">{{ title }}</h3>
<slot />
</div>
</template>

View File

@@ -1,16 +0,0 @@
<script setup>
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const page = usePage();
const flash = computed(() => page.props.flash || {});
</script>
<template>
<div v-if="flash.success" class="mb-4 rounded-md bg-green-900/50 border border-green-800 p-4">
<p class="text-sm font-medium text-green-300">{{ flash.success }}</p>
</div>
<div v-if="flash.error" class="mb-4 rounded-md bg-red-900/50 border border-red-800 p-4">
<p class="text-sm font-medium text-red-300">{{ flash.error }}</p>
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
href: String,
active: Boolean,
});
</script>
<template>
<Link
:href="href"
:class="[
'px-3 py-2 rounded-md text-sm font-medium',
active
? 'bg-gray-800 text-white'
: 'text-gray-300 hover:text-white hover:bg-gray-800',
]"
>
<slot />
</Link>
</template>

View File

@@ -1,58 +0,0 @@
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import FlashMessages from '@/Components/FlashMessages.vue';
const page = usePage();
const user = computed(() => page.props.auth?.user);
const domains = computed(() => page.props.domains);
const accountUrl = computed(() => `https://${domains.value?.account}`);
</script>
<template>
<div class="min-h-screen bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<Link href="/dashboard" class="text-xl font-bold text-white">
EZSCALE <span class="text-xs font-normal text-gray-400">Admin</span>
</Link>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link
href="/dashboard"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700"
>
Dashboard
</Link>
</div>
</div>
<div class="flex items-center space-x-4">
<a
v-if="user"
:href="accountUrl + '/dashboard'"
class="text-sm text-gray-400 hover:text-white"
>
Customer View
</a>
<span v-if="user" class="text-sm text-gray-300">{{ user.name }}</span>
<Link
v-if="user"
:href="accountUrl + '/logout'"
method="post"
as="button"
class="text-sm text-gray-400 hover:text-white"
>
Log out
</Link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<FlashMessages />
<slot />
</main>
</div>
</template>

View File

@@ -1,74 +0,0 @@
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import FlashMessages from '@/Components/FlashMessages.vue';
const page = usePage();
const user = computed(() => page.props.auth?.user);
const domains = computed(() => page.props.domains);
</script>
<template>
<div class="min-h-screen bg-gray-950">
<nav class="bg-gray-900 border-b border-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<Link href="/dashboard" class="text-xl font-bold text-white">
EZSCALE
</Link>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link
href="/dashboard"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800"
>
Dashboard
</Link>
<Link
href="/subscriptions"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800"
>
Subscriptions
</Link>
<Link
href="/billing"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800"
>
Billing
</Link>
<Link
href="/plans"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800"
>
Plans
</Link>
<Link
href="/profile"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800"
>
Profile
</Link>
</div>
</div>
<div class="flex items-center space-x-4">
<span v-if="user" class="text-sm text-gray-400">{{ user.name }}</span>
<Link
v-if="user"
href="/logout"
method="post"
as="button"
class="text-sm text-gray-400 hover:text-white"
>
Log out
</Link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<FlashMessages />
<slot />
</main>
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup>
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const page = usePage();
const domains = computed(() => page.props.domains);
const marketingUrl = computed(() => `https://${domains.value?.marketing}`);
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-950 py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a :href="marketingUrl" class="text-3xl font-bold text-white">EZSCALE</a>
<p class="mt-2 text-sm text-gray-400">Cloud Hosting Platform</p>
</div>
<div class="bg-gray-900 shadow-sm rounded-lg border border-gray-800 p-8">
<slot />
</div>
</div>
</div>
</template>

View File

@@ -1,34 +0,0 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
defineOptions({ layout: AdminLayout });
defineProps({
totalCustomers: Number,
totalServices: Number,
activeServices: Number,
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-white mb-6">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Total Customers</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ totalCustomers }}</p>
</div>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Total Services</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ totalServices }}</p>
</div>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Active Services</h3>
<p class="mt-2 text-3xl font-bold text-green-400">{{ activeServices }}</p>
</div>
</div>
</div>
</template>

View File

@@ -1,44 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
password: '',
});
const submit = () => {
form.post('/user/confirm-password', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-4">Confirm your password</h2>
<p class="text-sm text-gray-400 mb-6">Please confirm your password before continuing.</p>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="password" class="block text-sm font-medium text-gray-300">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
autofocus
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-400">{{ form.errors.password }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Confirm
</button>
</form>
</template>

View File

@@ -1,52 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
defineProps({
status: String,
});
const form = useForm({
email: '',
});
const submit = () => {
form.post('/forgot-password');
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-4">Reset your password</h2>
<p class="text-sm text-gray-400 mb-6">Enter your email and we'll send you a reset link.</p>
<div v-if="status" class="mb-4 text-sm font-medium text-green-400">{{ status }}</div>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autofocus
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-400">{{ form.errors.email }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Send reset link
</button>
<p class="text-center text-sm text-gray-400">
<a href="/login" class="text-blue-400 hover:text-blue-300">Back to login</a>
</p>
</form>
</template>

View File

@@ -1,69 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post('/login', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-6">Sign in to your account</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autofocus
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-400">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-400">{{ form.errors.password }}</p>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input v-model="form.remember" type="checkbox" class="rounded bg-gray-800 border-gray-700 text-blue-600" />
<span class="ml-2 text-sm text-gray-400">Remember me</span>
</label>
<a href="/forgot-password" class="text-sm text-blue-400 hover:text-blue-300">Forgot password?</a>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Sign in
</button>
<p class="text-center text-sm text-gray-400">
Don't have an account? <a href="/register" class="text-blue-400 hover:text-blue-300">Sign up</a>
</p>
</form>
</template>

View File

@@ -1,85 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post('/register', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-6">Create an account</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-300">Name</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.name" class="mt-1 text-sm text-red-400">{{ form.errors.name }}</p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-400">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-400">{{ form.errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-300">Confirm Password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Create account
</button>
<p class="text-center text-sm text-gray-400">
Already have an account? <a href="/login" class="text-blue-400 hover:text-blue-300">Sign in</a>
</p>
</form>
</template>

View File

@@ -1,73 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const props = defineProps({
token: String,
email: String,
});
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post('/reset-password', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-6">Set new password</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-400">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300">New Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-400">{{ form.errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-300">Confirm Password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Reset password
</button>
</form>
</template>

View File

@@ -1,81 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const useRecovery = ref(false);
const form = useForm({
code: '',
recovery_code: '',
});
const submit = () => {
form.post('/two-factor-challenge', {
onFinish: () => form.reset(),
});
};
const toggleRecovery = () => {
useRecovery.value = !useRecovery.value;
form.reset();
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-4">Two-Factor Authentication</h2>
<p class="text-sm text-gray-400 mb-6">
<template v-if="!useRecovery">
Enter the authentication code from your authenticator app.
</template>
<template v-else>
Enter one of your emergency recovery codes.
</template>
</p>
<form @submit.prevent="submit" class="space-y-4">
<div v-if="!useRecovery">
<label for="code" class="block text-sm font-medium text-gray-300">Code</label>
<input
id="code"
v-model="form.code"
type="text"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.code" class="mt-1 text-sm text-red-400">{{ form.errors.code }}</p>
</div>
<div v-else>
<label for="recovery_code" class="block text-sm font-medium text-gray-300">Recovery Code</label>
<input
id="recovery_code"
v-model="form.recovery_code"
type="text"
autofocus
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.recovery_code" class="mt-1 text-sm text-red-400">{{ form.errors.recovery_code }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Verify
</button>
<button
type="button"
@click="toggleRecovery"
class="w-full text-center text-sm text-gray-400 hover:text-white"
>
{{ useRecovery ? 'Use authentication code' : 'Use a recovery code' }}
</button>
</form>
</template>

View File

@@ -1,37 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
defineProps({
status: String,
});
const form = useForm({});
const submit = () => {
form.post('/email/verification-notification');
};
</script>
<template>
<h2 class="text-xl font-semibold text-white mb-4">Verify your email</h2>
<p class="text-sm text-gray-400 mb-6">
We've sent a verification link to your email. Please check your inbox and click the link to verify.
</p>
<div v-if="status === 'verification-link-sent'" class="mb-4 text-sm font-medium text-green-400">
A new verification link has been sent to your email address.
</div>
<form @submit.prevent="submit">
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500 disabled:opacity-50"
>
Resend verification email
</button>
</form>
</template>

View File

@@ -1,164 +0,0 @@
<script setup>
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
paymentMethods: Array,
invoices: Array,
transactions: Array,
intent: Object,
stripeKey: String,
});
const defaultForm = useForm({
payment_method_id: '',
});
const setDefault = (id) => {
defaultForm.payment_method_id = id;
defaultForm.post('/billing/payment-methods/default');
};
const removeMethod = (id) => {
if (confirm('Are you sure you want to remove this payment method?')) {
useForm({}).delete(`/billing/payment-methods/${id}`);
}
};
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-white mb-6">Billing</h1>
<div class="space-y-8">
<!-- Payment Methods -->
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Payment Methods</h2>
</div>
<div v-if="paymentMethods.length === 0" class="text-sm text-gray-500">
No payment methods on file.
</div>
<div v-else class="space-y-3">
<div
v-for="pm in paymentMethods"
:key="pm.id"
class="flex items-center justify-between p-3 border rounded-md"
:class="pm.is_default ? 'border-blue-700 bg-blue-900/20' : 'border-gray-700'"
>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-200 capitalize">{{ pm.brand }}</span>
<span class="text-sm text-gray-500">&bull;&bull;&bull;&bull; {{ pm.last_four }}</span>
<span class="text-sm text-gray-600">{{ pm.exp_month }}/{{ pm.exp_year }}</span>
<span v-if="pm.is_default" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-900/50 text-blue-300 border border-blue-800">Default</span>
</div>
<div class="flex items-center gap-2">
<button
v-if="!pm.is_default"
@click="setDefault(pm.id)"
:disabled="defaultForm.processing"
class="text-sm text-blue-400 hover:text-blue-300"
>
Make Default
</button>
<button
@click="removeMethod(pm.id)"
class="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
</div>
</div>
</div>
<!-- Recent Invoices -->
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Recent Invoices</h2>
<Link href="/billing/invoices" class="text-sm text-blue-400 hover:text-blue-300">View All</Link>
</div>
<div v-if="invoices.length === 0" class="text-sm text-gray-500">No invoices yet.</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left py-2 text-gray-500 font-medium">Number</th>
<th class="text-left py-2 text-gray-500 font-medium">Date</th>
<th class="text-left py-2 text-gray-500 font-medium">Status</th>
<th class="text-right py-2 text-gray-500 font-medium">Amount</th>
<th class="text-right py-2 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices" :key="invoice.id" class="border-b border-gray-800/50">
<td class="py-2 text-gray-200">{{ invoice.number }}</td>
<td class="py-2 text-gray-400">{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-900/50 text-green-300': invoice.status === 'paid',
'bg-yellow-900/50 text-yellow-300': invoice.status === 'pending',
'bg-red-900/50 text-red-300': invoice.status === 'overdue',
}"
>
{{ invoice.status }}
</span>
</td>
<td class="py-2 text-right text-gray-200">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="py-2 text-right">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-blue-400 hover:text-blue-300">Download</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Recent Transactions -->
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Recent Transactions</h2>
<Link href="/billing/transactions" class="text-sm text-blue-400 hover:text-blue-300">View All</Link>
</div>
<div v-if="transactions.length === 0" class="text-sm text-gray-500">No transactions yet.</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left py-2 text-gray-500 font-medium">Date</th>
<th class="text-left py-2 text-gray-500 font-medium">Gateway</th>
<th class="text-left py-2 text-gray-500 font-medium">Status</th>
<th class="text-left py-2 text-gray-500 font-medium">Description</th>
<th class="text-right py-2 text-gray-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id" class="border-b border-gray-800/50">
<td class="py-2 text-gray-400">{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="py-2 text-gray-400 capitalize">{{ tx.gateway }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-900/50 text-green-300': tx.status === 'succeeded',
'bg-red-900/50 text-red-300': tx.status === 'failed',
'bg-yellow-900/50 text-yellow-300': tx.status === 'pending',
}"
>
{{ tx.status }}
</span>
</td>
<td class="py-2 text-gray-400">{{ tx.description }}</td>
<td class="py-2 text-right text-gray-200">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@@ -1,81 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
invoices: Object,
});
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-sm text-blue-400 hover:text-blue-300">&larr; Back to Billing</Link>
</div>
<h1 class="text-2xl font-bold text-white mb-6">Invoices</h1>
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm overflow-hidden">
<div v-if="!invoices.data || invoices.data.length === 0" class="p-6 text-sm text-gray-500 text-center">
No invoices found.
</div>
<table v-else class="w-full text-sm">
<thead class="bg-gray-800/50">
<tr>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Number</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Date</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Gateway</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Status</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium">Amount</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium"></th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices.data" :key="invoice.id" class="border-t border-gray-800">
<td class="px-6 py-3 text-gray-200">{{ invoice.number }}</td>
<td class="px-6 py-3 text-gray-400">{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="px-6 py-3 text-gray-400 capitalize">{{ invoice.gateway }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-900/50 text-green-300': invoice.status === 'paid',
'bg-yellow-900/50 text-yellow-300': invoice.status === 'pending',
'bg-red-900/50 text-red-300': invoice.status === 'overdue',
}"
>
{{ invoice.status }}
</span>
</td>
<td class="px-6 py-3 text-right text-gray-200">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="px-6 py-3 text-right">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-blue-400 hover:text-blue-300">Download</a>
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="invoices.links && invoices.last_page > 1" class="px-6 py-3 border-t border-gray-800 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{ invoices.from }} to {{ invoices.to }} of {{ invoices.total }}
</div>
<div class="flex gap-1">
<Link
v-for="link in invoices.links"
:key="link.label"
:href="link.url || '#'"
:class="[
'px-3 py-1 text-sm rounded',
link.active ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-800',
!link.url && 'opacity-50 pointer-events-none',
]"
v-html="link.label"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,80 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
transactions: Object,
});
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-sm text-blue-400 hover:text-blue-300">&larr; Back to Billing</Link>
</div>
<h1 class="text-2xl font-bold text-white mb-6">Transactions</h1>
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm overflow-hidden">
<div v-if="!transactions.data || transactions.data.length === 0" class="p-6 text-sm text-gray-500 text-center">
No transactions found.
</div>
<table v-else class="w-full text-sm">
<thead class="bg-gray-800/50">
<tr>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Date</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Gateway</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Method</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Status</th>
<th class="text-left px-6 py-3 text-gray-500 font-medium">Description</th>
<th class="text-right px-6 py-3 text-gray-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.data" :key="tx.id" class="border-t border-gray-800">
<td class="px-6 py-3 text-gray-400">{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="px-6 py-3 text-gray-400 capitalize">{{ tx.gateway }}</td>
<td class="px-6 py-3 text-gray-400 capitalize">{{ tx.payment_method }}</td>
<td class="px-6 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize"
:class="{
'bg-green-900/50 text-green-300': tx.status === 'succeeded',
'bg-red-900/50 text-red-300': tx.status === 'failed',
'bg-yellow-900/50 text-yellow-300': tx.status === 'pending',
'bg-gray-800 text-gray-400': tx.status === 'refunded',
}"
>
{{ tx.status }}
</span>
</td>
<td class="px-6 py-3 text-gray-400">{{ tx.description }}</td>
<td class="px-6 py-3 text-right text-gray-200">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="transactions.links && transactions.last_page > 1" class="px-6 py-3 border-t border-gray-800 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{ transactions.from }} to {{ transactions.to }} of {{ transactions.total }}
</div>
<div class="flex gap-1">
<Link
v-for="link in transactions.links"
:key="link.label"
:href="link.url || '#'"
:class="[
'px-3 py-1 text-sm rounded',
link.active ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-800',
!link.url && 'opacity-50 pointer-events-none',
]"
v-html="link.label"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,191 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
plan: Object,
paymentMethods: Array,
intent: Object,
stripeKey: String,
});
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 () => {
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"]')?.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 = () => {
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-sm text-blue-400 hover:text-blue-300">&larr; Back to Plans</Link>
</div>
<h1 class="text-2xl font-bold text-white mb-6">Checkout</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Order Summary -->
<div class="lg:col-span-1 order-2 lg:order-1">
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h2 class="text-lg font-semibold text-white mb-4">Order Summary</h2>
<div class="space-y-3">
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ plan.name }}</span>
<span class="text-white">${{ parseFloat(plan.price).toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm text-gray-500">
<span>Billing Cycle</span>
<span class="capitalize">{{ plan.billing_cycle }}</span>
</div>
<div v-if="couponApplied" class="flex justify-between text-sm text-green-400">
<span>Discount</span>
<span>-${{ couponDiscount.toFixed(2) }}</span>
</div>
<hr class="border-gray-800">
<div class="flex justify-between font-semibold text-white">
<span>Total</span>
<span>${{ total }}/{{ plan.billing_cycle }}</span>
</div>
</div>
</div>
</div>
<!-- Checkout Form -->
<div class="lg:col-span-2 order-1 lg:order-2">
<form @submit.prevent="submit" class="space-y-6">
<!-- Payment Gateway -->
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h2 class="text-lg font-semibold text-white mb-4">Payment Method</h2>
<div class="space-y-3">
<label class="flex items-center p-3 border rounded-md cursor-pointer" :class="selectedGateway === 'stripe' ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700'">
<input v-model="selectedGateway" type="radio" value="stripe" class="mr-3">
<span class="text-sm font-medium text-gray-200">Credit / Debit Card (Stripe)</span>
</label>
<label class="flex items-center p-3 border rounded-md cursor-pointer" :class="selectedGateway === 'paypal' ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700'">
<input v-model="selectedGateway" type="radio" value="paypal" class="mr-3">
<span class="text-sm font-medium text-gray-200">PayPal</span>
</label>
</div>
<!-- Saved Payment Methods (Stripe) -->
<div v-if="selectedGateway === 'stripe' && paymentMethods.length > 0" class="mt-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Select Card</label>
<select v-model="selectedPaymentMethod" class="w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 text-sm">
<option v-for="pm in paymentMethods" :key="pm.id" :value="pm.id">
{{ pm.brand }} ending in {{ pm.last_four }} ({{ pm.exp_month }}/{{ pm.exp_year }})
<template v-if="pm.is_default"> - Default</template>
</option>
</select>
</div>
<div v-if="selectedGateway === 'stripe' && paymentMethods.length === 0" class="mt-4">
<p class="text-sm text-gray-500">
You have no saved payment methods.
<Link href="/billing" class="text-blue-400 hover:text-blue-300">Add one first</Link>.
</p>
</div>
</div>
<!-- Coupon -->
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h2 class="text-lg font-semibold text-white mb-4">Coupon Code</h2>
<div class="flex gap-3">
<input
v-model="couponCode"
type="text"
placeholder="Enter coupon code"
class="flex-1 rounded-md bg-gray-800 border-gray-700 text-gray-100 text-sm placeholder-gray-500"
:disabled="couponApplied"
>
<button
type="button"
@click="applyCoupon"
:disabled="!couponCode || couponApplied"
class="px-4 py-2 bg-gray-800 text-sm font-medium text-gray-300 rounded-md hover:bg-gray-700 border border-gray-700 disabled:opacity-50"
>
{{ couponApplied ? 'Applied' : 'Apply' }}
</button>
</div>
<p v-if="couponError" class="mt-2 text-sm text-red-400">{{ couponError }}</p>
<p v-if="couponApplied" class="mt-2 text-sm text-green-400">Coupon applied successfully!</p>
</div>
<!-- Errors -->
<div v-if="form.errors && Object.keys(form.errors).length" class="rounded-md bg-red-900/50 border border-red-800 p-4">
<ul class="list-disc list-inside text-sm text-red-300">
<li v-for="(error, field) in form.errors" :key="field">{{ error }}</li>
</ul>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="form.processing || (selectedGateway === 'stripe' && !selectedPaymentMethod)"
class="w-full px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
<span v-if="form.processing">Processing...</span>
<span v-else>Subscribe for ${{ total }}/{{ plan.billing_cycle }}</span>
</button>
</form>
</div>
</div>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
servicesCount: Number,
activeServicesCount: Number,
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-white mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-400">Total Services</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ servicesCount }}</p>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-400">Active Services</h3>
<p class="mt-2 text-3xl font-bold text-green-400">{{ activeServicesCount }}</p>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-400">Quick Actions</h3>
<div class="mt-3 space-y-2">
<Link href="/plans" class="block text-sm text-blue-400 hover:text-blue-300">Browse Plans</Link>
<Link href="/subscriptions" class="block text-sm text-blue-400 hover:text-blue-300">My Subscriptions</Link>
<Link href="/billing" class="block text-sm text-blue-400 hover:text-blue-300">Billing & Payments</Link>
<Link href="/profile" class="block text-sm text-blue-400 hover:text-blue-300">Edit Profile</Link>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,41 +0,0 @@
<script setup>
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const page = usePage();
const domains = computed(() => page.props.domains);
const accountUrl = computed(() => `https://${domains.value?.account}`);
</script>
<template>
<div class="min-h-screen bg-gray-950">
<nav class="border-b border-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<span class="text-xl font-bold text-white">EZSCALE</span>
<div class="space-x-4">
<a :href="accountUrl + '/login'" class="text-sm text-gray-400 hover:text-white">Sign in</a>
<a :href="accountUrl + '/register'" class="text-sm px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Get Started</a>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
<div class="text-center">
<h1 class="text-4xl font-bold text-white sm:text-5xl md:text-6xl">
Cloud Hosting
<span class="text-blue-400">Made Simple</span>
</h1>
<p class="mt-6 max-w-2xl mx-auto text-xl text-gray-400">
VPS, Dedicated Servers, Web Hosting, and Game Servers. Powered by EZSCALE.
</p>
<div class="mt-10">
<a :href="accountUrl + '/register'" class="px-8 py-3 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 text-lg">
Start Free Trial
</a>
</div>
</div>
</main>
</div>
</template>

View File

@@ -1,77 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
plansByType: Object,
});
const formatPrice = (price, cycle) => {
const amount = parseFloat(price).toFixed(2);
return `$${amount}/${cycle}`;
};
const serviceTypeLabels = {
vps: 'VPS Servers',
dedicated: 'Dedicated Servers',
hosting: 'Web Hosting',
game: 'Game Servers',
};
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-white mb-6">Plans & Pricing</h1>
<div v-for="(plans, type) in plansByType" :key="type" class="mb-10">
<h2 class="text-xl font-semibold text-gray-200 mb-4">
{{ serviceTypeLabels[type] || type }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="plan in plans"
:key="plan.id"
class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6 flex flex-col"
>
<h3 class="text-lg font-semibold text-white">{{ plan.name }}</h3>
<p v-if="plan.description" class="mt-1 text-sm text-gray-400">{{ plan.description }}</p>
<div class="mt-4">
<span class="text-3xl font-bold text-white">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</span>
</div>
<ul v-if="plan.features" class="mt-4 space-y-2 flex-1">
<li v-for="(value, feature) in plan.features" :key="feature" class="flex items-start text-sm text-gray-400">
<svg class="h-5 w-5 text-green-400 mr-2 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span><strong class="text-gray-300">{{ feature }}:</strong> {{ value }}</span>
</li>
</ul>
<div class="mt-6">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="block text-center text-sm font-medium text-red-400">
Out of Stock
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="block w-full text-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
Order Now
</Link>
</div>
</div>
</div>
</div>
<div v-if="!plansByType || Object.keys(plansByType).length === 0" class="text-center py-12">
<p class="text-gray-500">No plans are currently available.</p>
</div>
</div>
</template>

View File

@@ -1,59 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
plan: Object,
});
const formatPrice = (price, cycle) => {
const amount = parseFloat(price).toFixed(2);
return `$${amount}/${cycle}`;
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/plans" class="text-sm text-blue-400 hover:text-blue-300">&larr; Back to Plans</Link>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-8 max-w-2xl">
<h1 class="text-2xl font-bold text-white">{{ plan.name }}</h1>
<p v-if="plan.description" class="mt-2 text-gray-400">{{ plan.description }}</p>
<div class="mt-6">
<span class="text-4xl font-bold text-white">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</span>
</div>
<div v-if="plan.features" class="mt-8">
<h2 class="text-lg font-semibold text-white mb-3">Features</h2>
<ul class="space-y-2">
<li v-for="(value, feature) in plan.features" :key="feature" class="flex items-start text-sm text-gray-400">
<svg class="h-5 w-5 text-green-400 mr-2 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span><strong class="text-gray-300">{{ feature }}:</strong> {{ value }}</span>
</li>
</ul>
</div>
<div class="mt-8">
<span v-if="plan.stock_quantity !== null && plan.stock_quantity <= 0" class="block text-center text-sm font-medium text-red-400">
This plan is currently out of stock.
</span>
<Link
v-else
:href="`/checkout/${plan.id}`"
class="inline-block px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700"
>
Order Now
</Link>
</div>
</div>
</div>
</template>

View File

@@ -1,81 +0,0 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
user: Object,
});
const form = useForm({
name: props.user.name,
phone: props.user.phone || '',
company: props.user.company || '',
});
const submit = () => {
form.put('/profile');
};
</script>
<template>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold text-white mb-6">Profile Settings</h1>
<form @submit.prevent="submit" class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6 space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-300">Name</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.name" class="mt-1 text-sm text-red-400">{{ form.errors.name }}</p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300">Email</label>
<input
id="email"
:value="user.email"
type="email"
disabled
class="mt-1 block w-full rounded-md bg-gray-800/50 border-gray-700 shadow-sm px-3 py-2 border text-gray-500"
/>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-300">Phone</label>
<input
id="phone"
v-model="form.phone"
type="tel"
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<div>
<label for="company" class="block text-sm font-medium text-gray-300">Company</label>
<input
id="company"
v-model="form.company"
type="text"
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<div class="pt-2">
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Save Changes
</button>
</div>
</form>
</div>
</template>

View File

@@ -1,134 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useForm, usePage, router } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const page = usePage();
const enabling = ref(false);
const confirming = ref(false);
const disabling = ref(false);
const qrCode = ref('');
const recoveryCodes = ref([]);
const confirmationForm = useForm({
code: '',
});
const enableTwoFactor = () => {
enabling.value = true;
router.post('/user/two-factor-authentication', {}, {
preserveScroll: true,
onSuccess: () => {
confirming.value = true;
showQrCode();
showRecoveryCodes();
},
onFinish: () => {
enabling.value = false;
},
});
};
const showQrCode = () => {
fetch('/user/two-factor-qr-code')
.then(r => r.json())
.then(data => { qrCode.value = data.svg; });
};
const showRecoveryCodes = () => {
fetch('/user/two-factor-recovery-codes')
.then(r => r.json())
.then(data => { recoveryCodes.value = data; });
};
const confirmTwoFactor = () => {
confirmationForm.post('/user/confirmed-two-factor-authentication', {
preserveScroll: true,
onSuccess: () => {
confirming.value = false;
},
});
};
const disableTwoFactor = () => {
disabling.value = true;
router.delete('/user/two-factor-authentication', {
preserveScroll: true,
onSuccess: () => {
qrCode.value = '';
recoveryCodes.value = [];
},
onFinish: () => {
disabling.value = false;
},
});
};
</script>
<template>
<div class="max-w-2xl">
<h2 class="text-lg font-semibold text-white mb-4">Two-Factor Authentication</h2>
<p class="text-sm text-gray-400 mb-6">
Add an extra layer of security to your account using a TOTP authenticator app.
</p>
<div v-if="!page.props.auth?.user?.two_factor_enabled">
<button
@click="enableTwoFactor"
:disabled="enabling"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Enable Two-Factor Authentication
</button>
</div>
<div v-if="confirming" class="mt-6">
<p class="text-sm text-gray-400 mb-4">
Scan this QR code with your authenticator app, then enter the code below to confirm.
</p>
<div v-if="qrCode" v-html="qrCode" class="mb-4 [&_svg]:fill-white"></div>
<form @submit.prevent="confirmTwoFactor" class="space-y-4 max-w-xs">
<div>
<label for="code" class="block text-sm font-medium text-gray-300">Confirmation Code</label>
<input
id="code"
v-model="confirmationForm.code"
type="text"
inputmode="numeric"
class="mt-1 block w-full rounded-md bg-gray-800 border-gray-700 text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="confirmationForm.errors.code" class="mt-1 text-sm text-red-400">{{ confirmationForm.errors.code }}</p>
</div>
<button
type="submit"
:disabled="confirmationForm.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Confirm
</button>
</form>
</div>
<div v-if="recoveryCodes.length > 0 && !confirming" class="mt-6">
<h3 class="text-sm font-semibold text-white mb-2">Recovery Codes</h3>
<p class="text-sm text-gray-400 mb-3">Store these codes in a safe place. They can be used to access your account if you lose your authenticator device.</p>
<div class="bg-gray-800 rounded-md p-4 font-mono text-sm text-gray-300">
<div v-for="code in recoveryCodes" :key="code">{{ code }}</div>
</div>
</div>
<div v-if="page.props.auth?.user?.two_factor_enabled && !confirming" class="mt-6">
<p class="text-sm text-green-400 mb-4">Two-factor authentication is enabled.</p>
<button
@click="disableTwoFactor"
:disabled="disabling"
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-50"
>
Disable Two-Factor Authentication
</button>
</div>
</div>
</template>

View File

@@ -1,78 +0,0 @@
<script setup>
import { Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
subscriptions: Array,
});
const statusColors = {
active: 'bg-green-900/50 text-green-300 border border-green-800',
canceled: 'bg-red-900/50 text-red-300 border border-red-800',
past_due: 'bg-yellow-900/50 text-yellow-300 border border-yellow-800',
trialing: 'bg-blue-900/50 text-blue-300 border border-blue-800',
incomplete: 'bg-gray-800 text-gray-400 border border-gray-700',
};
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">Subscriptions</h1>
<Link href="/plans" class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700">
Browse Plans
</Link>
</div>
<div v-if="subscriptions.length === 0" class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-12 text-center">
<p class="text-gray-500 mb-4">You don't have any subscriptions yet.</p>
<Link href="/plans" class="text-blue-400 hover:text-blue-300 text-sm font-medium">Browse Available Plans</Link>
</div>
<div v-else class="space-y-4">
<div
v-for="subscription in subscriptions"
:key="subscription.id"
class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6"
>
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-white">
{{ subscription.plan?.name || subscription.type }}
</h3>
<p class="text-sm text-gray-500 mt-1">
{{ subscription.gateway || 'stripe' }} &middot;
<span v-if="subscription.current_period_end">
Renews {{ new Date(subscription.current_period_end).toLocaleDateString() }}
</span>
</p>
</div>
<div class="flex items-center gap-3">
<span
:class="statusColors[subscription.stripe_status] || 'bg-gray-800 text-gray-400'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
>
{{ subscription.stripe_status }}
</span>
<Link
:href="`/subscriptions/${subscription.id}`"
class="text-sm text-blue-400 hover:text-blue-300 font-medium"
>
Manage
</Link>
</div>
</div>
<div v-if="subscription.plan" class="mt-3 text-sm text-gray-400">
${{ parseFloat(subscription.plan.price).toFixed(2) }}/{{ subscription.plan.billing_cycle }}
</div>
<div v-if="subscription.ends_at" class="mt-2 text-sm text-red-400">
Cancels on {{ new Date(subscription.ends_at).toLocaleDateString() }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,163 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
subscription: Object,
availablePlans: Array,
});
const cancelImmediately = ref(false);
const cancelForm = useForm({
immediately: false,
});
const swapForm = useForm({
plan_id: '',
});
const statusColors = {
active: 'bg-green-900/50 text-green-300 border border-green-800',
canceled: 'bg-red-900/50 text-red-300 border border-red-800',
past_due: 'bg-yellow-900/50 text-yellow-300 border border-yellow-800',
trialing: 'bg-blue-900/50 text-blue-300 border border-blue-800',
incomplete: 'bg-gray-800 text-gray-400 border border-gray-700',
};
const cancelSubscription = () => {
cancelForm.immediately = cancelImmediately.value;
cancelForm.post(`/subscriptions/${props.subscription.id}/cancel`);
};
const resumeSubscription = () => {
useForm({}).post(`/subscriptions/${props.subscription.id}/resume`);
};
const swapPlan = () => {
swapForm.post(`/subscriptions/${props.subscription.id}/swap`);
};
</script>
<template>
<div>
<div class="mb-4">
<Link href="/subscriptions" class="text-sm text-blue-400 hover:text-blue-300">&larr; Back to Subscriptions</Link>
</div>
<h1 class="text-2xl font-bold text-white mb-6">Subscription Details</h1>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Subscription Info -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">
{{ subscription.plan?.name || subscription.type }}
</h2>
<span
:class="statusColors[subscription.stripe_status] || 'bg-gray-800 text-gray-400'"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize"
>
{{ subscription.stripe_status }}
</span>
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Gateway</dt>
<dd class="mt-1 text-gray-200 capitalize">{{ subscription.gateway || 'stripe' }}</dd>
</div>
<div v-if="subscription.plan">
<dt class="text-gray-500">Price</dt>
<dd class="mt-1 text-gray-200">${{ parseFloat(subscription.plan.price).toFixed(2) }}/{{ subscription.plan.billing_cycle }}</dd>
</div>
<div v-if="subscription.current_period_start">
<dt class="text-gray-500">Current Period Start</dt>
<dd class="mt-1 text-gray-200">{{ new Date(subscription.current_period_start).toLocaleDateString() }}</dd>
</div>
<div v-if="subscription.current_period_end">
<dt class="text-gray-500">Current Period End</dt>
<dd class="mt-1 text-gray-200">{{ new Date(subscription.current_period_end).toLocaleDateString() }}</dd>
</div>
<div v-if="subscription.ends_at">
<dt class="text-gray-500">Cancels On</dt>
<dd class="mt-1 text-red-400">{{ new Date(subscription.ends_at).toLocaleDateString() }}</dd>
</div>
<div>
<dt class="text-gray-500">Created</dt>
<dd class="mt-1 text-gray-200">{{ new Date(subscription.created_at).toLocaleDateString() }}</dd>
</div>
</dl>
</div>
<!-- Change Plan -->
<div v-if="availablePlans.length > 0 && subscription.stripe_status === 'active'" class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h2 class="text-lg font-semibold text-white mb-4">Change Plan</h2>
<form @submit.prevent="swapPlan" class="space-y-4">
<div class="space-y-2">
<label v-for="plan in availablePlans" :key="plan.id"
class="flex items-center justify-between p-3 border rounded-md cursor-pointer"
:class="swapForm.plan_id == plan.id ? 'border-blue-500 bg-blue-900/20' : 'border-gray-700'"
>
<div class="flex items-center">
<input v-model="swapForm.plan_id" type="radio" :value="plan.id" class="mr-3">
<span class="text-sm font-medium text-gray-200">{{ plan.name }}</span>
</div>
<span class="text-sm text-gray-400">${{ parseFloat(plan.price).toFixed(2) }}/{{ plan.billing_cycle }}</span>
</label>
</div>
<button
type="submit"
:disabled="!swapForm.plan_id || swapForm.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ swapForm.processing ? 'Changing...' : 'Change Plan' }}
</button>
</form>
</div>
</div>
<!-- Actions Sidebar -->
<div class="space-y-6">
<!-- Cancel -->
<div v-if="subscription.stripe_status === 'active' && !subscription.ends_at" class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h2 class="text-lg font-semibold text-white mb-4">Cancel Subscription</h2>
<div class="space-y-3">
<label class="flex items-center text-sm text-gray-300">
<input v-model="cancelImmediately" type="checkbox" class="mr-2 rounded bg-gray-800 border-gray-700">
Cancel immediately (no grace period)
</label>
<button
@click="cancelSubscription"
:disabled="cancelForm.processing"
class="w-full px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-50"
>
{{ cancelForm.processing ? 'Cancelling...' : 'Cancel Subscription' }}
</button>
</div>
</div>
<!-- Resume -->
<div v-if="subscription.ends_at && subscription.stripe_status !== 'canceled'" class="bg-gray-900 rounded-lg border border-gray-800 shadow-sm p-6">
<h2 class="text-lg font-semibold text-white mb-4">Resume Subscription</h2>
<p class="text-sm text-gray-500 mb-3">Your subscription is set to cancel. You can resume it before it expires.</p>
<button
@click="resumeSubscription"
class="w-full px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700"
>
Resume Subscription
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,24 +0,0 @@
import './bootstrap';
import '../css/app.css';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
const appName = import.meta.env.VITE_APP_NAME || 'EZSCALE';
createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName,
resolve: (name) => resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue'),
),
setup({ el, App, props, plugin }) {
return createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
progress: {
color: '#4B5563',
},
});

View File

@@ -0,0 +1,63 @@
@use "sass:map";
@use "@styles/variables/vuetify.scss";
@mixin elevation($z, $important: false) {
box-shadow: map.get(vuetify.$shadow-key-umbra, $z), map.get(vuetify.$shadow-key-penumbra, $z), map.get(vuetify.$shadow-key-ambient, $z) if($important, !important, null);
}
// #region before-pseudo
// This mixin is inspired from vuetify for adding hover styles via before pseudo element
@mixin before-pseudo() {
position: relative;
&::before {
position: absolute;
border-radius: inherit;
background: currentcolor;
block-size: 100%;
content: "";
inline-size: 100%;
inset: 0;
opacity: 0;
pointer-events: none;
}
}
// #endregion before-pseudo
@mixin bordered-skin($component, $border-property: "border", $important: false) {
#{$component} {
box-shadow: none !important;
// stylelint-disable-next-line annotation-no-unknown
#{$border-property}: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) if($important, !important, null);
}
}
// #region selected-states
// Inspired from vuetify's active-states mixin
// focus => 0.12 & selected => 0.08
@mixin selected-states($selector) {
#{$selector} {
opacity: calc(var(--v-selected-opacity) * var(--v-theme-overlay-multiplier));
}
&:hover
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-hover-opacity) * var(--v-theme-overlay-multiplier));
}
&:focus-visible
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
@supports not selector(:focus-visible) {
&:focus {
#{$selector} {
opacity: calc(var(--v-selected-opacity) + var(--v-focus-opacity) * var(--v-theme-overlay-multiplier));
}
}
}
}
// #endregion selected-states

View File

@@ -0,0 +1,90 @@
@use "sass:map";
@use "sass:list";
@use "@configured-variables" as variables;
// Thanks: https://css-tricks.com/snippets/sass/deep-getset-maps/
@function map-deep-get($map, $keys...) {
@each $key in $keys {
$map: map.get($map, $key);
}
@return $map;
}
@function map-deep-set($map, $keys, $value) {
$maps: ($map,);
$result: null;
// If the last key is a map already
// Warn the user we will be overriding it with $value
@if type-of(nth($keys, -1)) == "map" {
@warn "The last key you specified is a map; it will be overrided with `#{$value}`.";
}
// If $keys is a single key
// Just merge and return
@if length($keys) == 1 {
@return map-merge($map, ($keys: $value));
}
// Loop from the first to the second to last key from $keys
// Store the associated map to this key in the $maps list
// If the key doesn't exist, throw an error
@for $i from 1 through length($keys) - 1 {
$current-key: list.nth($keys, $i);
$current-map: list.nth($maps, -1);
$current-get: map.get($current-map, $current-key);
@if not $current-get {
@error "Key `#{$key}` doesn't exist at current level in map.";
}
$maps: list.append($maps, $current-get);
}
// Loop from the last map to the first one
// Merge it with the previous one
@for $i from length($maps) through 1 {
$current-map: list.nth($maps, $i);
$current-key: list.nth($keys, $i);
$current-val: if($i == list.length($maps), $value, $result);
$result: map.map-merge($current-map, ($current-key: $current-val));
}
// Return result
@return $result;
}
// font size utility classes
@each $name, $size in variables.$font-sizes {
.text-#{$name} {
font-size: $size;
line-height: map.get(variables.$font-line-height, $name);
}
}
// truncate utility class
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// gap utility class
@each $name, $size in variables.$gap {
.gap-#{$name} {
gap: $size;
}
.gap-x-#{$name} {
column-gap: $size;
}
.gap-y-#{$name} {
row-gap: $size;
}
}
.list-none {
list-style-type: none;
}

View File

@@ -0,0 +1,198 @@
@use "vuetify/lib/styles/tools/functions" as *;
/*
TODO: Add docs on when to use placeholder vs when to use SASS variable
Placeholder
- When we want to keep customization to our self between templates use it
Variables
- When we want to allow customization from both user and our side
- You can also use variable for consistency (e.g. mx 1 rem should be applied to both vertical nav items and vertical nav header)
*/
@forward "@layouts/styles/variables" with (
// Adjust z-index so vertical nav & overlay stays on top of v-layout in v-main. E.g. Email app
$layout-vertical-nav-z-index: 1003,
$layout-overlay-z-index: 1002,
);
@use "@layouts/styles/variables" as *;
// 👉 Default layout
$navbar-high-emphasis-text: true !default;
// @forward "@layouts/styles/variables" with (
// $layout-vertical-nav-width: 350px !default,
// );
$theme-colors-name: (
"primary",
"secondary",
"error",
"info",
"success",
"warning"
) !default;
// 👉 Default layout with vertical nav
$default-layout-with-vertical-nav-navbar-footer-roundness: 10px !default;
// 👉 Vertical nav
$vertical-nav-background-color-rgb: var(--v-theme-background) !default;
$vertical-nav-background-color: rgb(#{$vertical-nav-background-color-rgb}) !default;
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 1rem !default;
$vertical-nav-horizontal-padding: 0.75rem !default;
// Vertical nav header height. Mostly we will align it with navbar height;
$vertical-nav-header-height: $layout-vertical-nav-navbar-height !default;
$vertical-nav-navbar-elevation: 3 !default;
$vertical-nav-navbar-style: "elevated" !default; // options: elevated, floating
$vertical-nav-floating-navbar-top: 1rem !default;
// Vertical nav header padding
$vertical-nav-header-padding: 1rem $vertical-nav-horizontal-padding !default;
$vertical-nav-header-inline-spacing: $vertical-nav-horizontal-spacing !default;
// Move logo when vertical nav is mini (collapsed but not hovered)
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -4px !default;
// Space between logo and title
$vertical-nav-header-logo-title-spacing: 0.9rem !default;
// Section title margin top (when its not first child)
$vertical-nav-section-title-mt: 1.5rem !default;
// Section title margin bottom
$vertical-nav-section-title-mb: 0.5rem !default;
// Vertical nav icons
$vertical-nav-items-icon-size: 1.5rem !default;
$vertical-nav-items-nested-icon-size: 0.9rem !default;
$vertical-nav-items-icon-margin-inline-end: 0.5rem !default;
// Transition duration for nav group arrow
$vertical-nav-nav-group-arrow-transition-duration: 0.15s !default;
// Timing function for nav group arrow
$vertical-nav-nav-group-arrow-transition-timing-function: ease-in-out !default;
// 👉 Horizontal nav
/*
❗ Heads up
==================
Here we assume we will always use shorthand property which will apply same padding on four side
This is because this have been used as value of top property by `.popper-content`
*/
$horizontal-nav-padding: 0.6875rem !default;
// Gap between top level horizontal nav items
$horizontal-nav-top-level-items-gap: 4px !default;
// Horizontal nav icons
$horizontal-nav-items-icon-size: 1.5rem !default;
$horizontal-nav-third-level-icon-size: 0.9rem !default;
$horizontal-nav-items-icon-margin-inline-end: 0.625rem !default;
$horizontal-nav-group-arrow-icon-size: 1.375rem !default;
// We used SCSS variable because we want to allow users to update max height of popper content
// 120px is combined height of navbar & horizontal nav
$horizontal-nav-popper-content-max-height: calc(100dvh - 120px - 4rem) !default;
// This variable is used for horizontal nav popper content's `margin-top` and "The bridge"'s height. We need to sync both values.
$horizontal-nav-popper-content-top: calc($horizontal-nav-padding + 0.375rem) !default;
// 👉 Plugins
$plugin-ps-thumb-y-dark: rgba(var(--v-theme-surface-variant), 0.35) !default;
// 👉 Vuetify
// Used in src/@core-scss/base/libs/vuetify/_overrides.scss
$vuetify-reduce-default-compact-button-icon-size: true !default;
// 👉 Custom variables
// for utility classes
$font-sizes: () !default;
$font-sizes: map-deep-merge(
(
"xs": 0.75rem,
"sm": 0.875rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.25rem,
"2xl": 1.5rem,
"3xl": 1.875rem,
"4xl": 2.25rem,
"5xl": 3rem,
"6xl": 3.75rem,
"7xl": 4.5rem,
"8xl": 6rem,
"9xl": 8rem
),
$font-sizes
);
// line height
$font-line-height: () !default;
$font-line-height: map-deep-merge(
(
"xs": 1rem,
"sm": 1.25rem,
"base": 1.5rem,
"lg": 1.75rem,
"xl": 1.75rem,
"2xl": 2rem,
"3xl": 2.25rem,
"4xl": 2.5rem,
"5xl": 1,
"6xl": 1,
"7xl": 1,
"8xl": 1,
"9xl": 1
),
$font-line-height
);
// gap utility class
$gap: () !default;
$gap: map-deep-merge(
(
"0": 0,
"1": 0.25rem,
"2": 0.5rem,
"3": 0.75rem,
"4": 1rem,
"5": 1.25rem,
"6":1.5rem,
"7": 1.75rem,
"8": 2rem,
"9": 2.25rem,
"10": 2.5rem,
"11": 2.75rem,
"12": 3rem,
"14": 3.5rem,
"16": 4rem,
"20": 5rem,
"24": 6rem,
"28": 7rem,
"32": 8rem,
"36": 9rem,
"40": 10rem,
"44": 11rem,
"48": 12rem,
"52": 13rem,
"56": 14rem,
"60": 15rem,
"64": 16rem,
"72": 18rem,
"80": 20rem,
"96": 24rem
),
$gap
);

View File

@@ -0,0 +1 @@
@use "overrides";

View File

@@ -0,0 +1,262 @@
@use "@core-scss/base/utils";
@use "@configured-variables" as variables;
// 👉 Application
// We need accurate vh in mobile devices as well
.v-application__wrap {
/* stylelint-disable-next-line liberty/use-logical-spec */
min-height: 100dvh;
}
// 👉 Typography
h1,
h2,
h3,
h4,
h5,
h6,
.text-h1,
.text-h2,
.text-h3,
.text-h4,
.text-h5,
.text-h6,
.text-button,
.text-overline,
.v-card-title {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
body,
.text-body-1,
.text-body-2,
.text-subtitle-1,
.text-subtitle-2 {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
// 👉 Grid
// Remove margin-bottom of v-input_details inside grid (validation error message)
.v-row {
.v-col,
[class^="v-col-*"] {
.v-input__details {
margin-block-end: 0;
}
}
}
// 👉 Button
// Update tonal variant disabled opacity
.v-btn--disabled {
opacity: 0.65;
}
@if variables.$vuetify-reduce-default-compact-button-icon-size {
.v-btn--density-compact.v-btn--size-default {
.v-btn__content > svg {
block-size: 22px;
font-size: 22px;
inline-size: 22px;
}
}
}
// 👉 Card
// Removes padding-top for immediately placed v-card-text after itself
.v-card-text {
& + & {
padding-block-start: 0 !important;
}
}
/*
👉 Checkbox & Radio Ripple
TODO Checkbox and switch component. Remove it when vuetify resolve the extra spacing: https://github.com/vuetifyjs/vuetify/issues/15519
We need this because form elements likes checkbox and switches are by default set to height of textfield height which is way big than we want
Tested with checkbox & switches
*/
.v-checkbox.v-input,
.v-switch.v-input {
--v-input-control-height: auto;
flex: unset;
}
.v-radio-group {
.v-selection-control-group {
.v-radio:not(:last-child) {
margin-inline-end: 0.9rem;
}
}
}
/*
👉 Tabs
Disable tab transition
This is for tabs where we don't have card wrapper to tabs and have multiple cards as tab content.
This class will disable transition and adds `overflow: unset` on `VWindow` to allow spreading shadow
*/
.disable-tab-transition {
overflow: unset !important;
.v-window__container {
block-size: auto !important;
}
.v-window-item:not(.v-window-item--active) {
display: none !important;
}
.v-window__container .v-window-item {
transform: none !important;
}
}
// 👉 List
.v-list {
// Set icons opacity to .87
.v-list-item__prepend > .v-icon,
.v-list-item__append > .v-icon {
opacity: 1;
}
}
// 👉 Card list
/*
Custom class
Remove list spacing inside card
This is because card title gets padding of 20px and list item have padding of 16px. Moreover, list container have padding-bottom as well.
*/
.card-list {
--v-card-list-gap: 20px;
&.v-list {
padding-block: 0;
}
.v-list-item {
min-block-size: unset;
min-block-size: auto !important;
padding-block: 0 !important;
padding-inline: 0 !important;
> .v-ripple__container {
opacity: 0;
}
&:not(:last-child) {
padding-block-end: var(--v-card-list-gap) !important;
}
}
.v-list-item:hover,
.v-list-item:focus,
.v-list-item:active,
.v-list-item.active {
> .v-list-item__overlay {
opacity: 0 !important;
}
}
}
// 👉 Divider
.v-divider {
color: rgb(var(--v-border-color));
}
.v-divider.v-divider--vertical {
block-size: inherit;
}
// 👉 DataTable
.v-data-table {
/* stylelint-disable-next-line no-descending-specificity */
.v-checkbox-btn .v-selection-control__wrapper {
margin-inline-start: 0 !important;
}
.v-selection-control {
display: flex !important;
}
.v-pagination {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
}
// 👉 v-field
.v-field:hover .v-field__outline {
--v-field-border-opacity: var(--v-medium-emphasis-opacity);
}
// 👉 VLabel
.v-label {
opacity: 1 !important;
&:not(.v-field-label--floating) {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
}
// 👉 Overlay
.v-overlay__scrim,
.v-navigation-drawer__scrim {
background: rgba(var(--v-overlay-scrim-background), var(--v-overlay-scrim-opacity)) !important;
opacity: 1 !important;
}
// 👉 VMessages
.v-messages {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
opacity: 1 !important;
}
// 👉 Alert close btn
.v-alert__close {
.v-btn--icon .v-icon {
--v-icon-size-multiplier: 1.5;
}
}
// 👉 Badge icon alignment
.v-badge__badge {
display: flex;
align-items: center;
}
// 👉 Btn focus outline style removed
.v-btn:focus-visible::after {
opacity: 0 !important;
}
// .v-select chip spacing for slot
.v-input:not(.v-select--chips) .v-select__selection {
.v-chip {
margin-block: 2px var(--select-chips-margin-bottom);
}
}
// 👉 VCard and VList subtitle color
.v-list-item-subtitle {
color: rgba(var(--v-theme-on-background), var(--v-medium-emphasis-opacity));
}
// 👉 placeholders
.v-field__input {
@at-root {
& input::placeholder,
input#{&}::placeholder,
textarea#{&}::placeholder {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !important;
opacity: 1 !important;
}
}
}

View File

@@ -0,0 +1,62 @@
@use "sass:map";
/* 👉 Shadow opacities */
$shadow-key-umbra-opacity-custom: var(--v-shadow-key-umbra-opacity);
$shadow-key-penumbra-opacity-custom: var(--v-shadow-key-penumbra-opacity);
$shadow-key-ambient-opacity-custom: var(--v-shadow-key-ambient-opacity);
/* 👉 Card transition properties */
$card-transition-property-custom: box-shadow, opacity;
@forward "vuetify/settings" with (
// 👉 General settings
$color-pack: false !default,
// 👉 Shadow opacity
$shadow-key-umbra-opacity: $shadow-key-umbra-opacity-custom !default,
$shadow-key-penumbra-opacity: $shadow-key-penumbra-opacity-custom !default,
$shadow-key-ambient-opacity: $shadow-key-ambient-opacity-custom !default,
// 👉 Card
$card-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$card-elevation: 6 !default,
$card-title-line-height: 1.6 !default,
$card-actions-min-height: unset !default,
$card-text-padding: 1.25rem !default,
$card-item-padding: 1.25rem !default,
$card-actions-padding: 0 12px 12px !default,
$card-transition-property: $card-transition-property-custom !default,
$card-subtitle-opacity: 1 !default,
// 👉 Expansion Panel
$expansion-panel-active-title-min-height: 48px !default,
// 👉 List
$list-item-icon-margin-end: 16px !default,
$list-item-icon-margin-start: 16px !default,
$list-item-subtitle-opacity: 1 !default,
// 👉 Navigation Drawer
$navigation-drawer-content-overflow-y: hidden !default,
// 👉 Tooltip
$tooltip-background-color: rgba(59, 55, 68, 0.9) !default,
$tooltip-text-color: rgb(var(--v-theme-on-primary)) !default,
$tooltip-font-size: 0.75rem !default,
// 👉 VTimeline
$timeline-dot-size: 34px !default,
// 👉 table
$table-transition-property: height !default,
// 👉 VOverlay
$overlay-opacity: 1 !default,
// 👉 VContainer
$container-max-widths: (
"xl": 1440px,
"xxl": 1440px
) !default,
);

View File

@@ -0,0 +1,6 @@
@use "sass:map";
@use "@configured-variables" as variables;
@mixin custom-elevation($color, $size) {
box-shadow: (map.get(variables.$shadow-params, $size) rgba($color, map.get(variables.$shadow-opacity, $size)));
}

View File

@@ -0,0 +1,102 @@
@forward "@core-scss/base/variables" with (
$default-layout-with-vertical-nav-navbar-footer-roundness: 6px !default,
$vertical-nav-navbar-style: "floating" !default, // options: elevated, floating
// 👉 Vertical nav
$vertical-nav-background-color-rgb: var(--v-theme-surface) !default,
// This is used to keep consistency between nav items and nav header left & right margin
// This is used by nav items & nav header
$vertical-nav-horizontal-spacing: 0.75rem !default,
// Section title margin top (when its not first child)
$vertical-nav-section-title-mt: 1rem !default,
$vertical-nav-navbar-elevation: 4 !default,
$vertical-nav-horizontal-padding: 0.75rem !default,
$layout-vertical-nav-collapsed-width: 70px !default,
// Move logo when vertical nav is mini (collapsed but not hovered)
$vertical-nav-header-logo-translate-x-when-vertical-nav-mini: -1px !default,
// Section title margin bottom
$vertical-nav-section-title-mb: 0.375rem !default,
// Vertical nav header padding
$vertical-nav-header-padding: 1.25rem 0.5rem !default,
// Vertical nav icons
$vertical-nav-items-icon-size: 1.375rem !default,
$vertical-nav-items-nested-icon-size: 0.75rem !default,
// 👉Footer
$layout-vertical-nav-footer-height: 54px !default,
// Gap between top level horizontal nav items
$horizontal-nav-top-level-items-gap: 6px !default,
$horizontal-nav-items-icon-margin-inline-end: 0.5rem !default,
$horizontal-nav-popper-content-top: 0.375rem !default,
$horizontal-nav-group-arrow-icon-size: 1.25rem !default,
$horizontal-nav-third-level-icon-size: 0.75rem !default,
/*
❗ Heads up
==================
Here we assume we will always use shorthand property which will apply same padding on four side
This is because this have been used as value of top property by `.popper-content`
*/
$horizontal-nav-padding: 0.5rem !default,
// 👉 Navbar
$layout-vertical-nav-navbar-height: 54px !default,
$layout-horizontal-nav-navbar-height: 54px !default,
// Font sizes
$font-sizes: (
"xs": 0.6875rem,
"sm": 0.8125rem,
"base": 0.9375rem,
"lg": 1.125rem,
"xl": 1.5rem,
"2xl": 1.75rem,
"3xl": 2rem,
"4xl": 2.375rem,
"5xl": 3rem,
"6xl": 3.5rem,
"7xl": 4rem,
"8xl": 4.5rem,
"9xl": 5.25rem,
) !default,
// Line heights
$font-line-height: (
"xs": 0.9375rem,
"sm": 1.25rem,
"base": 1.375rem,
"lg": 1.75rem,
"xl": 2.375rem,
"2xl": 2.625rem,
"3xl": 2.75rem,
"4xl": 3.25rem,
"5xl": 1,
"6xl": 1,
"7xl": 1,
"8xl": 1,
"9xl": 1
) !default,
);
/* Custom shadow opacity */
$shadow-opacity: (
"sm": 0.3,
"md": 0.4,
"lg": 0.5,
) !default;
/* Custom shadow params */
$shadow-params: (
"sm": 0 2px 6px 0,
"md": 0 4px 16px 0,
"lg": 0 6px 20px 0,
) !default;

View File

@@ -0,0 +1,348 @@
@use "sass:math";
$font-family-custom: "Public Sans",sans-serif,-apple-system,blinkmacsystemfont,
"Segoe UI",roboto,"Helvetica Neue",arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
/* 👉 Typography custom variables */
$typography-h5-font-size: 1.125rem;
$typography-body-1-font-size: 0.9375rem;
$typography-body-1-line-height: 1.375rem;
@forward "../../../base/libs/vuetify/variables" with (
$body-font-family: $font-family-custom !default,
$border-radius-root: 6px !default,
// 👉 Rounded
$rounded: (
"sm": 4px,
"lg": 8px,
"shaped": 30px 0,
) !default,
// 👉 Shadows
$shadow-key-umbra: (
0: (0 0 0 0 rgba(var(--v-shadow-key-umbra-color), 1)),
1: (0 2px 4px rgba(var(--v-shadow-key-umbra-color), 0.12)),
2: (0 1px 6px rgba(var(--v-shadow-key-umbra-color), var(--v-shadow-xs-opacity))),
3: (0 3px 8px rgba(var(--v-shadow-key-umbra-color), 0.14)),
4: (0 2px 8px rgba(var(--v-shadow-key-umbra-color), var(--v-shadow-sm-opacity))),
5: (0 4px 10px rgba(var(--v-shadow-key-umbra-color), 0.15)),
6: (0 3px 12px rgba(var(--v-shadow-key-umbra-color), var(--v-shadow-md-opacity))),
7: (0 4px 18px rgba(var(--v-shadow-key-umbra-color), 0.1)),
8: (0 4px 18px rgba(var(--v-shadow-key-umbra-color), var(--v-shadow-lg-opacity))),
9: (0 5px 14px rgba(var(--v-shadow-key-umbra-color), 0.18)),
10: (0 5px 30px rgba(var(--v-shadow-key-umbra-color), var(--v-shadow-xl-opacity))),
11: (0 5px 16px rgba(var(--v-shadow-key-umbra-color), 0.2)),
12: (0 6px 17px rgba(var(--v-shadow-key-umbra-color), 0.22)),
13: (0 6px 18px rgba(var(--v-shadow-key-umbra-color), 0.22)),
14: (0 6px 19px rgba(var(--v-shadow-key-umbra-color), 0.24)),
15: (0 7px 20px rgba(var(--v-shadow-key-umbra-color), 0.24)),
16: (0 7px 21px rgba(var(--v-shadow-key-umbra-color), 0.26)),
17: (0 7px 22px rgba(var(--v-shadow-key-umbra-color), 0.26)),
18: (0 8px 23px rgba(var(--v-shadow-key-umbra-color), 0.28)),
19: (0 8px 24px 6px rgba(var(--v-shadow-key-umbra-color), 0.28)),
20: (0 9px 25px rgba(var(--v-shadow-key-umbra-color), 0.3)),
21: (0 9px 26px rgba(var(--v-shadow-key-umbra-color), 0.32)),
22: (0 9px 27px rgba(var(--v-shadow-key-umbra-color), 0.32)),
23: (0 10px 28px rgba(var(--v-shadow-key-umbra-color), 0.34)),
24: (0 10px 30px rgba(var(--v-shadow-key-umbra-color), 0.34))
) !default,
$shadow-key-penumbra: (
0: (0 0 transparent),
1: (0 0 transparent),
2: (0 0 transparent),
3: (0 0 transparent),
4: (0 0 transparent),
5: (0 0 transparent),
6: (0 0 transparent),
7: (0 0 transparent),
8: (0 0 transparent),
9: (0 0 transparent),
10: (0 0 transparent),
11: (0 0 transparent),
12: (0 0 transparent),
13: (0 0 transparent),
14: (0 0 transparent),
15: (0 0 transparent),
16: (0 0 transparent),
17: (0 0 transparent),
18: (0 0 transparent),
19: (0 0 transparent),
20: (0 0 transparent),
21: (0 0 transparent),
22: (0 0 transparent),
23: (0 0 transparent),
24: (0 0 transparent),
) !default,
$shadow-key-ambient: (
0: (0 0 transparent),
1: (0 0 transparent),
2: (0 0 transparent),
3: (0 0 transparent),
4: (0 0 transparent),
5: (0 0 transparent),
6: (0 0 transparent),
7: (0 0 transparent),
8: (0 0 transparent),
9: (0 0 transparent),
10: (0 0 transparent),
11: (0 0 transparent),
12: (0 0 transparent),
13: (0 0 transparent),
14: (0 0 transparent),
15: (0 0 transparent),
16: (0 0 transparent),
17: (0 0 transparent),
18: (0 0 transparent),
19: (0 0 transparent),
20: (0 0 transparent),
21: (0 0 transparent),
22: (0 0 transparent),
23: (0 0 transparent),
24: (0 0 transparent),
) !default,
// 👉 Typography
$typography: (
"h1": (
"size": 2.875rem,
"weight": 500,
"line-height": 4.25rem,
"letter-spacing": normal
),
"h2": (
"size": 2.375rem,
"weight": 500,
"line-height": 3.5rem,
"letter-spacing": normal
),
"h3": (
"size": 1.75rem,
"weight": 500,
"line-height": 2.625rem
),
"h4": (
"size": 1.5rem,
"weight": 500,
"line-height": 2.375rem,
"letter-spacing": normal
),
"h5": (
"size": $typography-h5-font-size,
"weight": 500,
"line-height": 1.75rem
),
"h6":(
"size": 0.9375rem,
"line-height": 1.375rem,
"letter-spacing": normal
),
"body-1":(
"size": $typography-body-1-font-size,
"line-height": $typography-body-1-line-height,
"letter-spacing": normal
),
"body-2": (
"size": 0.8125rem,
"line-height": 1.25rem,
"letter-spacing": normal
),
"subtitle-1":(
"size": 0.9375rem,
"weight": 400,
"line-height": 1.375rem
),
"subtitle-2": (
"size": 0.8125rem,
"weight": 400,
"line-height": 1.25rem,
"letter-spacing": normal
),
"button": (
"size": 0.9375rem,
"weight": 500,
"line-height": 1.125rem,
"letter-spacing": 0.0269rem,
"text-transform": capitalize
),
"caption":(
"size": 0.8125rem,
"line-height": 1.125rem,
"letter-spacing": 0.025rem
),
"overline": (
"size": 0.75rem,
"weight": 400,
"line-height": 0.875rem,
"letter-spacing": 0.05rem,
),
) !default,
// 👉 Alert
$alert-title-font-size: 1.125rem !default,
$alert-title-line-height: 1.5rem !default,
$alert-border-opacity: 0.38 !default,
// 👉 Badge
$badge-dot-height: 8px !default,
$badge-dot-width: 8px !default,
$badge-min-width: 24px !default,
$badge-height: 1.5rem !default,
$badge-font-size: 0.8125rem !default,
$badge-border-radius: 12px !default,
$badge-border-color: rgb(var(--v-theme-surface)) !default,
$badge-border-transform: scale(1.5) !default,
$badge-dot-border-width: 2px !default,
// 👉 Chip
$chip-font-size: 13px !default,
$chip-font-weight: 500 !default,
$chip-label-border-radius: 0.375rem !default,
$chip-height: 32px !default,
$chip-close-size: 1.25rem !default,
$chip-elevation: 0 !default,
// 👉 Button
$button-height: 38px !default,
$button-padding-ratio: 1.9 !default,
$button-line-height: 1.375rem !default,
$button-disabled-opacity: 0.45 !default,
$button-disabled-overlay: 0.2025 !default,
$button-icon-font-size: 0.9375rem !default,
// 👉 Button Group
$btn-group-border-radius: 8px !default,
// 👉 Dialog
$dialog-card-header-padding: 24px 24px 0 !default,
$dialog-card-header-text-padding-top: 24px !default,
$dialog-card-text-padding: 24px !default,
$dialog-elevation: 8 !default,
// 👉 Card
$card-title-font-size: $typography-h5-font-size !default,
$card-text-font-size: $typography-body-1-font-size !default,
$card-subtitle-font-size: 0.9375rem !default,
$card-subtitle-header-padding: 0 !default,
$card-subtitle-line-height: 1.375rem !default,
$card-title-line-height: 1.75rem !default,
$card-text-padding: 24px !default,
$card-text-line-height: 1.375rem !default,
$card-item-padding: 24px !default,
$card-elevation: 6 !default,
// 👉 Carousel
$carousel-dot-margin: 0 !default,
$carousel-dot-inactive-opacity: 0.4 !default,
// 👉 Expansion Panel
$expansion-panel-title-padding: 12px 20px 12px 24px !default,
$expansion-panel-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$expansion-panel-active-title-min-height: 46px !default,
$expansion-panel-title-min-height: 46px !default,
$expansion-panel-text-padding: 0 20px 20px 24px !default,
// 👉 Field
$field-font-size: 0.9375rem !default,
$input-density: ("default": -2, "comfortable": -4.5, "compact": -6.5) !default,
$field-outline-opacity: 0.22 !default,
$field-border-width: 1px !default,
$field-focused-border-width: 2px !default,
$field-control-affixed-padding: 14px !default,
// 👉 Input
$input-details-padding-above: 4px !default,
$input-details-font-size: 0.8125rem !default,
// 👉 List
$list-density: ("default": 0, "comfortable": -1.5, "compact": -2.5) !default,
$list-border-radius: 6px !default,
$list-item-padding: 8px 20px !default,
$list-item-icon-margin-end: 10px !default,
$list-item-icon-margin-start : 12px !default,
$list-item-subtitle-line-height: 20px !default,
$list-subheader-font-size: 13px !default,
$list-subheader-line-height: 1.25rem !default,
$list-subheader-padding-end: 20px !default,
$list-subheader-min-height: 40px !default,
$list-item-avatar-margin-start: 12px !default,
$list-item-avatar-margin-end: 12px !default,
$list-disabled-opacity: 0.4,
// 👉 label
$label-font-size: 0.9375rem !default,
// 👉 message
$messages-font-size: 13px !default,
// 👉 menu
$menu-elevation: 8 !default,
// 👉 navigation drawer
$navigation-drawer-temporary-elevation: 8 !default,
// 👉 pagination
$pagination-item-margin: 0.1875rem !default,
// 👉 Progress Linear
$progress-linear-background-opacity: 1 !default,
// 👉 Radio
$radio-group-label-selection-group-padding-inline: 0 !default,
// 👉 slider
$slider-thumb-hover-opacity: var(--v-activated-opacity) !default,
$slider-thumb-label-padding: 2px 10px !default,
$slider-thumb-label-font-size: 0.8125rem !default,
$slider-track-active-size: 6px !default,
// 👉 select
$select-chips-margin-bottom: ("default": 1, "comfortable": 1, "compact": 1) !default,
// 👉 snackbar
$snackbar-background: rgb(var(--v-tooltip-background)) !default,
$snackbar-color: rgb(var(--v-theme-surface)) !default,
$snackbar-content-padding: 12px 16px !default,
$snackbar-font-size: 0.8125rem !default,
$snackbar-elevation: 2 !default,
$snackbar-wrapper-min-height:44px !default,
$snackbar-btn-padding: 0 9px !default,
$snackbar-action-margin: 16px !default,
// 👉 switch
$switch-inset-track-width: 1.875rem !default,
$switch-inset-track-height: 1.125rem !default,
$switch-inset-thumb-height: 0.875rem !default,
$switch-inset-thumb-width: 0.875rem !default,
$switch-inset-thumb-off-height: 0.875rem !default,
$switch-inset-thumb-off-width: 0.875rem !default,
$switch-thumb-elevation: 2 !default,
$switch-track-opacity: 1 !default,
$switch-track-background: rgba(var(--v-theme-on-surface), var(--v-focus-opacity)) !default,
$switch-thumb-background: rgb(var(--v-theme-on-primary)),
// 👉 table
$table-row-height: 50px !default,
$table-color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !default,
$table-font-size: 0.9375rem !default,
// 👉 tabs
$tabs-height: 42px !default,
$tab-min-width: 50px !default,
// 👉 tooltip
$tooltip-background-color: rgb(var(--v-tooltip-background)) !default,
$tooltip-text-color: rgb(var(--v-theme-surface)) !default,
$tooltip-font-size: 0.8125rem !default,
$tooltip-border-radius: 0.25rem !default,
$tooltip-padding: 5px 12px !default,
// 👉 timeline
$timeline-dot-size: 34px !default,
$timeline-dot-divider-background: rgba(var(--v-border-color),0.08) !default,
$timeline-divider-line-background: rgba(var(--v-border-color), var(--v-border-opacity)) !default,
$timeline-divider-line-thickness: 1.5px !default,
$timeline-item-padding: 16px !default,
);

View File

@@ -0,0 +1,114 @@
@use "@core-scss/base/mixins";
@use "@configured-variables" as variables;
/* 👉 Alert
/ custom icon styling */
$alert-prepend-icon-font-size: 1.375rem !important;
.v-alert {
.v-alert__content {
line-height: 1.375rem;
}
&:not(.v-alert--prominent) .v-alert__prepend {
block-size: 1.875rem !important;
inline-size: 1.875rem !important;
.v-icon {
margin: auto;
block-size: 1.375rem !important;
font-size: 1.375rem !important;
inline-size: 1.375rem !important;
}
}
.v-alert-title {
margin-block-end: 0.25rem;
}
.v-alert__close {
.v-btn--icon {
.v-icon {
block-size: 1.25rem;
font-size: 1.25rem;
inline-size: 1.25rem;
}
.v-btn__overlay,
.v-ripple__container {
opacity: 0;
}
}
}
&:not(.v-alert--prominent) {
/* stylelint-disable-next-line no-duplicate-selectors */
.v-alert__prepend {
border-radius: 0.375rem;
}
&.v-alert--variant-flat,
&.v-alert--variant-elevated {
.v-alert__prepend {
background-color: #fff;
@include mixins.elevation(2);
}
}
&.v-alert--variant-tonal {
.v-alert__prepend {
z-index: 1;
background-color: rgb(var(--v-theme-surface));
}
}
}
}
.v-alert.v-alert--density-compact {
border-radius: 0.25rem;
}
.v-alert.v-alert--density-default {
border-radius: 0.5rem;
}
@each $color-name in variables.$theme-colors-name {
.v-alert {
&:not(.v-alert--prominent) {
&.bg-#{$color-name},
&.text-#{$color-name} {
.v-alert__prepend .v-icon {
color: rgb(var(--v-theme-#{$color-name})) !important;
}
}
&.v-alert--variant-tonal {
&.text-#{$color-name},
&.bg-#{$color-name} {
.v-alert__underlay {
background: rgb(var(--v-theme-#{$color-name})) !important;
}
.v-alert__prepend {
background-color: rgb(var(--v-theme-#{$color-name}));
.v-icon {
color: #fff !important;
}
}
}
}
&.v-alert--variant-outlined {
&.text-#{$color-name},
&.bg-#{$color-name} {
.v-alert__prepend {
background-color: rgba(var(--v-theme-#{$color-name}), 0.16);
}
}
}
}
}
}

View File

@@ -0,0 +1,27 @@
@use "@core-scss/base/mixins";
// 👉 Avatar
body {
.v-avatar {
.v-icon {
block-size: 1.5rem;
inline-size: 1.5rem;
}
&.v-avatar--variant-tonal:not([class*="text-"]) {
.v-avatar__underlay {
--v-activated-opacity: 0.08;
}
}
}
.v-avatar-group {
> * {
&:hover {
transform: translateY(-5px) scale(1);
@include mixins.elevation(6);
}
}
}
}

View File

@@ -0,0 +1,25 @@
@use "@configured-variables" as variables;
// 👉 Badge
.v-badge {
.v-badge__badge .v-icon {
font-size: 0.9375rem;
}
&.v-badge--bordered:not(.v-badge--dot) {
.v-badge__badge {
&::after {
transform: scale(1.05);
}
}
}
&.v-badge--tonal {
@each $color-name in variables.$theme-colors-name {
.v-badge__badge.bg-#{$color-name} {
background-color: rgba(var(--v-theme-#{$color-name}), 0.16) !important;
color: rgb(var(--v-theme-#{$color-name})) !important;
}
}
}
}

View File

@@ -0,0 +1,280 @@
@use "sass:list";
@use "sass:map";
@use "@core-scss/template/mixins" as templateMixins;
@use "@configured-variables" as variables;
/* 👉 Button
Above map but opacity values as key and variant as value */
$btn-active-overlay-opacity: (
0.08: (outlined, flat, text, plain),
0.24: (tonal),
);
$btn-hover-overlay-opacity: (
0: (elevated),
0.08: (outlined, flat, text, plain),
0.24: (tonal),
);
$btn-focus-overlay-opacity: (
0.08: (outlined, flat, text, plain),
0.24: (tonal),
);
body .v-btn {
// This is necessary because as we have darker overlay on hover for elevated variant, button text doesn't get dimmed
// This style is already applied to `.v-icon`
.v-btn__content {
z-index: 0;
}
transition: all 0.135s ease; /* Add transition */
&:active {
transform: scale(0.98);
}
// Add transition on hover
&:not(.v-btn--loading) .v-btn__overlay {
transition: opacity 0.15s ease-in-out;
will-change: opacity;
}
// box-shadow
@each $color-name in variables.$theme-colors-name {
&:not(.v-btn--disabled) {
&.bg-#{$color-name}.v-btn--variant-elevated {
&,
&:hover,
&:active,
&:focus {
@include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm");
}
}
}
}
/*
Loop over $btn-active-overlay-opacity map and add active styles for each variant.
Group variants with same opacity value.
*/
@each $opacity, $variants in $btn-active-overlay-opacity {
$overlay-selectors: ();
$underlay-selectors: ();
// append each variant to selectors list
@each $variant in $variants {
$overlay-selectors: list.append($overlay-selectors, "&.v-btn--variant-#{$variant}:active > .v-btn__overlay,");
$underlay-selectors: list.append($underlay-selectors, "&.v-btn--variant-#{$variant}:active > .v-btn__underlay,");
}
#{$overlay-selectors} {
--v-hover-opacity: #{$opacity};
opacity: var(--v-hover-opacity);
}
#{$underlay-selectors} {
opacity: 0;
}
}
@each $opacity, $variants in $btn-focus-overlay-opacity {
$selectors: ();
// append each variant to selectors list
@each $variant in $variants {
$selectors: list.append($selectors, "&.v-btn--variant-#{$variant}:focus > .v-btn__overlay,");
}
#{$selectors} {
opacity: $opacity;
}
}
/*
Loop over $btn-hover-overlay-opacity map and add hover styles for each variant.
Group variants with same opacity value.
*/
@each $opacity, $variants in $btn-hover-overlay-opacity {
$selectors: ();
// append each variant to selectors list
@each $variant in $variants {
$selectors: list.append($selectors, "&.v-btn--variant-#{$variant}:hover > .v-btn__overlay,");
}
#{$selectors} {
--v-hover-opacity: #{$opacity};
}
}
// Default (elevated) button
&--variant-elevated,
&--variant-flat {
// We want a darken color on hover
&:not(.v-btn--loading, .v-btn--disabled) {
@each $color-name in variables.$theme-colors-name {
&.bg-#{$color-name} {
&:hover,
&:active,
&:focus {
background-color: rgb(var(--v-theme-#{$color-name}-darken-1)) !important;
}
}
}
}
}
// Outlined button
&:not(.v-btn--icon, .v-tab).v-btn--variant-text {
&.v-btn--size-default {
padding-inline: 0.75rem;
}
&.v-btn--size-small {
padding-inline: 0.5625rem;
}
&.v-btn--size-large {
padding-inline: 1rem;
}
}
// Button border-radius
&:not(.v-btn--icon).v-btn--size-x-small {
border-radius: 2px;
}
&:not(.v-btn--icon).v-btn--size-small {
border-radius: 4px;
line-height: 1.125rem;
padding-block: 0;
padding-inline: 0.875rem;
}
&:not(.v-btn--icon).v-btn--size-default {
.v-btn__content,
.v-btn__append,
.v-btn__prepend {
.v-icon {
--v-icon-size-multiplier: 0.7113;
block-size: 1rem;
font-size: 1rem;
inline-size: 1rem;
}
.v-icon--start {
margin-inline: -2px 6px;
}
.v-icon--end {
margin-inline: 6px -2px;
}
}
}
&:not(.v-btn--icon).v-btn--size-large {
--v-btn-height: 3rem;
border-radius: 8px;
line-height: 1.625rem;
padding-block: 0;
padding-inline: 1.625rem;
}
&:not(.v-btn--icon).v-btn--size-x-large {
border-radius: 10px;
}
// icon buttons
&.v-btn--icon.v-btn--density-default {
block-size: var(--v-btn-height);
inline-size: var(--v-btn-height);
&.v-btn--size-default {
.v-icon {
--v-icon-size-multiplier: 0.978 !important;
block-size: 1.375rem;
font-size: 1.375rem;
inline-size: 1.375rem;
}
}
&.v-btn--size-small {
--v-btn-height: 2.125rem;
.v-icon {
block-size: 1.25rem;
font-size: 1.25rem;
inline-size: 1.25rem;
}
}
&.v-btn--size-large {
--v-btn-height: 2.625rem;
.v-icon {
block-size: 1.75rem;
font-size: 1.75rem;
inline-size: 1.75rem;
}
}
}
&-group.v-btn-toggle {
.v-btn {
border-radius: 0.5rem;
block-size: 52px !important;
border-inline-end: none;
inline-size: 52px !important;
&.v-btn--density-comfortable {
border-radius: 0.375rem;
block-size: 44px !important;
inline-size: 44px !important;
}
&.v-btn--density-compact {
border-radius: 0.25rem;
block-size: 36px !important;
inline-size: 36px !important;
}
&.v-btn--icon .v-icon {
block-size: 1.5rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 1.5rem;
inline-size: 1.5rem;
}
&.v-btn--icon.v-btn--active {
.v-icon {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
}
}
&.v-btn-group {
align-items: center;
padding: 7px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
block-size: 66px;
.v-btn.v-btn--active {
.v-btn__overlay {
--v-activated-opacity: 0.08;
}
}
&.v-btn-group--density-compact {
block-size: 50px;
}
&.v-btn-group--density-comfortable {
block-size: 58px;
}
}
}
}

View File

@@ -0,0 +1,3 @@
.v-card-subtitle {
color: rgba(var(--v-theme-on-background), 0.55);
}

View File

@@ -0,0 +1,65 @@
@use "sass:list";
@use "sass:map";
@use "@styles/variables/vuetify";
@use "@configured-variables" as variables;
// 👉 Checkbox
.v-checkbox {
// We adjusted it to vertically align the label
.v-selection-control--disabled {
--v-disabled-opacity: 0.45;
}
// Remove extra space below the label
.v-input__details {
min-block-size: unset !important;
padding-block-start: 0 !important;
}
}
// 👉 checkbox size and box shadow
.v-checkbox-btn {
// 👉 Checkbox icon opacity
.v-selection-control__input > .v-icon {
opacity: 1;
}
&.v-selection-control--dirty {
@each $color-name in variables.$theme-colors-name {
.v-selection-control__wrapper.text-#{$color-name} {
.v-selection-control__input {
/* Using filter: drop-shadow() instead of box-shadow because box-shadow creates white background for SVG; */
.v-icon {
filter: drop-shadow(0 2px 6px rgba(var(--v-theme-#{$color-name}), 0.3));
}
}
}
}
}
}
// checkbox icon size
.v-checkbox,
.v-checkbox-btn {
&.v-selection-control {
.v-selection-control__input {
svg {
font-size: 1.5rem;
}
}
.v-label {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
line-height: 1.375rem;
}
}
&:not(.v-selection-control--dirty) {
.v-selection-control__input {
> .custom-checkbox-indeterminate {
color: rgb(var(--v-theme-primary)) !important;
}
}
}
}

View File

@@ -0,0 +1,102 @@
// 👉 Chip
.v-chip {
line-height: 1.25rem;
.v-chip__close {
margin-inline: 4px -6px !important;
.v-icon {
opacity: 0.7;
}
}
.v-chip__content {
.v-icon {
block-size: 20px;
font-size: 20px;
inline-size: 20px;
}
}
&:not(.v-chip--variant-elevated) {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
&.v-chip--variant-elevated {
background-color: rgba(var(--v-theme-on-surface), var(--v-activated-opacity));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
&:not([class*="text-"]) {
--v-activated-opacity: 0.08;
}
// common style for all sizes
&.v-chip--size-default,
&.v-chip--size-small {
.v-icon--start,
.v-chip__filter {
margin-inline-start: 0 !important;
}
&:not(.v-chip--pill) {
.v-avatar--start {
margin-inline: -6px 4px;
}
.v-avatar--end {
margin-inline: 4px -6px;
}
}
}
// small size
&:not(.v-chip--pill).v-chip--size-small {
--v-chip-height: 24px;
&.v-chip--label {
border-radius: 0.25rem;
}
font-size: 13px;
.v-avatar {
--v-avatar-height: 16px;
}
.v-chip__close {
font-size: 16px;
max-block-size: 16px;
max-inline-size: 16px;
}
}
// extra small size
&:not(.v-chip--pill).v-chip--size-x-small {
--v-chip-height: 20px;
&.v-chip--label {
border-radius: 0.25rem;
padding-inline: 0.625rem;
}
font-size: 13px;
.v-avatar {
--v-avatar-height: 16px;
}
.v-chip__close {
font-size: 16px;
max-block-size: 16px;
max-inline-size: 16px;
}
}
// default size
&:not(.v-chip--pill).v-chip--size-default {
.v-avatar {
--v-avatar-height: 20px;
}
}
}

View File

@@ -0,0 +1,27 @@
@use "@layouts/styles/mixins" as layoutsMixins;
// 👉 Dialog
body .v-dialog {
// dialog custom close btn
.v-dialog-close-btn {
border-radius: 0.375rem;
background-color: rgb(var(--v-theme-surface)) !important;
block-size: 2rem;
inline-size: 2rem;
inset-block-start: 0;
inset-inline-end: 0;
transform: translate(0.5rem, -0.5rem);
@include layoutsMixins.rtl {
transform: translate(-0.5rem, -0.5rem);
}
&:hover {
transform: translate(0.3125rem, -0.3125rem);
@include layoutsMixins.rtl {
transform: translate(-0.3125rem, -0.3125rem);
}
}
}
}

View File

@@ -0,0 +1,106 @@
@use "@core-scss/base/mixins";
@use "@layouts/styles/mixins" as layoutsMixins;
// 👉 Expansion panels
body .v-layout .v-application__wrap .v-expansion-panels {
.v-expansion-panel {
margin-block-start: 0 !important;
// expansion panel arrow font size
.v-expansion-panel-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-weight: 500;
.v-expansion-panel-title__icon {
transition: transform 0.2s ease-in-out;
.v-icon {
block-size: 1.25rem !important;
font-size: 1.25rem !important;
inline-size: 1.25rem !important;
}
}
}
.v-expansion-panel-title,
.v-expansion-panel-title--active,
.v-expansion-panel-title:hover,
.v-expansion-panel-title:focus,
.v-expansion-panel-title:focus-visible,
.v-expansion-panel-title--active:focus,
.v-expansion-panel-title--active:hover {
.v-expansion-panel-title__overlay {
opacity: 0 !important;
}
}
// Set Elevation when panel open
&:not(.v-expansion-panels--variant-accordion) {
&.v-expansion-panel--active {
.v-expansion-panel__shadow {
@include mixins.elevation(6);
}
}
}
}
// custom style for expansion panels
&.expansion-panels-width-border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.375rem;
.v-expansion-panel-title {
background-color: rgb(var(--v-theme-grey-light));
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
margin-block-end: -1px;
}
.v-expansion-panel-text {
.v-expansion-panel-text__wrapper {
padding: 1.25rem;
}
}
}
&:not(.no-icon-rotate, .expansion-panels-width-border) {
.v-expansion-panel {
.v-expansion-panel-title__icon {
.v-icon {
@include layoutsMixins.rtl {
transform: scaleX(-1);
}
}
}
&.v-expansion-panel--active {
.v-expansion-panel-title__icon {
transform: rotate(90deg);
@include layoutsMixins.rtl {
transform: rotate(-90deg);
}
}
}
}
}
&:not(.expansion-panels-width-border) {
.v-expansion-panel {
&:not(:last-child) {
margin-block-end: 0.5rem;
}
&:not(:first-child)::after {
content: none;
}
// we have to use below style of increase the specificity and override the default style
/* stylelint-disable-next-line no-descending-specificity */
&:first-child:not(:last-child),
&:not(:first-child, :last-child),
&:not(:first-child) {
border-radius: 0.375rem !important;
}
}
}
}

View File

@@ -0,0 +1,308 @@
@use "sass:map";
@use "@configured-variables" as variables;
@use "@core-scss/template/mixins" as templateMixins;
$v-input-density: (
comfortable: (
icon-size: 1rem,
font-size: 0.9375rem,
line-height: 1.5rem,
),
default: (
icon-size: 1.125rem,
font-size: 1.0625rem,
line-height: 1.5rem,
),
compact: (
icon-size: 0.8125rem,
font-size: 0.8125rem,
line-height: 1.375rem,
),
);
// 👉 VInput
.v-input {
// 👉 VField
.v-field {
.v-field__loader {
.v-progress-linear {
.v-progress-linear__background {
background-color: transparent !important;
}
}
}
&.v-field--variant-solo,
&.v-field--variant-filled {
--v-field-padding-top: 7px !important;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
// Color for text field
.v-field__input {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
// Make field border width 2px when error
&.v-field--error {
.v-field__outline {
--v-field-border-width: 2px;
}
}
// Label
&.v-field--variant-outlined {
.v-label {
&.v-field-label--floating {
line-height: 0.9375rem;
margin-block: 0;
margin-inline: 6px;
}
}
}
&:not(.v-field--focused, .v-field--error):hover .v-field__outline {
--v-field-border-opacity: 0.6 !important;
}
// Shadow on focus
&.v-field--variant-outlined.v-field--focused:not(.v-field--error, .v-field--success) {
.v-field__outline {
@each $color-name in variables.$theme-colors-name {
&.text-#{$color-name} {
@include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm");
}
}
&:not([class*="text-"]) {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
}
}
}
}
// Give hint messages color based on theme color
@each $color-name in variables.$theme-colors-name {
&:has( .v-field.v-field--focused .v-field__outline.text-#{$color-name}) {
.v-messages {
color: rgb(var(--v-theme-#{$color-name}));
}
}
}
// Loop through each density setting in the map
@each $density, $settings in $v-input-density {
&.v-input--density-#{$density} {
.v-input__append,
.v-input__prepend,
.v-input__details,
.v-field .v-field__append-inner,
.v-field .v-field__prepend-inner,
.v-field .v-field__clearable {
> .v-icon {
block-size: map.get($settings, icon-size);
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: map.get($settings, icon-size);
inline-size: map.get($settings, icon-size);
opacity: 1;
}
}
.v-field {
.v-field__input {
font-size: map.get($settings, font-size);
line-height: map.get($settings, line-height);
}
}
}
}
}
// 👉 TextField, Select, AutoComplete, ComboBox, Textarea
// We added .v-application to increase the specificity of the selector
// Styles related to our custom input components
body {
.app-text-field,
.app-select,
.app-autocomplete,
.app-combobox,
.app-textarea,
.app-picker-field {
// making padding 0 for help text
.v-text-field .v-input__details {
padding-inline-start: 0;
}
// Placeholder
.v-input {
.v-field {
// Placeholder transition
input,
.v-field__input {
&::placeholder {
transition: transform 0.2s ease-out;
}
}
&.v-field--focused {
input,
.v-field__input {
&::placeholder {
transform: translateX(4px) !important;
[dir="rtl"] & {
transform: translateX(-4px) !important;
}
}
}
}
}
// padding for different density
&.v-input--density-default {
.v-field {
.v-field__input {
--v-field-padding-start: 16px;
--v-field-padding-end: 16px;
}
}
}
&.v-input--density-comfortable {
.v-field {
.v-field__input {
--v-field-padding-start: 14px;
--v-field-padding-end: 14px;
}
}
}
&.v-input--density-compact {
.v-field {
.v-field__input {
--v-field-padding-start: 12px;
--v-field-padding-end: 12px;
}
}
}
}
// Disabled state
&:has(.v-input.v-input--disabled) {
.v-label {
color: rgba(var(--v-theme-on-surface), 0.38);
}
.v-input {
.v-field.v-field--disabled {
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
opacity: 1;
.v-field__outline {
--v-field-border-opacity: 0.24;
}
.v-field__input {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
}
}
}
// Apply color to label
@each $color-name in variables.$theme-colors-name {
.v-label {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
&:has(+ .v-input .v-field.v-field--focused .v-field__outline.text-#{$color-name}) {
color: rgb(var(--v-theme-#{$color-name}));
}
&:has(+ .v-input .v-field.v-field--error) {
color: rgb(var(--v-theme-error));
}
}
}
}
@mixin style-selectable-component($component-name) {
.app-#{$component-name} {
.v-#{$component-name}__selection {
line-height: 24px;
margin-block: 0 !important;
}
// Vertical alignment of placeholder & text
.v-#{$component-name} .v-field .v-field__input > input {
align-self: center;
}
// Chips
.v-#{$component-name}.v-#{$component-name}--chips.v-input--dirty {
.v-#{$component-name}__selection {
margin: 0;
}
.v-field__input {
gap: 3px;
}
&.v-input--density-compact {
.v-field__input {
padding-inline-start: 0.5rem;
}
}
&.v-input--density-comfortable {
.v-field__input {
padding-inline-start: 0.75rem;
}
}
&.v-input--density-default {
.v-field__input {
padding-inline-start: 1rem;
}
}
}
}
}
@include style-selectable-component("autocomplete");
@include style-selectable-component("select");
@include style-selectable-component("combobox");
// AutoComplete
@at-root {
.app-autocomplete__content {
.v-list-item--active {
.v-autocomplete__mask {
background: rgba(92, 82, 192, 60%);
}
}
.v-theme--dark {
.v-list-item:not(.v-list-item--active) {
.v-autocomplete__mask {
background: rgba(59, 64, 92, 60%);
}
}
}
}
}
}
.app-inner-list {
// Hide checkboxes
.v-selection-control {
display: none;
}
}
// Hide resizer
::-webkit-resizer {
background: transparent;
}

View File

@@ -0,0 +1,30 @@
// 👉 List
.v-list-item {
--v-hover-opacity: 0.06 !important;
.v-checkbox-btn.v-selection-control--density-compact {
margin-inline-end: 0.5rem;
}
.v-list-item__overlay {
transition: none;
}
.v-list-item__prepend {
.v-icon {
font-size: 1.375rem;
}
}
&.v-list-item--active {
&.v-list-group__header {
color: rgb(var(--v-theme-primary));
}
&:not(.v-list-group__header) {
.v-list-item-subtitle {
color: rgb(var(--v-theme-primary));
}
}
}
}

View File

@@ -0,0 +1,35 @@
// Style list differently when it's used in a components like select, menu etc
.v-menu {
// Adjust padding of list item inside menu
.v-list-item {
padding-block: 8px !important;
padding-inline: 16px !important;
}
}
// 👉 Menu
// Menu custom style
.v-menu.v-overlay {
.v-overlay__content {
.v-list {
.v-list-item {
border-radius: 0.375rem !important;
margin-block: 0.125rem;
margin-inline: 0.5rem;
min-block-size: 2.375rem;
&:first-child {
margin-block-start: 0;
}
&:last-child {
margin-block-end: 0;
}
}
.v-list-item--density-default:not(.v-list-item--nav).v-list-item--one-line {
padding-block: 0.5rem;
}
}
}
}

View File

@@ -0,0 +1,17 @@
// otp input
.v-otp-input {
justify-content: unset !important;
.v-otp-input__content {
max-inline-size: 100%;
.v-field.v-field--focused {
.v-field__outline {
.v-field__outline__start,
.v-field__outline__end {
border-color: rgb(var(--v-theme-primary)) !important;
}
}
}
}
}

View File

@@ -0,0 +1,140 @@
/* stylelint-disable no-descending-specificity */
@use "@core-scss/template/mixins" as templateMixins;
@use "@configured-variables" as variables;
// 👉 Pagination
.v-pagination {
// pagination
.v-pagination__next,
.v-pagination__prev {
.v-btn--icon {
&.v-btn--size-small {
.v-icon {
font-size: 1rem;
}
}
&.v-btn--size-default {
.v-icon {
font-size: 1.125rem;
}
}
&.v-btn--size-large {
.v-icon {
font-size: 1.5rem;
}
}
}
}
// common style for all components
.v-pagination__next,
.v-pagination__prev,
.v-pagination__first,
.v-pagination__last,
.v-pagination__item {
.v-btn {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.8125rem;
font-weight: 400;
line-height: 1;
--v-activated-opacity: 0.08;
&:hover {
.v-btn__overlay {
--v-hover-opacity: 0.16;
}
}
&.v-btn--disabled {
opacity: 0.45;
}
&.v-btn--size-large {
font-size: 0.9375rem;
}
}
}
// Disable scale animation for button
.v-pagination__item {
.v-btn {
transform: scale(1) !important;
/* We disabled transition because it looks ugly 🤮 */
transition-duration: 0s;
&:active {
transform: scale(1);
}
}
}
.v-pagination__list {
@each $color-name in variables.$theme-colors-name {
&:has(.v-pagination__item.v-pagination__item--is-active .v-btn.text-#{$color-name}) {
.v-pagination__item {
.v-btn {
&:hover {
color: rgb(var(--v-theme-#{$color-name}));
.v-btn__overlay {
background-color: rgb(var(--v-theme-#{$color-name}));
}
}
}
}
}
}
.v-pagination__item--is-active {
.v-btn {
&:not([class*="text-"]) {
color: rgb(var(--v-theme-primary));
&:not(.v-btn--variant-outlined) {
.v-btn__underlay {
--v-activated-opacity: 0.04;
}
}
&.v-btn--variant-outlined {
border-color: rgb(var(--v-theme-primary));
.v-btn__overlay {
opacity: 0.16;
}
}
}
// box-shadow
@each $color-name in variables.$theme-colors-name {
&:not(.v-btn--disabled) {
&.text-#{$color-name} {
&,
&:hover,
&:active,
&:focus {
@include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm");
}
.v-btn__underlay {
opacity: 1 !important;
}
.v-btn__content {
color: #fff;
}
&.v-btn--variant-outlined {
background-color: rgb(var(--v-theme-#{$color-name}));
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
// @use "@core-scss/template/mixins" as templateMixins;
@use "@configured-variables" as variables;
// 👉 Progress
// .v-progress-linear {
// .v-progress-linear__determinate {
// @each $color-name in variables.$theme-colors-name {
// &.bg-#{$color-name} {
// // @include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm");
// }
// }
// }
// }

View File

@@ -0,0 +1,46 @@
@use "@core-scss/base/mixins";
@use "@configured-variables" as variables;
// 👉 Radio
.v-radio,
.v-radio-btn {
// 👉 radio icon opacity
.v-selection-control__input > .v-icon {
opacity: 1;
}
&.v-selection-control--disabled {
--v-disabled-opacity: 0.45;
}
&.v-selection-control--dirty {
@each $color-name in variables.$theme-colors-name {
.v-selection-control__wrapper.text-#{$color-name} {
.v-selection-control__input {
/* Using filter: drop-shadow() instead of box-shadow because box-shadow creates white background for SVG; */
.v-icon {
filter: drop-shadow(0 2px 6px rgba(var(--v-theme-#{$color-name}), 0.3));
}
}
}
}
}
&.v-selection-control {
.v-selection-control__input {
svg {
font-size: 1.5rem;
}
}
.v-label {
color: rgba(var(--v-theme-on-background), var(--v-high-emphasis-opacity));
}
}
}
// 👉 Radio, Checkbox
.v-input.v-radio-group > .v-input__control > .v-label {
margin-inline-start: 0;
}

View File

@@ -0,0 +1,20 @@
// 👉 Rating
.v-rating {
.v-rating__wrapper {
.v-btn .v-icon {
--v-icon-size-multiplier: 1;
}
.v-btn--density-default {
--v-btn-height: 26px;
}
.v-btn--density-compact {
--v-btn-height: 30px;
}
.v-btn--density-comfortable {
--v-btn-height: 32px;
}
}
}

View File

@@ -0,0 +1,27 @@
// 👉 Slider
.v-slider {
.v-slider-track__background--opacity {
opacity: 0.16;
}
}
.v-slider-thumb {
.v-slider-thumb__surface::after {
border-radius: 50%;
background-color: #fff;
block-size: calc(var(--v-slider-thumb-size) - 10px);
inline-size: calc(var(--v-slider-thumb-size) - 10px);
}
.v-slider-thumb__label {
background-color: rgb(var(--v-tooltip-background));
color: rgb(var(--v-theme-surface));
font-weight: 500;
letter-spacing: 0.15px;
line-height: 1.25rem;
&::before {
content: none;
}
}
}

View File

@@ -0,0 +1,10 @@
// 👉 snackbar
.v-snackbar {
.v-snackbar__actions {
.v-btn {
font-size: 13px;
line-height: 18px;
text-transform: capitalize;
}
}
}

View File

@@ -0,0 +1,58 @@
@use "@configured-variables" as variables;
@use "@core-scss/template/mixins" as templateMixins;
// 👉 switch
.v-switch {
&.v-switch--inset {
.v-selection-control {
.v-switch__track {
transition: all 0.1s;
}
&.v-selection-control--dirty {
@each $color-name in variables.$theme-colors-name {
.v-switch__track.bg-#{$color-name} {
@include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm");
}
}
}
&:not(.v-selection-control--dirty) {
.v-switch__track {
box-shadow: 0 0 4px 0 rgba(0, 0, 0, 16%) inset;
}
}
}
.v-selection-control__input {
transform: translateX(-6px) !important;
--v-selection-control-size: 0.875rem;
.v-switch__thumb {
box-shadow: 0 1px 6px rgba(var(--v-shadow-key-umbra-color), var(--v-shadow-xs-opacity));
transform: scale(1);
}
}
.v-selection-control--dirty {
.v-selection-control__input {
transform: translateX(6px) !important;
}
}
}
.v-label {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
line-height: 1.375rem !important;
}
}
.v-switch.v-input,
.v-checkbox-btn,
.v-radio-btn,
.v-radio {
--v-input-control-height: auto;
flex: unset;
}

View File

@@ -0,0 +1,48 @@
// 👉 Table
.v-table {
th {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
font-size: 0.8125rem;
letter-spacing: 0.2px;
text-transform: uppercase;
.v-data-table-header__content {
display: flex;
justify-content: space-between;
}
}
}
// 👉 Datatable
.v-data-table,
.v-table {
table {
thead,
tbody {
tr {
th,
td {
&:first-child:has(.v-checkbox-btn) {
padding-inline-start: 13px !important;
}
&:first-child {
padding-inline-start: 24px !important;
}
&:last-child {
padding-inline-end: 24px !important;
}
}
}
}
tbody {
.v-data-table-group-header-row {
td {
background-color: rgb(var(--v-theme-surface));
}
}
}
}
}

View File

@@ -0,0 +1,91 @@
@use "@configured-variables" as variables;
@use "@core-scss/template/mixins" as templateMixins;
// 👉 Tabs
.v-tabs {
&.v-tabs--vertical {
--v-tabs-height: 38px !important;
&:not(.v-tabs-pill) {
block-size: 100%;
border-inline-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
}
&.v-tabs--horizontal:not(.v-tabs-pill) {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
.v-tab__slider {
block-size: 3px;
}
}
/* stylelint-disable-next-line no-descending-specificity */
.v-btn {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transform: none !important;
.v-icon {
block-size: 1.125rem !important;
font-size: 1.125rem !important;
inline-size: 1.125rem !important;
}
&:hover:not(.v-tab--selected) {
color: rgb(var(--v-theme-primary));
.v-btn__content {
.v-tab__slider {
opacity: var(--v-activated-opacity);
}
}
}
&.v-btn--stacked {
/* stylelint-disable-next-line no-descending-specificity */
.v-icon {
block-size: 1.5rem !important;
font-size: 1.5rem !important;
inline-size: 1.5rem !important;
}
}
/* stylelint-disable-next-line no-descending-specificity */
.v-btn__overlay,
.v-ripple__container {
opacity: 0 !important;
}
/* stylelint-disable-next-line no-descending-specificity */
.v-tab__slider {
inset-inline-end: 0;
inset-inline-start: unset;
}
}
}
// 👉 Tab Pill
.v-tabs.v-tabs-pill {
.v-slide-group__content {
gap: 0.25rem;
}
@each $color-name in variables.$theme-colors-name {
.v-tab--selected.text-#{$color-name} {
@include templateMixins.custom-elevation(var(--v-theme-#{$color-name}), "sm");
}
}
&.v-slide-group,
.v-slide-group__container {
box-sizing: content-box;
padding: 1rem;
margin: -1rem;
}
.v-tab.v-btn:not(.v-tab--selected) {
&:hover {
background-color: rgba(var(--v-theme-primary), var(--v-activated-opacity));
}
}
}

View File

@@ -0,0 +1,9 @@
.v-textarea {
textarea {
opacity: 0 !important;
}
& .v-field--active textarea {
opacity: 1 !important;
}
}

View File

@@ -0,0 +1,99 @@
@use "@configured-variables" as variables;
// 👉 Timeline
.v-timeline {
// timeline items
.v-timeline-item {
&:not(:last-child) {
.v-timeline-item__body {
margin-block-end: 0.5rem;
}
}
.app-timeline-title {
line-height: 1.375rem;
}
.app-timeline-meta {
font-size: 0.8125rem;
font-weight: 400;
letter-spacing: 0.025rem;
line-height: 1.125rem;
}
.app-timeline-text {
font-size: 0.9375rem;
font-weight: 400;
line-height: 1.375rem;
}
}
// timeline icon only
&.v-timeline-icon-only {
.v-timeline-divider__dot {
.v-timeline-divider__inner-dot {
background: rgb(var(--v-theme-background));
box-shadow: none;
}
}
}
&:not(.v-timeline--variant-outlined) .v-timeline-divider__dot {
background: none !important;
.v-timeline-divider__inner-dot {
box-shadow: 0 0 0 0.1875rem rgb(var(--v-theme-on-surface-variant));
@each $color-name in variables.$theme-colors-name {
&.bg-#{$color-name} {
box-shadow: 0 0 0 0.1875rem rgba(var(--v-theme-#{$color-name}), 0.12);
}
}
}
}
&.v-timeline--variant-outlined {
.v-timeline-item {
.v-timeline-divider {
.v-timeline-divider__dot {
background: none !important;
}
}
.v-timeline-divider__after {
border: 1.5px dashed rgba(var(--v-border-color), var(--v-border-opacity));
background: none;
}
.v-timeline-divider__before {
background: none;
}
}
}
// we have to override the default bg-color of the timeline dot in the card
.v-card:not(.v-card--variant-text, .v-card--variant-plain, .v-card--variant-outlined) & {
&.v-timeline-icon-only {
.v-timeline-divider__dot {
.v-timeline-divider__inner-dot {
/* stylelint-disable-next-line no-descending-specificity */
background: rgb(var(--v-theme-surface));
}
}
}
}
.v-card.v-card--variant-tonal & {
&.v-timeline-icon-only {
.v-timeline-divider__dot {
.v-timeline-divider__inner-dot {
/* stylelint-disable-next-line no-descending-specificity */
.v-icon {
background: none;
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
// 👉 Tooltip
.v-tooltip {
.v-overlay__content {
font-weight: 500;
}
}

View File

@@ -0,0 +1,25 @@
@use "alert";
@use "avatar";
@use "button";
@use "badge";
@use "cards";
@use "chip";
@use "dialog";
@use "expansion-panels";
@use "list";
@use "menu";
@use "pagination";
@use "progress";
@use "rating";
@use "snackbar";
@use "slider";
@use "table";
@use "tabs";
@use "timeline";
@use "tooltip";
@use "otp-input";
@use "field";
@use "checkbox";
@use "textarea";
@use "radio";
@use "switch";

View File

@@ -0,0 +1,3 @@
@use "@core-scss/base/libs/vuetify";
@use "overrides";
@use "components/index.scss";

View File

@@ -0,0 +1,18 @@
@use "@core-scss/base/utils";
@use "@configured-variables" as variables;
// 👉 Body
// set body font size 15px
body {
font-size: 15px !important;
// We reduced this margin to get 40px input height
.v-input--density-compact {
--v-input-chips-margin-bottom: 1px;
}
}
.text-caption {
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity));
}

View File

@@ -0,0 +1,63 @@
.layout-blank {
.auth-wrapper {
min-block-size: 100dvh;
}
.auth-v1-top-shape,
.auth-v1-bottom-shape {
position: absolute;
}
.auth-footer-mask {
position: absolute;
inset-block-end: 0;
min-inline-size: 100%;
}
.auth-card {
z-index: 1 !important;
}
.auth-illustration {
z-index: 1;
}
.auth-v1-top-shape {
inset-block-start: -77px;
inset-inline-start: -45px;
}
.auth-v1-bottom-shape {
inset-block-end: -58px;
inset-inline-end: -58px;
}
@media (min-width: 1264px), (max-width: 959px) and (min-width: 450px) {
.v-otp-input .v-otp-input__content {
gap: 1rem;
}
}
}
@media (min-width: 960px) {
.skin--bordered {
.auth-card-v2 {
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
}
}
.auth-logo {
position: absolute;
z-index: 2;
inset-block-start: 2rem;
inset-inline-start: 2.3rem;
}
.auth-title {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: 0.25px;
line-height: 1.5rem;
text-transform: capitalize;
}

View File

@@ -0,0 +1,35 @@
// Import Vuexy's full Vuetify component override chain
@use "@core-scss/template/libs/vuetify";
// ━━━ Project-specific overrides ━━━
html {
scroll-behavior: smooth;
}
html,
body,
#app {
min-height: 100vh;
}
// Invert white logo for light backgrounds
.logo-light {
filter: brightness(0) saturate(100%);
}
// Links
a {
text-decoration: none;
}
// Iconify icon size
svg.iconify {
block-size: 1em;
inline-size: 1em;
}
// Vuetify 3 paragraph margin (Vuexy convention)
p {
margin-block-end: 1rem;
}

View File

@@ -0,0 +1 @@
@forward "@core-scss/template/variables";

View File

@@ -0,0 +1,2 @@
// Forward Vuexy's Vuetify variable chain
@forward "../@core/template/libs/vuetify/variables";

View File

@@ -0,0 +1,30 @@
@use "placeholders";
@use "@configured-variables" as variables;
@mixin rtl {
@if variables.$enable-rtl-styles {
[dir="rtl"] & {
@content;
}
}
}
@mixin boxed-content($nest-selector: false) {
& {
@extend %boxed-content-spacing;
@at-root {
@if $nest-selector == false {
.layout-content-width-boxed#{&} {
@extend %boxed-content;
}
}
// stylelint-disable-next-line @stylistic/indentation
@else {
.layout-content-width-boxed & {
@extend %boxed-content;
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
// Stub — placeholders not used in our Inertia setup
%boxed-content {
max-inline-size: 1440px;
margin-inline: auto;
}
%boxed-content-spacing {
padding-inline: 1.5rem;
}

View File

@@ -0,0 +1,29 @@
// @use "@styles/style.scss";
// 👉 Vertical nav
$layout-vertical-nav-z-index: 12 !default;
$layout-vertical-nav-width: 260px !default;
$layout-vertical-nav-collapsed-width: 80px !default;
$selector-vertical-nav-mini: ".layout-vertical-nav-collapsed .layout-vertical-nav:not(:hover)";
// 👉 Horizontal nav
$layout-horizontal-nav-z-index: 11 !default;
$layout-horizontal-nav-navbar-height: 64px !default;
// 👉 Navbar
$layout-vertical-nav-navbar-height: 64px !default;
$layout-vertical-nav-navbar-is-contained: true !default;
$layout-vertical-nav-layout-navbar-z-index: 11 !default;
$layout-horizontal-nav-layout-navbar-z-index: 11 !default;
// 👉 Main content
$layout-boxed-content-width: 1440px !default;
// 👉Footer
$layout-vertical-nav-footer-height: 56px !default;
// 👉 Layout overlay
$layout-overlay-z-index: 11 !default;
// 👉 RTL
$enable-rtl-styles: true !default;

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const page = usePage()
const flash = computed(() => (page.props as Record<string, unknown>).flash as Record<string, string> || {})
</script>
<template>
<VAlert v-if="flash.success" type="success" variant="tonal" closable class="mb-4">
{{ flash.success }}
</VAlert>
<VAlert v-if="flash.error" type="error" variant="tonal" closable class="mb-4">
{{ flash.error }}
</VAlert>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
interface Props {
title: string
stats: string | number
icon: string
color?: string
}
withDefaults(defineProps<Props>(), {
color: 'primary',
})
</script>
<template>
<VCard>
<VCardText class="d-flex align-center gap-4">
<VAvatar :color="color" variant="tonal" rounded size="44">
<VIcon :icon="icon" size="26" />
</VAvatar>
<div>
<div class="text-body-2 text-medium-emphasis">{{ title }}</div>
<div class="text-h5 font-weight-semibold">{{ stats }}</div>
</div>
</VCardText>
</VCard>
</template>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
interface Props {
status: string
color: string
}
defineProps<Props>()
</script>
<template>
<VChip :color="color" size="small" class="text-capitalize">
{{ status }}
</VChip>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
const theme = useTheme()
function toggleTheme(): void {
theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark'
}
</script>
<template>
<VBtn icon variant="text" size="small" @click="toggleTheme">
<VIcon :icon="theme.global.current.value.dark ? 'tabler-sun' : 'tabler-moon'" />
</VBtn>
</template>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import { computed, useAttrs, useId } from 'vue'
defineOptions({
name: 'AppSelect',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-select-${_elementIdToken}` : _id
})
const label = computed(() => useAttrs().label as string | undefined)
</script>
<template>
<div
class="app-select flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
style="line-height: 15px;"
:text="label"
/>
<VSelect
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
menuProps: { contentClass: ['app-inner-list', 'app-select__content', 'v-select__content', $attrs.multiple !== undefined ? 'v-list-select-multiple' : ''] },
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VSelect>
</div>
</template>

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { computed, useAttrs, useId } from 'vue'
defineOptions({
name: 'AppTextField',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-text-field-${_elementIdToken}` : _id
})
const label = computed(() => useAttrs().label as string | undefined)
</script>
<template>
<div
class="app-text-field flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2 text-wrap"
style="line-height: 15px;"
:text="label"
/>
<VTextField
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextField>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { computed, useAttrs, useId } from 'vue'
defineOptions({
name: 'AppTextarea',
inheritAttrs: false,
})
const elementId = computed(() => {
const attrs = useAttrs()
const _elementIdToken = attrs.id
const _id = useId()
return _elementIdToken ? `app-textarea-${_elementIdToken}` : _id
})
const label = computed(() => useAttrs().label as string | undefined)
</script>
<template>
<div
class="app-textarea flex-grow-1"
:class="$attrs.class"
>
<VLabel
v-if="label"
:for="elementId"
class="mb-1 text-body-2"
:text="label"
/>
<VTextarea
v-bind="{
...$attrs,
class: null,
label: undefined,
variant: 'outlined',
id: elementId,
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotProps"
>
<slot
:name="name"
v-bind="slotProps || {}"
/>
</template>
</VTextarea>
</div>
</template>

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup>
import { Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { accountNavItems } from '@/navigation/account'
import FlashMessages from '@/Components/FlashMessages.vue'
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
import logoWhite from '@images/ezscale_logo_white.png'
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
interface AuthUser {
name: string
email: string
}
interface PageProps {
auth: { user: AuthUser | null }
domains: { marketing: string; account: string; admin: string }
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const user = computed(() => props.value.auth?.user)
const currentUrl = computed(() => page.url)
function isActive(matchPrefix: string): boolean {
return currentUrl.value.startsWith(matchPrefix)
}
</script>
<template>
<VApp>
<VAppBar flat>
<VContainer class="d-flex align-center">
<Link href="/dashboard" class="d-inline-flex align-center">
<img
:src="logoWhite"
alt="EZSCALE"
:class="{ 'logo-light': !isDark }"
style="height: 32px; width: auto;"
>
</Link>
<VSpacer />
<div class="d-flex align-center ga-1">
<Link
v-for="item in accountNavItems"
:key="item.href"
:href="item.href"
class="text-decoration-none"
>
<VBtn
variant="text"
:color="isActive(item.matchPrefix) ? 'primary' : undefined"
size="small"
>
<VIcon :icon="item.icon" start />
{{ item.title }}
</VBtn>
</Link>
</div>
<VSpacer />
<div class="d-flex align-center ga-2">
<ThemeSwitcher />
<span v-if="user" class="text-body-2">
{{ user.name }}
</span>
<Link
v-if="user"
href="/logout"
method="post"
as="button"
class="text-decoration-none"
>
<VBtn variant="text" size="small" color="error">
<VIcon icon="tabler-logout" start />
Log out
</VBtn>
</Link>
</div>
</VContainer>
</VAppBar>
<VMain>
<VContainer>
<FlashMessages />
<slot />
</VContainer>
</VMain>
<VFooter app class="text-center d-flex align-center justify-center">
<span class="text-body-2 text-medium-emphasis">
&copy; {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
</span>
</VFooter>
</VApp>
</template>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
import { Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { adminNavItems } from '@/navigation/admin'
import FlashMessages from '@/Components/FlashMessages.vue'
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
import logoWhite from '@images/ezscale_logo_white.png'
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
interface AuthUser {
name: string
email: string
}
interface PageProps {
auth: { user: AuthUser | null }
domains: { marketing: string; account: string; admin: string }
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const user = computed(() => props.value.auth?.user)
const accountDomain = computed(() => props.value.domains?.account)
const accountUrl = computed(() => `https://${accountDomain.value}`)
const currentUrl = computed(() => page.url)
function isActive(matchPrefix: string): boolean {
return currentUrl.value.startsWith(matchPrefix)
}
</script>
<template>
<VApp>
<VAppBar flat>
<VContainer class="d-flex align-center">
<Link href="/dashboard" class="d-inline-flex align-center ga-2">
<img
:src="logoWhite"
alt="EZSCALE"
:class="{ 'logo-light': !isDark }"
style="height: 32px; width: auto;"
>
<VChip size="small" color="error" variant="flat">
Admin
</VChip>
</Link>
<VSpacer />
<div class="d-flex align-center ga-1">
<Link
v-for="item in adminNavItems"
:key="item.href"
:href="item.href"
class="text-decoration-none"
>
<VBtn
variant="text"
:color="isActive(item.matchPrefix) ? 'primary' : undefined"
size="small"
>
<VIcon :icon="item.icon" start />
{{ item.title }}
</VBtn>
</Link>
</div>
<VSpacer />
<div class="d-flex align-center ga-2">
<a
v-if="user"
:href="accountUrl + '/dashboard'"
class="text-decoration-none"
>
<VBtn variant="text" size="small">
<VIcon icon="tabler-external-link" start />
Customer View
</VBtn>
</a>
<ThemeSwitcher />
<span v-if="user" class="text-body-2">
{{ user.name }}
</span>
<Link
v-if="user"
:href="accountUrl + '/logout'"
method="post"
as="button"
class="text-decoration-none"
>
<VBtn variant="text" size="small" color="error">
<VIcon icon="tabler-logout" start />
Log out
</VBtn>
</Link>
</div>
</VContainer>
</VAppBar>
<VMain>
<VContainer>
<FlashMessages />
<slot />
</VContainer>
</VMain>
<VFooter app class="text-center d-flex align-center justify-center">
<span class="text-body-2 text-medium-emphasis">
&copy; {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
</span>
</VFooter>
</VApp>
</template>

View File

@@ -0,0 +1,100 @@
<script lang="ts" setup>
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import logoWhite from '@images/ezscale_logo_white.png'
interface PageProps {
domains: { marketing: string; account: string; admin: string }
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const marketingUrl = computed(() => `https://${props.value.domains?.marketing}`)
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
</script>
<template>
<VApp>
<!-- Logo (absolute top-left, above everything) -->
<a :href="marketingUrl" class="auth-logo d-flex align-center gap-x-3">
<img
:src="logoWhite"
alt="EZSCALE"
:class="{ 'logo-light': !isDark }"
style="height: 38px; width: auto;"
>
</a>
<VRow
no-gutters
class="auth-wrapper bg-surface"
>
<!-- Left: Illustration -->
<VCol
md="8"
class="d-none d-md-flex"
>
<div class="position-relative bg-background w-100 me-0">
<div
class="d-flex align-center justify-center w-100 h-100"
style="padding-inline: 6.25rem;"
>
<div class="d-flex flex-column align-center justify-center" style="max-width: 500px;">
<img
:src="logoWhite"
alt="EZSCALE"
class="auth-illustration mb-8"
:class="{ 'logo-light': !isDark }"
style="max-width: 360px; height: auto;"
>
<p class="text-body-1 text-center mb-0" style="max-width: 400px;">
Deploy VPS, Dedicated Servers, Web Hosting, and Game Servers in minutes.
Enterprise-grade infrastructure made simple.
</p>
<div class="d-flex ga-8 mt-8">
<div class="text-center">
<div class="text-h5 font-weight-bold text-primary">99.99%</div>
<div class="text-caption">Uptime</div>
</div>
<div class="text-center">
<div class="text-h5 font-weight-bold text-primary">50+</div>
<div class="text-caption">Locations</div>
</div>
<div class="text-center">
<div class="text-h5 font-weight-bold text-primary">24/7</div>
<div class="text-caption">Support</div>
</div>
</div>
</div>
</div>
</div>
</VCol>
<!-- Right: Auth Form -->
<VCol
cols="12"
md="4"
class="auth-card-v2 d-flex align-center justify-center"
>
<VCard
flat
:max-width="500"
class="mt-12 mt-sm-0 pa-6"
>
<slot />
</VCard>
</VCol>
</VRow>
</VApp>
</template>
<style lang="scss">
@use "@styles/@core/template/pages/page-auth";
.logo-light {
filter: brightness(0) saturate(100%);
}
</style>

View File

@@ -0,0 +1,325 @@
<script lang="ts" setup>
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { marketingNavItems } from '@/navigation/marketing'
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
import logoWhite from '@images/ezscale_logo_white.png'
const theme = useTheme()
const isDark = computed(() => theme.global.current.value.dark)
interface PageProps {
domains: { marketing: string; account: string; admin: string }
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
const footerLinks = {
products: [
{ title: 'VPS Hosting', href: '/vps-hosting' },
{ title: 'Dedicated Servers', href: '/dedicated-servers' },
{ title: 'Web Hosting', href: '/web-hosting' },
{ title: 'Game Servers', href: '/game-servers' },
],
company: [
{ title: 'About', href: '/about' },
{ title: 'Pricing', href: '/pricing' },
{ title: 'Contact', href: '/contact' },
{ title: 'Blog', href: '/blog' },
],
support: [
{ title: 'Help Center', href: '/support' },
{ title: 'Documentation', href: '/docs' },
{ title: 'API Reference', href: '/api' },
{ title: 'Status Page', href: '/status' },
],
}
const socialLinks = [
{ title: 'twitter', icon: 'tabler-brand-twitter-filled', href: '#' },
{ title: 'facebook', icon: 'tabler-brand-facebook-filled', href: '#' },
{ title: 'github', icon: 'tabler-brand-github-filled', href: '#' },
{ title: 'discord', icon: 'tabler-brand-discord-filled', href: '#' },
]
</script>
<template>
<VApp>
<VAppBar flat>
<VContainer class="d-flex align-center">
<a href="/" class="d-inline-flex align-center">
<img
:src="logoWhite"
alt="EZSCALE"
:class="{ 'logo-light': !isDark }"
style="height: 32px; width: auto;"
>
</a>
<VSpacer />
<div class="d-flex align-center ga-1">
<template v-for="item in marketingNavItems" :key="item.title">
<VMenu v-if="item.children" open-on-hover>
<template #activator="{ props: menuProps }">
<VBtn variant="text" size="small" v-bind="menuProps">
{{ item.title }}
<VIcon icon="tabler-chevron-down" end size="small" />
</VBtn>
</template>
<VList>
<VListItem
v-for="child in item.children"
:key="child.href"
:href="child.href"
>
<template #prepend>
<VIcon v-if="child.icon" :icon="child.icon" />
</template>
<VListItemTitle>{{ child.title }}</VListItemTitle>
<VListItemSubtitle v-if="child.description">
{{ child.description }}
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
<a v-else :href="item.href" class="text-decoration-none">
<VBtn variant="text" size="small">
{{ item.title }}
</VBtn>
</a>
</template>
</div>
<VSpacer />
<div class="d-flex align-center ga-2">
<ThemeSwitcher />
<a :href="accountUrl + '/login'" class="text-decoration-none">
<VBtn variant="text" size="small">
Login
</VBtn>
</a>
<a :href="accountUrl + '/register'" class="text-decoration-none">
<VBtn variant="flat" size="small" color="primary">
Sign Up
</VBtn>
</a>
</div>
</VContainer>
</VAppBar>
<VMain>
<slot />
</VMain>
<!-- Footer -->
<div class="footer">
<div class="footer-top pt-11">
<VContainer>
<VRow>
<!-- Logo + Description + Newsletter -->
<VCol cols="12" md="5">
<div
class="mb-4"
:class="$vuetify.display.smAndDown ? 'w-100' : 'w-75'"
>
<div class="d-flex align-center mb-6">
<img
:src="logoWhite"
alt="EZSCALE"
style="height: 32px; width: auto;"
>
</div>
<div class="text-white-variant mb-6">
High-performance VPS, dedicated servers, and hosting solutions with 24/7 support and enterprise-grade infrastructure.
</div>
<VForm class="subscribe-form d-flex align-center">
<VTextField
label="Subscribe to newsletter"
placeholder="john@email.com"
variant="outlined"
density="comfortable"
hide-details
/>
<VBtn class="align-self-end rounded-s-0">
Subscribe
</VBtn>
</VForm>
</div>
</VCol>
<!-- Products -->
<VCol md="2" sm="4" xs="6">
<div class="footer-links">
<h6 class="footer-title text-h6 mb-6">
Products
</h6>
<ul style="list-style: none; padding: 0;">
<li
v-for="link in footerLinks.products"
:key="link.href"
class="mb-4"
>
<a
:href="link.href"
class="text-white-variant"
>
{{ link.title }}
</a>
</li>
</ul>
</div>
</VCol>
<!-- Company -->
<VCol md="2" sm="4" xs="6">
<div class="footer-links">
<h6 class="footer-title text-h6 mb-6">
Company
</h6>
<ul style="list-style: none; padding: 0;">
<li
v-for="link in footerLinks.company"
:key="link.href"
class="mb-4"
>
<a
:href="link.href"
class="text-white-variant"
>
{{ link.title }}
</a>
</li>
</ul>
</div>
</VCol>
<!-- Support -->
<VCol cols="12" md="3" sm="4">
<div class="footer-links">
<h6 class="footer-title text-h6 mb-6">
Support
</h6>
<ul style="list-style: none; padding: 0;">
<li
v-for="link in footerLinks.support"
:key="link.href"
class="mb-4"
>
<a
:href="link.href"
class="text-white-variant"
>
{{ link.title }}
</a>
</li>
</ul>
</div>
</VCol>
</VRow>
</VContainer>
</div>
<!-- Footer Line -->
<div class="footer-line w-100">
<VContainer>
<div class="d-flex justify-space-between flex-wrap gap-y-5 align-center">
<div class="text-body-1 text-white-variant text-wrap me-4">
&copy; {{ new Date().getFullYear() }}
<span class="font-weight-bold ms-1 text-white">EZSCALE</span>,
All rights reserved.
</div>
<div class="d-flex gap-x-6">
<a
v-for="item in socialLinks"
:key="item.title"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
>
<VIcon
:icon="item.icon"
size="16"
color="white"
/>
</a>
</div>
</div>
</VContainer>
</div>
</div>
</VApp>
</template>
<style lang="scss" scoped>
.footer-title {
color: rgba(255, 255, 255, 92%);
}
.footer-top {
border-radius: 60px 60px 0 0;
background-color: #2f3349;
background-size: cover;
color: #fff;
}
.footer-links {
a {
text-decoration: none;
&:hover {
color: #fff !important;
}
}
}
.footer-line {
background: #282c3e;
}
.text-white-variant {
color: rgba(255, 255, 255, 70%);
}
</style>
<style lang="scss">
.subscribe-form {
.v-label {
color: rgba(225, 222, 245, 90%) !important;
}
.v-field {
border-end-end-radius: 0;
border-end-start-radius: 10px;
border-start-end-radius: 0;
border-start-start-radius: 10px;
input.v-field__input::placeholder {
color: rgba(225, 222, 245, 40%) !important;
}
input.v-field__input {
color: rgba(255, 255, 255, 78%);
}
}
}
.footer {
@media (min-width: 600px) and (max-width: 960px) {
.v-container {
padding-inline: 2rem !important;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<script lang="ts" setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
import StatCard from '@/Components/StatCard.vue'
interface Props {
totalCustomers: number
totalServices: number
activeServices: number
}
defineOptions({ layout: AdminLayout })
defineProps<Props>()
</script>
<template>
<div>
<div class="text-h4 font-weight-bold mb-6">Admin Dashboard</div>
<VRow>
<VCol cols="12" md="4">
<StatCard
title="Total Customers"
:stats="totalCustomers"
icon="tabler-users"
color="primary"
/>
</VCol>
<VCol cols="12" md="4">
<StatCard
title="Total Services"
:stats="totalServices"
icon="tabler-server"
color="info"
/>
</VCol>
<VCol cols="12" md="4">
<StatCard
title="Active Services"
:stats="activeServices"
icon="tabler-circle-check"
color="success"
/>
</VCol>
</VRow>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import AuthLayout from '@/Layouts/AuthLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
defineOptions({ layout: AuthLayout })
const form = useForm({
password: '',
})
const submit = (): void => {
form.post('/user/confirm-password', {
onFinish: () => form.reset('password'),
})
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Confirm your password
</h4>
<p class="mb-0">
Please confirm your password before continuing
</p>
</VCardText>
<VCardText>
<VForm @submit.prevent="submit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.password"
label="Password"
placeholder="············"
type="password"
required
autofocus
:error-messages="form.errors.password ? [form.errors.password] : []"
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
block
:loading="form.processing"
:disabled="form.processing"
>
Confirm
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import AuthLayout from '@/Layouts/AuthLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
interface Props {
status?: string
}
defineOptions({ layout: AuthLayout })
defineProps<Props>()
const form = useForm({
email: '',
})
const submit = (): void => {
form.post('/forgot-password')
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Forgot Password?
</h4>
<p class="mb-0">
Enter your email and we'll send you a reset link
</p>
</VCardText>
<VCardText>
<VAlert
v-if="status"
type="success"
variant="tonal"
class="mb-4"
>
{{ status }}
</VAlert>
<VForm @submit.prevent="submit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.email"
label="Email"
type="email"
required
autofocus
placeholder="john@example.com"
:error-messages="form.errors.email ? [form.errors.email] : []"
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
block
:loading="form.processing"
:disabled="form.processing"
>
Send Reset Link
</VBtn>
</VCol>
<VCol cols="12">
<a
href="/login"
class="d-flex align-center justify-center"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1"
/>
<span>Back to login</span>
</a>
</VCol>
</VRow>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,118 @@
<script lang="ts" setup>
import { useForm, Link } from '@inertiajs/vue3'
import { ref } from 'vue'
import AuthLayout from '@/Layouts/AuthLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
defineOptions({ layout: AuthLayout })
interface Props {
status?: string
}
defineProps<Props>()
const isPasswordVisible = ref(false)
const form = useForm({
email: '',
password: '',
remember: false,
})
const submit = (): void => {
form.post('/login', {
onFinish: () => form.reset('password'),
})
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Welcome to <span class="text-capitalize">EZSCALE</span>!
</h4>
<p class="mb-0">
Please sign-in to your account and start the adventure
</p>
</VCardText>
<VCardText>
<VAlert
v-if="status"
type="success"
variant="tonal"
class="mb-4"
>
{{ status }}
</VAlert>
<VForm @submit.prevent="submit">
<VRow>
<!-- email -->
<VCol cols="12">
<AppTextField
v-model="form.email"
autofocus
label="Email"
type="email"
placeholder="john@example.com"
:error-messages="form.errors.email ? [form.errors.email] : []"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<AppTextField
v-model="form.password"
label="Password"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="form.errors.password ? [form.errors.password] : []"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center flex-wrap justify-space-between my-6">
<VCheckbox
v-model="form.remember"
label="Remember me"
/>
<Link
class="text-primary"
href="/forgot-password"
>
Forgot Password?
</Link>
</div>
<VBtn
block
type="submit"
:loading="form.processing"
:disabled="form.processing"
>
Login
</VBtn>
</VCol>
<!-- create account -->
<VCol
cols="12"
class="text-body-1 text-center"
>
<span class="d-inline-block">
New on our platform?
</span>
<Link
class="text-primary ms-1 d-inline-block text-body-1"
href="/register"
>
Create an account
</Link>
</VCol>
</VRow>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { useForm, Link } from '@inertiajs/vue3'
import { ref } from 'vue'
import AuthLayout from '@/Layouts/AuthLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
defineOptions({ layout: AuthLayout })
const isPasswordVisible = ref(false)
const privacyPolicy = ref(false)
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
})
const submit = (): void => {
form.post('/register', {
onFinish: () => form.reset('password', 'password_confirmation'),
})
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Adventure starts here
</h4>
<p class="mb-0">
Start hosting your projects with EZSCALE
</p>
</VCardText>
<VCardText>
<VForm @submit.prevent="submit">
<VRow>
<!-- name -->
<VCol cols="12">
<AppTextField
v-model="form.name"
autofocus
label="Full Name"
placeholder="John Doe"
:error-messages="form.errors.name ? [form.errors.name] : []"
/>
</VCol>
<!-- email -->
<VCol cols="12">
<AppTextField
v-model="form.email"
label="Email"
type="email"
placeholder="john@example.com"
:error-messages="form.errors.email ? [form.errors.email] : []"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<AppTextField
v-model="form.password"
label="Password"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
autocomplete="new-password"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="form.errors.password ? [form.errors.password] : []"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<!-- confirm password -->
<VCol cols="12">
<AppTextField
v-model="form.password_confirmation"
label="Confirm Password"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
autocomplete="new-password"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div class="d-flex align-center my-6">
<VCheckbox
v-model="privacyPolicy"
inline
>
<template #label>
<span class="me-1">I agree to the</span>
<a href="/terms" class="text-primary" target="_blank">Terms of Service</a>
<span class="mx-1">&amp;</span>
<a href="/privacy" class="text-primary" target="_blank">Privacy Policy</a>
</template>
</VCheckbox>
</div>
<VBtn
block
type="submit"
:loading="form.processing"
:disabled="form.processing || !privacyPolicy"
>
Sign Up
</VBtn>
</VCol>
<!-- login link -->
<VCol
cols="12"
class="text-body-1 text-center"
>
<span class="d-inline-block">
Already have an account?
</span>
<Link
class="text-primary ms-1 d-inline-block text-body-1"
href="/login"
>
Sign in instead
</Link>
</VCol>
</VRow>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import AuthLayout from '@/Layouts/AuthLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
interface Props {
token: string
email: string
}
defineOptions({ layout: AuthLayout })
const props = defineProps<Props>()
const isPasswordVisible = ref(false)
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
})
const submit = (): void => {
form.post('/reset-password', {
onFinish: () => form.reset('password', 'password_confirmation'),
})
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Set new password
</h4>
<p class="mb-0">
Your new password must be different from previously used passwords
</p>
</VCardText>
<VCardText>
<VForm @submit.prevent="submit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.email"
label="Email"
type="email"
required
placeholder="john@example.com"
:error-messages="form.errors.email ? [form.errors.email] : []"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.password"
label="New Password"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
required
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="form.errors.password ? [form.errors.password] : []"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.password_confirmation"
label="Confirm Password"
placeholder="············"
:type="isPasswordVisible ? 'text' : 'password'"
required
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
block
:loading="form.processing"
:disabled="form.processing"
>
Reset password
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,93 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useForm } from '@inertiajs/vue3'
import AuthLayout from '@/Layouts/AuthLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
defineOptions({ layout: AuthLayout })
const useRecovery = ref(false)
const form = useForm({
code: '',
recovery_code: '',
})
const submit = (): void => {
form.post('/two-factor-challenge', {
onFinish: () => form.reset(),
})
}
const toggleRecovery = (): void => {
useRecovery.value = !useRecovery.value
form.reset()
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Two-Factor Authentication
</h4>
<p class="mb-0">
<template v-if="!useRecovery">
Enter the authentication code from your authenticator app
</template>
<template v-else>
Enter one of your emergency recovery codes
</template>
</p>
</VCardText>
<VCardText>
<VForm @submit.prevent="submit">
<VRow>
<VCol v-if="!useRecovery" cols="12">
<AppTextField
v-model="form.code"
label="Code"
type="text"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
placeholder="000000"
:error-messages="form.errors.code ? [form.errors.code] : []"
/>
</VCol>
<VCol v-else cols="12">
<AppTextField
v-model="form.recovery_code"
label="Recovery Code"
type="text"
autofocus
placeholder="Enter recovery code"
:error-messages="form.errors.recovery_code ? [form.errors.recovery_code] : []"
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
block
:loading="form.processing"
:disabled="form.processing"
>
Verify
</VBtn>
</VCol>
<VCol cols="12">
<VBtn
variant="text"
block
@click="toggleRecovery"
>
{{ useRecovery ? 'Use authentication code' : 'Use a recovery code' }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import AuthLayout from '@/Layouts/AuthLayout.vue'
interface Props {
status?: string
}
defineOptions({ layout: AuthLayout })
defineProps<Props>()
const form = useForm({})
const submit = (): void => {
form.post('/email/verification-notification')
}
</script>
<template>
<VCardText>
<h4 class="text-h4 mb-1">
Verify your email
</h4>
<p class="mb-0">
We've sent a verification link to your email. Please check your inbox and click the link to verify.
</p>
</VCardText>
<VCardText>
<VAlert
v-if="status === 'verification-link-sent'"
type="success"
variant="tonal"
class="mb-4"
>
A new verification link has been sent to your email address.
</VAlert>
<VForm @submit.prevent="submit">
<VBtn
type="submit"
block
:loading="form.processing"
:disabled="form.processing"
>
Resend verification email
</VBtn>
</VForm>
</VCardText>
</template>

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { useForm, Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveInvoiceStatusColor, resolveTransactionStatusColor } from '@/utils/resolvers'
import type { PaymentMethod, Invoice, Transaction } from '@/types'
interface Props {
paymentMethods: PaymentMethod[]
invoices: Invoice[]
transactions: Transaction[]
intent: Record<string, unknown>
stripeKey: string
}
defineOptions({ layout: AccountLayout })
defineProps<Props>()
const defaultForm = useForm({
payment_method_id: '',
})
const setDefault = (id: string): void => {
defaultForm.payment_method_id = id
defaultForm.post('/billing/payment-methods/default')
}
const removeMethod = (id: string): void => {
if (confirm('Are you sure you want to remove this payment method?')) {
useForm({}).delete(`/billing/payment-methods/${id}`)
}
}
</script>
<template>
<div>
<div class="text-h4 font-weight-bold mb-6">Billing</div>
<!-- Payment Methods -->
<VCard class="mb-6">
<VCardTitle>Payment Methods</VCardTitle>
<VCardText>
<div v-if="paymentMethods.length === 0" class="text-body-2 text-medium-emphasis">
No payment methods on file.
</div>
<div v-else class="d-flex flex-column ga-3">
<VSheet
v-for="pm in paymentMethods"
:key="pm.id"
rounded
border
class="pa-3 d-flex align-center justify-space-between"
:class="pm.is_default ? 'border-primary' : ''"
>
<div class="d-flex align-center ga-3">
<span class="text-body-2 font-weight-medium text-capitalize">{{ pm.brand }}</span>
<span class="text-body-2 text-medium-emphasis">&bull;&bull;&bull;&bull; {{ pm.last_four }}</span>
<span class="text-body-2 text-disabled">{{ pm.exp_month }}/{{ pm.exp_year }}</span>
<VChip v-if="pm.is_default" color="primary" size="x-small">Default</VChip>
</div>
<div class="d-flex align-center ga-2">
<VBtn
v-if="!pm.is_default"
variant="text"
size="small"
:disabled="defaultForm.processing"
@click="setDefault(pm.id)"
>
Make Default
</VBtn>
<VBtn
variant="text"
color="error"
size="small"
@click="removeMethod(pm.id)"
>
Remove
</VBtn>
</div>
</VSheet>
</div>
</VCardText>
</VCard>
<!-- Recent Invoices -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center justify-space-between">
<span>Recent Invoices</span>
<Link href="/billing/invoices" class="text-primary text-body-2 text-decoration-none">View All</Link>
</VCardTitle>
<VCardText>
<div v-if="invoices.length === 0" class="text-body-2 text-medium-emphasis">No invoices yet.</div>
<VTable v-else>
<thead>
<tr>
<th>Number</th>
<th>Date</th>
<th>Status</th>
<th class="text-end">Amount</th>
<th class="text-end" />
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices" :key="invoice.id">
<td>{{ invoice.number }}</td>
<td>{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</td>
<td class="text-end">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="text-end">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-primary text-decoration-none">Download</a>
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
<!-- Recent Transactions -->
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<span>Recent Transactions</span>
<Link href="/billing/transactions" class="text-primary text-body-2 text-decoration-none">View All</Link>
</VCardTitle>
<VCardText>
<div v-if="transactions.length === 0" class="text-body-2 text-medium-emphasis">No transactions yet.</div>
<VTable v-else>
<thead>
<tr>
<th>Date</th>
<th>Gateway</th>
<th>Status</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id">
<td>{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="text-capitalize">{{ tx.gateway }}</td>
<td>
<VChip
:color="resolveTransactionStatusColor(tx.status)"
size="small"
class="text-capitalize"
>
{{ tx.status }}
</VChip>
</td>
<td>{{ tx.description }}</td>
<td class="text-end">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveInvoiceStatusColor } from '@/utils/resolvers'
import type { Invoice, PaginatedResponse } from '@/types'
interface Props {
invoices: PaginatedResponse<Invoice>
}
defineOptions({ layout: AccountLayout })
defineProps<Props>()
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-primary text-body-2 text-decoration-none">&larr; Back to Billing</Link>
</div>
<div class="text-h4 font-weight-bold mb-6">Invoices</div>
<VCard>
<VCardText v-if="!invoices.data || invoices.data.length === 0" class="text-center py-6">
<div class="text-medium-emphasis">No invoices found.</div>
</VCardText>
<template v-else>
<VTable>
<thead>
<tr>
<th>Number</th>
<th>Date</th>
<th>Gateway</th>
<th>Status</th>
<th class="text-end">Amount</th>
<th class="text-end" />
</tr>
</thead>
<tbody>
<tr v-for="invoice in invoices.data" :key="invoice.id">
<td>{{ invoice.number }}</td>
<td>{{ new Date(invoice.created_at).toLocaleDateString() }}</td>
<td class="text-capitalize">{{ invoice.gateway }}</td>
<td>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</td>
<td class="text-end">${{ parseFloat(invoice.total).toFixed(2) }}</td>
<td class="text-end">
<a :href="`/billing/invoices/${invoice.id}/download`" class="text-primary text-decoration-none">Download</a>
</td>
</tr>
</tbody>
</VTable>
<!-- Pagination -->
<div v-if="invoices.links && invoices.last_page > 1" class="d-flex align-center justify-space-between pa-4">
<div class="text-body-2 text-medium-emphasis">
Showing {{ invoices.from }} to {{ invoices.to }} of {{ invoices.total }}
</div>
<div class="d-flex ga-1">
<Link
v-for="link in invoices.links"
:key="link.label"
:href="link.url || '#'"
class="text-decoration-none"
>
<VBtn
size="small"
:color="link.active ? 'primary' : undefined"
:variant="link.active ? 'flat' : 'text'"
:disabled="!link.url"
v-html="link.label"
/>
</Link>
</div>
</div>
</template>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { resolveTransactionStatusColor } from '@/utils/resolvers'
import type { Transaction, PaginatedResponse } from '@/types'
interface Props {
transactions: PaginatedResponse<Transaction>
}
defineOptions({ layout: AccountLayout })
defineProps<Props>()
</script>
<template>
<div>
<div class="mb-4">
<Link href="/billing" class="text-primary text-body-2 text-decoration-none">&larr; Back to Billing</Link>
</div>
<div class="text-h4 font-weight-bold mb-6">Transactions</div>
<VCard>
<VCardText v-if="!transactions.data || transactions.data.length === 0" class="text-center py-6">
<div class="text-medium-emphasis">No transactions found.</div>
</VCardText>
<template v-else>
<VTable>
<thead>
<tr>
<th>Date</th>
<th>Gateway</th>
<th>Method</th>
<th>Status</th>
<th>Description</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.data" :key="tx.id">
<td>{{ new Date(tx.created_at).toLocaleDateString() }}</td>
<td class="text-capitalize">{{ tx.gateway }}</td>
<td class="text-capitalize">{{ tx.payment_method }}</td>
<td>
<VChip
:color="resolveTransactionStatusColor(tx.status)"
size="small"
class="text-capitalize"
>
{{ tx.status }}
</VChip>
</td>
<td>{{ tx.description }}</td>
<td class="text-end">${{ parseFloat(tx.amount).toFixed(2) }}</td>
</tr>
</tbody>
</VTable>
<!-- Pagination -->
<div v-if="transactions.links && transactions.last_page > 1" class="d-flex align-center justify-space-between pa-4">
<div class="text-body-2 text-medium-emphasis">
Showing {{ transactions.from }} to {{ transactions.to }} of {{ transactions.total }}
</div>
<div class="d-flex ga-1">
<Link
v-for="link in transactions.links"
:key="link.label"
:href="link.url || '#'"
class="text-decoration-none"
>
<VBtn
size="small"
:color="link.active ? 'primary' : undefined"
:variant="link.active ? 'flat' : 'text'"
:disabled="!link.url"
v-html="link.label"
/>
</Link>
</div>
</div>
</template>
</VCard>
</div>
</template>

View 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">&larr; 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>

View File

@@ -0,0 +1,73 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import StatCard from '@/Components/StatCard.vue'
interface Props {
servicesCount: number
activeServicesCount: number
}
defineOptions({ layout: AccountLayout })
defineProps<Props>()
</script>
<template>
<div>
<div class="text-h4 font-weight-bold mb-6">Dashboard</div>
<VRow>
<VCol cols="12" md="4">
<StatCard
title="Total Services"
:stats="servicesCount"
icon="tabler-server"
color="primary"
/>
</VCol>
<VCol cols="12" md="4">
<StatCard
title="Active Services"
:stats="activeServicesCount"
icon="tabler-circle-check"
color="success"
/>
</VCol>
<VCol cols="12" md="4">
<VCard>
<VCardText>
<div class="d-flex align-center gap-4">
<VAvatar color="info" variant="tonal" rounded size="44">
<VIcon icon="tabler-bolt" size="26" />
</VAvatar>
<div>
<div class="text-body-2 text-medium-emphasis">Quick Actions</div>
</div>
</div>
<div class="mt-4 d-flex flex-column ga-2">
<Link href="/plans" class="text-primary text-body-2 text-decoration-none d-flex align-center ga-1">
<VIcon icon="tabler-chevron-right" size="16" />
Browse Plans
</Link>
<Link href="/subscriptions" class="text-primary text-body-2 text-decoration-none d-flex align-center ga-1">
<VIcon icon="tabler-chevron-right" size="16" />
My Subscriptions
</Link>
<Link href="/billing" class="text-primary text-body-2 text-decoration-none d-flex align-center ga-1">
<VIcon icon="tabler-chevron-right" size="16" />
Billing &amp; Payments
</Link>
<Link href="/profile" class="text-primary text-body-2 text-decoration-none d-flex align-center ga-1">
<VIcon icon="tabler-chevron-right" size="16" />
Edit Profile
</Link>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</template>

View File

@@ -0,0 +1,108 @@
<script lang="ts" setup>
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
defineOptions({ layout: MarketingLayout })
const values = [
{ icon: 'tabler-heart', title: 'Customer First', description: 'Every decision we make starts with what\'s best for our customers.', color: 'error' },
{ icon: 'tabler-shield-check', title: 'Reliability', description: 'We engineer our infrastructure for maximum uptime and performance.', color: 'success' },
{ icon: 'tabler-eye', title: 'Transparency', description: 'No hidden fees, no surprise charges. What you see is what you pay.', color: 'primary' },
{ icon: 'tabler-rocket', title: 'Innovation', description: 'We continuously invest in the latest hardware and software technologies.', color: 'warning' },
]
const milestones = [
{ year: '2024', event: 'EZSCALE founded with a mission to simplify cloud hosting.' },
{ year: '2024', event: 'Launched VPS and Dedicated Server product lines.' },
{ year: '2025', event: 'Expanded to 50+ data center locations worldwide.' },
{ year: '2025', event: 'Surpassed 10,000 active customers.' },
]
</script>
<template>
<div>
<!-- Hero -->
<VContainer class="py-16">
<VRow align="center">
<VCol cols="12" md="8" class="mx-auto text-center">
<h1 class="text-h2 font-weight-bold mb-4">About EZSCALE</h1>
<p class="text-h6 text-medium-emphasis font-weight-regular">
We're on a mission to make cloud hosting simple, affordable, and accessible to everyone.
From individual developers to growing businesses, EZSCALE provides the infrastructure you need to succeed.
</p>
</VCol>
</VRow>
</VContainer>
<!-- Values -->
<div class="bg-surface-variant py-16">
<VContainer>
<div class="text-center mb-12">
<h2 class="text-h3 font-weight-bold mb-3">Our Values</h2>
</div>
<VRow>
<VCol v-for="value in values" :key="value.title" cols="12" sm="6" md="3">
<VCard variant="flat" class="text-center pa-6 h-100 bg-transparent">
<VAvatar :color="value.color" variant="tonal" size="64" class="mb-4">
<VIcon :icon="value.icon" size="32" />
</VAvatar>
<h3 class="text-h6 font-weight-bold mb-2">{{ value.title }}</h3>
<p class="text-body-2 text-medium-emphasis mb-0">{{ value.description }}</p>
</VCard>
</VCol>
</VRow>
</VContainer>
</div>
<!-- Timeline -->
<VContainer class="py-16">
<div class="text-center mb-12">
<h2 class="text-h3 font-weight-bold mb-3">Our Journey</h2>
</div>
<VRow justify="center">
<VCol cols="12" md="8">
<VTimeline side="end" density="compact">
<VTimelineItem
v-for="(milestone, index) in milestones"
:key="index"
dot-color="primary"
size="small"
>
<VCard variant="outlined">
<VCardText>
<div class="text-caption text-primary font-weight-bold mb-1">{{ milestone.year }}</div>
<div class="text-body-1">{{ milestone.event }}</div>
</VCardText>
</VCard>
</VTimelineItem>
</VTimeline>
</VCol>
</VRow>
</VContainer>
<!-- Stats -->
<div class="bg-surface-variant py-16">
<VContainer>
<VRow>
<VCol cols="6" md="3" class="text-center">
<div class="text-h3 font-weight-bold text-primary">10K+</div>
<div class="text-body-1 text-medium-emphasis mt-1">Customers</div>
</VCol>
<VCol cols="6" md="3" class="text-center">
<div class="text-h3 font-weight-bold text-primary">99.99%</div>
<div class="text-body-1 text-medium-emphasis mt-1">Uptime</div>
</VCol>
<VCol cols="6" md="3" class="text-center">
<div class="text-h3 font-weight-bold text-primary">50+</div>
<div class="text-body-1 text-medium-emphasis mt-1">Locations</div>
</VCol>
<VCol cols="6" md="3" class="text-center">
<div class="text-h3 font-weight-bold text-primary">24/7</div>
<div class="text-body-1 text-medium-emphasis mt-1">Support</div>
</VCol>
</VRow>
</VContainer>
</div>
</div>
</template>

View File

@@ -0,0 +1,153 @@
<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
defineOptions({ layout: MarketingLayout })
const form = useForm({
name: '',
email: '',
subject: '',
message: '',
})
const subjects = [
'General Inquiry',
'Sales',
'Technical Support',
'Billing',
'Partnership',
'Other',
]
function submit(): void {
form.post('/contact', {
preserveScroll: true,
onSuccess: () => form.reset(),
})
}
const contactInfo = [
{ icon: 'tabler-mail', title: 'Email', detail: 'support@ezscale.cloud', href: 'mailto:support@ezscale.cloud' },
{ icon: 'tabler-clock', title: 'Support Hours', detail: '24/7/365', href: null },
{ icon: 'tabler-message-circle', title: 'Live Chat', detail: 'Available on dashboard', href: null },
]
</script>
<template>
<div>
<VContainer class="py-16">
<div class="text-center mb-12">
<h1 class="text-h2 font-weight-bold mb-3">Contact Us</h1>
<p class="text-h6 text-medium-emphasis font-weight-regular">
Have a question? We'd love to hear from you.
</p>
</div>
<VRow>
<!-- Contact Form -->
<VCol cols="12" md="7">
<VCard variant="outlined">
<VCardText class="pa-6">
<h2 class="text-h5 font-weight-bold mb-6">Send us a message</h2>
<form @submit.prevent="submit">
<VRow>
<VCol cols="12" sm="6">
<AppTextField
v-model="form.name"
label="Name"
placeholder="John Doe"
:error-messages="form.errors.name"
required
/>
</VCol>
<VCol cols="12" sm="6">
<AppTextField
v-model="form.email"
label="Email"
type="email"
placeholder="john@example.com"
:error-messages="form.errors.email"
required
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.subject"
:items="subjects"
label="Subject"
:error-messages="form.errors.subject"
required
/>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="form.message"
label="Message"
rows="5"
placeholder="How can we help you?"
:error-messages="form.errors.message"
required
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
color="primary"
size="large"
:loading="form.processing"
:disabled="form.processing"
>
Send Message
<VIcon icon="tabler-send" end />
</VBtn>
</VCol>
</VRow>
</form>
</VCardText>
</VCard>
</VCol>
<!-- Contact Info -->
<VCol cols="12" md="5">
<div class="d-flex flex-column ga-4">
<VCard v-for="info in contactInfo" :key="info.title" variant="outlined">
<VCardText class="d-flex align-center ga-4">
<VAvatar color="primary" variant="tonal" size="48">
<VIcon :icon="info.icon" size="24" />
</VAvatar>
<div>
<div class="text-subtitle-2 text-medium-emphasis">{{ info.title }}</div>
<a
v-if="info.href"
:href="info.href"
class="text-body-1 font-weight-medium text-decoration-none text-primary"
>
{{ info.detail }}
</a>
<div v-else class="text-body-1 font-weight-medium">{{ info.detail }}</div>
</div>
</VCardText>
</VCard>
<VCard variant="outlined" color="primary">
<VCardText class="pa-6">
<h3 class="text-h6 font-weight-bold mb-2">Need immediate help?</h3>
<p class="text-body-2 mb-3">
Our support team is available 24/7 through your account dashboard.
</p>
<VBtn variant="outlined" size="small">
Visit Knowledge Base
</VBtn>
</VCardText>
</VCard>
</div>
</VCol>
</VRow>
</VContainer>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More