develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
15 changed files with 571 additions and 265 deletions
Showing only changes of commit 5ae0a15a30 - Show all commits

View File

@@ -682,5 +682,12 @@ export class Api<
export const client = new Api({
baseUrl: 'r',
// 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)
}
});

View File

@@ -10,7 +10,12 @@ export const customFetch = async (url: string, options: RequestInit) => {
Object.assign(options, {
headers: c.req.header()
});
console.log("url", url)
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) => {
c.header(key, value);
});

View File

@@ -1,12 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 468 532">
<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 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>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
<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"

View File

@@ -24,7 +24,7 @@ app.use(cors(), async (c, next) => {
c.set("isMobile", isMobile({ ua }));
await next();
}, async (c, next) => {
const path = c.req.path
const path = c.req.path
if (path !== '/r' && !path.startsWith('/r/')) {
return await next()
@@ -34,12 +34,45 @@ app.use(cors(), async (c, next) => {
url.protocol = 'https:'
url.pathname = path.replace(/^\/r/, '') || '/'
url.port = ''
const req = new Request(url.toString(), c.req.raw);
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("url", url.toString())
// console.log("c.req.raw", c.req.raw)
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
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) => {
return c.json({ ok: true });

View File

@@ -256,7 +256,7 @@ function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error
const fetcher = data || fn
if (
!fetcher ||
(!(config as any).isDocumentVisible() && !isFirstFetch) ||
(!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) ||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
) {
stateRef.isValidating = false

View File

@@ -112,7 +112,7 @@ const routes: RouteData[] = [
{
path: "notification",
name: "notification",
component: () => import("./add/Add.vue"), // TODO: create notification page
component: () => import("./notification/Notification.vue"), // TODO: create notification page
meta: {
head: {
title: 'Notification - Holistream',

View File

@@ -0,0 +1,8 @@
<template>
<div>
notification
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -33,7 +33,10 @@ const auth = useAuthStore()
const isCopied = ref(false)
const url = location.origin + '/ref/' + auth.user?.username
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)
isCopied.value = true
setTimeout(() => {

View File

@@ -3,18 +3,16 @@ import { client, type ModelPlan } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import useSWRV from '@/lib/swr';
import { useAuthStore } from '@/stores/auth';
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable from 'primevue/datatable';
import Dialog from 'primevue/dialog';
import ProgressBar from 'primevue/progressbar';
import Skeleton from 'primevue/skeleton';
import Tag from 'primevue/tag';
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import CurrentPlanCard from './components/CurrentPlanCard.vue';
import UsageStatsCard from './components/UsageStatsCard.vue';
import PlanList from './components/PlanList.vue';
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
import EditPlanDialog from './components/EditPlanDialog.vue';
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
const auth = useAuthStore();
const plans = ref<ModelPlan[]>([]);
const error = ref<string | null>(null);
const subscribing = ref<string | null>(null);
const showManageDialog = ref(false);
const cancelling = ref(false);
@@ -31,11 +29,8 @@ const paymentHistory = ref([
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;
@@ -47,27 +42,63 @@ const currentPlan = computed(() => {
if (!Array.isArray(plans.value)) return undefined;
return plans.value.find(p => p.id === currentPlanId.value);
});
const { isLoading } = useSWRV("plans", client.plans.plansList)
// const fetchPlans = async () => {
// loading.value = true;
// error.value = null;
// try {
// 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 {
// plans.value = [];
// }
// } catch (err: any) {
// console.error(err);
// error.value = err.message || 'Failed to load plans';
// } finally {
// loading.value = false;
// }
// };
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", async () => {
console.log("plansList")
const res = await client.plans.plansList()
return res.data
})
watch(data, (newValue) => {
if (newValue) {
// Handle potentially different response structures
// Safe access to avoid SSR crash if data is null/undefined
const plansList = newValue?.data?.data?.plans;
if (Array.isArray(plansList)) {
plans.value = plansList;
}
}
}, { immediate: true });
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) => {
if (!plan.id) return;
@@ -110,40 +141,6 @@ const cancelSubscription = async () => {
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>
<template>
@@ -161,190 +158,45 @@ const getStatusSeverity = (status: string) => {
<!-- Hero Section: Current Plan & Usage -->
<div v-if="!isLoading" 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>
<CurrentPlanCard
:current-plan="currentPlan"
@manage="showManageDialog = true"
/>
<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>
<UsageStatsCard
:storage-used="storageUsed"
:storage-limit="storageLimit"
:uploads-used="uploadsUsed"
:uploads-limit="uploadsLimit"
/>
</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>
<PlanList
:plans="plans"
:is-loading="isLoading"
:current-plan-id="currentPlanId"
:subscribing-plan-id="subscribing"
:is-admin="auth.user?.role === 'admin'"
@subscribe="subscribe"
@edit="openEditPlan"
/>
<!-- 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>
<PlanPaymentHistory :history="paymentHistory" />
<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>
<ManageSubscriptionDialog
v-model:visible="showManageDialog"
:current-plan="currentPlan"
:cancelling="cancelling"
@cancel-subscription="cancelSubscription"
/>
</div>
<EditPlanDialog
v-model:visible="showEditDialog"
:plan="editingPlan"
:loading="isSaving"
@save="savePlan"
/>
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>