develop-updateui #1
@@ -681,6 +681,6 @@ export class Api<
|
||||
|
||||
export const client = new Api({
|
||||
baseUrl: 'r',
|
||||
// baseUrl: 'https://cheapest-representations-corporations-related.trycloudflare.com',
|
||||
// baseUrl: 'https://api.pipic.fun',
|
||||
customFetch
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export const customFetch = async (url: string, options: RequestInit) => {
|
||||
Object.assign(options, {
|
||||
headers: c.req.header()
|
||||
});
|
||||
const res = await fetch(["https://cheapest-representations-corporations-related.trycloudflare.com", url.replace(/r\//, '')].join('/'), options);
|
||||
const res = await fetch(["https://api.pipic.fun", url.replace(/r\//, '')].join('/'), options);
|
||||
res.headers.forEach((value, key) => {
|
||||
c.header(key, value);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ const links = [
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||
{ href: "/plans", label: "Plans", icon: Credit, type: "a", className },
|
||||
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "a", className },
|
||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' },
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { VNode } from 'vue';
|
||||
import VueHead from '@/components/VueHead';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
@@ -67,6 +68,7 @@ const getButtonClass = (variant?: string) => {
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-1">{{ title }}</h1>
|
||||
<vue-head :input="{ title, meta: [{ name: 'description', content: description || '' }] }" />
|
||||
<p v-if="description" class="text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,18 +27,19 @@ app.use(cors(), async (c, next) => {
|
||||
const path = c.req.path
|
||||
|
||||
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||
return next()
|
||||
return await next()
|
||||
}
|
||||
const url = new URL(c.req.url)
|
||||
url.host = 'cheapest-representations-corporations-related.trycloudflare.com'
|
||||
url.host = 'api.pipic.fun'
|
||||
url.protocol = 'https:'
|
||||
url.pathname = path.replace(/^\/r/, '') || '/'
|
||||
url.port = ''
|
||||
const req = new Request(url.toString(), c.req.raw);
|
||||
const res = await fetch(req).catch(err => console.error('Error during proxy request: ', err.message));
|
||||
return fetch(req);
|
||||
// const res = await fetch(req).catch(err => console.error('Error during proxy request: ', err.message));
|
||||
// return c.body(res, res.status, res.headers);
|
||||
console.log('Proxy request to: ', url.toString(), ' response: ', res?.status, JSON.stringify(c.req.header(), null, 2));
|
||||
return res
|
||||
// console.log('Proxy request to: ', url.toString(), ' response: ', res?.status, JSON.stringify(c.req.header(), null, 2));
|
||||
// return res
|
||||
});
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -7,7 +7,7 @@ export function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
export function debounce<Func extends (...args: any[]) => any>(func: Func, wait: number): Func {
|
||||
let timeout: ReturnType<typeof setTimeout> | null;
|
||||
return function(this: any, ...args: any[]) {
|
||||
return function (this: any, ...args: any[]) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
@@ -47,4 +47,6 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
@@ -149,8 +150,51 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||
<div class="i-svg-spinners-180-ring-with-bg text-4xl text-primary"></div>
|
||||
<div v-if="loading" class="animate-pulse">
|
||||
<!-- Stats Grid Skeleton -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="8rem" height="2rem"></Skeleton>
|
||||
</div>
|
||||
<Skeleton shape="circle" size="3rem"></Skeleton>
|
||||
</div>
|
||||
<Skeleton width="4rem" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Skeleton -->
|
||||
<div class="mb-8">
|
||||
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos Skeleton -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton width="8rem" height="1.5rem"></Skeleton>
|
||||
<Skeleton width="5rem" height="1rem"></Skeleton>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||
<div class="flex gap-4">
|
||||
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||
<Skeleton width="20%" height="0.8rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
|
||||
@@ -97,12 +97,12 @@ const routes: RouteData[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "plans",
|
||||
name: "plans",
|
||||
path: "payments-and-plans",
|
||||
name: "payments-and-plans",
|
||||
component: () => import("./plans/Plans.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Plans & Billing',
|
||||
title: 'Payments & Plans - Holistream',
|
||||
meta: [
|
||||
{ name: 'description', content: 'Manage your plans and billing information.' },
|
||||
],
|
||||
@@ -122,7 +122,7 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "profile",
|
||||
name: "profile",
|
||||
component: () => import("./add/Add.vue"), // TODO: create profile page
|
||||
component: () => import("./profile/Profile.vue"), // TODO: create profile page
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Profile - Holistream',
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold mb-6">Choose Your Plan</h1>
|
||||
<div v-if="loading" class="flex justify-center">
|
||||
<div class="i-svg-spinners-180-ring-with-bg text-4xl"></div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div v-for="plan in plans" :key="plan.id" class="border rounded-lg p-6 shadow-md bg-white dark:bg-gray-800 flex flex-col">
|
||||
<h2 class="text-xl font-semibold mb-2">{{ plan.name }}</h2>
|
||||
<div class="text-3xl font-bold mb-4">${{ plan.price }}<span class="text-sm font-normal text-gray-500">/{{ plan.cycle }}</span></div>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6 flex-grow">{{ plan.description }}</p>
|
||||
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li class="flex items-center">
|
||||
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
|
||||
<span>Storage: {{ formatBytes(plan.storage_limit || 0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
|
||||
<span>Max Duration: {{ formatDuration(plan.duration_limit || 0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
|
||||
<span>Uploads: {{ plan.upload_limit }} / day</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
@click="subscribe(plan)"
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white rounded transition-colors disabled:opacity-50"
|
||||
:disabled="subscribing === plan.id"
|
||||
>
|
||||
<span v-if="subscribing === plan.id" class="i-svg-spinners-180-ring-with-bg mr-2"></span>
|
||||
{{ subscribing === plan.id ? 'Processing...' : 'Subscribe' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import Card from 'primevue/card';
|
||||
import Button from 'primevue/button';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import Dialog from 'primevue/dialog';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const plans = ref<ModelPlan[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const subscribing = ref<string | null>(null);
|
||||
const showManageDialog = ref(false);
|
||||
const cancelling = ref(false);
|
||||
|
||||
// Mock Payment History Data
|
||||
const paymentHistory = ref([
|
||||
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
|
||||
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
|
||||
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
||||
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
||||
]);
|
||||
|
||||
// Computed Usage (Mock if not in store)
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||
// Default limit 10GB if no plan
|
||||
const storageLimit = computed(() => 10737418240);
|
||||
const storagePercentage = computed(() => Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100));
|
||||
|
||||
const uploadsUsed = ref(12);
|
||||
const uploadsLimit = ref(50);
|
||||
const uploadsPercentage = computed(() => Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100));
|
||||
|
||||
const currentPlanId = computed(() => {
|
||||
if (auth.user?.plan_id) return auth.user.plan_id;
|
||||
if (Array.isArray(plans.value) && plans.value.length > 0) return plans.value[0].id; // Fallback to first plan
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const currentPlan = computed(() => {
|
||||
if (!Array.isArray(plans.value)) return undefined;
|
||||
return plans.value.find(p => p.id === currentPlanId.value);
|
||||
});
|
||||
|
||||
const fetchPlans = async () => {
|
||||
loading.value = true;
|
||||
@@ -57,20 +56,11 @@ const fetchPlans = async () => {
|
||||
const response = await client.plans.plansList();
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
plans.value = response.data;
|
||||
} else if (response.data && Array.isArray((response.data as any).data)) {
|
||||
// Handle paginated or wrapped response
|
||||
plans.value = (response.data as any).data;
|
||||
} else {
|
||||
// Fallback or handle unexpected structure?
|
||||
// Based on client.ts it returns response.data as ModelPlan[] directly in the custom wrapper?
|
||||
// Wait, client.ts says: r.data = data. So if the response matches schema, it's inside data.
|
||||
// Let's re-read client.ts.
|
||||
// plansList defined as request<ResponseResponse & { data?: ModelPlan[] }>
|
||||
// So yes, response.data which is the body, and inside that, there is a data property.
|
||||
// wait, let's check client.ts generated code again.
|
||||
|
||||
// plansList returns Promise<HttpResponse<...>>
|
||||
// HttpResponse has .data property which IS the body.
|
||||
// The body type is ResponseResponse & { data?: ModelPlan[] }
|
||||
// So we access response.data.data
|
||||
plans.value = response.data.data || [];
|
||||
plans.value = [];
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
@@ -84,13 +74,22 @@ const subscribe = async (plan: ModelPlan) => {
|
||||
if (!plan.id) return;
|
||||
subscribing.value = plan.id;
|
||||
try {
|
||||
// Mock payment for now as per plan, or call API if ready
|
||||
// client.payments.paymentsCreate({ amount: plan.price || 0, plan_id: plan.id });
|
||||
await client.payments.paymentsCreate({
|
||||
amount: plan.price || 0,
|
||||
plan_id: plan.id
|
||||
});
|
||||
// Update local state mock
|
||||
// In real app, we would re-fetch user profile
|
||||
alert(`Successfully subscribed to ${plan.name}`);
|
||||
|
||||
paymentHistory.value.unshift({
|
||||
id: `inv_${Date.now()}`,
|
||||
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||
amount: plan.price || 0,
|
||||
plan: plan.name || 'Unknown',
|
||||
status: 'success',
|
||||
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert('Failed to subscribe: ' + (err.message || 'Unknown error'));
|
||||
@@ -99,6 +98,20 @@ const subscribe = async (plan: ModelPlan) => {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSubscription = async () => {
|
||||
cancelling.value = true;
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Subscription has been canceled.');
|
||||
showManageDialog.value = false;
|
||||
} catch (e) {
|
||||
alert('Failed to cancel subscription.');
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -107,11 +120,236 @@ const formatBytes = (bytes: number) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '0 mins';
|
||||
return `${Math.floor(seconds / 60)} mins`;
|
||||
};
|
||||
|
||||
const isPopular = (plan: ModelPlan) => {
|
||||
return plan.name?.toLowerCase().includes('pro') || plan.name?.toLowerCase().includes('premium');
|
||||
};
|
||||
|
||||
const isCurrentComp = (plan: ModelPlan) => {
|
||||
return plan.id === currentPlanId.value;
|
||||
}
|
||||
|
||||
const getStatusSeverity = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'danger';
|
||||
case 'pending':
|
||||
return 'warn';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlans();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plans-page">
|
||||
<PageHeader
|
||||
title="Subscription"
|
||||
description="Manage your workspace plan and usage"
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Subscription' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="content max-w-7xl mx-auto space-y-12 pb-12">
|
||||
|
||||
<!-- Hero Section: Current Plan & Usage -->
|
||||
<div v-if="!loading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Current Plan Card -->
|
||||
<div class="lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8 shadow-xl">
|
||||
<!-- Background decorations -->
|
||||
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full mix-blend-overlay filter blur-3xl opacity-20"></div>
|
||||
<div class="absolute bottom-0 left-0 -mb-16 -ml-16 w-64 h-64 bg-purple-500 rounded-full mix-blend-overlay filter blur-3xl opacity-20"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
|
||||
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
|
||||
<Tag value="Active" severity="success" class="px-3" rounded></Tag>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold text-white">${{ currentPlan?.price || 0 }}<span class="text-lg text-gray-400 font-normal">/mo</span></div>
|
||||
<p class="text-gray-400 text-sm mt-1">Next billing on Feb 24, 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-700/50 flex gap-4">
|
||||
<Button label="Manage Subscription" severity="secondary" class="bg-white/10 border-white/10 text-white hover:bg-white/20" @click="showManageDialog = true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm flex flex-col justify-center">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-6">Usage Statistics</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-600 font-medium">Storage</span>
|
||||
<span class="text-gray-900 font-bold">{{ storagePercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 8px" :class="storagePercentage > 90 ? 'p-progressbar-danger' : ''"></ProgressBar>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-600 font-medium">Monthly Uploads</span>
|
||||
<span class="text-gray-900 font-bold">{{ uploadsPercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="uploadsPercentage" :showValue="false" style="height: 8px"></ProgressBar>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ uploadsUsed }} of {{ uploadsLimit }} uploads</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade Section -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Upgrade your workspace</h2>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div v-for="i in 3" :key="i" class="h-full">
|
||||
<Skeleton height="300px" borderRadius="16px"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
|
||||
<div v-for="plan in plans" :key="plan.id" class="relative group h-full">
|
||||
<div v-if="isPopular(plan) && !isCurrentComp(plan)" class="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs font-bold px-3 py-1 rounded-full z-10 shadow-md uppercase tracking-wide">
|
||||
Recommended
|
||||
</div>
|
||||
|
||||
<div :class="[
|
||||
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
|
||||
isCurrentComp(plan) ? 'border-primary ring-1 ring-primary/50 bg-primary-50/10' : 'border-gray-200 hover:border-gray-300 hover:shadow-lg',
|
||||
isPopular(plan) && !isCurrentComp(plan) ? 'shadow-md border-primary/20' : ''
|
||||
]">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ plan.name }}</h3>
|
||||
<p class="text-gray-500 text-sm min-h-[2.5rem] mt-2">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-gray-900">${{ plan.price }}</span>
|
||||
<span class="text-gray-500 text-sm">/{{ plan.cycle }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 mb-8 flex-grow">
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ formatBytes(plan.storage_limit || 0) }} Storage
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ formatDuration(plan.duration_limit) }} Max Duration
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ plan.upload_limit }} Uploads / day
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
:label="isCurrentComp(plan) ? 'Current Plan' : (subscribing === plan.id ? 'Processing...' : 'Upgrade')"
|
||||
:icon="subscribing === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
|
||||
class="w-full"
|
||||
:severity="isCurrentComp(plan) ? 'secondary' : 'primary'"
|
||||
:outlined="isCurrentComp(plan)"
|
||||
:disabled="!!subscribing || isCurrentComp(plan)"
|
||||
@click="subscribe(plan)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Payment History Section -->
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Billing History</h2>
|
||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<DataTable :value="paymentHistory" tableStyle="min-width: 50rem"
|
||||
:pt="{
|
||||
thead: { class: 'bg-gray-50 border-b border-gray-200' },
|
||||
headerRow: { class: 'text-gray-500 text-xs font-semibold uppercase tracking-wider' },
|
||||
bodyRow: { class: 'text-gray-700 hover:bg-gray-50/50' }
|
||||
}"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-center py-8 text-gray-500">No payment history found.</div>
|
||||
</template>
|
||||
<Column field="date" header="Date" class="font-medium"></Column>
|
||||
<Column field="amount" header="Amount">
|
||||
<template #body="slotProps">
|
||||
${{ slotProps.data.amount }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="plan" header="Plan"></Column>
|
||||
<Column field="status" header="Status">
|
||||
<template #body="slotProps">
|
||||
<Tag
|
||||
:value="slotProps.data.status"
|
||||
:severity="getStatusSeverity(slotProps.data.status)"
|
||||
class="capitalize px-2 py-0.5 text-xs"
|
||||
:rounded="true"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="" style="width: 3rem">
|
||||
<template #body>
|
||||
<Button icon="i-heroicons-arrow-down-tray" text rounded severity="secondary" size="small" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog v-model:visible="showManageDialog" modal header="Manage Subscription" :style="{ width: '30rem' }">
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-600 mb-4">You are currently subscribed to <span class="font-bold text-gray-900">{{ currentPlan?.name }}</span>.</p>
|
||||
<div class="bg-gray-50 p-4 rounded-lg space-y-2 border border-gray-200">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Status</span>
|
||||
<span class="text-sm font-medium text-green-600">Active</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Renewal Date</span>
|
||||
<span class="text-sm font-medium text-gray-900">Feb 24, 2026</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Amount</span>
|
||||
<span class="text-sm font-medium text-gray-900">${{ currentPlan?.price || 0 }}/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Canceling your subscription will downgrade you to the Free plan at the end of your current billing period.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Close" text severity="secondary" @click="showManageDialog = false" />
|
||||
<Button
|
||||
label="Cancel Subscription"
|
||||
severity="danger"
|
||||
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
|
||||
@click="cancelSubscription"
|
||||
:disabled="cancelling"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
182
src/routes/profile/Profile.vue
Normal file
182
src/routes/profile/Profile.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import Card from 'primevue/card';
|
||||
import Avatar from 'primevue/avatar';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
// Computed for display
|
||||
const joinDate = computed(() => {
|
||||
return new Date(auth.user?.created_at || Date.now()).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
});
|
||||
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0);
|
||||
const storageLimit = computed(() => 10737418240); // 10GB default
|
||||
const storagePercentage = computed(() => Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100));
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<PageHeader
|
||||
title="Profile Settings"
|
||||
description="Manage your account information and preferences."
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Profile' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="max-w-5xl mx-auto space-y-8 pb-12">
|
||||
|
||||
<!-- Hero Identity Card -->
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900 text-white p-8 md:p-10 shadow-xl">
|
||||
<!-- Background decorations -->
|
||||
<div class="absolute top-0 right-0 -mt-20 -mr-20 w-80 h-80 bg-primary-500 rounded-full mix-blend-overlay filter blur-3xl opacity-20"></div>
|
||||
<div class="absolute bottom-0 left-0 -mb-20 -ml-20 w-80 h-80 bg-purple-500 rounded-full mix-blend-overlay filter blur-3xl opacity-20"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-primary-500 rounded-full blur-lg opacity-40"></div>
|
||||
<Avatar
|
||||
:label="auth.user?.username?.charAt(0).toUpperCase() || 'U'"
|
||||
class="relative border-4 border-gray-800 text-3xl font-bold bg-gradient-to-br from-primary-400 to-primary-600 text-white shadow-2xl"
|
||||
size="xlarge"
|
||||
shape="circle"
|
||||
style="width: 120px; height: 120px; font-size: 3rem;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center md:text-left space-y-2 flex-grow">
|
||||
<div class="flex flex-col md:flex-row items-center gap-3 justify-center md:justify-start">
|
||||
<h2 class="text-3xl font-bold text-white">{{ auth.user?.username || 'User' }}</h2>
|
||||
<Tag :value="auth.user?.role || 'User'" severity="info" class="uppercase tracking-wider px-2 header-tag" rounded></Tag>
|
||||
</div>
|
||||
<p class="text-gray-400 text-lg">{{ auth.user?.email }}</p>
|
||||
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
|
||||
<span class="i-heroicons-calendar"></span>
|
||||
Member since {{ joinDate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button label="Logout" icon="i-heroicons-arrow-right-on-rectangle" severity="secondary" class="border-white/10 text-white hover:bg-white/10 bg-white/5" @click="auth.logout()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Personal Info -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
|
||||
<Button label="Edit Profile" icon="i-heroicons-pencil" text severity="secondary" disabled />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="username" class="text-sm font-medium text-gray-700">Username</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 i-heroicons-user"></span>
|
||||
<InputText id="username" :value="auth.user?.username" class="w-full pl-10" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email Address</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 i-heroicons-envelope"></span>
|
||||
<InputText id="email" :value="auth.user?.email" class="w-full pl-10" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="role" class="text-sm font-medium text-gray-700">Role</label>
|
||||
<InputText id="role" :value="auth.user?.role || 'User'" class="w-full capitalize bg-gray-50" readonly />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="id" class="text-sm font-medium text-gray-700">User ID</label>
|
||||
<InputText id="id" :value="auth.user?.id || 'N/A'" class="w-full font-mono text-sm bg-gray-50" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Side -->
|
||||
<div class="md:col-span-1 space-y-6">
|
||||
<!-- Account Status -->
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Account Status</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-600">Storage Used</span>
|
||||
<span class="font-bold text-gray-900">{{ storagePercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px"></ProgressBar>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 border border-green-100 flex items-start gap-3">
|
||||
<span class="i-heroicons-check-circle text-green-600 text-xl mt-0.5"></span>
|
||||
<div>
|
||||
<h4 class="font-bold text-green-800 text-sm">Account Active</h4>
|
||||
<p class="text-green-600 text-xs mt-0.5">Your subscription is in good standing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linked Accounts (Mock) -->
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Linked Accounts</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-gray-200 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center text-red-600 font-bold text-xs">G</div>
|
||||
<span class="font-medium text-gray-700">Google</span>
|
||||
</div>
|
||||
<Tag value="Connected" severity="success" class="text-xs px-2"></Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom override for PrimeVue Avatar size if class utils fail */
|
||||
:deep(.p-avatar-xl) {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
:deep(.header-tag) {
|
||||
background: rgba(255,255,255,0.2) !important;
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
:deep(.p-inputtext[readonly]) {
|
||||
background-color: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, createStaticVNode } from 'vue';
|
||||
import { ref, onMounted, createStaticVNode, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
const videos = ref<ModelVideo[]>([]);
|
||||
@@ -131,6 +133,8 @@ const deleteVideo = async (videoId?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
fetchVideos();
|
||||
});
|
||||
@@ -157,7 +161,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-6">
|
||||
<div class="bg-white border-b border-gray-200 pb-4 mb-6">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1">
|
||||
@@ -209,8 +213,36 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||
<div class="i-svg-spinners-180-ring-with-bg text-4xl text-primary"></div>
|
||||
<div v-if="loading" class="animate-pulse">
|
||||
<!-- Grid Skeleton -->
|
||||
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-for="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<Skeleton height="150px" width="100%"></Skeleton>
|
||||
<div class="p-4">
|
||||
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
|
||||
<div class="flex justify-between">
|
||||
<Skeleton width="3rem" height="1rem"></Skeleton>
|
||||
<Skeleton width="3rem" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Table Skeleton -->
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||
<div class="flex gap-4 items-center">
|
||||
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
|
||||
<div class="flex-1">
|
||||
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||
</div>
|
||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
||||
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
|
||||
Reference in New Issue
Block a user