new selfhosted version

This commit is contained in:
antonio
2025-11-28 14:11:51 +01:00
parent afda29997d
commit 951860f67e
1046 changed files with 72586 additions and 574750 deletions

View File

@@ -1,91 +0,0 @@
<script lang="ts" setup>
import type { SettingsTemplateEntry } from './Template.vue';
const entries: SettingsTemplateEntry[] = [
{ id: 'change_pass', title: 'Change password', text: 'Change your password' },
{ id: 'delete', title: 'Delete account', text: 'Delete your account' },
]
const { user } = useLoggedUser();
const { setToken } = useAccessToken();
const canChangePassword = useFetch('/api/user/password/can_change', {
headers: useComputedHeaders({ useSnapshotDates: false })
});
async function deleteAccount() {
const sure = confirm("Are you sure you want to delete this account ?");
if (!sure) return;
await $fetch("/api/user/delete_account", {
...signHeaders(),
method: "DELETE"
})
setToken('');
location.href = "/login"
}
const old_password = ref<string>("");
const new_password = ref<string>("");
const { createAlert } = useAlert()
async function changePassword() {
try {
const res = await $fetch("/api/user/password/change", {
...signHeaders({ 'Content-Type': 'application/json' }),
method: "POST",
body: JSON.stringify({ old_password: old_password.value, new_password: new_password.value })
})
if (!res) throw Error('No response');
if (res.error) return createAlert('Error', res.message, 'far fa-triangle-exclamation', 5000);
old_password.value = '';
new_password.value = '';
return createAlert('Success', 'Password changed successfully', 'far fa-circle-check', 5000);
} catch (ex) {
console.error(ex);
createAlert('Error', 'Internal error', 'far fa-triangle-exclamation', 5000);
}
}
</script>
<template>
<SettingsTemplate :entries="entries">
<template #change_pass>
<div v-if="canChangePassword.data.value?.can_change">
<div class="flex flex-col gap-4">
<LyxUiInput type="password" class="py-1 px-2" v-model="old_password" placeholder="Current password"></LyxUiInput>
<LyxUiInput type="password" class="py-1 px-2" v-model="new_password" placeholder="New password"></LyxUiInput>
<LyxUiButton type="primary" @click="changePassword()"> Change password </LyxUiButton>
</div>
</div>
<div v-if="!canChangePassword.data.value?.can_change">
You cannot change the password for accounts created using social login options.
</div>
</template>
<template #delete>
<div
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
<div class="poppins font-semibold"> Deleting this account will also remove its projects </div>
<div @click="deleteAccount()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
Delete account
</div>
</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -1,66 +0,0 @@
<script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue';
const { project, isGuest } = useProject();
const entries: SettingsTemplateEntry[] = [
{ id: 'acodes', title: 'Appsumo codes', text: 'Redeem appsumo codes' },
]
const { createAlert } = useAlert()
const currentCode = ref<string>("");
const redeeming = ref<boolean>(false);
const valid_codes = useFetch('/api/pay/valid_codes', signHeaders({ 'x-pid': project.value?._id.toString() ?? '' }));
async function redeemCode() {
redeeming.value = true;
try {
const res = await $fetch<TApiSettings>('/api/pay/redeem_appsumo_code', {
method: 'POST', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ code: currentCode.value })
});
createAlert('Success', 'Code redeem success.', 'far fa-check', 5000);
valid_codes.refresh();
} catch (ex: any) {
createAlert('Error', ex?.response?.statusText || 'Unexpected error. Contact support.', 'far fa-error', 5000);
} finally {
currentCode.value = '';
}
redeeming.value = false;
}
</script>
<template>
<SettingsTemplate v-if="!isGuest" :entries="entries" :key="project?.name || 'NONE'">
<template #acodes>
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" placeholder="Appsumo code" v-model="currentCode"></LyxUiInput>
<LyxUiButton v-if="!redeeming" :disabled="currentCode.length == 0" @click="redeemCode()" type="primary">
Redeem
</LyxUiButton>
<div v-if="redeeming">
Redeeming...
</div>
</div>
<div class="text-lyx-text-darker mt-1 text-[.9rem] poppins">
Redeemed codes: {{ valid_codes.data.value?.count || '0' }}
</div>
<div class="poppins text-[1.1rem] text-lyx-lightmode-text dark:text-yellow-400 mb-2">
*Plan upgrades are applicable exclusively to this project(workspace).
</div>
</template>
</SettingsTemplate>
<div v-if="isGuest" class="text-lyx-text-darker flex w-full h-full justify-center mt-20">
Guests cannot view billing
</div>
</template>

