mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-10 15:58:38 +01:00
new selfhosted version
This commit is contained in:
84
dashboard/components/billing/BillingAddress.vue
Normal file
84
dashboard/components/billing/BillingAddress.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: billingAddress, status: billingAddressStatus, refresh: refreshBillingAddress } = useAuthFetch('/api/user/customer');
|
||||
|
||||
const currentBillingAddress = ref({
|
||||
line1: '',
|
||||
line2: '',
|
||||
country: '',
|
||||
postal_code: '',
|
||||
city: '',
|
||||
state: ''
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!billingAddress.value) return false;
|
||||
if (currentBillingAddress.value.line1 !== billingAddress.value.line1) return true;
|
||||
if (currentBillingAddress.value.line2 !== billingAddress.value.line2) return true;
|
||||
if (currentBillingAddress.value.country !== billingAddress.value.country) return true;
|
||||
if (currentBillingAddress.value.postal_code !== billingAddress.value.postal_code) return true;
|
||||
if (currentBillingAddress.value.city !== billingAddress.value.city) return true;
|
||||
if (currentBillingAddress.value.state !== billingAddress.value.state) return true;
|
||||
return false;
|
||||
})
|
||||
|
||||
watch(billingAddress, () => {
|
||||
if (!billingAddress.value) return;
|
||||
currentBillingAddress.value.line1 = billingAddress.value.line1;
|
||||
currentBillingAddress.value.line2 = billingAddress.value.line2;
|
||||
currentBillingAddress.value.country = billingAddress.value.country;
|
||||
currentBillingAddress.value.postal_code = billingAddress.value.postal_code;
|
||||
currentBillingAddress.value.city = billingAddress.value.city;
|
||||
currentBillingAddress.value.state = billingAddress.value.state;
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function updateCustomer() {
|
||||
await useCatch({
|
||||
toast: true,
|
||||
toastTitle: 'Error updating customer',
|
||||
async action() {
|
||||
await useAuthFetchSync('/api/user/update_customer', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: currentBillingAddress.value
|
||||
});
|
||||
await refreshBillingAddress();
|
||||
},
|
||||
onSuccess(_, showToast) {
|
||||
showToast('Update success', { description: 'Customer updated successfully', position: 'top-right' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div v-if="billingAddressStatus === 'success'" class="flex justify-center flex-col gap-4">
|
||||
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<Input v-model="currentBillingAddress.line1" placeholder="Address line 1"></Input>
|
||||
<Input v-model="currentBillingAddress.line2" placeholder="Address line 2"></Input>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="currentBillingAddress.country" placeholder="Country"></Input>
|
||||
<Input v-model="currentBillingAddress.postal_code" placeholder="Postal code"></Input>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="currentBillingAddress.city" placeholder="City"></Input>
|
||||
<Input v-model="currentBillingAddress.state" placeholder="State"></Input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="!canSave" @click="updateCustomer()" class="w-fit px-10"> Save </Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-else class="flex justify-center">
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
64
dashboard/components/billing/InvoicesView.vue
Normal file
64
dashboard/components/billing/InvoicesView.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { FileIcon, FileCheck } from 'lucide-vue-next';
|
||||
|
||||
const { data: invoices, status: invoicesStatus } = useAuthFetch('/api/user/invoices');
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<Card class="w-full" v-if="invoicesStatus === 'success' && invoices">
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="*:text-center">
|
||||
<TableHead class="w-[5%]"></TableHead>
|
||||
<TableHead class="w-fit"> Date </TableHead>
|
||||
<TableHead class="w-fit"> Price </TableHead>
|
||||
<TableHead class="w-fit"> Number </TableHead>
|
||||
<TableHead class="w-fit"> Status </TableHead>
|
||||
<TableHead class="w-fit"> Actions </TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="invoice of invoices.data" class="h-[2rem]">
|
||||
<TableCell>
|
||||
<FileCheck class="size-4"></FileCheck>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ new Date(invoice.created * 1000).toLocaleString() }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
€ {{ invoice.amount_due / 100 }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ invoice.number }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :class="{
|
||||
'bg-red-300': invoice.status === 'open'
|
||||
}">
|
||||
{{ invoice.status }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<NuxtLink target="_blank" :to="invoice.hosted_invoice_url ?? '#'">
|
||||
<Button variant="ghost">
|
||||
<FileIcon></FileIcon>
|
||||
Manage
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div v-else class="flex justify-center">
|
||||
<Loader></Loader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
111
dashboard/components/billing/PlanView.vue
Normal file
111
dashboard/components/billing/PlanView.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts" setup>
|
||||
import { LoaderCircle, TriangleAlert } from 'lucide-vue-next';
|
||||
import type { TUserPlanInfo } from '~/server/api/user/plan';
|
||||
import { getPlanFromId, PREMIUM_PLAN, type PLAN_TAG } from '@data/PLANS';
|
||||
|
||||
const { data: planInfo, status: planInfoStatus } = useAuthFetch<TUserPlanInfo>('/api/user/plan', {
|
||||
key: 'current_plan'
|
||||
});
|
||||
|
||||
const premiumStore = usePremiumStore();
|
||||
|
||||
function getPrice(type: number) {
|
||||
const plan = getPlanFromId(type);
|
||||
if (!plan) return 'ERROR';
|
||||
return (plan.COST / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
const billingPeriodPercent = computed(() => {
|
||||
if (!planInfo.value) return 0;
|
||||
const start = planInfo.value.start_at;
|
||||
const end = planInfo.value.end_at;
|
||||
const duration = end - start;
|
||||
const remaining = end - Date.now();
|
||||
const percent = 100 - Math.floor(100 / duration * remaining);
|
||||
return percent;
|
||||
});
|
||||
|
||||
const billingDaysRemaining = computed(() => {
|
||||
if (!planInfo.value) return 0;
|
||||
const end = planInfo.value.end_at;
|
||||
const remaining = end - Date.now();
|
||||
return Math.floor(remaining / (1000 * 60 * 60 * 24))
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex justify-center">
|
||||
|
||||
<Card class="w-full">
|
||||
<CardContent>
|
||||
|
||||
<div v-if="planInfo && planInfoStatus === 'success'" class="flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold shrink-0">
|
||||
{{ planInfo.premium ? 'Premium' : 'Free' }} plan
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{{ premiumStore.planInfo?.NAME ?? '???' }}
|
||||
</Badge>
|
||||
|
||||
<Tooltip v-if="planInfo.payment_failed">
|
||||
<TooltipTrigger as-child>
|
||||
<TriangleAlert class="size-5 text-red-400">
|
||||
</TriangleAlert>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
Please update your billing details to avoid service interruption.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<div class="grow"></div>
|
||||
<div v-if="!isSelfhosted()" class="shrink-0">
|
||||
<span class="text-[1.3rem] font-semibold">€ {{ getPrice(planInfo.premium_type) }}</span>
|
||||
<span class="text-muted-foreground text-[1.1rem]">
|
||||
{{ premiumStore.isAnnual ? ' per year' : ' per month' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
Billing period:
|
||||
</div>
|
||||
<div class="flex gap-8 items-center">
|
||||
<Progress class="mt-[1px]" :model-value="billingPeriodPercent"> </Progress>
|
||||
<div class="shrink-0 font-medium">
|
||||
{{ billingDaysRemaining }} days left
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator></Separator>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-muted-foreground">
|
||||
Expire date: {{ new Date(planInfo.end_at).toLocaleDateString() }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NuxtLink :to="isSelfhosted() ? 'https://litlyx.com/pricing-selfhosted': '/plans'">
|
||||
<Button> Upgrade plan </Button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center" v-else>
|
||||
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
62
dashboard/components/billing/UsageView.vue
Normal file
62
dashboard/components/billing/UsageView.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import { LoaderCircle } from 'lucide-vue-next';
|
||||
import type { TUserPlanInfo } from '~/server/api/user/plan';
|
||||
import { getPlanFromId, PREMIUM_PLAN, type PLAN_TAG } from '@data/PLANS';
|
||||
|
||||
const { data: planInfo, status: planInfoStatus } = useAuthFetch<TUserPlanInfo>('/api/user/plan', {
|
||||
key: 'current_plan'
|
||||
});
|
||||
|
||||
|
||||
const usagePercent = computed(() => {
|
||||
if (!planInfo.value) return 0;
|
||||
return 100 / planInfo.value.limit * planInfo.value.count;
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="flex justify-center">
|
||||
|
||||
<Card class="w-full">
|
||||
<CardContent>
|
||||
|
||||
<div v-if="planInfo && planInfoStatus === 'success'" class="flex flex-col gap-4">
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="font-semibold shrink-0">
|
||||
Usage
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Check the usage limits of your project.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
Usage:
|
||||
</div>
|
||||
<div class="flex gap-8 items-center">
|
||||
<Progress class="mt-[1px]" :model-value="Math.floor(usagePercent)"> </Progress>
|
||||
<div class="shrink-0 font-medium">
|
||||
{{ usagePercent.toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
{{ formatNumberK(planInfo.count) }} / {{ formatNumberK(planInfo.limit) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center" v-else>
|
||||
<LoaderCircle class="size-10 animate-[spin_1s_ease-in-out_infinite] duration-500">
|
||||
</LoaderCircle>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
Reference in New Issue
Block a user