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:
92
CLAUDE.md
92
CLAUDE.md
@@ -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
|
||||||
|
|||||||
47
TASKS.md
47
TASKS.md
@@ -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
|
||||||
|
|||||||
18
website/app/Http/Responses/RegisterResponse.php
Normal file
18
website/app/Http/Responses/RegisterResponse.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
65
website/config/inertia.php
Normal file
65
website/config/inertia.php
Normal 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),
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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
15
website/env.d.ts
vendored
Normal 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
1172
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
}
|
|
||||||
BIN
website/resources/images/ezscale_logo_white.png
Normal file
BIN
website/resources/images/ezscale_logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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">•••• {{ 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>
|
|
||||||
@@ -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">← 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>
|
|
||||||
@@ -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">← 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>
|
|
||||||
@@ -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">← 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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">← 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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' }} ·
|
|
||||||
<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>
|
|
||||||
@@ -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">← 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>
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
63
website/resources/styles/@core/base/_mixins.scss
Normal file
63
website/resources/styles/@core/base/_mixins.scss
Normal 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
|
||||||
90
website/resources/styles/@core/base/_utils.scss
Normal file
90
website/resources/styles/@core/base/_utils.scss
Normal 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;
|
||||||
|
}
|
||||||
198
website/resources/styles/@core/base/_variables.scss
Normal file
198
website/resources/styles/@core/base/_variables.scss
Normal 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
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@use "overrides";
|
||||||
262
website/resources/styles/@core/base/libs/vuetify/_overrides.scss
Normal file
262
website/resources/styles/@core/base/libs/vuetify/_overrides.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
);
|
||||||
6
website/resources/styles/@core/template/_mixins.scss
Normal file
6
website/resources/styles/@core/template/_mixins.scss
Normal 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)));
|
||||||
|
}
|
||||||
102
website/resources/styles/@core/template/_variables.scss
Normal file
102
website/resources/styles/@core/template/_variables.scss
Normal 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;
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.v-card-subtitle {
|
||||||
|
color: rgba(var(--v-theme-on-background), 0.55);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 👉 snackbar
|
||||||
|
.v-snackbar {
|
||||||
|
.v-snackbar__actions {
|
||||||
|
.v-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.v-textarea {
|
||||||
|
textarea {
|
||||||
|
opacity: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .v-field--active textarea {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// 👉 Tooltip
|
||||||
|
.v-tooltip {
|
||||||
|
.v-overlay__content {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@use "@core-scss/base/libs/vuetify";
|
||||||
|
@use "overrides";
|
||||||
|
@use "components/index.scss";
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
63
website/resources/styles/@core/template/pages/page-auth.scss
Normal file
63
website/resources/styles/@core/template/pages/page-auth.scss
Normal 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;
|
||||||
|
}
|
||||||
35
website/resources/styles/styles.scss
Normal file
35
website/resources/styles/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
1
website/resources/styles/variables/_template.scss
Normal file
1
website/resources/styles/variables/_template.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@forward "@core-scss/template/variables";
|
||||||
2
website/resources/styles/variables/_vuetify.scss
Normal file
2
website/resources/styles/variables/_vuetify.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Forward Vuexy's Vuetify variable chain
|
||||||
|
@forward "../@core/template/libs/vuetify/variables";
|
||||||
30
website/resources/ts/@layouts/styles/_mixins.scss
Normal file
30
website/resources/ts/@layouts/styles/_mixins.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
website/resources/ts/@layouts/styles/_placeholders.scss
Normal file
9
website/resources/ts/@layouts/styles/_placeholders.scss
Normal 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;
|
||||||
|
}
|
||||||
29
website/resources/ts/@layouts/styles/_variables.scss
Normal file
29
website/resources/ts/@layouts/styles/_variables.scss
Normal 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;
|
||||||
16
website/resources/ts/Components/FlashMessages.vue
Normal file
16
website/resources/ts/Components/FlashMessages.vue
Normal 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>
|
||||||
26
website/resources/ts/Components/StatCard.vue
Normal file
26
website/resources/ts/Components/StatCard.vue
Normal 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>
|
||||||
14
website/resources/ts/Components/StatusChip.vue
Normal file
14
website/resources/ts/Components/StatusChip.vue
Normal 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>
|
||||||
15
website/resources/ts/Components/ThemeSwitcher.vue
Normal file
15
website/resources/ts/Components/ThemeSwitcher.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
104
website/resources/ts/Layouts/AccountLayout.vue
Normal file
104
website/resources/ts/Layouts/AccountLayout.vue
Normal 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">
|
||||||
|
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
||||||
|
</span>
|
||||||
|
</VFooter>
|
||||||
|
</VApp>
|
||||||
|
</template>
|
||||||
120
website/resources/ts/Layouts/AdminLayout.vue
Normal file
120
website/resources/ts/Layouts/AdminLayout.vue
Normal 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">
|
||||||
|
© {{ new Date().getFullYear() }} EZSCALE. All rights reserved.
|
||||||
|
</span>
|
||||||
|
</VFooter>
|
||||||
|
</VApp>
|
||||||
|
</template>
|
||||||
100
website/resources/ts/Layouts/AuthLayout.vue
Normal file
100
website/resources/ts/Layouts/AuthLayout.vue
Normal 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>
|
||||||
325
website/resources/ts/Layouts/MarketingLayout.vue
Normal file
325
website/resources/ts/Layouts/MarketingLayout.vue
Normal 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">
|
||||||
|
© {{ 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>
|
||||||
49
website/resources/ts/Pages/Admin/Dashboard.vue
Normal file
49
website/resources/ts/Pages/Admin/Dashboard.vue
Normal 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>
|
||||||
57
website/resources/ts/Pages/Auth/ConfirmPassword.vue
Normal file
57
website/resources/ts/Pages/Auth/ConfirmPassword.vue
Normal 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>
|
||||||
84
website/resources/ts/Pages/Auth/ForgotPassword.vue
Normal file
84
website/resources/ts/Pages/Auth/ForgotPassword.vue
Normal 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>
|
||||||
118
website/resources/ts/Pages/Auth/Login.vue
Normal file
118
website/resources/ts/Pages/Auth/Login.vue
Normal 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>
|
||||||
129
website/resources/ts/Pages/Auth/Register.vue
Normal file
129
website/resources/ts/Pages/Auth/Register.vue
Normal 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">&</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
Reference in New Issue
Block a user