View File

@@ -1,162 +0,0 @@
<script lang="ts" setup>
import DeleteDomainData from '../dialog/DeleteDomainData.vue';
import type { SettingsTemplateEntry } from './Template.vue';
const { isGuest } = useProject();
const entries: SettingsTemplateEntry[] = [
{ id: 'delete_dns', title: 'Delete domain data', text: 'Delete data of a specific domain from this project' },
{ id: 'delete_data', title: 'Delete project data', text: 'Delete all data from this project' },
]
const domains = useFetch('/api/settings/domains', {
headers: useComputedHeaders({ useSnapshotDates: false }),
transform: (e) => {
if (!e) return [];
return e.sort((a, b) => {
return a.count - b.count;
}).map(e => {
return { id: e._id, label: `${e._id} - ${e.count} visits` }
})
}
})
const selectedDomain = ref<{ id: string, label: string }>();
const selectedVisits = ref<boolean>(true);
const selectedSessions = ref<boolean>(true);
const selectedEvents = ref<boolean>(true);
const domainCounts = useFetch(() => `/api/settings/domain_counts?domain=${selectedDomain.value?.id}`, {
headers: useComputedHeaders({ useSnapshotDates: false }),
})
const { setToken } = useAccessToken();
const modal = useModal();
function openDeleteDomainDataDialog() {
modal.open(DeleteDomainData, {
preventClose: true,
deleteData: {
isAll: false,
domain: selectedDomain.value?.id as string,
visits: selectedVisits.value,
sessions: selectedSessions.value,
events: selectedEvents.value,
},
buttonType: 'primary',
message: 'This action is irreversable and will wipe all the data from the selected domain.',
onSuccess: () => {
modal.close()
},
onCancel: () => {
modal.close()
},
});
}
function openDeleteAllDomainDataDialog() {
modal.open(DeleteDomainData, {
preventClose: true,
deleteData: {
isAll: true,
domain: '',
visits: false,
sessions: false,
events: false,
},
buttonType: 'danger',
message: 'This action is irreversable and will wipe all the data from the entire project.',
onSuccess: () => {
modal.close()
},
onCancel: () => {
modal.close()
},
});
}
const visitsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Visits loading...';
if (domainCounts.data.value?.error === true) return 'Visits (too many to compute)';
return 'Visits ' + (domainCounts.data.value?.visits ?? '');
})
const eventsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Events loading...';
if (domainCounts.data.value?.error === true) return 'Events (too many to compute)';
return 'Events ' + (domainCounts.data.value?.events ?? '');
})
const sessionsLabel = computed(() => {
if (domainCounts.pending.value === true) return 'Sessions loading...';
if (domainCounts.data.value?.error === true) return 'Sessions (too many to compute)';
return 'Sessions ' + (domainCounts.data.value?.sessions ?? '');
})
</script>
<template>
<SettingsTemplate :entries="entries">
<template #delete_dns>
<div class="flex flex-col">
<!-- <div class="text-[.9rem] text-lyx-text-darker"> Select a domain </div> -->
<USelectMenu v-if="!isGuest" placeholder="Select a domain" :uiMenu="{
select: 'bg-lyx-lightmode-widget-light !ring-lyx-lightmode-widget dark:!bg-lyx-widget-light !shadow-none focus:!ring-lyx-widget-lighter dark:!ring-lyx-widget-lighter',
base: '!bg-lyx-lightmode-widget dark:!bg-lyx-widget',
option: {
base: 'hover:!bg-lyx-lightmode-widget-light dark:hover:!bg-lyx-widget-lighter cursor-pointer',
active: '!bg-lyx-lightmode-widget-light dark:!bg-lyx-widget-lighter'
}
}" :options="domains.data.value ?? []" v-model="selectedDomain"></USelectMenu>
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
<div v-if="selectedDomain" class="flex flex-col gap-2 mt-4">
<div class="text-[.9rem] text-lyx-text-dark"> Select data to delete </div>
<div class="flex flex-col gap-1">
<UCheckbox :ui="{ color: 'actionable-visits-color-checkbox' }" v-model="selectedVisits"
:label="visitsLabel" />
<UCheckbox :ui="{ color: 'actionable-sessions-color-checkbox' }" v-model="selectedSessions"
:label="sessionsLabel" />
<UCheckbox :ui="{ color: 'actionable-events-color-checkbox' }" v-model="selectedEvents"
:label="eventsLabel" />
</div>
<LyxUiButton class="mt-2" v-if="selectedVisits || selectedSessions || selectedEvents"
@click="openDeleteDomainDataDialog()" type="outline">
Delete data
</LyxUiButton>
<div class="text-lyx-text-dark">
This action will delete all data from the project creation date.
</div>
</div>
</div>
</template>
<template #delete_data>
<div v-if="!isGuest"
class="outline rounded-lg w-full px-8 py-4 flex flex-col gap-4 outline-[1px] outline-[#541c15] bg-lyx-lightmode-widget-light dark:bg-[#1e1412]">
<div class="poppins font-semibold"> This operation will reset this project to it's initial state (0
visits 0 events 0 sessions) </div>
<div @click="openDeleteAllDomainDataDialog()"
class="text-[#e95b61] poppins font-semibold cursor-pointer hover:text-black hover:bg-red-700 outline rounded-lg w-fit px-8 py-2 outline-[1px] outline-[#532b26] bg-lyx-lightmode-widget-light dark:bg-[#291415]">
Delete all data
</div>
</div>
<div v-if="isGuest" class="text-lyx-text-darker"> Guests cannot delete data</div>
</template>
</SettingsTemplate>
</template>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { toast } from 'vue-sonner';
import { CopyIcon, LoaderCircle, Trash } from 'lucide-vue-next';
import type { TDomainRes } from '~/server/api/domains/list_count';
import { DialogDeleteDomainData } from '#components';
const { data: domainsCount } = useAuthFetch<(TDomainRes & { percent: number })[]>('/api/domains/list_count', {
transform: (data) => {
const max = Math.max(...data.map(e => e.visits));
return data.toSorted((a, b) => b.visits - a.visits).map(e => {
return { ...e, percent: 100 / max * e.visits }
}).filter(e => e._id != '*');
}
});
const selectedDomain = ref<string>('');
const deleteVisits = ref<boolean>(false);
const deleteEvents = ref<boolean>(false);
watch(selectedDomain, () => {
deleteVisits.value = false;
deleteEvents.value = false;
})
const dialog = useDialog();
async function showDeleteDataDialog() {
dialog.open({
body: DialogDeleteDomainData,
title: 'Delete domain data',
props: { domain: selectedDomain.value },
onSuccess(_, close) {
close();
deleteData(selectedDomain.value);
},
})
}
async function deleteData(domain: string) {
await useCatch({
toast: true,
toastTitle: 'Error deleting domain data',
async action() {
await useAuthFetchSync('/api/domains/delete_data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: { domain, visits: deleteVisits.value, events: deleteEvents.value }
})
},
onSuccess(_, showToast) {
showToast('Deleting scheduled', { description: 'Data deletetion is scheduled. You will see the results shortly.', position: 'top-right' })
selectedDomain.value = '';
},
})
}
</script>
<template>
<Card>
<CardContent class="flex flex-col gap-2">
<div class="p-4 flex flex-col gap-3">
<Label> Delete data of a specific domain </Label>
<div class="flex gap-4">
<div class="flex flex-col gap-2" v-if="domainsCount">
<Select v-model="selectedDomain" v-if="domainsCount.length > 0">
<SelectTrigger>
<SelectValue class="min-w-[15rem] max-w-[25rem]" placeholder="Select a domain">
</SelectValue>
</SelectTrigger>
<SelectContent class="max-h-[20rem]">
<SelectGroup>
<SelectItem v-for="domain of domainsCount" :value="domain.name">
{{ domain.name }} [{{ domain.visits }}] ({{ domain.percent.toFixed(1) }}%)
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div v-if="domainsCount.length == 0">
No domains to manage
</div>
<div v-if="selectedDomain" class="flex items-center gap-2">
<Checkbox v-model="deleteVisits"></Checkbox>
<div>Visits</div>
</div>
<div v-if="selectedDomain" class="flex items-center gap-2">
<Checkbox v-model="deleteEvents"></Checkbox>
<div>Events</div>
</div>
<Button @click="showDeleteDataDialog()" v-if="selectedDomain"
:disabled="!deleteVisits && !deleteEvents" variant="destructive">
Delete
</Button>
</div>
<div class="poppins" v-else>
<div class="flex items-center gap-2">
<LoaderCircle class="size-5 animate-[spin_1s_ease-in-out_infinite] duration-500">
</LoaderCircle>
<div>Loading domains data</div>
</div>
</div>
</div>
</div>
<!-- <div class="p-4 flex flex-col gap-3">
<Label> Project id </Label>
<div class="flex gap-4">
<Input class="poppins" :default-value="projectStore.activeProject?._id.toString()" readonly></Input>
<Button @click="copyProjectId()" class="w-[6rem]">
<CopyIcon></CopyIcon>
Copy
</Button>
</div>
</div>
<div class="p-4 flex flex-col gap-3">
<Label> Script </Label>
<div class="flex gap-4 items-center">
<Textarea class="w-full poppins resize-none" :default-value="scriptValue.join('')" readonly></Textarea>
<Button @click="copyScript()" class="w-[6rem]">
<CopyIcon></CopyIcon>
Copy
</Button>
</div>
</div>
<div class="flex justify-center mt-8">
<Button variant="destructive">
<Trash></Trash>
Delete project
</Button>
</div> -->
</CardContent>
</Card>
</template>

