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

@@ -14,6 +14,28 @@ All work MUST be tracked against GitHub issues and `TASKS.md`:
GitHub issues: https://github.com/EZSCALE/accounting/issues GitHub issues: https://github.com/EZSCALE/accounting/issues
## Documentation Updates (MANDATORY)
When a phase is completed or a significant task within a phase is finished:
1. **Update `TASKS.md`** — Check off completed items, add new items discovered during work
2. **Update GitHub issues** — Add progress comments (`gh issue comment <number> --body "..."`) and close completed issues
3. **Update `CLAUDE.md`** — Reflect any new tech stack changes, directory structure changes, or convention updates
4. **Update memory files** — Record key patterns, gotchas, and environment details learned during the work
5. **Update `PROJECT_DEVELOPMENT.md`** — If architectural decisions changed or new integrations were added
### Visual Verification (MANDATORY)
After every successful `npm run build`:
1. Take headless Chrome screenshots of affected pages
2. Compare layout and design against the Vuexy demo at: https://demos.pixinvent.com/vuexy-vuejs-laravel-admin-template/demo-6/
3. Fix any visual discrepancies before considering the task complete
4. Key demo pages for reference:
- Login: `.../demo-6/pages/authentication/login-v2`
- Register: `.../demo-6/pages/authentication/register-v2`
- Forgot Password: `.../demo-6/pages/authentication/forgot-password-v2`
- Dashboard: `.../demo-6/dashboards/analytics`
- Account Settings: `.../demo-6/pages/account-settings/account`
- Pricing: `.../demo-6/pages/pricing`
## Laravel App Location ## Laravel App Location
The Laravel application is in **`website/`**. All artisan, composer, and npm commands run from there. The Laravel application is in **`website/`**. All artisan, composer, and npm commands run from there.
@@ -33,12 +55,23 @@ website/
│ ├── factories/ # 7 factories │ ├── factories/ # 7 factories
│ └── seeders/ # Roles, plans, admin user │ └── seeders/ # Roles, plans, admin user
├── resources/ ├── resources/
│ ├── js/ # Vue 3 + Inertia pages (TO BE MIGRATED TO TypeScript) │ ├── ts/ # TypeScript source (migrated from js/)
│ │ ├── Layouts/ # AppLayout, AuthLayout, AdminLayout │ │ ├── app.ts # Entry point with Vuetify + Pinia
│ │ ├── Pages/ # Auth/, Billing/, Plans/, Subscriptions/, Admin/ │ │ ├── bootstrap.ts
│ │ ── Components/ # Card, Button, NavLink, FlashMessages │ │ ── types/ # TypeScript interfaces
│ ├── css/app.css # Tailwind CSS 4 │ ├── utils/ # Resolvers, formatters
└── views/app.blade.php # Inertia root template │ ├── navigation/ # account.ts, admin.ts, marketing.ts
│ │ ├── plugins/vuetify/ # theme.ts, defaults.ts, icons.ts, index.ts
│ │ ├── @layouts/ # Layout SCSS stubs for Vuexy compatibility
│ │ ├── Layouts/ # AccountLayout, AdminLayout, AuthLayout, MarketingLayout
│ │ ├── Components/ # FlashMessages, StatCard, StatusChip, ThemeSwitcher, app-form-elements/
│ │ └── Pages/ # Auth/ (7), Profile/ (2), Plans/ (2), Checkout/ (1), Subscriptions/ (2), Billing/ (3), Admin/ (1), Marketing/ (9), Dashboard
│ ├── styles/ # SCSS with Vuexy @core overrides
│ │ ├── @core/ # Copied from Vuexy: base + template SCSS overrides
│ │ ├── variables/ # _vuetify.scss, _template.scss
│ │ └── styles.scss # Main entry — imports @core SCSS chain
│ ├── images/
│ └── views/app.blade.php # Inertia root template
├── routes/ # web.php, account.php, admin.php, marketing.php, webhooks.php, api.php ├── routes/ # web.php, account.php, admin.php, marketing.php, webhooks.php, api.php
├── tests/ # 53 Pest tests (Phase 1 + Phase 2) ├── tests/ # 53 Pest tests (Phase 1 + Phase 2)
├── composer.json ├── composer.json
@@ -48,8 +81,8 @@ website/
## Tech Stack ## Tech Stack
- **Framework:** Laravel 12 (PHP 8.3), Laravel 12 slim structure (no Kernel files) - **Framework:** Laravel 12 (PHP 8.3), Laravel 12 slim structure (no Kernel files)
- **Frontend:** Vue 3 + Inertia.js v2 + TypeScript (REQUIRED) + Tailwind CSS 4 + Vite 7 - **Frontend:** Vue 3 + Inertia.js v2 + TypeScript (REQUIRED) + Vuetify 3 (Vuexy design system) + Vite 7
- **UI Theme:** Vuexy Vue + Laravel Admin Dashboard (reference at `../vuexy-theme-vue-laravel-full-example-typescript/`) - **UI Theme:** Vuexy Vue + Laravel Admin Dashboard — SCSS overrides from @core integrated, AppTextField/AppSelect/AppTextarea wrapper components, purple primary (#7367F0)
- **Testing:** Pest 4 + PHPUnit 12 - **Testing:** Pest 4 + PHPUnit 12
- **Formatting:** Laravel Pint - **Formatting:** Laravel Pint
- **Payments:** Laravel Cashier (Stripe) + srmklive/paypal (PayPal) - **Payments:** Laravel Cashier (Stripe) + srmklive/paypal (PayPal)
@@ -142,6 +175,43 @@ vuexy-theme-vue-laravel-full-example-typescript/
└── tsconfig.json # Strict TypeScript config └── tsconfig.json # Strict TypeScript config
``` ```
## Agent Usage (MANDATORY)
Always maximize use of subagents (Task tool) to reduce context usage in the main conversation. This keeps the main terminal fast and avoids running out of context on large tasks.
### Rules
- **Parallel agents**: When multiple independent tasks exist (e.g., reading several files, researching different topics, building separate components), launch them as parallel agents in a single message rather than doing them sequentially in the main context.
- **Delegate research**: Use `Explore` agents for codebase exploration, file searches, and understanding existing patterns. Do not manually grep/read dozens of files in the main context.
- **Delegate implementation**: Use `general-purpose` agents for self-contained implementation tasks (e.g., "create this component", "write this migration", "update these 5 files with this pattern").
- **Delegate reviews**: Use `feature-dev:code-reviewer` agents to review code after writing it.
- **Delegate architecture**: Use `feature-dev:code-architect` or `Plan` agents for designing features before implementing.
- **Keep main context for orchestration**: The main conversation should coordinate agents, communicate with the user, and handle simple/quick edits. Heavy lifting goes to agents.
- **Background agents**: Use `run_in_background: true` for long-running tasks that don't block other work. Check results later with the Read tool on the output file.
- **Batch similar work**: If updating 10+ files with the same pattern, send them to an agent rather than editing each one in the main context.
### Headless Chrome
Chrome is available for visual testing and screenshot comparison. Use it to verify UI matches design references.
```bash
google-chrome --headless=new --disable-gpu --no-sandbox --screenshot=/tmp/screenshot.png --window-size=1920,1080 --virtual-time-budget=15000 "URL"
```
- **Must use `--headless=new`** (not just `--headless`) for modern headless mode
- **Must use `--virtual-time-budget=15000`** (or higher) to wait for SPA JavaScript to render before capturing
- **Must use `--no-sandbox`** in this environment
- **Vuexy demo base URL**: https://demos.pixinvent.com/vuexy-vuejs-laravel-admin-template/demo-6/
- dbus errors in output are harmless — ignore them
- Use for comparing our pages against the Vuexy demo visually
- After every successful `npm run build`, screenshot key pages and compare layout/design against the Vuexy demo to ensure visual parity
- Use for verifying layout, spacing, and component rendering
- Can be delegated to agents for parallel screenshot capture
- Read the resulting PNG with the Read tool to view it
### Examples of When to Use Agents
- Exploring how a feature works across multiple files → `Explore` agent
- Creating multiple Vue pages/components → parallel `general-purpose` agents
- Writing tests for new code → `general-purpose` agent
- Researching Vuexy theme patterns → `Explore` agent
- Building a new API endpoint (controller + request + test) → `general-purpose` agent
- Reviewing changes before committing → `feature-dev:code-reviewer` agent
## Code Conventions ## Code Conventions
### PHP ### PHP
@@ -165,10 +235,12 @@ vuexy-theme-vue-laravel-full-example-typescript/
### Frontend (Vue/TypeScript) ### Frontend (Vue/TypeScript)
- All components use `<script setup lang="ts">` - All components use `<script setup lang="ts">`
- Props defined via `interface Props` + `defineProps<Props>()` - Props defined via `interface Props` + `defineProps<Props>()`
- Dark mode is the default UI theme (`bg-gray-950` pages, `bg-gray-900` cards, `bg-gray-800` inputs) - Dark mode is the default UI theme via Vuetify theme system
- Use Vuetify components (VCard, VBtn, VTextField via AppTextField wrapper, VChip, etc.) — not raw HTML
- Use AppTextField, AppSelect, AppTextarea wrappers (in Components/app-form-elements/) instead of VTextField/VSelect/VTextarea directly
- Use Inertia `Link` component for navigation (not `<a>` tags for internal links) - Use Inertia `Link` component for navigation (not `<a>` tags for internal links)
- Use `useForm()` from `@inertiajs/vue3` for form submissions - Use `useForm()` from `@inertiajs/vue3` for form submissions
- Status badges use semi-transparent colored backgrounds (e.g., `bg-green-900/50 text-green-300`) - Status badges use VChip with color prop and resolveStatusColor() utilities
- Refer to Vuexy theme components and patterns when building new UI - Refer to Vuexy theme components and patterns when building new UI
## Security ## Security

View File

@@ -16,6 +16,7 @@
- [x] Build customer dashboard + admin dashboard (placeholder) - [x] Build customer dashboard + admin dashboard (placeholder)
- [x] Set up Pest testing framework (24 Phase 1 tests passing) - [x] Set up Pest testing framework (24 Phase 1 tests passing)
- [x] Dark mode UI across all pages - [x] Dark mode UI across all pages
- [x] Migrate frontend from Tailwind CSS to Vuetify 3 (Vuexy design system) with TypeScript
- [ ] Configure Cloudflare Zero Trust for admin panel - [ ] Configure Cloudflare Zero Trust for admin panel
- [ ] Set up GitHub Actions CI/CD pipeline - [ ] Set up GitHub Actions CI/CD pipeline
- [ ] Create staging environment (staging.account.ezscale.cloud) - [ ] Create staging environment (staging.account.ezscale.cloud)
@@ -44,6 +45,24 @@
- [ ] Admin coupon management CRUD - [ ] Admin coupon management CRUD
- [ ] Email notifications for payment events - [ ] Email notifications for payment events
## Frontend Migration: Tailwind → Vuetify/Vuexy ✅
- [x] Install Vuetify 3, TypeScript, Pinia, Sass, vite-plugin-vuetify
- [x] Remove Tailwind CSS
- [x] Configure TypeScript (tsconfig.json, path aliases, strict mode)
- [x] Set up Vuetify plugin (theme, defaults, icons)
- [x] Rename resources/js → resources/ts, convert to TypeScript
- [x] Copy Vuexy @core SCSS overrides (38 files — base + template + 25 components)
- [x] Create @layouts stubs for Vuexy SCSS compatibility
- [x] Configure Vite aliases (@core-scss, @configured-variables, @layouts)
- [x] Create 4 layouts: AccountLayout, AdminLayout, AuthLayout, MarketingLayout
- [x] Create shared components: FlashMessages, StatCard, StatusChip, ThemeSwitcher
- [x] Create AppTextField, AppSelect, AppTextarea wrapper components (Vuexy pattern)
- [x] Migrate all 19 existing pages to Vuetify + TypeScript
- [x] Create 9 marketing pages (Home, Products, VPS, Dedicated, Web, Game, Pricing, About, Contact)
- [x] Create navigation configs (account.ts, admin.ts, marketing.ts)
- [x] Set purple primary color (#7367F0) matching Vuexy demo
- [x] All 53 tests passing, build clean
## Phase 3: Provisioning Automation ## Phase 3: Provisioning Automation
- [ ] Create `ProvisioningServiceInterface` abstraction - [ ] Create `ProvisioningServiceInterface` abstraction
- [ ] Build VirtFusion provisioning service: - [ ] Build VirtFusion provisioning service:
@@ -206,23 +225,23 @@
- [ ] Ticket escalated (high priority) - [ ] Ticket escalated (high priority)
## Phase 8: Marketing Frontend (ezscale.cloud) ## Phase 8: Marketing Frontend (ezscale.cloud)
- [ ] Homepage: - [x] Homepage:
- [ ] Hero section with value proposition - [x] Hero section with value proposition
- [ ] Featured services/plans - [x] Featured services/plans
- [ ] Trust indicators (uptime, customers, years in business) - [x] Trust indicators (uptime, customers, years in business)
- [ ] Call to action (Get Started, View Plans) - [x] Call to action (Get Started, View Plans)
- [ ] Product pages: - [x] Product pages:
- [ ] VPS hosting page with plan comparison - [x] VPS hosting page with plan comparison
- [ ] Dedicated servers page with configurations - [x] Dedicated servers page with configurations
- [ ] Web hosting page with features - [x] Web hosting page with features
- [ ] Game server hosting page with supported games - [x] Game server hosting page with supported games
- [ ] Pricing page: - [x] Pricing page:
- [ ] Interactive plan comparison table - [x] Interactive plan comparison table
- [ ] Currency selector (USD, EUR, GBP) - [ ] Currency selector (USD, EUR, GBP)
- [ ] Coupon code application - [ ] Coupon code application
- [ ] Add to cart / checkout flow - [ ] Add to cart / checkout flow
- [ ] About page - [x] About page
- [ ] Contact page - [x] Contact page
- [ ] Blog/news section (optional, or use WordPress?) - [ ] Blog/news section (optional, or use WordPress?)
- [ ] Knowledge base / FAQ: - [ ] Knowledge base / FAQ:
- [ ] Getting started guides - [ ] Getting started guides

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Responses;
use Illuminate\Http\Request;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Symfony\Component\HttpFoundation\Response;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): Response
{
/** @var Request $request */
return redirect()->intended('/dashboard');
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Http\Responses\LoginResponse; use App\Http\Responses\LoginResponse;
use App\Http\Responses\RegisterResponse;
use App\Services\Billing\BillingServiceFactory; use App\Services\Billing\BillingServiceFactory;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -12,12 +13,14 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
$this->app->singleton(LoginResponseContract::class, LoginResponse::class); $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
$this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
$this->app->singleton(BillingServiceFactory::class); $this->app->singleton(BillingServiceFactory::class);
} }

