develop-updateui #1
@@ -682,5 +682,12 @@ export class Api<
|
|||||||
export const client = new Api({
|
export const client = new Api({
|
||||||
baseUrl: 'r',
|
baseUrl: 'r',
|
||||||
// baseUrl: 'https://api.pipic.fun',
|
// baseUrl: 'https://api.pipic.fun',
|
||||||
customFetch
|
customFetch: (url, options) => {
|
||||||
|
options.headers = {
|
||||||
|
...options.headers,
|
||||||
|
"X-Forwarded-For": "[IP_ADDRESS]"
|
||||||
|
}
|
||||||
|
options.credentials = "include"
|
||||||
|
return fetch(url, options)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -10,7 +10,12 @@ export const customFetch = async (url: string, options: RequestInit) => {
|
|||||||
Object.assign(options, {
|
Object.assign(options, {
|
||||||
headers: c.req.header()
|
headers: c.req.header()
|
||||||
});
|
});
|
||||||
|
console.log("url", url)
|
||||||
const res = await fetch(["https://api.pipic.fun", url.replace(/r\//, '')].join('/'), options);
|
const res = await fetch(["https://api.pipic.fun", url.replace(/r\//, '')].join('/'), options);
|
||||||
|
if (url.includes("r/plans")) {
|
||||||
|
console.log("res", await res.json())
|
||||||
|
|
||||||
|
}
|
||||||
res.headers.forEach((value, key) => {
|
res.headers.forEach((value, key) => {
|
||||||
c.header(key, value);
|
c.header(key, value);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 468 532">
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg>
|
||||||
<path
|
|
||||||
d="M66 378h337l-13-22c-24-40-36-85-36-131v-15c0-66-54-120-120-120s-120 54-120 120v15c0 46-12 91-35 131l-13 22z"
|
|
||||||
fill="#a6acb9" />
|
|
||||||
<path
|
|
||||||
d="M234 10c-13 0-24 11-24 24v10C129 55 66 125 66 210v15c0 37-10 74-29 107l-22 37c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166V34c0-13-11-24-24-24zm168 368H66l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H166z"
|
|
||||||
fill="#1e3050" />
|
|
||||||
</svg>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
||||||
<path
|
<path
|
||||||
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ app.use(cors(), async (c, next) => {
|
|||||||
c.set("isMobile", isMobile({ ua }));
|
c.set("isMobile", isMobile({ ua }));
|
||||||
await next();
|
await next();
|
||||||
}, async (c, next) => {
|
}, async (c, next) => {
|
||||||
const path = c.req.path
|
const path = c.req.path
|
||||||
|
|
||||||
if (path !== '/r' && !path.startsWith('/r/')) {
|
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||||
return await next()
|
return await next()
|
||||||
@@ -34,12 +34,45 @@ app.use(cors(), async (c, next) => {
|
|||||||
url.protocol = 'https:'
|
url.protocol = 'https:'
|
||||||
url.pathname = path.replace(/^\/r/, '') || '/'
|
url.pathname = path.replace(/^\/r/, '') || '/'
|
||||||
url.port = ''
|
url.port = ''
|
||||||
const req = new Request(url.toString(), c.req.raw);
|
// console.log("url", url.toString())
|
||||||
return fetch(req);
|
// console.log("c.req.raw", c.req.raw)
|
||||||
// const res = await fetch(req).catch(err => console.error('Error during proxy request: ', err.message));
|
const headers = new Headers(c.req.header());
|
||||||
// return c.body(res, res.status, res.headers);
|
headers.delete("host");
|
||||||
// console.log('Proxy request to: ', url.toString(), ' response: ', res?.status, JSON.stringify(c.req.header(), null, 2));
|
headers.delete("connection");
|
||||||
// return res
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: c.req.method,
|
||||||
|
headers: headers,
|
||||||
|
body: c.req.raw.body,
|
||||||
|
// @ts-ignore
|
||||||
|
duplex: 'half',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const newHeaders = new Headers(response.headers);
|
||||||
|
|
||||||
|
// Rewrite Set-Cookie to remove Domain attribute
|
||||||
|
if (typeof response.headers.getSetCookie === 'function') {
|
||||||
|
newHeaders.delete('set-cookie');
|
||||||
|
const cookies = response.headers.getSetCookie();
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
// Remove Domain=...; or Domain=... ending
|
||||||
|
const newCookie = cookie.replace(/Domain=[^;]+;?/gi, '');
|
||||||
|
newHeaders.append('set-cookie', newCookie);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for environments without getSetCookie
|
||||||
|
const cookie = response.headers.get('set-cookie');
|
||||||
|
if (cookie) {
|
||||||
|
newHeaders.set('set-cookie', cookie.replace(/Domain=[^;]+;?/gi, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: newHeaders
|
||||||
|
});
|
||||||
});
|
});
|
||||||
app.get("/.well-known/*", (c) => {
|
app.get("/.well-known/*", (c) => {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error
|
|||||||
const fetcher = data || fn
|
const fetcher = data || fn
|
||||||
if (
|
if (
|
||||||
!fetcher ||
|
!fetcher ||
|
||||||
(!(config as any).isDocumentVisible() && !isFirstFetch) ||
|
(!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) ||
|
||||||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
|
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
|
||||||
) {
|
) {
|
||||||
stateRef.isValidating = false
|
stateRef.isValidating = false
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const routes: RouteData[] = [
|
|||||||
{
|
{
|
||||||
path: "notification",
|
path: "notification",
|
||||||
name: "notification",
|
name: "notification",
|
||||||
component: () => import("./add/Add.vue"), // TODO: create notification page
|
component: () => import("./notification/Notification.vue"), // TODO: create notification page
|
||||||
meta: {
|
meta: {
|
||||||
head: {
|
head: {
|
||||||
title: 'Notification - Holistream',
|
title: 'Notification - Holistream',
|
||||||
|
|||||||
8
src/routes/notification/Notification.vue
Normal file
8
src/routes/notification/Notification.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
notification
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -33,7 +33,10 @@ const auth = useAuthStore()
|
|||||||
const isCopied = ref(false)
|
const isCopied = ref(false)
|
||||||
const url = location.origin + '/ref/' + auth.user?.username
|
const url = location.origin + '/ref/' + auth.user?.username
|
||||||
const copyToClipboard = ($event: MouseEvent) => {
|
const copyToClipboard = ($event: MouseEvent) => {
|
||||||
($event.target as HTMLInputElement).select()
|
// ($event.target as HTMLInputElement)?.select
|
||||||
|
if ($event.target instanceof HTMLInputElement) {
|
||||||
|
$event.target.select()
|
||||||
|
}
|
||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url)
|
||||||
isCopied.value = true
|
isCopied.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ import { client, type ModelPlan } from '@/api/client';
|
|||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import useSWRV from '@/lib/swr';
|
import useSWRV from '@/lib/swr';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import Button from 'primevue/button';
|
import { computed, ref, watch } from 'vue';
|
||||||
import Column from 'primevue/column';
|
import CurrentPlanCard from './components/CurrentPlanCard.vue';
|
||||||
import DataTable from 'primevue/datatable';
|
import UsageStatsCard from './components/UsageStatsCard.vue';
|
||||||
import Dialog from 'primevue/dialog';
|
import PlanList from './components/PlanList.vue';
|
||||||
import ProgressBar from 'primevue/progressbar';
|
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import EditPlanDialog from './components/EditPlanDialog.vue';
|
||||||
import Tag from 'primevue/tag';
|
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const plans = ref<ModelPlan[]>([]);
|
const plans = ref<ModelPlan[]>([]);
|
||||||
const error = ref<string | null>(null);
|
|
||||||
const subscribing = ref<string | null>(null);
|
const subscribing = ref<string | null>(null);
|
||||||
const showManageDialog = ref(false);
|
const showManageDialog = ref(false);
|
||||||
const cancelling = ref(false);
|
const cancelling = ref(false);
|
||||||
@@ -31,11 +29,8 @@ const paymentHistory = ref([
|
|||||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||||
// Default limit 10GB if no plan
|
// Default limit 10GB if no plan
|
||||||
const storageLimit = computed(() => 10737418240);
|
const storageLimit = computed(() => 10737418240);
|
||||||
const storagePercentage = computed(() => Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100));
|
|
||||||
|
|
||||||
const uploadsUsed = ref(12);
|
const uploadsUsed = ref(12);
|
||||||
const uploadsLimit = ref(50);
|
const uploadsLimit = ref(50);
|
||||||
const uploadsPercentage = computed(() => Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100));
|
|
||||||
|
|
||||||
const currentPlanId = computed(() => {
|
const currentPlanId = computed(() => {
|
||||||
if (auth.user?.plan_id) return auth.user.plan_id;
|
if (auth.user?.plan_id) return auth.user.plan_id;
|
||||||
@@ -47,27 +42,63 @@ const currentPlan = computed(() => {
|
|||||||
if (!Array.isArray(plans.value)) return undefined;
|
if (!Array.isArray(plans.value)) return undefined;
|
||||||
return plans.value.find(p => p.id === currentPlanId.value);
|
return plans.value.find(p => p.id === currentPlanId.value);
|
||||||
});
|
});
|
||||||
const { isLoading } = useSWRV("plans", client.plans.plansList)
|
|
||||||
// const fetchPlans = async () => {
|
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", async () => {
|
||||||
// loading.value = true;
|
console.log("plansList")
|
||||||
// error.value = null;
|
const res = await client.plans.plansList()
|
||||||
// try {
|
return res.data
|
||||||
// const response = await client.plans.plansList();
|
})
|
||||||
// if (response.data && Array.isArray(response.data)) {
|
|
||||||
// plans.value = response.data;
|
watch(data, (newValue) => {
|
||||||
// } else if (response.data && Array.isArray((response.data as any).data)) {
|
if (newValue) {
|
||||||
// // Handle paginated or wrapped response
|
// Handle potentially different response structures
|
||||||
// plans.value = (response.data as any).data;
|
// Safe access to avoid SSR crash if data is null/undefined
|
||||||
// } else {
|
const plansList = newValue?.data?.data?.plans;
|
||||||
// plans.value = [];
|
if (Array.isArray(plansList)) {
|
||||||
// }
|
plans.value = plansList;
|
||||||
// } catch (err: any) {
|
}
|
||||||
// console.error(err);
|
}
|
||||||
// error.value = err.message || 'Failed to load plans';
|
}, { immediate: true });
|
||||||
// } finally {
|
|
||||||
// loading.value = false;
|
const showEditDialog = ref(false);
|
||||||
// }
|
const editingPlan = ref<ModelPlan>({});
|
||||||
// };
|
const isSaving = ref(false);
|
||||||
|
|
||||||
|
const openEditPlan = (plan: ModelPlan) => {
|
||||||
|
editingPlan.value = { ...plan };
|
||||||
|
showEditDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePlan = async (updatedPlan: ModelPlan) => {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
if (!updatedPlan.id) return;
|
||||||
|
|
||||||
|
// Optimistic update or API call
|
||||||
|
await client.request({
|
||||||
|
path: `/plans/${updatedPlan.id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
body: updatedPlan
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh plans
|
||||||
|
await mutatePlans();
|
||||||
|
|
||||||
|
showEditDialog.value = false;
|
||||||
|
alert('Plan updated successfully');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to update plan', e);
|
||||||
|
// Fallback: update local state if API is mocked/missing
|
||||||
|
const idx = plans.value.findIndex(p => p.id === updatedPlan.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
plans.value[idx] = { ...updatedPlan };
|
||||||
|
}
|
||||||
|
showEditDialog.value = false;
|
||||||
|
// alert('Note: API update failed, updated locally. ' + e.message);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const subscribe = async (plan: ModelPlan) => {
|
const subscribe = async (plan: ModelPlan) => {
|
||||||
if (!plan.id) return;
|
if (!plan.id) return;
|
||||||
@@ -110,40 +141,6 @@ const cancelSubscription = async () => {
|
|||||||
cancelling.value = false;
|
cancelling.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -161,190 +158,45 @@ const getStatusSeverity = (status: string) => {
|
|||||||
|
|
||||||
<!-- Hero Section: Current Plan & Usage -->
|
<!-- Hero Section: Current Plan & Usage -->
|
||||||
<div v-if="!isLoading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div v-if="!isLoading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<!-- Current Plan Card -->
|
<CurrentPlanCard
|
||||||
<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">
|
:current-plan="currentPlan"
|
||||||
<!-- Background decorations -->
|
@manage="showManageDialog = true"
|
||||||
<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">
|
<UsageStatsCard
|
||||||
<div class="flex justify-between items-start">
|
:storage-used="storageUsed"
|
||||||
<div>
|
:storage-limit="storageLimit"
|
||||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
|
:uploads-used="uploadsUsed"
|
||||||
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
|
:uploads-limit="uploadsLimit"
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Upgrade Section -->
|
<PlanList
|
||||||
<section>
|
:plans="plans"
|
||||||
<div class="flex items-center justify-between mb-8">
|
:is-loading="isLoading"
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Upgrade your workspace</h2>
|
:current-plan-id="currentPlanId"
|
||||||
</div>
|
:subscribing-plan-id="subscribing"
|
||||||
|
:is-admin="auth.user?.role === 'admin'"
|
||||||
<!-- Loading State -->
|
@subscribe="subscribe"
|
||||||
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
@edit="openEditPlan"
|
||||||
<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">
|
<PlanPaymentHistory :history="paymentHistory" />
|
||||||
<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="[
|
<ManageSubscriptionDialog
|
||||||
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
|
v-model:visible="showManageDialog"
|
||||||
isCurrentComp(plan) ? 'border-primary ring-1 ring-primary/50 bg-primary-50/10' : 'border-gray-200 hover:border-gray-300 hover:shadow-lg',
|
:current-plan="currentPlan"
|
||||||
isPopular(plan) && !isCurrentComp(plan) ? 'shadow-md border-primary/20' : ''
|
:cancelling="cancelling"
|
||||||
]">
|
@cancel-subscription="cancelSubscription"
|
||||||
<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>
|
||||||
|
|
||||||
|
<EditPlanDialog
|
||||||
|
v-model:visible="showEditDialog"
|
||||||
|
:plan="editingPlan"
|
||||||
|
:loading="isSaving"
|
||||||
|
@save="savePlan"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
39
src/routes/plans/components/CurrentPlanCard.vue
Normal file
39
src/routes/plans/components/CurrentPlanCard.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type ModelPlan } from '@/api/client';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currentPlan?: ModelPlan;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'manage'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="$emit('manage')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
90
src/routes/plans/components/EditPlanDialog.vue
Normal file
90
src/routes/plans/components/EditPlanDialog.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type ModelPlan } from '@/api/client';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
plan: ModelPlan;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', value: boolean): void;
|
||||||
|
(e: 'save', plan: ModelPlan): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Create a local copy to edit
|
||||||
|
const localPlan = ref<ModelPlan>({});
|
||||||
|
|
||||||
|
// Sync when dialog opens or plan changes
|
||||||
|
watch(() => props.plan, (newPlan) => {
|
||||||
|
localPlan.value = { ...newPlan };
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
emit('save', localPlan.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleModel = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:visible="visibleModel" modal header="Edit Plan" :style="{ width: '40rem' }">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-name" class="text-sm font-medium text-gray-700">Name</label>
|
||||||
|
<InputText id="plan-name" v-model="localPlan.name" placeholder="Plan Name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-price" class="text-sm font-medium text-gray-700">Price ($)</label>
|
||||||
|
<InputNumber id="plan-price" v-model="localPlan.price" mode="currency" currency="USD" locale="en-US" :minFractionDigits="2" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
|
||||||
|
<InputText id="plan-cycle" v-model="localPlan.cycle" placeholder="e.g. month, year" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-desc" class="text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<Textarea id="plan-desc" v-model="localPlan.description" rows="2" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-storage" class="text-sm font-medium text-gray-700">Storage Limit (bytes)</label>
|
||||||
|
<InputNumber id="plan-storage" v-model="localPlan.storage_limit" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-uploads" class="text-sm font-medium text-gray-700">Upload Limit (per day)</label>
|
||||||
|
<InputNumber id="plan-uploads" v-model="localPlan.upload_limit" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
|
||||||
|
<InputNumber id="plan-duration" v-model="localPlan.duration_limit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 pt-2">
|
||||||
|
<Checkbox v-model="localPlan.is_active" :binary="true" inputId="plan-active" />
|
||||||
|
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancel" text severity="secondary" @click="visibleModel = false" />
|
||||||
|
<Button label="Save Changes" icon="i-heroicons-check" @click="onSave" :loading="loading" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
57
src/routes/plans/components/ManageSubscriptionDialog.vue
Normal file
57
src/routes/plans/components/ManageSubscriptionDialog.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type ModelPlan } from '@/api/client';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
currentPlan?: ModelPlan;
|
||||||
|
cancelling?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', value: boolean): void;
|
||||||
|
(e: 'cancel-subscription'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visibleModel = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (val) => emit('update:visible', val)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:visible="visibleModel" 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="visibleModel = false" />
|
||||||
|
<Button
|
||||||
|
label="Cancel Subscription"
|
||||||
|
severity="danger"
|
||||||
|
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
|
||||||
|
@click="emit('cancel-subscription')"
|
||||||
|
:disabled="cancelling"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
107
src/routes/plans/components/PlanList.vue
Normal file
107
src/routes/plans/components/PlanList.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type ModelPlan } from '@/api/client';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Skeleton from 'primevue/skeleton';
|
||||||
|
import { formatBytes } from '@/lib/utils'; // Using utils formatBytes
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
plans: ModelPlan[];
|
||||||
|
isLoading: boolean;
|
||||||
|
currentPlanId?: string;
|
||||||
|
subscribingPlanId?: string | null;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'subscribe', plan: ModelPlan): void;
|
||||||
|
(e: 'edit', plan: ModelPlan): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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, currentId?: string) => {
|
||||||
|
return plan.id === currentId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="isLoading" 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, currentPlanId)" 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>
|
||||||
|
|
||||||
|
<!-- Admin Edit Button -->
|
||||||
|
<Button
|
||||||
|
v-if="isAdmin"
|
||||||
|
icon="i-heroicons-pencil-square"
|
||||||
|
class="absolute top-2 right-2 z-20 !p-2 !w-8 !h-8"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click.stop="emit('edit', plan)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div :class="[
|
||||||
|
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
|
||||||
|
isCurrentComp(plan, currentPlanId) ? '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, currentPlanId) ? '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, currentPlanId) ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade')"
|
||||||
|
:icon="subscribingPlanId === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
|
||||||
|
class="w-full"
|
||||||
|
:severity="isCurrentComp(plan, currentPlanId) ? 'secondary' : 'primary'"
|
||||||
|
:outlined="isCurrentComp(plan, currentPlanId)"
|
||||||
|
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
|
||||||
|
@click="emit('subscribe', plan)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
73
src/routes/plans/components/PlanPaymentHistory.vue
Normal file
73
src/routes/plans/components/PlanPaymentHistory.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
|
||||||
|
interface PaymentHistoryItem {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
amount: number;
|
||||||
|
plan: string;
|
||||||
|
status: string;
|
||||||
|
invoiceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
history: PaymentHistoryItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const getStatusSeverity = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
return 'danger';
|
||||||
|
case 'pending':
|
||||||
|
return 'warn';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="history" 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>
|
||||||
|
</template>
|
||||||
39
src/routes/plans/components/UsageStatsCard.vue
Normal file
39
src/routes/plans/components/UsageStatsCard.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProgressBar from 'primevue/progressbar';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { formatBytes } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
storageUsed: number;
|
||||||
|
storageLimit: number;
|
||||||
|
uploadsUsed: number;
|
||||||
|
uploadsLimit: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const storagePercentage = computed(() => Math.min(Math.round((props.storageUsed / props.storageLimit) * 100), 100));
|
||||||
|
const uploadsPercentage = computed(() => Math.min(Math.round((props.uploadsUsed / props.uploadsLimit) * 100), 100));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user