View File

@@ -1,226 +1,152 @@
<script lang="ts" setup>
import type { TApiSettings } from '@schema/ApiSettingsSchema';
import type { SettingsTemplateEntry } from './Template.vue';
<script setup lang="ts">
import { toast } from 'vue-sonner';
import { CopyIcon, LoaderCircle, Trash } from 'lucide-vue-next';
import DeleteProject from '../dialog/DeleteProject.vue';
const { project, actions, projectList, isGuest, projectId } = useProject();
const { createErrorAlert, createAlert } = useAlert();
const entries: SettingsTemplateEntry[] = [
{ id: 'pname', title: 'Name', text: 'Project name' },
{ id: 'api', title: 'ApiKeys', text: 'Manage your authorization token' },
{ id: 'pid', title: 'Id', text: 'Project id' },
{ id: 'pscript', title: 'Script', text: 'Universal javascript integration' },
{ id: 'pdelete', title: 'Delete', text: 'Delete current project' },
]
const projectNameInputVal = ref<string>(project.value?.name || '');
const apiKeys = ref<TApiSettings[]>([]);
const newApiKeyName = ref<string>('');
async function updateApiKeys() {
newApiKeyName.value = '';
apiKeys.value = await $fetch<TApiSettings[]>('/api/keys/get_all', signHeaders({
'x-pid': project.value?._id.toString() ?? ''
}));
}
async function createApiKey() {
try {
const res = await $fetch<TApiSettings>('/api/keys/create', {
method: 'POST', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ name: newApiKeyName.value })
});
apiKeys.value.push(res);
newApiKeyName.value = '';
} catch (ex: any) {
createErrorAlert('Error', ex.message, 10000);
}
}
async function deleteApiKey(api_id: string) {
try {
const res = await $fetch<TApiSettings>('/api/keys/delete', {
method: 'DELETE', ...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ api_id })
});
newApiKeyName.value = '';
await updateApiKeys();
} catch (ex: any) {
createErrorAlert('Error', ex.message, 10000);
}
}
onMounted(() => {
updateApiKeys();
});
watch(project, () => {
projectNameInputVal.value = project.value?.name || "";
updateApiKeys();
});
const canChange = computed(() => {
if (project.value?.name == projectNameInputVal.value) return false;
if (projectNameInputVal.value.length === 0) return false;
return true;
});
async function changeProjectName() {
await $fetch("/api/project/change_name", {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ name: projectNameInputVal.value })
});
location.reload();
}
const projectStore = useProjectStore();
const { open } = useDialog();
const router = useRouter();
async function deleteProject() {
if (!project.value) return;
const sure = confirm(`Are you sure to delete the project ${project.value.name} ?`);
if (!sure) return;
const currentProjectName = ref<string>(projectStore.activeProject?.name ?? 'ERROR_LOADING_PROJECT_NAME');
try {
await $fetch('/api/project/delete', {
method: 'DELETE',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': project.value?._id.toString() ?? ''
}),
body: JSON.stringify({ project_id: project.value._id.toString() })
});
await actions.refreshProjectsList()
const firstProjectId = projectList.value?.[0]?._id.toString();
if (firstProjectId) {
await actions.setActiveProject(firstProjectId);
router.push('/')
}
} catch (ex: any) {
createErrorAlert('Error', ex.message);
}
}
function copyScript() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
const createScriptText = () => {
return [
'<script defer ',
`data-project="${projectId.value}" `,
'src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></',
'script>'
].join('')
}
navigator.clipboard.writeText(createScriptText());
createAlert('Success', 'Script copied successfully.', 'far fa-circle-check', 5000);
}
const scriptValue = [
'<',
'script defer data-workspace="',
projectStore.activeProject?._id.toString(),
'" src="https://cdn.jsdelivr.net/npm/litlyx-js@latest/browser/litlyx.js"></',
'script>'
]
function copyProjectId() {
if (!navigator.clipboard) alert('You can\'t copy in HTTP');
navigator.clipboard.writeText(projectId.value || '');
createAlert('Success', 'Project id copied successfully.', 'far fa-circle-check', 5000);
if (!navigator.clipboard) return toast('Error', { position: 'top-right', description: 'Error copying' });
navigator.clipboard.writeText(projectStore.activeProject?._id.toString() ?? 'ERROR_COPYING_WORKSPACE_ID');
return toast.info('Success', { position: 'top-right', description: 'The workspace id has been copied to your clipboard' });
}
function copyScript() {
if (!navigator.clipboard) return toast('Error', { position: 'top-right', description: 'Error copying' });
navigator.clipboard.writeText(scriptValue.join(''));
return toast.info('Success', { position: 'top-right', description: 'The workspace script has been copied to your clipboard' });
}
const changing = ref<boolean>(false);
async function changeProjectName() {
changing.value = true;
await new Promise(e => setTimeout(e, 1000));
await useCatch({
toast: true,
toastTitle: 'Error changing name',
async action() {
await useAuthFetchSync('/api/project/change_name', {
method: 'POST',
headers: { 'ContentType': 'application/json' },
body: { name: currentProjectName.value }
})
},
async onSuccess(_, showToast) {
showToast('Name changed', { position: 'top-right', description: 'Workspace name changed' });
await projectStore.fetchProjects();
currentProjectName.value = projectStore.activeProject?.name ?? 'ERROR_LOADING_WORKSPACE_NAME'
},
})
changing.value = false;
}
async function showDeleteProjectDialog() {
if (!projectStore.pid) return;
if (!projectStore.activeProject) return;
await open({
body: DeleteProject,
title: 'Delete workspace',
props: {
project_id: projectStore.pid,
project_name: projectStore.activeProject.name
},
onSuccess(_, close) {
close();
deleteProject();
},
})
}
async function deleteProject() {
await useCatch({
toast: true,
toastTitle: 'Error during deletation',
async action() {
await useAuthFetchSync('/api/project/delete', {
method: 'DELETE'
})
},
async onSuccess(_, showToast) {
showToast('Workspace deleted', { description: 'Workspace deleted successfully' });
await projectStore.fetchProjects();
router.push('/');
},
})
}
</script>
<template>
<SettingsTemplate :entries="entries" :key="project?.name || 'NONE'">
<template #pname>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4">
<LyxUiInput class="w-full px-4 py-2" :disabled="isGuest" v-model="projectNameInputVal"></LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="changeProjectName()" :disabled="!canChange" type="primary">
Change
</LyxUiButton>
</div>
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot change project name </div>
</div>
</template>
<template #api>
<div class="flex flex-col gap-2" v-if="apiKeys && apiKeys.length < 5">
<div class="flex items-center gap-4">
<LyxUiInput class="grow px-4 py-2" :disabled="isGuest" placeholder="ApiKeyName"
v-model="newApiKeyName">
</LyxUiInput>
<LyxUiButton v-if="!isGuest" @click="createApiKey()" :disabled="newApiKeyName.trim().length < 3"
type="primary">
<i class="far fa-plus"></i>
</LyxUiButton>
</div>
<div v-if="isGuest" class="text-lyx-text-darker"> *Guests cannot manage api keys </div>
</div>
<LyxUiCard v-if="apiKeys && apiKeys.length > 0" class="w-full flex flex-col gap-4 items-center mt-4">
<div v-for="apiKey of apiKeys" class="flex flex-col w-full">
<Card>
<CardContent class="grid grid-cols-1 gap-4">
<div class="flex gap-8 items-center">
<div class="grow">Name: {{ apiKey.apiName }}</div>
<div>{{ apiKey.apiKey }}</div>
<div class="flex justify-end" v-if="!isGuest">
<i class="far fa-trash cursor-pointer" @click="deleteApiKey(apiKey._id.toString())"></i>
</div>
</div>
<div class="py-4 flex flex-col gap-3">
<Label> Workspace name </Label>
<div class="flex gap-4 relative">
<Input class="poppins h-12 pr-[6.5rem]" v-model="currentProjectName"></Input>
<Button @click="changeProjectName()"
:disabled="changing || currentProjectName === projectStore.activeProject?.name"
class="absolute right-1.5 top-1.5 rounded">
<span v-if="changing">
<LoaderCircle class="size-5 animate-[spin_1s_ease-in-out_infinite] duration-500">
</LoaderCircle>
</span>
<span v-else> Change </span>
</Button>
</div>
</div>
<div class="bg-muted dark:bg-muted/40 w-full h-full rounded-md">
<div class="p-4 flex flex-col gap-3 ">
<Label> Workspace id </Label>
<div class="flex gap-4 relative">
<Input class="poppins h-12 bg-white dark:bg-black" :default-value="projectStore.activeProject?._id.toString()" readonly></Input>
<Button @click="copyProjectId()"variant="ghost" class="absolute right-1.5 top-1.5">
<CopyIcon class="size-4"/>
</Button>
</div>
</div>
<div class="p-4 flex flex-col gap-3">
<Label> Script </Label>
<div class="flex gap-4 items-center relative">
<Textarea class="w-full poppins resize-none bg-white dark:bg-black" :default-value="scriptValue.join('')"
readonly></Textarea>
<Button @click="copyScript()" variant="ghost" class="absolute right-1.5 top-1.5">
<CopyIcon class="size-4"/>
</Button>
</div>
</LyxUiCard>
</template>
<template #pid>
<LyxUiCard class="w-full flex items-center">
<div class="grow">{{ project?._id.toString() }}</div>
<div><i class="far fa-copy" @click="copyProjectId()"></i></div>
</LyxUiCard>
</template>
<template #pscript>
<LyxUiCard class="w-full flex items-center">
<div class="grow">
{{ `
<script defer data-project="${project?._id}"
src="https://cdn.jsdelivr.net/gh/litlyx/litlyx-js/browser/litlyx.js"></script>` }}
</div>
<div class="hidden lg:flex"><i class="far fa-copy" @click="copyScript()"></i></div>
</LyxUiCard>
<div class="flex justify-end w-full">
<LyxUiButton type="outline" class="flex lg:hidden mt-4">
Copy script
</LyxUiButton>
</div>
</template>
<template #pdelete>
<div class="flex lg:justify-end" v-if="!isGuest">
<LyxUiButton type="danger" @click="deleteProject()">
Delete project
</LyxUiButton>
</div>
<div v-if="isGuest"> *Guests cannot delete project </div>
</template>
</SettingsTemplate>
</template>
</div>
</CardContent>
<CardFooter>
<Button :disabled="projectStore.projects.filter(e => !e.guest).length <= 1"
@click="showDeleteProjectDialog" variant="destructive">
<Trash></Trash>
Delete workspace
</Button>
</CardFooter>
</Card>
</template>