View File

@@ -41,6 +41,7 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
$middleware->redirectGuestsTo(fn () => 'https://'.config('app.domains.account').'/login'); $middleware->redirectGuestsTo(fn () => 'https://'.config('app.domains.account').'/login');
$middleware->redirectUsersTo('/dashboard');
$middleware->alias([ $middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,

View File

@@ -0,0 +1,65 @@
<?php
return [
'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),
'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
],
'ensure_pages_exist' => false,
'page_paths' => [
resource_path('ts/Pages'),
],
'page_extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
'use_script_element_for_initial_page' => (bool) env('INERTIA_USE_SCRIPT_ELEMENT_FOR_INITIAL_PAGE', false),
'testing' => [
'ensure_pages_exist' => true,
'page_paths' => [
resource_path('ts/Pages'),
],
'page_extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
],
'history' => [
'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false),
],
];

View File

@@ -11,79 +11,493 @@ class PlanSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
Plan::query()->delete();
$plans = [ $plans = [
// VPS Plans // ─── VPS Plans ───────────────────────────────────────────────
[ [
'name' => 'VPS Starter', 'name' => 'Micro VPS',
'slug' => 'vps-starter', 'slug' => 'micro-vps',
'description' => 'Perfect for small projects and development.', 'description' => 'Lightweight VPS for simple tasks, testing, and small projects.',
'service_type' => 'vps', 'service_type' => 'vps',
'price' => 5.99, 'price' => 4.20,
'billing_cycle' => 'monthly', 'billing_cycle' => 'monthly',
'features' => ['cpu' => '1 vCPU', 'ram' => '1GB', 'disk' => '25GB SSD', 'bandwidth' => '1TB'], 'features' => [
'cpu' => '1 vCPU',
'ram' => '1 GB',
'storage' => '25 GB SSD',
'bandwidth' => '2 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 1, 'sort_order' => 1,
], ],
[ [
'name' => 'VPS Pro', 'name' => 'Mini VPS',
'slug' => 'vps-pro', 'slug' => 'mini-vps',
'description' => 'Ideal for growing applications and websites.', 'description' => 'Compact VPS with extra memory for light workloads.',
'service_type' => 'vps', 'service_type' => 'vps',
'price' => 19.99, 'price' => 6.00,
'billing_cycle' => 'monthly', 'billing_cycle' => 'monthly',
'features' => ['cpu' => '2 vCPU', 'ram' => '4GB', 'disk' => '80GB SSD', 'bandwidth' => '3TB'], 'features' => [
'cpu' => '1 vCPU',
'ram' => '2 GB',
'storage' => '50 GB SSD',
'bandwidth' => '4 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 2, 'sort_order' => 2,
], ],
[ [
'name' => 'VPS Enterprise', 'name' => 'Dev Starter',
'slug' => 'vps-enterprise', 'slug' => 'dev-starter',
'description' => 'High-performance VPS for demanding workloads.', 'description' => 'Dual-core VPS ideal for development environments and staging.',
'service_type' => 'vps', 'service_type' => 'vps',
'price' => 49.99, 'price' => 8.00,
'billing_cycle' => 'monthly', 'billing_cycle' => 'monthly',
'features' => ['cpu' => '4 vCPU', 'ram' => '16GB', 'disk' => '200GB SSD', 'bandwidth' => '10TB'], 'features' => [
'cpu' => '2 vCPU',
'ram' => '2 GB',
'storage' => '60 GB SSD',
'bandwidth' => '4 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 3, 'sort_order' => 3,
], ],
// Dedicated Server Plans
[ [
'name' => 'Dedicated Starter', 'name' => 'Basic VPS',
'slug' => 'dedicated-starter', 'slug' => 'basic-vps',
'description' => 'Entry-level dedicated server.', 'description' => 'Balanced VPS for web apps, databases, and general-purpose workloads.',
'service_type' => 'dedicated', 'service_type' => 'vps',
'price' => 99.99, 'price' => 12.00,
'billing_cycle' => 'monthly', 'billing_cycle' => 'monthly',
'features' => ['cpu' => 'Intel Xeon E-2236', 'ram' => '32GB DDR4', 'disk' => '2x 500GB SSD', 'bandwidth' => '10TB'], 'features' => [
'stock_quantity' => 5, 'cpu' => '2 vCPU',
'ram' => '4 GB',
'storage' => '80 GB SSD',
'bandwidth' => '6 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 4,
],
[
'name' => 'Storage Box',
'slug' => 'storage-box',
'description' => 'High-storage VPS for backups, media, and file-heavy applications.',
'service_type' => 'vps',
'price' => 15.00,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2 vCPU',
'ram' => '2 GB',
'storage' => '500 GB SSD',
'bandwidth' => '8 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 5,
],
[
'name' => 'Standard VPS',
'slug' => 'standard-vps',
'description' => 'Quad-core VPS with 8 GB RAM for production applications.',
'service_type' => 'vps',
'price' => 15.60,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '4 vCPU',
'ram' => '8 GB',
'storage' => '160 GB SSD',
'bandwidth' => '8 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 6,
],
[
'name' => 'RAM Optimized',
'slug' => 'ram-optimized',
'description' => 'Memory-optimized VPS for databases, caching, and in-memory workloads.',
'service_type' => 'vps',
'price' => 19.00,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '4 vCPU',
'ram' => '16 GB',
'storage' => '240 GB SSD',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 7,
],
[
'name' => 'Advanced VPS',
'slug' => 'advanced-vps',
'description' => 'Six-core VPS with 16 GB RAM for demanding applications and multi-service setups.',
'service_type' => 'vps',
'price' => 21.60,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '6 vCPU',
'ram' => '16 GB',
'storage' => '320 GB SSD',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 8,
],
[
'name' => 'Pro VPS',
'slug' => 'pro-vps',
'description' => 'Eight-core powerhouse with 32 GB RAM for enterprise workloads and heavy traffic.',
'service_type' => 'vps',
'price' => 30.00,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '8 vCPU',
'ram' => '32 GB',
'storage' => '640 GB SSD',
'bandwidth' => '16 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'control_panel' => 'VirtFusion',
'os' => 'Linux & Windows (BYOL)',
],
'sort_order' => 9,
],
// ─── Dedicated Server Plans ──────────────────────────────────
[
'name' => 'Dell R330 LFF',
'slug' => 'dell-r330-lff',
'description' => 'Entry-level single-socket dedicated server with 4 large form factor drive bays.',
'service_type' => 'dedicated',
'price' => 44.39,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '1x Intel Xeon E3-1220 v5',
'cores' => '4C/4T',
'ram' => '16 GB',
'storage_bays' => '4x 3.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 3,
'sort_order' => 10, 'sort_order' => 10,
], ],
// Web Hosting Plans
[ [
'name' => 'Hosting Basic', 'name' => 'Dell R420 LFF',
'slug' => 'hosting-basic', 'slug' => 'dell-r420-lff',
'description' => 'Shared hosting for small websites.', 'description' => 'Dual-socket server with 12 cores and 32 GB RAM for mid-range workloads.',
'service_type' => 'dedicated',
'price' => 58.79,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2430v2',
'cores' => '12C/24T',
'ram' => '32 GB',
'storage_bays' => '4x 3.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 0,
'sort_order' => 11,
],
[
'name' => 'Dell R620 SFF 10-Bay',
'slug' => 'dell-r620-sff-10-bay',
'description' => 'High-performance 1U server with 10 small form factor bays and 16 cores.',
'service_type' => 'dedicated',
'price' => 61.19,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2667v2',
'cores' => '16C/32T',
'ram' => '32 GB',
'storage_bays' => '10x 2.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 0,
'sort_order' => 12,
],
[
'name' => 'Dell R620 SFF 8-Bay',
'slug' => 'dell-r620-sff-8-bay',
'description' => 'High-performance 1U server with 8 small form factor bays and 16 cores.',
'service_type' => 'dedicated',
'price' => 61.19,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2667v2',
'cores' => '16C/32T',
'ram' => '32 GB',
'storage_bays' => '8x 2.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 0,
'sort_order' => 13,
],
[
'name' => 'Dell R520 LFF',
'slug' => 'dell-r520-lff',
'description' => 'Storage-friendly 2U server with 8 large form factor bays and dual Xeons.',
'service_type' => 'dedicated',
'price' => 64.79,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2420v2',
'cores' => '12C/24T',
'ram' => '32 GB',
'storage_bays' => '8x 3.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 2,
'sort_order' => 14,
],
[
'name' => 'Dell R430 LFF',
'slug' => 'dell-r430-lff',
'description' => 'Compact 1U server with high-clock v4 Xeons for compute-intensive tasks.',
'service_type' => 'dedicated',
'price' => 87.59,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2667v4',
'cores' => '16C/32T',
'ram' => '32 GB',
'storage_bays' => '4x 3.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 1,
'sort_order' => 15,
],
[
'name' => 'Dell R630 SFF',
'slug' => 'dell-r630-sff',
'description' => 'High-core-count 1U server with 32 cores and 64 threads for heavy multi-threaded workloads.',
'service_type' => 'dedicated',
'price' => 93.59,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2697A v4',
'cores' => '32C/64T',
'ram' => '32 GB',
'storage_bays' => '8x 2.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 1,
'sort_order' => 16,
],
[
'name' => 'Dell R730 LFF',
'slug' => 'dell-r730-lff',
'description' => 'Flagship 2U server with 28 cores, 56 threads, and 8 large form factor bays.',
'service_type' => 'dedicated',
'price' => 107.99,
'billing_cycle' => 'monthly',
'features' => [
'cpu' => '2x Intel Xeon E5-2680v4',
'cores' => '28C/56T',
'ram' => '32 GB',
'storage_bays' => '8x 3.5" bays',
'bandwidth' => '10 TB',
'ipv4' => '1 IPv4',
'ipv6' => '1 /64 IPv6',
'management' => 'SynergyCP',
],
'stock_quantity' => 1,
'sort_order' => 17,
],
// ─── Web Hosting Plans ───────────────────────────────────────
[
'name' => 'Small',
'slug' => 'hosting-small',
'description' => 'Starter web hosting plan for personal sites and blogs.',
'service_type' => 'hosting',
'price' => 2.39,
'billing_cycle' => 'monthly',
'features' => [
'storage' => '10 GB SSD',
'databases' => '2 MySQL',
'email' => '5 Accounts',
'domains' => '1',
'bandwidth' => '1 TB',
'ram' => '512 MB',
'cpu' => '1 Core',
'ssl' => 'Free SSL',
'panel' => 'Enhance',
],
'sort_order' => 20,
],
[
'name' => 'Medium',
'slug' => 'hosting-medium',
'description' => 'Mid-range hosting for small businesses and growing websites.',
'service_type' => 'hosting', 'service_type' => 'hosting',
'price' => 3.99, 'price' => 3.99,
'billing_cycle' => 'monthly', 'billing_cycle' => 'monthly',
'features' => ['disk' => '10GB SSD', 'bandwidth' => '100GB', 'domains' => '1', 'email' => '5 accounts'], 'features' => [
'sort_order' => 20, 'storage' => '25 GB SSD',
'databases' => '6 MySQL',
'email' => '20 Accounts',
'domains' => '4',
'bandwidth' => '1 TB',
'ram' => '1 GB',
'cpu' => '1 Core',
'ssl' => 'Free SSL',
'panel' => 'Enhance',
],
'sort_order' => 21,
], ],
// Game Server Plans
[ [
'name' => 'Minecraft Standard', 'name' => 'Large',
'slug' => 'minecraft-standard', 'slug' => 'hosting-large',
'description' => 'Minecraft server for up to 20 players.', 'description' => 'High-capacity hosting with unlimited databases and email for agencies and power users.',
'service_type' => 'game_server', 'service_type' => 'hosting',
'price' => 9.99, 'price' => 7.19,
'billing_cycle' => 'monthly', 'billing_cycle' => 'monthly',
'features' => ['cpu' => '2 vCPU', 'ram' => '4GB', 'disk' => '30GB SSD', 'players' => '20'], 'features' => [
'storage' => '100 GB SSD',
'databases' => 'Unlimited MySQL',
'email' => 'Unlimited Accounts',
'domains' => '30',
'bandwidth' => '2 TB',
'ram' => '4 GB',
'cpu' => '4 Cores',
'ssl' => 'Free SSL',
'panel' => 'Enhance',
],
'sort_order' => 22,
],
[
'name' => 'Dedicated',
'slug' => 'hosting-dedicated',
'description' => 'Dedicated hosting resources with maximum performance and capacity.',
'service_type' => 'hosting',
'price' => 15.99,
'billing_cycle' => 'monthly',
'features' => [
'storage' => '160 GB SSD',
'databases' => 'Unlimited MySQL',
'email' => 'Unlimited Accounts',
'domains' => '100',
'bandwidth' => '4 TB',
'ram' => '8 GB',
'cpu' => '4 Cores',
'ssl' => 'Free SSL',
'panel' => 'Enhance',
],
'sort_order' => 23,
],
// ─── MySQL Hosting Plans ─────────────────────────────────────
[
'name' => 'Bronze',
'slug' => 'mysql-bronze',
'description' => 'Entry-level managed MySQL hosting with daily backups.',
'service_type' => 'mysql',
'price' => 6.00,
'billing_cycle' => 'monthly',
'features' => [
'storage' => '5 GB',
'backups' => 'Daily',
'ssl' => 'SSL Encrypted',
],
'sort_order' => 30, 'sort_order' => 30,
], ],
[
'name' => 'Silver',
'slug' => 'mysql-silver',
'description' => 'Mid-tier managed MySQL hosting for growing applications.',
'service_type' => 'mysql',
'price' => 12.00,
'billing_cycle' => 'monthly',
'features' => [
'storage' => '15 GB',
'backups' => 'Daily',
'ssl' => 'SSL Encrypted',
],
'sort_order' => 31,
],
[
'name' => 'Gold',
'slug' => 'mysql-gold',
'description' => 'High-capacity managed MySQL hosting for production databases.',
'service_type' => 'mysql',
'price' => 18.00,
'billing_cycle' => 'monthly',
'features' => [
'storage' => '30 GB',
'backups' => 'Daily',
'ssl' => 'SSL Encrypted',
],
'sort_order' => 32,
],
[
'name' => 'Platinum',
'slug' => 'mysql-platinum',
'description' => 'Enterprise managed MySQL hosting with 100 GB storage for large-scale databases.',
'service_type' => 'mysql',
'price' => 30.00,
'billing_cycle' => 'monthly',
'features' => [
'storage' => '100 GB',
'backups' => 'Daily',
'ssl' => 'SSL Encrypted',
],
'sort_order' => 33,
],
]; ];
foreach ($plans as $plan) { foreach ($plans as $plan) {
Plan::create(array_merge([ Plan::updateOrCreate(
'currency' => 'USD', ['slug' => $plan['slug']],
'status' => 'active', array_merge([
], $plan)); 'currency' => 'USD',
'status' => 'active',
], $plan),
);
} }
} }
} }

15
website/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
interface ImportMetaEnv {
readonly VITE_APP_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

1172
website/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,24 @@
"dev": "vite" "dev": "vite"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@iconify-json/tabler": "^1.2.26",
"@types/node": "^25.2.2",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "typescript": "^5.9.3",
"vite": "^7.0.7" "vite": "^7.0.7",
"vue-tsc": "^3.2.4"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.0",
"@inertiajs/vue3": "^2.3.13", "@inertiajs/vue3": "^2.3.13",
"@mdi/font": "^7.4.47",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"vue": "^3.5.27" "pinia": "^3.0.4",
"sass": "^1.97.3",
"vite-plugin-vuetify": "^2.1.3",
"vue": "^3.5.27",
"vuetify": "^3.11.8"
} }
} }

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>

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