Add account settings page and admin analytics dashboard
Phase 4 (Customer Dashboard): - Build tabbed account settings page (Account, Security, Billing tabs) - Account tab: profile info, address, company fields with useForm() - Security tab: password change, 2FA management, device sessions - Billing tab: payment methods link, billing address, tax ID - Create UpdateProfileRequest and UpdatePasswordRequest validators - Expand ProfileController with update, updatePassword, updateBilling Phase 5 (Admin Panel): - Build rich admin analytics dashboard with 14 data points - Stats: total customers, MRR, active services, pending invoices - Recent subscriptions and invoices tables with customer info - Popular plans with subscriber counts - Revenue by service type breakdown - Quick stats: monthly revenue, new customers, overdue accounts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Account;
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\UpdatePasswordRequest;
|
||||||
|
use App\Http\Requests\UpdateProfileRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -13,21 +16,82 @@ class ProfileController extends Controller
|
|||||||
{
|
{
|
||||||
public function show(Request $request): Response
|
public function show(Request $request): Response
|
||||||
{
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$user->load('profile');
|
||||||
|
|
||||||
|
$profile = $user->profile;
|
||||||
|
|
||||||
return Inertia::render('Profile/Show', [
|
return Inertia::render('Profile/Show', [
|
||||||
'user' => $request->user()->load('profile'),
|
'user' => $user,
|
||||||
|
'profile' => $profile ? [
|
||||||
|
'billing_address_line1' => $profile->billing_address_line1,
|
||||||
|
'billing_address_line2' => $profile->billing_address_line2,
|
||||||
|
'billing_city' => $profile->billing_city,
|
||||||
|
'billing_state' => $profile->billing_state,
|
||||||
|
'billing_zip' => $profile->billing_zip,
|
||||||
|
'billing_country' => $profile->billing_country,
|
||||||
|
'tax_id' => $profile->tax_id,
|
||||||
|
'tax_exempt' => $profile->tax_exempt,
|
||||||
|
'company_name' => $profile->company_name,
|
||||||
|
'company_vat' => $profile->company_vat,
|
||||||
|
] : null,
|
||||||
|
'twoFactorEnabled' => (bool) $user->two_factor_secret,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request): \Illuminate\Http\RedirectResponse
|
public function update(UpdateProfileRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$user->update([
|
||||||
|
'name' => $validated['first_name'].' '.$validated['last_name'],
|
||||||
|
'phone' => $validated['phone'] ?? null,
|
||||||
|
'company' => $validated['company'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->profile()->updateOrCreate(
|
||||||
|
['user_id' => $user->id],
|
||||||
|
[
|
||||||
|
'billing_address_line1' => $validated['address_line1'] ?? null,
|
||||||
|
'billing_address_line2' => $validated['address_line2'] ?? null,
|
||||||
|
'billing_city' => $validated['city'] ?? null,
|
||||||
|
'billing_state' => $validated['state'] ?? null,
|
||||||
|
'billing_zip' => $validated['zip'] ?? null,
|
||||||
|
'billing_country' => $validated['country'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return back()->with('success', 'Profile updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(UpdatePasswordRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => $request->validated('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Password updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateBilling(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'billing_address_line1' => ['nullable', 'string', 'max:255'],
|
||||||
'phone' => ['nullable', 'string', 'max:20'],
|
'billing_address_line2' => ['nullable', 'string', 'max:255'],
|
||||||
'company' => ['nullable', 'string', 'max:255'],
|
'billing_city' => ['nullable', 'string', 'max:100'],
|
||||||
|
'billing_state' => ['nullable', 'string', 'max:100'],
|
||||||
|
'billing_zip' => ['nullable', 'string', 'max:20'],
|
||||||
|
'billing_country' => ['nullable', 'string', 'max:100'],
|
||||||
|
'tax_id' => ['nullable', 'string', 'max:50'],
|
||||||
|
'company_vat' => ['nullable', 'string', 'max:50'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$request->user()->update($validated);
|
$request->user()->profile()->updateOrCreate(
|
||||||
|
['user_id' => $request->user()->id],
|
||||||
|
$validated
|
||||||
|
);
|
||||||
|
|
||||||
return back();
|
return back()->with('success', 'Billing information updated successfully.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,134 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\Plan;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
|
$totalCustomers = User::role('customer')->count();
|
||||||
|
|
||||||
|
// MRR: sum of plan prices for active subscriptions
|
||||||
|
$mrr = Subscription::query()
|
||||||
|
->where('stripe_status', 'active')
|
||||||
|
->whereNotNull('plan_id')
|
||||||
|
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||||
|
->sum('plans.price');
|
||||||
|
|
||||||
|
// Total revenue: sum of paid invoice totals
|
||||||
|
$totalRevenue = Invoice::query()
|
||||||
|
->where('status', 'paid')
|
||||||
|
->sum('total');
|
||||||
|
|
||||||
|
$activeServices = Service::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Pending invoices
|
||||||
|
$pendingInvoicesCount = Invoice::query()
|
||||||
|
->where('status', 'pending')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$pendingInvoicesAmount = Invoice::query()
|
||||||
|
->where('status', 'pending')
|
||||||
|
->sum('total');
|
||||||
|
|
||||||
|
// Overdue invoices
|
||||||
|
$overdueCount = Invoice::query()
|
||||||
|
->where('status', 'overdue')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$overdueAmount = Invoice::query()
|
||||||
|
->where('status', 'overdue')
|
||||||
|
->sum('total');
|
||||||
|
|
||||||
|
// Recent invoices with user info
|
||||||
|
$recentInvoices = Invoice::query()
|
||||||
|
->with('user:id,name,email')
|
||||||
|
->latest()
|
||||||
|
->limit(10)
|
||||||
|
->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']);
|
||||||
|
|
||||||
|
// Recent subscriptions with user and plan
|
||||||
|
$recentSubscriptions = Subscription::query()
|
||||||
|
->select([
|
||||||
|
'subscriptions.id',
|
||||||
|
'subscriptions.user_id',
|
||||||
|
'subscriptions.plan_id',
|
||||||
|
'subscriptions.type',
|
||||||
|
'subscriptions.stripe_status',
|
||||||
|
'subscriptions.gateway',
|
||||||
|
'subscriptions.created_at',
|
||||||
|
])
|
||||||
|
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||||
|
->addSelect([
|
||||||
|
'plans.name as plan_name',
|
||||||
|
'plans.price as plan_price',
|
||||||
|
'plans.billing_cycle as plan_billing_cycle',
|
||||||
|
])
|
||||||
|
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
|
||||||
|
->addSelect([
|
||||||
|
'users.name as user_name',
|
||||||
|
'users.email as user_email',
|
||||||
|
])
|
||||||
|
->orderByDesc('subscriptions.created_at')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Popular plans: ordered by active subscription count
|
||||||
|
$popularPlans = Plan::query()
|
||||||
|
->withCount(['services as active_services_count' => function ($query): void {
|
||||||
|
$query->where('status', 'active');
|
||||||
|
}])
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderByDesc('active_services_count')
|
||||||
|
->limit(8)
|
||||||
|
->get(['id', 'name', 'service_type', 'price', 'billing_cycle']);
|
||||||
|
|
||||||
|
// Revenue by service type from plans linked through invoices
|
||||||
|
$revenueByServiceType = Invoice::query()
|
||||||
|
->where('invoices.status', 'paid')
|
||||||
|
->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id')
|
||||||
|
->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||||
|
->select('plans.service_type', DB::raw('SUM(invoices.total) as revenue'), DB::raw('COUNT(invoices.id) as invoice_count'))
|
||||||
|
->groupBy('plans.service_type')
|
||||||
|
->orderByDesc('revenue')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// New customers this month
|
||||||
|
$newCustomersThisMonth = User::role('customer')
|
||||||
|
->where('created_at', '>=', now()->startOfMonth())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Revenue this month
|
||||||
|
$revenueThisMonth = Invoice::query()
|
||||||
|
->where('status', 'paid')
|
||||||
|
->where('paid_at', '>=', now()->startOfMonth())
|
||||||
|
->sum('total');
|
||||||
|
|
||||||
return Inertia::render('Admin/Dashboard', [
|
return Inertia::render('Admin/Dashboard', [
|
||||||
'totalCustomers' => User::role('customer')->count(),
|
'totalCustomers' => $totalCustomers,
|
||||||
'totalServices' => Service::count(),
|
'mrr' => (float) $mrr,
|
||||||
'activeServices' => Service::where('status', 'active')->count(),
|
'totalRevenue' => (float) $totalRevenue,
|
||||||
|
'activeServices' => $activeServices,
|
||||||
|
'pendingInvoicesCount' => $pendingInvoicesCount,
|
||||||
|
'pendingInvoicesAmount' => (float) $pendingInvoicesAmount,
|
||||||
|
'overdueCount' => $overdueCount,
|
||||||
|
'overdueAmount' => (float) $overdueAmount,
|
||||||
|
'recentInvoices' => $recentInvoices,
|
||||||
|
'recentSubscriptions' => $recentSubscriptions,
|
||||||
|
'popularPlans' => $popularPlans,
|
||||||
|
'revenueByServiceType' => $revenueByServiceType,
|
||||||
|
'newCustomersThisMonth' => $newCustomersThisMonth,
|
||||||
|
'revenueThisMonth' => (float) $revenueThisMonth,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
website/app/Http/Requests/UpdatePasswordRequest.php
Normal file
35
website/app/Http/Requests/UpdatePasswordRequest.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class UpdatePasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<int, mixed>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_password' => ['required', 'string', 'current_password'],
|
||||||
|
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
|
||||||
|
'password_confirmation' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_password.current_password' => 'The current password is incorrect.',
|
||||||
|
'password.confirmed' => 'The password confirmation does not match.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
website/app/Http/Requests/UpdateProfileRequest.php
Normal file
41
website/app/Http/Requests/UpdateProfileRequest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateProfileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<int, string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:20'],
|
||||||
|
'company' => ['nullable', 'string', 'max:255'],
|
||||||
|
'address_line1' => ['nullable', 'string', 'max:255'],
|
||||||
|
'address_line2' => ['nullable', 'string', 'max:255'],
|
||||||
|
'city' => ['nullable', 'string', 'max:100'],
|
||||||
|
'state' => ['nullable', 'string', 'max:100'],
|
||||||
|
'zip' => ['nullable', 'string', 'max:20'],
|
||||||
|
'country' => ['nullable', 'string', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'first_name.required' => 'First name is required.',
|
||||||
|
'last_name.required' => 'Last name is required.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,128 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
import StatCard from '@/Components/StatCard.vue'
|
import StatCard from '@/Components/StatCard.vue'
|
||||||
|
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
||||||
|
|
||||||
|
interface RecentInvoice {
|
||||||
|
id: number
|
||||||
|
number: string
|
||||||
|
total: string
|
||||||
|
status: string
|
||||||
|
gateway: string
|
||||||
|
created_at: string
|
||||||
|
user: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentSubscription {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
plan_id: number | null
|
||||||
|
type: string
|
||||||
|
stripe_status: string
|
||||||
|
gateway: string
|
||||||
|
created_at: string
|
||||||
|
plan_name: string | null
|
||||||
|
plan_price: string | null
|
||||||
|
plan_billing_cycle: string | null
|
||||||
|
user_name: string | null
|
||||||
|
user_email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopularPlan {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
service_type: string
|
||||||
|
price: string
|
||||||
|
billing_cycle: string
|
||||||
|
active_services_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevenueByServiceType {
|
||||||
|
service_type: string
|
||||||
|
revenue: string
|
||||||
|
invoice_count: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalCustomers: number
|
totalCustomers: number
|
||||||
totalServices: number
|
mrr: number
|
||||||
|
totalRevenue: number
|
||||||
activeServices: number
|
activeServices: number
|
||||||
|
pendingInvoicesCount: number
|
||||||
|
pendingInvoicesAmount: number
|
||||||
|
overdueCount: number
|
||||||
|
overdueAmount: number
|
||||||
|
recentInvoices: RecentInvoice[]
|
||||||
|
recentSubscriptions: RecentSubscription[]
|
||||||
|
popularPlans: PopularPlan[]
|
||||||
|
revenueByServiceType: RevenueByServiceType[]
|
||||||
|
newCustomersThisMonth: number
|
||||||
|
revenueThisMonth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ layout: AdminLayout })
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
function formatCurrency(value: number | string): string {
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||||
|
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServiceTypeColor(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
vps: 'primary',
|
||||||
|
dedicated: 'info',
|
||||||
|
web_hosting: 'success',
|
||||||
|
game: 'warning',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServiceTypeIcon(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
vps: 'tabler-server',
|
||||||
|
dedicated: 'tabler-server-2',
|
||||||
|
web_hosting: 'tabler-world',
|
||||||
|
game: 'tabler-device-gamepad-2',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'tabler-box'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatServiceType(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
vps: 'VPS',
|
||||||
|
dedicated: 'Dedicated',
|
||||||
|
web_hosting: 'Web Hosting',
|
||||||
|
game: 'Game Hosting',
|
||||||
|
}
|
||||||
|
return map[type] ?? type
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-h4 font-weight-bold mb-6">Admin Dashboard</div>
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-bold">Admin Dashboard</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Overview of your business metrics and recent activity
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<VRow>
|
<!-- Row 1: Key Metrics -->
|
||||||
<VCol cols="12" md="4">
|
<VRow class="mb-2">
|
||||||
|
<VCol cols="12" sm="6" lg="3">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Customers"
|
title="Total Customers"
|
||||||
:stats="totalCustomers"
|
:stats="totalCustomers"
|
||||||
@@ -27,23 +131,319 @@ defineProps<Props>()
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" sm="6" lg="3">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Services"
|
title="Monthly Recurring Revenue"
|
||||||
:stats="totalServices"
|
:stats="formatCurrency(mrr)"
|
||||||
|
icon="tabler-currency-dollar"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" sm="6" lg="3">
|
||||||
|
<StatCard
|
||||||
|
title="Active Services"
|
||||||
|
:stats="activeServices"
|
||||||
icon="tabler-server"
|
icon="tabler-server"
|
||||||
color="info"
|
color="info"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="4">
|
<VCol cols="12" sm="6" lg="3">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Active Services"
|
title="Pending Invoices"
|
||||||
:stats="activeServices"
|
:stats="`${pendingInvoicesCount} (${formatCurrency(pendingInvoicesAmount)})`"
|
||||||
icon="tabler-circle-check"
|
icon="tabler-alert-triangle"
|
||||||
color="success"
|
color="warning"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Row 2: Recent Subscriptions & Recent Invoices -->
|
||||||
|
<VRow class="mb-2">
|
||||||
|
<!-- Recent Subscriptions -->
|
||||||
|
<VCol cols="12" lg="6">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-receipt" size="22" />
|
||||||
|
<span>Recent Subscriptions</span>
|
||||||
|
</div>
|
||||||
|
<VChip size="small" color="primary" variant="tonal">
|
||||||
|
Latest 10
|
||||||
|
</VChip>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="recentSubscriptions.length === 0" class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">No subscriptions yet.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Price</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="sub in recentSubscriptions" :key="sub.id">
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ sub.user_name ?? 'Unknown' }}</span>
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ sub.user_email ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-body-2">{{ sub.plan_name ?? sub.type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveSubscriptionStatusColor(sub.stripe_status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ sub.stripe_status }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<template v-if="sub.plan_price">
|
||||||
|
{{ formatCurrency(sub.plan_price) }}/{{ sub.plan_billing_cycle ?? 'mo' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-medium-emphasis">—</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatDate(sub.created_at) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Recent Invoices -->
|
||||||
|
<VCol cols="12" lg="6">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-file-invoice" size="22" />
|
||||||
|
<span>Recent Invoices</span>
|
||||||
|
</div>
|
||||||
|
<VChip size="small" color="info" variant="tonal">
|
||||||
|
Latest 10
|
||||||
|
</VChip>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="recentInvoices.length === 0" class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">No invoices yet.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Invoice #</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="invoice in recentInvoices" :key="invoice.id">
|
||||||
|
<td class="text-body-2 font-weight-medium">
|
||||||
|
{{ invoice.number }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ invoice.user?.name ?? 'Unknown' }}</span>
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ invoice.user?.email ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveInvoiceStatusColor(invoice.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ invoice.status }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-body-2 font-weight-medium">
|
||||||
|
{{ formatCurrency(invoice.total) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatDate(invoice.created_at) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Row 3: Popular Plans & Quick Stats -->
|
||||||
|
<VRow>
|
||||||
|
<!-- Popular Plans -->
|
||||||
|
<VCol cols="12" lg="6">
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-star" size="22" />
|
||||||
|
<span>Popular Plans</span>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="popularPlans.length === 0" class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-package" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">No plans configured yet.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VList v-else lines="two" density="comfortable">
|
||||||
|
<VListItem
|
||||||
|
v-for="plan in popularPlans"
|
||||||
|
:key="plan.id"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
:color="resolveServiceTypeColor(plan.service_type)"
|
||||||
|
variant="tonal"
|
||||||
|
rounded
|
||||||
|
size="40"
|
||||||
|
>
|
||||||
|
<VIcon :icon="resolveServiceTypeIcon(plan.service_type)" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle class="font-weight-medium">
|
||||||
|
{{ plan.name }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
{{ formatServiceType(plan.service_type) }} · {{ formatCurrency(plan.price) }}/{{ plan.billing_cycle }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="text-body-2 font-weight-semibold">
|
||||||
|
{{ plan.active_services_count }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ plan.active_services_count === 1 ? 'subscriber' : 'subscribers' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Quick Stats & Revenue by Type -->
|
||||||
|
<VCol cols="12" lg="6">
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<VCard class="mb-4">
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-chart-bar" size="22" />
|
||||||
|
<span>Quick Stats</span>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="d-flex align-center gap-3 mb-4">
|
||||||
|
<VAvatar color="success" variant="tonal" rounded size="40">
|
||||||
|
<VIcon icon="tabler-trending-up" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">Total Revenue</div>
|
||||||
|
<div class="text-body-1 font-weight-semibold">{{ formatCurrency(totalRevenue) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="d-flex align-center gap-3 mb-4">
|
||||||
|
<VAvatar color="primary" variant="tonal" rounded size="40">
|
||||||
|
<VIcon icon="tabler-user-plus" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">New This Month</div>
|
||||||
|
<div class="text-body-1 font-weight-semibold">{{ newCustomersThisMonth }} customers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<VAvatar color="info" variant="tonal" rounded size="40">
|
||||||
|
<VIcon icon="tabler-report-money" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">Revenue This Month</div>
|
||||||
|
<div class="text-body-1 font-weight-semibold">{{ formatCurrency(revenueThisMonth) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="d-flex align-center gap-3">
|
||||||
|
<VAvatar :color="overdueCount > 0 ? 'error' : 'success'" variant="tonal" rounded size="40">
|
||||||
|
<VIcon :icon="overdueCount > 0 ? 'tabler-alert-circle' : 'tabler-circle-check'" size="22" />
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-medium-emphasis">Overdue Accounts</div>
|
||||||
|
<div class="text-body-1 font-weight-semibold" :class="overdueCount > 0 ? 'text-error' : ''">
|
||||||
|
{{ overdueCount }} ({{ formatCurrency(overdueAmount) }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Revenue by Service Type -->
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="d-flex align-center gap-2">
|
||||||
|
<VIcon icon="tabler-report-analytics" size="22" />
|
||||||
|
<span>Revenue by Service Type</span>
|
||||||
|
</VCardTitle>
|
||||||
|
|
||||||
|
<VCardText v-if="revenueByServiceType.length === 0" class="text-center py-6">
|
||||||
|
<div class="text-medium-emphasis">No revenue data available yet.</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VList v-else density="comfortable">
|
||||||
|
<VListItem
|
||||||
|
v-for="item in revenueByServiceType"
|
||||||
|
:key="item.service_type"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VAvatar
|
||||||
|
:color="resolveServiceTypeColor(item.service_type)"
|
||||||
|
variant="tonal"
|
||||||
|
rounded
|
||||||
|
size="36"
|
||||||
|
>
|
||||||
|
<VIcon :icon="resolveServiceTypeIcon(item.service_type)" size="20" />
|
||||||
|
</VAvatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle class="font-weight-medium">
|
||||||
|
{{ formatServiceType(item.service_type) }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
{{ item.invoice_count }} {{ item.invoice_count === 1 ? 'invoice' : 'invoices' }} paid
|
||||||
|
</VListItemSubtitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="text-body-2 font-weight-semibold">
|
||||||
|
{{ formatCurrency(item.revenue) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
275
website/resources/ts/Pages/Profile/AccountTab.vue
Normal file
275
website/resources/ts/Pages/Profile/AccountTab.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
import type { User, UserProfile } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User
|
||||||
|
profile: UserProfile | null
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
first_name: props.firstName,
|
||||||
|
last_name: props.lastName,
|
||||||
|
phone: props.user.phone ?? '',
|
||||||
|
company: props.user.company ?? '',
|
||||||
|
address_line1: props.profile?.billing_address_line1 ?? '',
|
||||||
|
address_line2: props.profile?.billing_address_line2 ?? '',
|
||||||
|
city: props.profile?.billing_city ?? '',
|
||||||
|
state: props.profile?.billing_state ?? '',
|
||||||
|
zip: props.profile?.billing_zip ?? '',
|
||||||
|
country: props.profile?.billing_country ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarInitials = computed<string>(() => {
|
||||||
|
const first = props.firstName.charAt(0).toUpperCase()
|
||||||
|
const last = props.lastName.charAt(0).toUpperCase()
|
||||||
|
return `${first}${last}` || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
const countries: string[] = [
|
||||||
|
'United States',
|
||||||
|
'Canada',
|
||||||
|
'United Kingdom',
|
||||||
|
'Australia',
|
||||||
|
'Germany',
|
||||||
|
'France',
|
||||||
|
'Netherlands',
|
||||||
|
'Japan',
|
||||||
|
'Singapore',
|
||||||
|
'India',
|
||||||
|
'Brazil',
|
||||||
|
]
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
form.put('/profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = (): void => {
|
||||||
|
form.reset()
|
||||||
|
form.clearErrors()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VCardText class="d-flex">
|
||||||
|
<VAvatar
|
||||||
|
rounded
|
||||||
|
size="100"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
class="me-6"
|
||||||
|
>
|
||||||
|
<span class="text-h4">{{ avatarInitials }}</span>
|
||||||
|
</VAvatar>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column justify-center gap-2">
|
||||||
|
<div class="d-flex flex-wrap gap-4">
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-cloud-upload"
|
||||||
|
class="d-sm-none"
|
||||||
|
/>
|
||||||
|
<span class="d-none d-sm-block">Upload new photo</span>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<span class="d-none d-sm-block">Reset</span>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-refresh"
|
||||||
|
class="d-sm-none"
|
||||||
|
/>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||||
|
Allowed JPG, GIF or PNG. Max size of 800K
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText class="pt-2">
|
||||||
|
<VForm
|
||||||
|
class="mt-3"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
md="6"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.first_name"
|
||||||
|
label="First Name"
|
||||||
|
placeholder="John"
|
||||||
|
:error-messages="form.errors.first_name ? [form.errors.first_name] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
md="6"
|
||||||
|
cols="12"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.last_name"
|
||||||
|
label="Last Name"
|
||||||
|
placeholder="Doe"
|
||||||
|
:error-messages="form.errors.last_name ? [form.errors.last_name] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
:model-value="user.email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
disabled
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.company"
|
||||||
|
label="Organization"
|
||||||
|
placeholder="EZSCALE"
|
||||||
|
:error-messages="form.errors.company ? [form.errors.company] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.phone"
|
||||||
|
label="Phone Number"
|
||||||
|
placeholder="+1 (917) 543-9876"
|
||||||
|
:error-messages="form.errors.phone ? [form.errors.phone] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.address_line1"
|
||||||
|
label="Address"
|
||||||
|
placeholder="123 Main St"
|
||||||
|
:error-messages="form.errors.address_line1 ? [form.errors.address_line1] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.address_line2"
|
||||||
|
label="Address Line 2"
|
||||||
|
placeholder="Apt 4B"
|
||||||
|
:error-messages="form.errors.address_line2 ? [form.errors.address_line2] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.city"
|
||||||
|
label="City"
|
||||||
|
placeholder="New York"
|
||||||
|
:error-messages="form.errors.city ? [form.errors.city] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.state"
|
||||||
|
label="State"
|
||||||
|
placeholder="New York"
|
||||||
|
:error-messages="form.errors.state ? [form.errors.state] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.zip"
|
||||||
|
label="Zip Code"
|
||||||
|
placeholder="10001"
|
||||||
|
:error-messages="form.errors.zip ? [form.errors.zip] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.country"
|
||||||
|
label="Country"
|
||||||
|
:items="countries"
|
||||||
|
placeholder="Select Country"
|
||||||
|
:error-messages="form.errors.country ? [form.errors.country] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
class="d-flex flex-wrap gap-4"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
@click.prevent="resetForm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
202
website/resources/ts/Pages/Profile/BillingTab.vue
Normal file
202
website/resources/ts/Pages/Profile/BillingTab.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
import type { UserProfile } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: UserProfile | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const billingForm = useForm({
|
||||||
|
billing_address_line1: props.profile?.billing_address_line1 ?? '',
|
||||||
|
billing_address_line2: props.profile?.billing_address_line2 ?? '',
|
||||||
|
billing_city: props.profile?.billing_city ?? '',
|
||||||
|
billing_state: props.profile?.billing_state ?? '',
|
||||||
|
billing_zip: props.profile?.billing_zip ?? '',
|
||||||
|
billing_country: props.profile?.billing_country ?? '',
|
||||||
|
tax_id: props.profile?.tax_id ?? '',
|
||||||
|
company_vat: props.profile?.company_vat ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const countries: string[] = [
|
||||||
|
'United States',
|
||||||
|
'Canada',
|
||||||
|
'United Kingdom',
|
||||||
|
'Australia',
|
||||||
|
'Germany',
|
||||||
|
'France',
|
||||||
|
'Netherlands',
|
||||||
|
'Japan',
|
||||||
|
'Singapore',
|
||||||
|
'India',
|
||||||
|
'Brazil',
|
||||||
|
]
|
||||||
|
|
||||||
|
const submitBilling = (): void => {
|
||||||
|
billingForm.put('/profile/billing')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBillingForm = (): void => {
|
||||||
|
billingForm.reset()
|
||||||
|
billingForm.clearErrors()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow>
|
||||||
|
<!-- Payment Methods Link -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VCardText class="d-flex align-center justify-space-between flex-wrap gap-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-h5 mb-1">
|
||||||
|
Payment Methods
|
||||||
|
</h5>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||||
|
Manage your payment methods, view invoices, and transaction history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/billing"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-credit-card"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
Manage Payment Methods
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Billing Address -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard title="Billing Address">
|
||||||
|
<VCardText>
|
||||||
|
<VForm @submit.prevent="submitBilling">
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.billing_address_line1"
|
||||||
|
label="Billing Address"
|
||||||
|
placeholder="123 Main St"
|
||||||
|
:error-messages="billingForm.errors.billing_address_line1 ? [billingForm.errors.billing_address_line1] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.billing_address_line2"
|
||||||
|
label="Address Line 2"
|
||||||
|
placeholder="Suite 100"
|
||||||
|
:error-messages="billingForm.errors.billing_address_line2 ? [billingForm.errors.billing_address_line2] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.billing_city"
|
||||||
|
label="City"
|
||||||
|
placeholder="New York"
|
||||||
|
:error-messages="billingForm.errors.billing_city ? [billingForm.errors.billing_city] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.billing_state"
|
||||||
|
label="State"
|
||||||
|
placeholder="New York"
|
||||||
|
:error-messages="billingForm.errors.billing_state ? [billingForm.errors.billing_state] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.billing_zip"
|
||||||
|
label="Zip Code"
|
||||||
|
placeholder="10001"
|
||||||
|
:error-messages="billingForm.errors.billing_zip ? [billingForm.errors.billing_zip] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
v-model="billingForm.billing_country"
|
||||||
|
label="Country"
|
||||||
|
:items="countries"
|
||||||
|
placeholder="Select Country"
|
||||||
|
:error-messages="billingForm.errors.billing_country ? [billingForm.errors.billing_country] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.tax_id"
|
||||||
|
label="Tax ID"
|
||||||
|
placeholder="123-45-6789"
|
||||||
|
:error-messages="billingForm.errors.tax_id ? [billingForm.errors.tax_id] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.company_vat"
|
||||||
|
label="VAT Number"
|
||||||
|
placeholder="GB123456789"
|
||||||
|
:error-messages="billingForm.errors.company_vat ? [billingForm.errors.company_vat] : []"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
class="d-flex flex-wrap gap-4"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
:loading="billingForm.processing"
|
||||||
|
:disabled="billingForm.processing"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
@click="resetBillingForm"
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
351
website/resources/ts/Pages/Profile/SecurityTab.vue
Normal file
351
website/resources/ts/Pages/Profile/SecurityTab.vue
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useForm, usePage, router } from '@inertiajs/vue3'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import type { SharedPageProps } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
twoFactorEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const page = usePage<SharedPageProps>()
|
||||||
|
|
||||||
|
const isCurrentPasswordVisible = ref<boolean>(false)
|
||||||
|
const isNewPasswordVisible = ref<boolean>(false)
|
||||||
|
const isConfirmPasswordVisible = ref<boolean>(false)
|
||||||
|
|
||||||
|
const passwordForm = useForm({
|
||||||
|
current_password: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitPassword = (): void => {
|
||||||
|
passwordForm.put('/profile/password', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
passwordForm.reset()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPasswordForm = (): void => {
|
||||||
|
passwordForm.reset()
|
||||||
|
passwordForm.clearErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRequirements: string[] = [
|
||||||
|
'Minimum 8 characters long - the more, the better',
|
||||||
|
'At least one lowercase character',
|
||||||
|
'At least one uppercase character',
|
||||||
|
'At least one number, symbol, or whitespace character',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Two-factor authentication
|
||||||
|
const enabling = ref<boolean>(false)
|
||||||
|
const confirming = ref<boolean>(false)
|
||||||
|
const disabling = ref<boolean>(false)
|
||||||
|
const qrCode = ref<string>('')
|
||||||
|
const recoveryCodes = ref<string[]>([])
|
||||||
|
|
||||||
|
const confirmationForm = useForm({
|
||||||
|
code: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const enableTwoFactor = (): void => {
|
||||||
|
enabling.value = true
|
||||||
|
router.post('/user/two-factor-authentication', {}, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
confirming.value = true
|
||||||
|
showQrCode()
|
||||||
|
showRecoveryCodes()
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
enabling.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showQrCode = (): void => {
|
||||||
|
fetch('/user/two-factor-qr-code')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: { svg: string }) => { qrCode.value = data.svg })
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRecoveryCodes = (): void => {
|
||||||
|
fetch('/user/two-factor-recovery-codes')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((data: string[]) => { recoveryCodes.value = data })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmTwoFactor = (): void => {
|
||||||
|
confirmationForm.post('/user/confirmed-two-factor-authentication', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
confirming.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableTwoFactor = (): void => {
|
||||||
|
disabling.value = true
|
||||||
|
router.delete('/user/two-factor-authentication', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
qrCode.value = ''
|
||||||
|
recoveryCodes.value = []
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
disabling.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow>
|
||||||
|
<!-- Change Password -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard title="Change Password">
|
||||||
|
<VForm @submit.prevent="submitPassword">
|
||||||
|
<VCardText class="pt-0">
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="passwordForm.current_password"
|
||||||
|
:type="isCurrentPasswordVisible ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="isCurrentPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
|
||||||
|
label="Current Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="............"
|
||||||
|
:error-messages="passwordForm.errors.current_password ? [passwordForm.errors.current_password] : []"
|
||||||
|
@click:append-inner="isCurrentPasswordVisible = !isCurrentPasswordVisible"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VRow>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="passwordForm.password"
|
||||||
|
:type="isNewPasswordVisible ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="isNewPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
|
||||||
|
label="New Password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="............"
|
||||||
|
:error-messages="passwordForm.errors.password ? [passwordForm.errors.password] : []"
|
||||||
|
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="passwordForm.password_confirmation"
|
||||||
|
:type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||||
|
:append-inner-icon="isConfirmPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
|
||||||
|
label="Confirm New Password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="............"
|
||||||
|
:error-messages="passwordForm.errors.password_confirmation ? [passwordForm.errors.password_confirmation] : []"
|
||||||
|
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText>
|
||||||
|
<h6 class="text-h6 text-medium-emphasis mb-4">
|
||||||
|
Password Requirements:
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<VList class="card-list">
|
||||||
|
<VListItem
|
||||||
|
v-for="item in passwordRequirements"
|
||||||
|
:key="item"
|
||||||
|
:title="item"
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon
|
||||||
|
size="10"
|
||||||
|
icon="tabler-circle-filled"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardText class="d-flex flex-wrap gap-4">
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
:loading="passwordForm.processing"
|
||||||
|
:disabled="passwordForm.processing"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
@click="resetPasswordForm"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</VBtn>
|
||||||
|
</VCardText>
|
||||||
|
</VForm>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Two-Factor Authentication -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard title="Two-Factor Authentication">
|
||||||
|
<VCardText>
|
||||||
|
<div v-if="!twoFactorEnabled && !confirming">
|
||||||
|
<h5 class="text-h5 text-medium-emphasis mb-4">
|
||||||
|
Two-factor authentication is not enabled yet.
|
||||||
|
</h5>
|
||||||
|
<p class="mb-6">
|
||||||
|
Two-factor authentication adds an additional layer of security to your account by
|
||||||
|
requiring more than just a password to log in. You will need to enter a code from your
|
||||||
|
authenticator app each time you sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
:loading="enabling"
|
||||||
|
:disabled="enabling"
|
||||||
|
@click="enableTwoFactor"
|
||||||
|
>
|
||||||
|
Enable two-factor authentication
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code confirmation step -->
|
||||||
|
<div v-if="confirming">
|
||||||
|
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Scan this QR code with your authenticator app, then enter the code below to confirm.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-if="qrCode"
|
||||||
|
v-html="qrCode"
|
||||||
|
class="mb-4 d-inline-block pa-4 rounded"
|
||||||
|
style="background: white;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VForm
|
||||||
|
@submit.prevent="confirmTwoFactor"
|
||||||
|
style="max-width: 300px;"
|
||||||
|
>
|
||||||
|
<AppTextField
|
||||||
|
v-model="confirmationForm.code"
|
||||||
|
label="Confirmation Code"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="000000"
|
||||||
|
:error-messages="confirmationForm.errors.code ? [confirmationForm.errors.code] : []"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
:loading="confirmationForm.processing"
|
||||||
|
:disabled="confirmationForm.processing"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</VBtn>
|
||||||
|
</VForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recovery codes -->
|
||||||
|
<div v-if="recoveryCodes.length > 0 && !confirming">
|
||||||
|
<div class="text-subtitle-2 font-weight-bold mb-2">
|
||||||
|
Recovery Codes
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||||
|
Store these codes in a safe place. They can be used to access your account if you lose your authenticator device.
|
||||||
|
</div>
|
||||||
|
<VSheet
|
||||||
|
rounded
|
||||||
|
color="surface-variant"
|
||||||
|
class="pa-4 font-weight-medium"
|
||||||
|
style="font-family: monospace;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="code in recoveryCodes"
|
||||||
|
:key="code"
|
||||||
|
>
|
||||||
|
{{ code }}
|
||||||
|
</div>
|
||||||
|
</VSheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA enabled state -->
|
||||||
|
<div v-if="twoFactorEnabled && !confirming">
|
||||||
|
<VAlert
|
||||||
|
type="success"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Two-factor authentication is enabled.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
:loading="disabling"
|
||||||
|
:disabled="disabling"
|
||||||
|
@click="disableTwoFactor"
|
||||||
|
>
|
||||||
|
Disable Two-Factor Authentication
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Recent Devices (placeholder) -->
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard title="Recent Devices">
|
||||||
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
|
<VTable>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>BROWSER</th>
|
||||||
|
<th>DEVICE</th>
|
||||||
|
<th>LOCATION</th>
|
||||||
|
<th>RECENT ACTIVITY</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-medium-emphasis pa-6">
|
||||||
|
Session tracking will be available soon.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.card-list {
|
||||||
|
--v-card-list-gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,83 +1,87 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { ref, computed } from 'vue'
|
||||||
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
import AccountTab from '@/Pages/Profile/AccountTab.vue'
|
||||||
import type { User } from '@/types'
|
import SecurityTab from '@/Pages/Profile/SecurityTab.vue'
|
||||||
|
import BillingTab from '@/Pages/Profile/BillingTab.vue'
|
||||||
|
import type { User, UserProfile } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User & { phone?: string; company?: string }
|
user: User
|
||||||
|
profile: UserProfile | null
|
||||||
|
twoFactorEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({ layout: AccountLayout })
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const form = useForm({
|
const activeTab = ref<string>('account')
|
||||||
name: props.user.name,
|
|
||||||
phone: props.user.phone || '',
|
interface TabItem {
|
||||||
company: props.user.company || '',
|
title: string
|
||||||
|
icon: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: TabItem[] = [
|
||||||
|
{ title: 'Account', icon: 'tabler-user', value: 'account' },
|
||||||
|
{ title: 'Security', icon: 'tabler-lock', value: 'security' },
|
||||||
|
{ title: 'Billing', icon: 'tabler-file-text', value: 'billing' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const firstName = computed<string>(() => {
|
||||||
|
const parts = props.user.name?.split(' ') ?? []
|
||||||
|
return parts[0] ?? ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const submit = (): void => {
|
const lastName = computed<string>(() => {
|
||||||
form.put('/profile')
|
const parts = props.user.name?.split(' ') ?? []
|
||||||
}
|
return parts.slice(1).join(' ') ?? ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div style="max-width: 600px;">
|
<div>
|
||||||
<div class="text-h4 font-weight-bold mb-6">Profile Settings</div>
|
<VTabs
|
||||||
|
v-model="activeTab"
|
||||||
<VCard>
|
class="v-tabs-pill"
|
||||||
<VCardText>
|
|
||||||
<VForm @submit.prevent="submit">
|
|
||||||
<VRow>
|
|
||||||
<VCol cols="12">
|
|
||||||
<AppTextField
|
|
||||||
v-model="form.name"
|
|
||||||
label="Name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
:error-messages="form.errors.name ? [form.errors.name] : []"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12">
|
|
||||||
<AppTextField
|
|
||||||
:model-value="user.email"
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12">
|
|
||||||
<AppTextField
|
|
||||||
v-model="form.phone"
|
|
||||||
label="Phone"
|
|
||||||
type="tel"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12">
|
|
||||||
<AppTextField
|
|
||||||
v-model="form.company"
|
|
||||||
label="Company"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12">
|
|
||||||
<VBtn
|
|
||||||
type="submit"
|
|
||||||
:loading="form.processing"
|
|
||||||
:disabled="form.processing"
|
|
||||||
>
|
>
|
||||||
Save Changes
|
<VTab
|
||||||
</VBtn>
|
v-for="tab in tabs"
|
||||||
</VCol>
|
:key="tab.value"
|
||||||
</VRow>
|
:value="tab.value"
|
||||||
</VForm>
|
>
|
||||||
</VCardText>
|
<VIcon
|
||||||
</VCard>
|
size="20"
|
||||||
|
start
|
||||||
|
:icon="tab.icon"
|
||||||
|
/>
|
||||||
|
{{ tab.title }}
|
||||||
|
</VTab>
|
||||||
|
</VTabs>
|
||||||
|
|
||||||
|
<VWindow
|
||||||
|
v-model="activeTab"
|
||||||
|
class="mt-6 disable-tab-transition"
|
||||||
|
:touch="false"
|
||||||
|
>
|
||||||
|
<VWindowItem value="account">
|
||||||
|
<AccountTab
|
||||||
|
:user="user"
|
||||||
|
:profile="profile"
|
||||||
|
:first-name="firstName"
|
||||||
|
:last-name="lastName"
|
||||||
|
/>
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<VWindowItem value="security">
|
||||||
|
<SecurityTab :two-factor-enabled="twoFactorEnabled" />
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<VWindowItem value="billing">
|
||||||
|
<BillingTab :profile="profile" />
|
||||||
|
</VWindowItem>
|
||||||
|
</VWindow>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export const accountNavItems: NavItem[] = [
|
|||||||
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
||||||
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
||||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
||||||
{ title: 'Profile', href: '/profile', icon: 'tabler-user', matchPrefix: '/profile' },
|
{ title: 'Settings', href: '/profile', icon: 'tabler-settings', matchPrefix: '/profile' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,10 +2,25 @@ export interface User {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
|
phone: string | null
|
||||||
|
company: string | null
|
||||||
status: string
|
status: string
|
||||||
two_factor_enabled?: boolean
|
two_factor_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
billing_address_line1: string | null
|
||||||
|
billing_address_line2: string | null
|
||||||
|
billing_city: string | null
|
||||||
|
billing_state: string | null
|
||||||
|
billing_zip: string | null
|
||||||
|
billing_country: string | null
|
||||||
|
tax_id: string | null
|
||||||
|
tax_exempt: boolean
|
||||||
|
company_name: string | null
|
||||||
|
company_vat: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthProps {
|
export interface AuthProps {
|
||||||
user: User | null
|
user: User | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.d
|
|||||||
|
|
||||||
Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');
|
Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');
|
||||||
Route::put('/profile', [ProfileController::class, 'update'])->name('account.profile.update');
|
Route::put('/profile', [ProfileController::class, 'update'])->name('account.profile.update');
|
||||||
|
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('account.profile.password');
|
||||||
|
Route::put('/profile/billing', [ProfileController::class, 'updateBilling'])->name('account.profile.billing');
|
||||||
|
|
||||||
// Plans
|
// Plans
|
||||||
Route::get('/plans', [PlanController::class, 'index'])->name('account.plans.index');
|
Route::get('/plans', [PlanController::class, 'index'])->name('account.plans.index');
|
||||||
|
|||||||
Reference in New Issue
Block a user