View File

@@ -1,37 +0,0 @@
<script lang="ts" setup>
export type SettingsTemplateEntry = {
title: string,
text: string,
id: string
}
type SettingsTemplateProp = {
entries: SettingsTemplateEntry[]
}
const props = defineProps<SettingsTemplateProp>();
</script>
<template>
<div class="mt-10 px-4 xl:pb-0 pb-[10rem]">
<div v-for="(entry, index) of props.entries" class="flex flex-col">
<div class="flex xl:flex-row flex-col gap-4 xl:gap-0">
<div class="xl:flex-[2]">
<div class="poppins font-medium text-lyx-lightmode-text dark:text-lyx-text">
{{ entry.title }}
</div>
<div class="poppins font-regular text-lyx-lightmode-text-dark dark:text-lyx-text-dark whitespace-pre-wrap">
{{ entry.text }}
</div>
</div>
<div class="xl:flex-[3]">
<slot :name="entry.id"></slot>
</div>
</div>
<div v-if="index < props.entries.length - 1" class="h-[2px] bg-lyx-lightmode-widget dark:bg-lyx-widget-lighter w-full my-10"></div>
</div>
</div>
</template>

View File

@@ -1,294 +0,0 @@
<script lang="ts" setup>
import dayjs from 'dayjs';
import type { SettingsTemplateEntry } from './Template.vue';
import { getPlanFromId, PREMIUM_PLAN, type PREMIUM_TAG } from '@data/PREMIUM';
const { projectId, isGuest } = useProject();
definePageMeta({ layout: 'dashboard' });
const { data: planData, pending: planPending } = useFetch('/api/project/plan', {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
const { data: customerAddress } = useFetch(`/api/pay/customer_info`, {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
});
const percent = computed(() => {
if (!planData.value) return '-';
return (100 / planData.value.limit * planData.value.count).toFixed(2) + '%';
});
const color = computed(() => {
if (!planData.value) return 'blue';
if (planData.value.count >= planData.value.limit) return 'red';
return 'blue';
});
const daysLeft = computed(() => {
if (!planData.value) return '-';
return (-dayjs().diff(planData.value.billing_expire_at, 'days')).toString();
});
const leftPercent = computed(() => {
if (!planData.value) return 0;
const left = dayjs().diff(planData.value.billing_expire_at, 'days');
const total = dayjs(planData.value.billing_start_at).diff(planData.value.billing_expire_at, 'days');
const percent = 100 - (100 / total * left);
return percent;
});
const prettyExpireDate = computed(() => {
if (!planData.value) return '';
return dayjs(planData.value.billing_expire_at).format('DD/MM/YYYY');
});
const { data: invoices, pending: invoicesPending } = useFetch(`/api/pay/invoices`, {
lazy: true, headers: useComputedHeaders({ useSnapshotDates: false })
})
function openInvoice(link: string) {
window.open(link, '_blank');
}
function getPremiumName(type: number) {
return Object.keys(PREMIUM_PLAN).map(e => ({
...PREMIUM_PLAN[e as PREMIUM_TAG], name: e
})).find(e => e.ID == type)?.name;
}
function getPremiumPrice(type: number) {
const PLAN = getPlanFromId(type);
if (!PLAN) return '0,00';
return (PLAN.COST / 100).toFixed(2).replace('.', ',')
}
const entries: SettingsTemplateEntry[] = [
{ id: 'plan', title: 'Current plan', text: 'Manage current plan for this project' },
{ id: 'usage', title: 'Usage', text: 'Show usage of current project' },
{ id: 'info', title: 'Billing address', text: 'This will be reflected in every upcoming invoice,\npast invoices are not affected' },
{ id: 'invoices', title: 'Invoices', text: 'Manage invoices of current project' },
]
watch(customerAddress, () => {
console.log('UPDATE', customerAddress.value)
if (!customerAddress.value) return;
currentBillingInfo.value = customerAddress.value;
});
const currentBillingInfo = ref<any>({
line1: '',
line2: '',
city: '',
country: '',
postal_code: '',
state: ''
});
const { createAlert } = useAlert()
async function saveBillingInfo() {
try {
const res = await $fetch(`/api/pay/update_customer`, {
method: 'POST',
...signHeaders({
'Content-Type': 'application/json',
'x-pid': projectId.value ?? ''
}),
body: JSON.stringify(currentBillingInfo.value)
});
createAlert('Customer updated', 'Customer updated successfully', 'far fa-check', 5000);
} catch (ex) {
createAlert('Error updating customer', 'An error occurred while updating the customer', 'far fa-error', 8000);
}
}
const { showDrawer } = useDrawer();
</script>
<template>
<div class="relative pb-[6rem]">
<div v-if="invoicesPending || planPending"
class="backdrop-blur-[1px] z-[20] mt-20 w-full h-full flex items-center justify-center font-bold">
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
</div>
<SettingsTemplate v-if="!invoicesPending && !planPending && !isGuest" :entries="entries">
<template #info>
<div v-if="!isGuest">
<div class="flex flex-col gap-4">
<LyxUiInput class="px-2 py-2 dark:!bg-[#161616]" placeholder="Address line 1"
v-model="currentBillingInfo.line1">
</LyxUiInput>
<LyxUiInput class="px-2 py-2 dark:!bg-[#161616]" placeholder="Address line 2"
v-model="currentBillingInfo.line2">
</LyxUiInput>
<div class="flex gap-4 w-full">
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="Country"
v-model="currentBillingInfo.country">
</LyxUiInput>
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="Postal code"
v-model="currentBillingInfo.postal_code">
</LyxUiInput>
</div>
<div class="flex gap-4 w-full">
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="City"
v-model="currentBillingInfo.city">
</LyxUiInput>
<LyxUiInput class="px-2 py-2 w-full dark:!bg-[#161616]" placeholder="State"
v-model="currentBillingInfo.state">
</LyxUiInput>
</div>
</div>
<div class="mt-5 flex justify-end">
<LyxUiButton type="primary" @click="saveBillingInfo">
Save
</LyxUiButton>
</div>
</div>
</template>
<template #plan>
<LyxUiCard v-if="planData" class="flex flex-col w-full">
<div class="flex flex-col gap-6 px-8 grow">
<div class="flex justify-between items-center flex-col sm:flex-row">
<div class="flex flex-col">
<div class="flex gap-3 items-center">
<div class="poppins font-semibold text-[1.1rem]">
{{ planData.premium ? 'Premium plan' : 'Basic plan' }}
</div>
<div
class="flex lato text-[.7rem] bg-transparent border-[#262626] border-[1px] px-[.6rem] rounded-sm">
{{ planData.premium ? getPremiumName(planData.premium_type) : 'FREE' }}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<div class="poppins font-semibold text-[2rem]">
{{ getPremiumPrice(planData.premium_type) }} </div>
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub mt-2"> per month
</div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Billing period:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress color="green" :min="0" :max="100" :value="leftPercent"></UProgress>
</div>
<div class="poppins"> {{ daysLeft }} days left </div>
</div>
<div class="flex justify-center">
Subscription: {{ planData.subscription_status }}
</div>
</div>
</div>
<div class="my-4 w-full bg-gray-400/30 h-[1px]">
</div>
<div class="flex justify-between px-8 flex-col lg:flex-row gap-2 lg:gap-0 items-center">
<div class="flex gap-2 text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
<div class="poppins"> Expire date:</div>
<div> {{ prettyExpireDate }}</div>
</div>
<LyxUiButton v-if="!isGuest" @click="showDrawer('PRICING')" type="primary">
Upgrade plan
</LyxUiButton>
</div>
</LyxUiCard>
</template>
<template #usage>
<LyxUiCard v-if="planData" class="flex flex-col w-full">
<div class="flex flex-col gap-6 px-8">
<div class="flex justify-between">
<div class="flex flex-col">
<div class="poppins font-semibold text-[1.1rem]">
Usage
</div>
<div class="poppins text-lyx-lightmode-text-dark dark:text-text-sub text-[.9rem]">
Check the usage limits of your project.
</div>
</div>
</div>
<div class="flex flex-col">
<div class="poppins"> Usage:</div>
<div class="flex items-center gap-2 md:gap-4 flex-col pt-4 md:pt-0 md:flex-row">
<div class="grow w-full md:w-auto">
<UProgress :color="color" :min="0" :max="planData.limit" :value="planData.count">
</UProgress>
</div>
<div class="poppins"> {{ percent }}</div>
</div>
<div class="flex justify-center">
{{ formatNumberK(planData.count) }} / {{ formatNumberK(planData.limit) }}
</div>
</div>
</div>
</LyxUiCard>
</template>
<template #invoices>
<CardTitled v-if="!isGuest" title="Invoices"
:sub="(invoices && invoices.length == 0) ? 'No invoices yet' : ''" class="p-4 mt-8 w-full">
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center outline-[1px] outline outline-lyx-lightmode-widget dark:outline-none bg-lyx-lightmode-widget-light dark:bg-[#161616] p-4 rounded-lg"
v-for="invoice of invoices">
<div> <i class="fal fa-file-invoice"></i> </div>
<div class="flex flex-col md:flex-row md:justify-around md:grow items-center gap-2">
<div> {{ new Date(invoice.date).toLocaleString() }} </div>
<div> {{ invoice.cost / 100 }} </div>
<div> {{ invoice.id }} </div>
<div
class="flex items-center lato text-[.8rem] bg-accent/25 border-accent/40 border-[1px] px-[.6rem] rounded-lg">
{{ invoice.status }}
</div>
</div>
<div>
<i @click="openInvoice(invoice.link)"
class="far fa-download cursor-pointer hover:text-white/80"></i>
</div>
</div>
</div>
</CardTitled>
</template>
</SettingsTemplate>
<div v-if="isGuest" class="text-lyx-text-darker flex w-full h-full justify-center mt-20">
Guests cannot view billing
</div>
</div>
</template>
<style scoped lang="scss">
.pdrawer-enter-active,
.pdrawer-leave-active {
transition: all .5s ease-in-out;
}
.pdrawer-enter-from,
.pdrawer-leave-to {
transform: translateX(100%)
}
.pdrawer-enter-to,
.pdrawer-leave-from {
transform: translateX(0)
}
</style>