add: full multi-tenancy control

This commit is contained in:
Cauê Faleiros
2026-02-02 15:31:15 -03:00
commit c6ec92802b
1711 changed files with 258106 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
@props([
'isActive' => true,
])
<div {{ $attributes->merge(['class' => 'box-shadow rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900']) }}>
<v-accordion
is-active="{{ $isActive }}"
{{ $attributes }}
>
<x-admin::shimmer.accordion class="h-[271px] w-[360px]" />
@isset($header)
<template v-slot:header="{ toggle, isOpen }">
<div {{ $header->attributes->merge(['class' => 'flex items-center justify-between p-1.5']) }}>
{{ $header }}
<span
:class="`cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-100 dark:hover:bg-gray-950 ${isOpen ? 'icon-up-arrow' : 'icon-down-arrow'}`"
@click="toggle"
></span>
</div>
</template>
@endisset
@isset($content)
<template v-slot:content="{ isOpen }">
<div
{{ $content->attributes->merge(['class' => 'px-4 pb-4']) }}
v-show="isOpen"
>
{{ $content }}
</div>
</template>
@endisset
</v-accordion>
</div>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-accordion-template"
>
<div>
<slot
name="header"
:toggle="toggle"
:isOpen="isOpen"
>
Default Header
</slot>
<slot
name="content"
:isOpen="isOpen"
>
Default Content
</slot>
</div>
</script>
<script type="module">
app.component('v-accordion', {
template: '#v-accordion-template',
props: [
'isActive',
],
data() {
return {
isOpen: this.isActive,
};
},
methods: {
toggle() {
this.isOpen = ! this.isOpen;
this.$emit('toggle', { isActive: this.isOpen });
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,285 @@
@props([
'entity' => null,
'entityControlName' => null,
])
<!-- Activity Button -->
<div>
{!! view_render_event('admin.components.activities.actions.activity.create_btn.before') !!}
<button
class="flex h-[74px] w-[84px] flex-col items-center justify-center gap-1 rounded-lg border border-transparent bg-blue-200 font-medium text-blue-800 transition-all hover:border-blue-400"
@click="$refs.actionComponent.openModal('mail')"
>
<span class="icon-activity text-2xl dark:!text-blue-800"></span>
@lang('admin::app.components.activities.actions.activity.btn')
</button>
{!! view_render_event('admin.components.activities.actions.activity.create_btn.after') !!}
{!! view_render_event('admin.components.activities.actions.activity.before') !!}
<!-- Note Action Vue Component -->
<v-activity
ref="actionComponent"
:entity="{{ json_encode($entity) }}"
entity-control-name="{{ $entityControlName }}"
></v-activity>
{!! view_render_event('admin.components.activities.actions.activity.after') !!}
</div>
@pushOnce('scripts')
<script type="text/x-template" id="v-activity-template">
<Teleport to="body">
{!! view_render_event('admin.components.activities.actions.activity.form_controls.before') !!}
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
as="div"
ref="modalForm"
>
<form @submit="handleSubmit($event, save)">
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.before') !!}
<x-admin::modal
ref="activityModal"
position="bottom-right"
>
<x-slot:header>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.header.dropdown.before') !!}
<x-admin::dropdown>
<x-slot:toggle>
<h3 class="flex cursor-pointer items-center gap-1 text-base font-semibold dark:text-white">
@lang('admin::app.components.activities.actions.activity.title') - @{{ selectedType.label }}
<span class="icon-down-arrow text-2xl"></span>
</h3>
</x-slot>
<x-slot:menu>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.header.dropdown.menu_item.before') !!}
<x-admin::dropdown.menu.item
::class="{ 'bg-gray-100 dark:bg-gray-950': selectedType.value === type.value }"
v-for="type in availableTypes"
@click="selectedType = type"
>
@{{ type.label }}
</x-admin::dropdown.menu.item>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.header.dropdown.menu_item.after') !!}
</x-slot>
</x-admin::dropdown>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.header.dropdown.after') !!}
</x-slot>
<x-slot:content>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.content.controls.before') !!}
<!-- Activity Type -->
<x-admin::form.control-group.control
type="hidden"
name="type"
v-model="selectedType.value"
/>
<!-- Id -->
<x-admin::form.control-group.control
type="hidden"
::name="entityControlName"
::value="entity.id"
/>
<!-- Title -->
<x-admin::form.control-group>
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.activity.title-control')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="text"
name="title"
rules="required|max:80"
:label="trans('admin::app.components.activities.actions.activity.title-control')"
/>
<x-admin::form.control-group.error control-name="title" />
</x-admin::form.control-group>
<!-- Description -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.activity.description')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="textarea"
name="comment"
rules="max:500"
/>
<x-admin::form.control-group.error control-name="comment" />
</x-admin::form.control-group>
<!-- Participants -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.activity.participants.title')
</x-admin::form.control-group.label>
<x-admin::activities.actions.activity.participants />
</x-admin::form.control-group>
<!-- Schedule Date -->
<div class="flex gap-4">
<!-- Started From -->
<x-admin::form.control-group class="w-full">
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.activity.schedule-from')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="datetime"
name="schedule_from"
rules="required"
:label="trans('admin::app.components.activities.actions.activity.schedule-from')"
/>
<x-admin::form.control-group.error control-name="schedule_from" />
</x-admin::form.control-group>
<!-- Started To -->
<x-admin::form.control-group class="w-full">
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.activity.schedule-to')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="datetime"
name="schedule_to"
rules="required"
:label="trans('admin::app.components.activities.actions.activity.schedule-to')"
/>
<x-admin::form.control-group.error control-name="schedule_to" />
</x-admin::form.control-group>
</div>
<!-- Location -->
<x-admin::form.control-group class="!mb-0">
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.activity.location')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="text"
name="location"
/>
</x-admin::form.control-group>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.content.controls.after') !!}
</x-slot>
<x-slot:footer>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.footer.save_button.before') !!}
<x-admin::button
class="primary-button"
:title="trans('admin::app.components.activities.actions.activity.save-btn')"
::loading="isStoring"
::disabled="isStoring"
/>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.footer.save_button.after') !!}
</x-slot>
</x-admin::modal>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.modal.after') !!}
</form>
</x-admin::form>
{!! view_render_event('admin.components.activities.actions.activity.form_controls.after') !!}
</Teleport>
</script>
<script type="module">
app.component('v-activity', {
template: '#v-activity-template',
props: {
entity: {
type: Object,
required: true,
default: () => {}
},
entityControlName: {
type: String,
required: true,
default: ''
}
},
data: function () {
return {
isStoring: false,
selectedType: {
label: "{{ trans('admin::app.components.activities.actions.activity.call') }}",
value: 'call'
},
availableTypes: [
{
label: "{{ trans('admin::app.components.activities.actions.activity.call') }}",
value: 'call'
}, {
label: "{{ trans('admin::app.components.activities.actions.activity.meeting') }}",
value: 'meeting'
}, {
label: "{{ trans('admin::app.components.activities.actions.activity.lunch') }}",
value: 'lunch'
},
]
}
},
methods: {
openModal(type) {
this.$refs.activityModal.open();
},
save(params) {
this.isStoring = true;
this.$axios.post("{{ route('admin.activities.store') }}", params)
.then (response => {
this.isStoring = false;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
this.$emitter.emit('on-activity-added', response.data.data);
this.$refs.activityModal.close();
})
.catch (error => {
this.isStoring = false;
if (error.response.status == 422) {
setErrors(error.response.data.errors);
} else {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
this.$refs.activityModal.close();
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,245 @@
{!! view_render_event('admin.components.activities.actions.activity.participants.before') !!}
<!-- Participants Vue Component -->
<v-activity-participants></v-activity-participants>
{!! view_render_event('admin.components.activities.actions.activity.participants.after') !!}
@pushOnce('scripts')
<script type="text/x-template" id="v-activity-participants-template">
<!-- Search Button -->
<div class="relative">
<div
class="relative rounded border border-gray-200 px-2 py-1 hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:hover:border-gray-400"
role="button"
>
<ul class="flex flex-wrap items-center gap-1">
<template v-for="userType in ['users', 'persons']">
{!! view_render_event('admin.components.activities.actions.activity.participants.user_type.before') !!}
<li
class="flex items-center gap-1 rounded-md bg-slate-100 pl-2 dark:bg-gray-950 dark:text-gray-300"
v-for="(user, index) in addedParticipants[userType]"
>
{!! view_render_event('admin.components.activities.actions.activity.participants.user_type.user.before') !!}
<!-- User Id -->
<x-admin::form.control-group.control
type="hidden"
::name="'participants.' + userType + '[' + index + ']'"
::value="user.id"
/>
@{{ user.name }}
<span
class="icon-cross-large cursor-pointer p-0.5 text-xl"
@click="remove(userType, user)"
></span>
{!! view_render_event('admin.components.activities.actions.activity.participants.user_type.user.after') !!}
</li>
{!! view_render_event('admin.components.activities.actions.activity.participants.user_type.after') !!}
</template>
<li>
{!! view_render_event('admin.components.activities.actions.activity.participants.search_term.before') !!}
<input
type="text"
class="w-full px-1 py-1 dark:bg-gray-900 dark:text-gray-300"
placeholder="@lang('admin::app.components.activities.actions.activity.participants.placeholder')"
v-model.lazy="searchTerm"
v-debounce="500"
/>
{!! view_render_event('admin.components.activities.actions.activity.participants.search_term.after') !!}
</li>
</ul>
<div>
<template v-if="! isSearching.users && ! isSearching.persons">
<span
class="absolute right-1.5 top-1.5 text-2xl"
:class="[searchTerm.length >= 2 ? 'icon-up-arrow' : 'icon-down-arrow']"
></span>
</template>
<template v-else>
<x-admin::spinner class="absolute right-2 top-2" />
</template>
</div>
</div>
{!! view_render_event('admin.components.activities.actions.activity.participants.dropdown.before') !!}
<!-- Search Dropdown -->
<div
class="absolute z-10 w-full rounded bg-white shadow-[0px_10px_20px_0px_#0000001F] dark:bg-gray-900"
v-if="searchTerm.length >= 2"
>
<ul class="flex flex-col gap-1 p-2">
<!-- Users -->
<li
class="flex flex-col gap-2"
v-for="userType in ['users', 'persons']"
>
{!! view_render_event('admin.components.activities.actions.activity.participants.dropdown.user_type.before') !!}
<h3 class="text-sm font-bold text-gray-600 dark:text-gray-300">
<template v-if="userType === 'users'">
@lang('admin::app.components.activities.actions.activity.participants.users')
</template>
<template v-else>
@lang('admin::app.components.activities.actions.activity.participants.persons')
</template>
</h3>
{!! view_render_event('admin.components.activities.actions.activity.participants.dropdown.user_type.after') !!}
{!! view_render_event('admin.components.activities.actions.activity.participants.dropdown.no_results.before') !!}
<ul>
<li
class="rounded-sm px-5 py-2 text-sm text-gray-800 dark:text-white"
v-if="! searchedParticipants[userType].length && ! isSearching[userType]"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
@lang('admin::app.components.activities.actions.activity.participants.no-results')
</p>
</li>
<li
class="cursor-pointer rounded-sm px-3 py-2 text-sm text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-950"
v-for="user in searchedParticipants[userType]"
@click="add(userType, user)"
>
@{{ user.name }}
</li>
</ul>
{!! view_render_event('admin.components.activities.actions.activity.participants.dropdown.no_results.after') !!}
</li>
</ul>
</div>
{!! view_render_event('admin.components.activities.actions.activity.participants.dropdown.after') !!}
</div>
</script>
<script type="module">
app.component('v-activity-participants', {
template: '#v-activity-participants-template',
props: {
participants: {
type: Object,
default: () => ({
users: [],
persons: [],
})
}
},
data: function () {
return {
isSearching: {
users: false,
persons: false,
},
searchTerm: '',
addedParticipants: {
users: [],
persons: [],
},
searchedParticipants: {
users: [],
persons: [],
},
searchEnpoints: {
users: "{{ route('admin.settings.users.search') }}",
persons: "{{ route('admin.contacts.persons.search') }}",
},
}
},
watch: {
searchTerm(newVal, oldVal) {
this.search('users');
this.search('persons');
},
},
mounted() {
this.addedParticipants = this.participants;
},
methods: {
search(userType) {
if (this.searchTerm.length <= 1) {
this.searchedParticipants[userType] = [];
this.isSearching[userType] = false;
return;
}
this.isSearching[userType] = true;
let self = this;
this.$axios.get(this.searchEnpoints[userType], {
params: {
search: 'name:' + this.searchTerm,
searchFields: 'name:like',
}
})
.then (function(response) {
self.addedParticipants[userType].forEach(function(addedParticipant) {
response.data.data = response.data.data.filter(function(participant) {
return participant.id !== addedParticipant.id;
});
});
self.searchedParticipants[userType] = response.data.data;
self.isSearching[userType] = false;
})
.catch (function (error) {
self.isSearching[userType] = false;
});
},
add(userType, participant) {
this.addedParticipants[userType].push(participant);
this.searchTerm = '';
this.searchedParticipants = {
users: [],
persons: [],
};
},
remove(userType, participant) {
this.addedParticipants[userType] = this.addedParticipants[userType].filter(function(addedParticipant) {
return addedParticipant.id !== participant.id;
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,216 @@
@props([
'entity' => null,
'entityControlName' => null,
])
<!-- File Button -->
<div>
{!! view_render_event('admin.components.activities.actions.file.create_btn.before') !!}
<button
class="flex h-[74px] w-[84px] flex-col items-center justify-center gap-1 rounded-lg border border-transparent bg-cyan-200 font-medium text-cyan-900 transition-all hover:border-cyan-400"
@click="$refs.fileActionComponent.openModal('mail')"
>
<span class="icon-file text-2xl dark:!text-cyan-900"></span>
@lang('admin::app.components.activities.actions.file.btn')
</button>
{!! view_render_event('admin.components.activities.actions.file.create_btn.after') !!}
{!! view_render_event('admin.components.activities.actions.file.before') !!}
<!-- File Action Vue Component -->
<v-file-activity
ref="fileActionComponent"
:entity="{{ json_encode($entity) }}"
entity-control-name="{{ $entityControlName }}"
></v-file-activity>
{!! view_render_event('admin.components.activities.actions.file.after') !!}
</div>
@pushOnce('scripts')
<script type="text/x-template" id="v-file-activity-template">
<Teleport to="body">
{!! view_render_event('admin.components.activities.actions.file.form_controls.before') !!}
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
as="div"
ref="modalForm"
>
<form @submit="handleSubmit($event, save)">
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.before') !!}
<x-admin::modal
ref="fileActivityModal"
position="bottom-right"
>
<x-slot:header>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.header.title.before') !!}
<h3 class="text-base font-semibold dark:text-white">
@lang('admin::app.components.activities.actions.file.title')
</h3>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.header.title.after') !!}
</x-slot>
<x-slot:content>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.content.controls.before') !!}
<!-- Activity Type -->
<x-admin::form.control-group.control
type="hidden"
name="type"
value="file"
/>
<!-- Id -->
<x-admin::form.control-group.control
type="hidden"
::name="entityControlName"
::value="entity.id"
/>
<!-- Title -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.file.title-control')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="text"
name="title"
/>
</x-admin::form.control-group>
<!-- Description -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.file.description')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="textarea"
name="comment"
/>
</x-admin::form.control-group>
<!-- File Name -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.file.name')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="text"
name="name"
/>
</x-admin::form.control-group>
<!-- File -->
<x-admin::form.control-group class="!mb-0">
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.file.file')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="file"
id="file"
name="file"
rules="required"
:label="trans('admin::app.components.activities.actions.file.file')"
/>
<x-admin::form.control-group.error control-name="file" />
</x-admin::form.control-group>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.content.controls.after') !!}
</x-slot>
<x-slot:footer>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.footer.save_buton.before') !!}
<x-admin::button
class="primary-button"
:title="trans('admin::app.components.activities.actions.file.save-btn')"
::loading="isStoring"
::disabled="isStoring"
/>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.footer.save_buton.after') !!}
</x-slot>
</x-admin::modal>
{!! view_render_event('admin.components.activities.actions.file.form_controls.modal.after') !!}
</form>
</x-admin::form>
{!! view_render_event('admin.components.activities.actions.file.form_controls.after') !!}
</Teleport>
</script>
<script type="module">
app.component('v-file-activity', {
template: '#v-file-activity-template',
props: {
entity: {
type: Object,
required: true,
default: () => {}
},
entityControlName: {
type: String,
required: true,
default: ''
}
},
data: function () {
return {
isStoring: false,
}
},
methods: {
openModal(type) {
this.$refs.fileActivityModal.open();
},
save(params, { setErrors }) {
this.isStoring = true;
this.$axios.post("{{ route('admin.activities.store') }}", params, {
headers: {
'Content-Type': 'multipart/form-data',
}
})
.then (response => {
this.isStoring = false;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
this.$emitter.emit('on-activity-added', response.data.data);
this.$refs.fileActivityModal.close();
})
.catch (error => {
this.isStoring = false;
if (error.response.status == 422) {
setErrors(error.response.data.errors);
} else {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
this.$refs.fileActivityModal.close();
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,293 @@
@props([
'entity' => null,
'entityControlName' => null,
])
<!-- Mail Button -->
<div>
{!! view_render_event('admin.components.activities.actions.mail.create_btn.before') !!}
<button
class="flex h-[74px] w-[84px] flex-col items-center justify-center gap-1 rounded-lg border border-transparent bg-green-200 font-medium text-green-900 transition-all hover:border-green-400"
@click="$refs.mailActionComponent.openModal('mail')"
>
<span class="icon-mail text-2xl dark:!text-green-900"></span>
@lang('admin::app.components.activities.actions.mail.btn')
</button>
{!! view_render_event('admin.components.activities.actions.mail.create_btn.after') !!}
{!! view_render_event('admin.components.activities.actions.mail.before') !!}
<!-- Mail Activity Action Vue Component -->
<v-mail-activity
ref="mailActionComponent"
:entity="{{ json_encode($entity) }}"
entity-control-name="{{ $entityControlName }}"
></v-mail-activity>
{!! view_render_event('admin.components.activities.actions.mail.after') !!}
</div>
@pushOnce('scripts')
<script type="text/x-template" id="v-mail-activity-template">
<Teleport to="body">
{!! view_render_event('admin.components.activities.actions.mail.form_controls.before') !!}
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
enctype="multipart/form-data"
as="div"
>
<form
@submit="handleSubmit($event, save)"
ref="mailActionForm"
>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.before') !!}
<x-admin::modal
ref="mailActivityModal"
position="bottom-right"
>
<x-slot:header>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.header.before') !!}
<h3 class="text-base font-semibold dark:text-white">
@lang('admin::app.components.activities.actions.mail.title')
</h3>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.header.before') !!}
</x-slot>
<x-slot:content>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.content.controls.before') !!}
<!-- Activity Type -->
<x-admin::form.control-group.control
type="hidden"
name="type"
value="email"
/>
<!-- Id -->
<x-admin::form.control-group.control
type="hidden"
::name="entityControlName"
::value="entity.id"
/>
<!-- To -->
<x-admin::form.control-group>
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.mail.to')
</x-admin::form.control-group.label>
<div class="relative">
<x-admin::form.control-group.control
type="tags"
name="reply_to"
rules="required"
input-rules="email"
:label="trans('admin::app.components.activities.actions.mail.to')"
:placeholder="trans('admin::app.components.activities.actions.mail.enter-emails')"
/>
<div class="absolute top-[9px] flex items-center gap-2 ltr:right-2 rtl:left-2">
<span
class="cursor-pointer font-medium hover:underline dark:text-white"
@click="showCC = ! showCC"
>
@lang('admin::app.components.activities.actions.mail.cc')
</span>
<span
class="cursor-pointer font-medium hover:underline dark:text-white"
@click="showBCC = ! showBCC"
>
@lang('admin::app.components.activities.actions.mail.bcc')
</span>
</div>
</div>
<x-admin::form.control-group.error control-name="reply_to" />
</x-admin::form.control-group>
<template v-if="showCC">
<!-- Cc -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.mail.cc')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="tags"
name="cc"
input-rules="email"
:label="trans('admin::app.components.activities.actions.mail.cc')"
:placeholder="trans('admin::app.components.activities.actions.mail.enter-emails')"
/>
<x-admin::form.control-group.error control-name="cc" />
</x-admin::form.control-group>
</template>
<template v-if="showBCC">
<!-- Cc -->
<x-admin::form.control-group>
<x-admin::form.control-group.label>
@lang('admin::app.components.activities.actions.mail.bcc')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="tags"
name="bcc"
input-rules="email"
:label="trans('admin::app.components.activities.actions.mail.bcc')"
:placeholder="trans('admin::app.components.activities.actions.mail.enter-emails')"
/>
<x-admin::form.control-group.error control-name="bcc" />
</x-admin::form.control-group>
</template>
<!-- Subject -->
<x-admin::form.control-group>
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.mail.subject')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="text"
id="subject"
name="subject"
rules="required"
:label="trans('admin::app.components.activities.actions.mail.subject')"
:placeholder="trans('admin::app.components.activities.actions.mail.subject')"
/>
<x-admin::form.control-group.error control-name="subject" />
</x-admin::form.control-group>
<!-- Content -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="textarea"
name="reply"
id="reply"
rules="required"
{{-- tinymce="true" --}}
:label="trans('admin::app.components.activities.actions.mail.message')"
/>
<x-admin::form.control-group.error control-name="reply" />
</x-admin::form.control-group>
<!-- Attachments -->
<x-admin::form.control-group class="!mb-0">
<x-admin::attachments
allow-multiple="true"
hide-button="true"
/>
</x-admin::form.control-group>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.content.controls.after') !!}
</x-slot>
<x-slot:footer>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.footer.save_button.before') !!}
<div class="flex w-full items-center justify-between">
<label
class="icon-attachment cursor-pointer p-1 text-2xl hover:rounded-md hover:bg-gray-100 dark:hover:bg-gray-950"
for="file-upload"
></label>
<x-admin::button
class="primary-button"
:title="trans('admin::app.components.activities.actions.mail.send-btn')"
::loading="isStoring"
::disabled="isStoring"
/>
</div>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.footer.save_button.after') !!}
</x-slot>
</x-admin::modal>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.modal.after') !!}
</form>
</x-admin::form>
{!! view_render_event('admin.components.activities.actions.mail.form_controls.after') !!}
</Teleport>
</script>
<script type="module">
app.component('v-mail-activity', {
template: '#v-mail-activity-template',
props: {
entity: {
type: Object,
required: true,
default: () => {}
},
entityControlName: {
type: String,
required: true,
default: ''
}
},
data() {
return {
showCC: false,
showBCC: false,
isStoring: false,
}
},
methods: {
openModal(type) {
this.$refs.mailActivityModal.open();
},
save(params, { resetForm, setErrors }) {
this.isStoring = true;
let formData = new FormData(this.$refs.mailActionForm);
this.$axios.post("{{ route('admin.leads.emails.store', 'replaceLeadId') }}".replace('replaceLeadId', this.entity.id), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then (response => {
this.isStoring = false;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
this.$emitter.emit('on-activity-added', response.data.data);
this.$refs.mailActivityModal.close();
})
.catch (error => {
this.isStoring = false;
if (error.response.status == 422) {
setErrors(error.response.data.errors);
} else {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
this.$refs.mailActivityModal.close();
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,175 @@
@props([
'entity' => null,
'entityControlName' => null,
])
<!-- Note Button -->
<div>
{!! view_render_event('admin.components.activities.actions.note.create_btn.before') !!}
<button
class="flex h-[74px] w-[84px] flex-col items-center justify-center gap-1 rounded-lg border border-transparent bg-orange-200 font-medium text-orange-800 transition-all hover:border-orange-400"
@click="$refs.noteActionComponent.openModal('mail')"
>
<span class="icon-note text-2xl dark:!text-orange-800"></span>
@lang('admin::app.components.activities.actions.note.btn')
</button>
{!! view_render_event('admin.components.activities.actions.note.create_btn.after') !!}
{!! view_render_event('admin.components.activities.actions.note.before') !!}
<!-- Note Action Vue Component -->
<v-note-activity
ref="noteActionComponent"
:entity="{{ json_encode($entity) }}"
entity-control-name="{{ $entityControlName }}"
></v-note-activity>
{!! view_render_event('admin.components.activities.actions.note.after') !!}
</div>
@pushOnce('scripts')
<script type="text/x-template" id="v-note-activity-template">
<Teleport to="body">
{!! view_render_event('admin.components.activities.actions.note.form_controls.before') !!}
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
as="div"
ref="modalForm"
>
<form @submit="handleSubmit($event, save)">
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.before') !!}
<x-admin::modal
ref="noteActivityModal"
position="bottom-right"
>
<x-slot:header>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.header.title.before') !!}
<h3 class="text-base font-semibold dark:text-white">
@lang('admin::app.components.activities.actions.note.title')
</h3>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.header.title.after') !!}
</x-slot>
<x-slot:content>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.header.content.controls.before') !!}
<!-- Activity Type -->
<x-admin::form.control-group.control
type="hidden"
name="type"
value="note"
/>
<!-- Id -->
<x-admin::form.control-group.control
type="hidden"
::name="entityControlName"
::value="entity.id"
/>
<!-- Comment -->
<x-admin::form.control-group class="!mb-0">
<x-admin::form.control-group.label class="required">
@lang('admin::app.components.activities.actions.note.comment')
</x-admin::form.control-group.label>
<x-admin::form.control-group.control
type="textarea"
name="comment"
rules="required"
:label="trans('admin::app.components.activities.actions.note.comment')"
/>
<x-admin::form.control-group.error control-name="comment" />
</x-admin::form.control-group>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.header.content.controls.after') !!}
</x-slot>
<x-slot:footer>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.header.footer.save_button.before') !!}
<x-admin::button
class="primary-button"
:title="trans('admin::app.components.activities.actions.note.save-btn')"
::loading="isStoring"
::disabled="isStoring"
/>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.header.footer.save_button.after') !!}
</x-slot>
</x-admin::modal>
{!! view_render_event('admin.components.activities.actions.note.form_controls.modal.after') !!}
</form>
</x-admin::form>
{!! view_render_event('admin.components.activities.actions.note.form_controls.after') !!}
</Teleport>
</script>
<script type="module">
app.component('v-note-activity', {
template: '#v-note-activity-template',
props: {
entity: {
type: Object,
required: true,
default: () => {}
},
entityControlName: {
type: String,
required: true,
default: ''
}
},
data: function () {
return {
isStoring: false,
}
},
methods: {
openModal(type) {
this.$refs.noteActivityModal.open();
},
save(params) {
this.isStoring = true;
this.$axios.post("{{ route('admin.activities.store') }}", params)
.then (response => {
this.isStoring = false;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
this.$emitter.emit('on-activity-added', response.data.data);
this.$refs.noteActivityModal.close();
})
.catch (error => {
this.isStoring = false;
if (error.response.status == 422) {
setErrors(error.response.data.errors);
} else {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
this.$refs.noteActivityModal.close();
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,640 @@
@props([
'endpoint',
'emailDetachEndpoint' => null,
'activeType' => 'all',
'types' => null,
'extraTypes' => null,
])
{!! view_render_event('admin.components.activities.before') !!}
<!-- Lead Activities Vue Component -->
<v-activities
endpoint="{{ $endpoint }}"
email-detach-endpoint="{{ $emailDetachEndpoint }}"
active-type="{{ $activeType }}"
@if($types):types='@json($types)'@endif
@if($extraTypes):extra-types='@json($extraTypes)'@endif
ref="activities"
>
<!-- Shimmer -->
<x-admin::shimmer.activities />
@foreach ($extraTypes ?? [] as $type)
<template v-slot:{{ $type['name'] }}>
{{ ${$type['name']} ?? '' }}
</template>
@endforeach
</v-activities>
{!! view_render_event('admin.components.activities.after') !!}
@pushOnce('scripts')
<script type="text/x-template" id="v-activities-template">
<template v-if="isLoading">
<!-- Shimmer -->
<x-admin::shimmer.activities />
</template>
<template v-else>
{!! view_render_event('admin.components.activities.content.before') !!}
<div class="w-full rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div class="flex gap-2 overflow-x-auto border-b border-gray-200 dark:border-gray-800">
{!! view_render_event('admin.components.activities.content.types.before') !!}
<div
v-for="type in types"
class="cursor-pointer px-3 py-2.5 text-sm font-medium dark:text-white"
:class="{'border-brandColor border-b-2 !text-brandColor transition': selectedType == type.name }"
@click="selectedType = type.name"
>
@{{ type.label }}
</div>
{!! view_render_event('admin.components.activities.content.types.after') !!}
</div>
<!-- Show Default Activities if selectedType not in extraTypes -->
<template v-if="! extraTypes.find(type => type.name == selectedType)">
<div class="animate-[on-fade_0.5s_ease-in-out] p-4">
{!! view_render_event('admin.components.activities.content.activity.list.before') !!}
<!-- Activity List -->
<div class="flex flex-col gap-4">
{!! view_render_event('admin.components.activities.content.activity.item.before') !!}
<!-- Activity Item -->
<div
class="flex gap-2"
v-for="(activity, index) in filteredActivities"
>
{!! view_render_event('admin.components.activities.content.activity.item.icon.before') !!}
<!-- Activity Icon -->
<div
class="mt-2 flex h-9 min-h-9 w-9 min-w-9 items-center justify-center rounded-full text-xl"
:class="typeClasses[activity.type] ?? typeClasses['default']"
>
</div>
{!! view_render_event('admin.components.activities.content.activity.item.icon.after') !!}
{!! view_render_event('admin.components.activities.content.activity.item.details.before') !!}
<!-- Activity Details -->
<div
class="flex w-full justify-between gap-4 rounded-md p-4"
:class="{'bg-gray-100 dark:bg-gray-950': index % 2 != 0 }"
>
<div class="flex flex-col gap-2">
{!! view_render_event('admin.components.activities.content.activity.item.title.before') !!}
<!-- Activity Title -->
<div
class="flex flex-col gap-1"
v-if="activity.title"
>
<p class="flex flex-wrap items-center gap-1 font-medium dark:text-white">
@{{ activity.title }}
<template v-if="activity.type == 'system' && activity.additional">
<p class="flex items-center gap-1">
<span>:</span>
<span class="break-words">
@{{ (activity.additional.old.label ? String(activity.additional.old.label).replaceAll('<br>', ' ') : "@lang('admin::app.components.activities.index.empty')") }}
</span>
<span class="icon-stats-up rotate-90 text-xl"></span>
<span class="break-words">
@{{ (activity.additional.new.label ? String(activity.additional.new.label).replaceAll('<br>', ' ') : "@lang('admin::app.components.activities.index.empty')") }}
</span>
</p>
</template>
</p>
<template v-if="activity.type == 'email'">
<p class="dark:text-white">
@lang('admin::app.components.activities.index.from'):
@{{ activity.additional.from }}
</p>
<p class="dark:text-white">
@lang('admin::app.components.activities.index.to'):
@{{ activity.additional.to.join(', ') }}
</p>
<p
v-if="activity.additional.cc"
class="dark:text-white"
>
@lang('admin::app.components.activities.index.cc'):
@{{ activity.additional.cc.join(', ') }}
</p>
<p
v-if="activity.additional.bcc"
class="dark:text-white"
>
@lang('admin::app.components.activities.index.bcc'):
@{{ activity.additional.bcc.join(', ') }}
</p>
</template>
<template v-else>
<!-- Activity Schedule -->
<p
v-if="activity.schedule_from && activity.schedule_from"
class="dark:text-white"
>
@lang('admin::app.components.activities.index.scheduled-on'):
@{{ $admin.formatDate(activity.schedule_from, 'd MMM yyyy, h:mm A', timezone) + ' - ' + $admin.formatDate(activity.schedule_to, 'd MMM yyyy, h:mm A', timezone) }}
</p>
<!-- Activity Participants -->
<p
v-if="activity.participants?.length"
class="dark:text-white"
>
@lang('admin::app.components.activities.index.participants'):
<span class="after:content-[',_'] last:after:content-['']" v-for="(participant, index) in activity.participants">
@{{ participant.user?.name ?? participant.person.name }}
</span>
</p>
<!-- Activity Location -->
<p
v-if="activity.location"
class="dark:text-white"
>
@lang('admin::app.components.activities.index.location'):
@{{ activity.location }}
</p>
</template>
</div>
{!! view_render_event('admin.components.activities.content.activity.item.title.after') !!}
{!! view_render_event('admin.components.activities.content.activity.item.description.before') !!}
<!-- Activity Description -->
<p
class="dark:text-white"
v-if="activity.comment"
v-safe-html="activity.comment"
></p>
{!! view_render_event('admin.components.activities.content.activity.item.description.after') !!}
{!! view_render_event('admin.components.activities.content.activity.item.attachments.before') !!}
<!-- Attachments -->
<div
class="flex flex-wrap gap-2"
v-if="activity.files.length"
>
<a
:href="
activity.type == 'email'
? `{{ route('admin.mail.attachment_download', 'replaceID') }}`.replace('replaceID', file.id)
: `{{ route('admin.activities.file_download', 'replaceID') }}`.replace('replaceID', file.id)
"
class="flex cursor-pointer items-center gap-1 rounded-md p-1.5"
target="_blank"
v-for="(file, index) in activity.files"
>
<span class="icon-attached-file text-xl"></span>
<span class="font-medium text-brandColor">
@{{ file.name }}
</span>
</a>
</div>
{!! view_render_event('admin.components.activities.content.activity.item.attachments.after') !!}
{!! view_render_event('admin.components.activities.content.activity.item.time_and_user.before') !!}
<!-- Activity Time and User -->
<div class="text-gray-500 dark:text-gray-300">
@{{ $admin.formatDate(activity.created_at, 'd MMM yyyy, h:mm A', timezone) }},
@{{ "@lang('admin::app.components.activities.index.by-user', ['user' => 'replace'])".replace('replace', activity.user?.name ?? '@lang('admin::app.components.activities.index.system')') }}
</div>
{!! view_render_event('admin.components.activities.content.activity.item.time_and_user.after') !!}
</div>
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.before') !!}
<!-- Activity More Options -->
<template v-if="activity.type != 'system'">
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.dropdown.after') !!}
<x-admin::dropdown position="bottom-{{ in_array(app()->getLocale(), ['fa', 'ar']) ? 'left' : 'right' }}">
<x-slot:toggle>
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.dropdown.toggle.before') !!}
<template v-if="! isUpdating[activity.id]">
<button
class="icon-more flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800"
></button>
</template>
<template v-else>
<x-admin::spinner />
</template>
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.dropdown.toggle.after') !!}
</x-slot>
<x-slot:menu class="!min-w-40">
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.dropdown.menu_item.before') !!}
<template v-if="activity.type != 'email'">
@if (bouncer()->hasPermission('activities.edit'))
<x-admin::dropdown.menu.item
v-if="! activity.is_done && ['call', 'meeting', 'lunch'].includes(activity.type)"
@click="markAsDone(activity)"
>
<div class="flex items-center gap-2">
<span class="icon-tick text-2xl"></span>
@lang('admin::app.components.activities.index.mark-as-done')
</div>
</x-admin::dropdown.menu.item>
<x-admin::dropdown.menu.item v-if="['call', 'meeting', 'lunch'].includes(activity.type)">
<a
class="flex items-center gap-2"
:href="'{{ route('admin.activities.edit', 'replaceId') }}'.replace('replaceId', activity.id)"
target="_blank"
>
<span class="icon-edit text-2xl"></span>
@lang('admin::app.components.activities.index.edit')
</a>
</x-admin::dropdown.menu.item>
@endif
@if (bouncer()->hasPermission('activities.delete'))
<x-admin::dropdown.menu.item @click="remove(activity)">
<div class="flex items-center gap-2">
<span class="icon-delete text-2xl"></span>
@lang('admin::app.components.activities.index.delete')
</div>
</x-admin::dropdown.menu.item>
@endif
</template>
<template v-else>
@if (bouncer()->hasPermission('mail.view'))
<x-admin::dropdown.menu.item>
<a
:href="'{{ route('admin.mail.view', ['route' => 'replaceFolder', 'id' => 'replaceMailId']) }}'.replace('replaceFolder', activity.additional.folders[0]).replace('replaceMailId', activity.id)"
class="flex items-center gap-2"
target="_blank"
>
<span class="icon-eye text-2xl"></span>
@lang('admin::app.components.activities.index.view')
</a>
</x-admin::dropdown.menu.item>
@endif
<x-admin::dropdown.menu.item @click="unlinkEmail(activity)">
<div class="flex items-center gap-2">
<span class="icon-attachment text-2xl"></span>
@lang('admin::app.components.activities.index.unlink')
</div>
</x-admin::dropdown.menu.item>
</template>
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.dropdown.menu_item.after') !!}
</x-slot>
</x-admin::dropdown>
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.dropdown.after') !!}
</template>
{!! view_render_event('admin.components.activities.content.activity.item.more_actions.after') !!}
</div>
{!! view_render_event('admin.components.activities.content.activity.item.details.after') !!}
</div>
{!! view_render_event('admin.components.activities.content.activity.item.after') !!}
<!-- Empty Placeholder -->
<div
class="grid justify-center justify-items-center gap-3.5 py-12"
v-if="! filteredActivities.length"
>
<img
class="dark:mix-blend-exclusion dark:invert"
:src="typeIllustrations[selectedType]?.image ?? typeIllustrations['all'].image"
>
<div class="flex flex-col items-center gap-2">
<p class="text-xl font-semibold dark:text-white">
@{{ typeIllustrations[selectedType]?.title ?? typeIllustrations['all'].title }}
</p>
<p class="text-gray-400 dark:text-gray-400">
@{{ typeIllustrations[selectedType]?.description ?? typeIllustrations['all'].description }}
</p>
</div>
</div>
</div>
{!! view_render_event('admin.components.activities.content.activity.list.after') !!}
</div>
</template>
<template v-else>
<template v-for="type in extraTypes">
{!! view_render_event('admin.components.activities.content.activity.extra_types.before') !!}
<div v-show="selectedType == type.name">
<slot :name="type.name"></slot>
</div>
{!! view_render_event('admin.components.activities.content.activity.extra_types.after') !!}
</template>
</template>
</div>
{!! view_render_event('admin.components.activities.content.after') !!}
</template>
</script>
<script type="module">
app.component('v-activities', {
template: '#v-activities-template',
props: {
endpoint: {
type: String,
default: '',
},
emailDetachEndpoint: {
type: String,
default: '',
},
activeType: {
type: String,
default: 'all',
},
types: {
type: Array,
default: [
{
name: 'all',
label: "{{ trans('admin::app.components.activities.index.all') }}",
}, {
name: 'planned',
label: "{{ trans('admin::app.components.activities.index.planned') }}",
}, {
name: 'note',
label: "{{ trans('admin::app.components.activities.index.notes') }}",
}, {
name: 'call',
label: "{{ trans('admin::app.components.activities.index.calls') }}",
}, {
name: 'meeting',
label: "{{ trans('admin::app.components.activities.index.meetings') }}",
}, {
name: 'lunch',
label: "{{ trans('admin::app.components.activities.index.lunches') }}",
}, {
name: 'file',
label: "{{ trans('admin::app.components.activities.index.files') }}",
}, {
name: 'email',
label: "{{ trans('admin::app.components.activities.index.emails') }}",
}, {
name: 'system',
label: "{{ trans('admin::app.components.activities.index.change-log') }}",
}
],
},
extraTypes: {
type: Array,
default: [],
},
},
data() {
return {
isLoading: false,
isUpdating: {},
activities: [],
selectedType: this.activeType,
typeClasses: {
email: 'icon-mail bg-green-200 text-green-900 dark:!text-green-900',
note: 'icon-note bg-orange-200 text-orange-800 dark:!text-orange-800',
call: 'icon-call bg-cyan-200 text-cyan-800 dark:!text-cyan-800',
meeting: 'icon-activity bg-blue-200 text-blue-800 dark:!text-blue-800',
lunch: 'icon-activity bg-blue-200 text-blue-800 dark:!text-blue-800',
file: 'icon-file bg-green-200 text-green-900 dark:!text-green-900',
system: 'icon-system-generate bg-yellow-200 text-yellow-900 dark:!text-yellow-900',
default: 'icon-activity bg-blue-200 text-blue-800 dark:!text-blue-800',
},
typeIllustrations: {
all: {
image: "{{ vite()->asset('images/empty-placeholders/activities.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.all.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.all.description') }}",
},
planned: {
image: "{{ vite()->asset('images/empty-placeholders/plans.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.planned.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.planned.description') }}",
},
note: {
image: "{{ vite()->asset('images/empty-placeholders/notes.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.notes.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.notes.description') }}",
},
call: {
image: "{{ vite()->asset('images/empty-placeholders/calls.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.calls.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.calls.description') }}",
},
meeting: {
image: "{{ vite()->asset('images/empty-placeholders/meetings.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.meetings.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.meetings.description') }}",
},
lunch: {
image: "{{ vite()->asset('images/empty-placeholders/lunches.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.lunches.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.lunches.description') }}",
},
file: {
image: "{{ vite()->asset('images/empty-placeholders/files.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.files.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.files.description') }}",
},
email: {
image: "{{ vite()->asset('images/empty-placeholders/emails.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.emails.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.emails.description') }}",
},
system: {
image: "{{ vite()->asset('images/empty-placeholders/activities.svg') }}",
title: "{{ trans('admin::app.components.activities.index.empty-placeholders.system.title') }}",
description: "{{ trans('admin::app.components.activities.index.empty-placeholders.system.description') }}",
}
},
timezone: "{{ config('app.timezone') }}",
}
},
computed: {
filteredActivities() {
if (this.selectedType == 'all') {
return this.activities;
} else if (this.selectedType == 'planned') {
return this.activities.filter(activity => ! activity.is_done);
}
return this.activities.filter(activity => activity.type == this.selectedType);
}
},
mounted() {
this.get();
if (this.extraTypes?.length) {
this.extraTypes.forEach(type => {
this.types.push(type);
});
}
this.$emitter.on('on-activity-added', (activity) => this.activities.unshift(activity));
},
methods: {
get() {
this.isLoading = true;
this.$axios.get(this.endpoint)
.then(response => {
this.activities = response.data.data;
this.isLoading = false;
})
.catch(error => {
console.error(error);
});
},
markAsDone(activity) {
this.$emitter.emit('open-confirm-modal', {
agree: () => {
this.isUpdating[activity.id] = true;
this.$axios.put("{{ route('admin.activities.update', 'replaceId') }}".replace('replaceId', activity.id), {
'is_done': 1
})
.then((response) => {
this.isUpdating[activity.id] = false;
activity.is_done = 1;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.isUpdating[activity.id] = false;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
},
});
},
remove(activity) {
this.$emitter.emit('open-confirm-modal', {
agree: () => {
this.isUpdating[activity.id] = true;
this.$axios.delete("{{ route('admin.activities.delete', 'replaceId') }}".replace('replaceId', activity.id))
.then((response) => {
this.isUpdating[activity.id] = false;
this.activities.splice(this.activities.indexOf(activity), 1);
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.isUpdating[activity.id] = false;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
},
});
},
unlinkEmail(activity) {
this.$emitter.emit('open-confirm-modal', {
agree: () => {
let emailId = activity.parent_id ?? activity.id;
this.$axios.delete(this.emailDetachEndpoint, {
data: {
email_id: emailId,
}
})
.then((response) => {
let relatedActivities = this.activities.filter(activity => activity.parent_id == emailId || activity.id == emailId);
relatedActivities.forEach(activity => {
const index = this.activities.findIndex(a => a === activity);
if (index !== -1) {
this.activities.splice(index, 1);
}
});
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,184 @@
@props([
'name' => 'attachments',
'validations' => null,
'uploadedAttachments' => [],
'allowMultiple' => false,
'hideButton' => false,
])
<v-attachments
name="{{ $name }}"
validations="{{ $validations }}"
:uploaded-attachments='{{ json_encode($uploadedAttachments) }}'
:allow-multiple="{{ $allowMultiple }}"
:hide-button="{{ $hideButton }}"
>
</v-attachments>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-attachments-template"
>
<!-- File Attachment Input -->
<div
class="relative items-center"
v-show="! hideButton"
>
<input
type="file"
class="hidden"
id="file-upload"
accept="attachment/*"
:multiple="allowMultiple"
:ref="$.uid + '_attachmentInput'"
@change="add"
/>
<label
class="flex cursor-pointer items-center gap-1"
for="file-upload"
>
<i class="icon-attachment text-xl font-medium"></i>
<span class="font-semibold">
@lang('Add Attachments')
</span>
</label>
</div>
<!-- Uploaded attachments -->
<div
v-if="attachments?.length"
class="flex flex-wrap gap-2"
>
<template v-for="(attachment, index) in attachments">
<v-attachment-item
:name="name"
:index="index"
:attachment="attachment"
@onRemove="remove($event)"
>
</v-attachment-item>
</template>
</div>
</script>
<script type="text/x-template" id="v-attachment-item-template">
<div class="flex items-center gap-2 rounded-md bg-gray-100 px-2.5 py-1 dark:bg-gray-950">
<span class="max-w-xs truncate dark:text-white">
@{{ attachment.name }}
</span>
<x-admin::form.control-group.control
type="file"
::name="name + '[]'"
class="hidden"
::ref="$.uid + '_attachmentInput_' + index"
/>
<i
class="icon-cross-large cursor-pointer rounded-md p-0.5 text-xl hover:bg-gray-200 dark:hover:bg-gray-800"
@click="remove"
></i>
</div>
</script>
<script type="module">
app.component('v-attachments', {
template: '#v-attachments-template',
props: {
name: {
type: String,
default: 'attachments',
},
validations: {
type: String,
default: '',
},
uploadedAttachments: {
type: Array,
default: () => []
},
allowMultiple: {
type: Boolean,
default: false,
},
hideButton: {
type: Boolean,
default: false,
},
errors: {
type: Object,
default: () => {}
}
},
data() {
return {
attachments: [],
}
},
mounted() {
this.attachments = this.uploadedAttachments;
},
methods: {
add() {
let attachmentInput = this.$refs[this.$.uid + '_attachmentInput'];
if (attachmentInput.files == undefined) {
return;
}
attachmentInput.files.forEach((file, index) => {
this.attachments.push({
id: 'attachment_' + this.attachments.length,
name: file.name,
file: file
});
});
},
remove(attachment) {
let index = this.attachments.indexOf(attachment);
this.attachments.splice(index, 1);
},
}
});
app.component('v-attachment-item', {
template: '#v-attachment-item-template',
props: ['index', 'attachment', 'name'],
mounted() {
if (this.attachment.file instanceof File) {
this.setFile(this.attachment.file);
}
},
methods: {
remove() {
this.$emit('onRemove', this.attachment)
},
setFile(file) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.$refs[this.$.uid + '_attachmentInput_' + this.index].files = dataTransfer.files;
},
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,165 @@
@if (isset($attribute))
<v-address-component
:attribute='@json($attribute)'
:validations="'{{ $validations }}'"
:data='@json(old($attribute->code) ?: $value)'
>
<!-- Addresses Shimmer -->
<x-admin::shimmer.common.address />
</v-address-component>
@endif
@pushOnce('scripts')
<script
type="text/x-template"
id="v-address-component-template"
>
<div class="flex gap-4 max-md:flex-wrap">
<div class="w-full">
<!-- Address (Textarea field) -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="textarea"
::name="attribute['code'] + '[address]'"
rows="10"
::value="data ? data['address'] : ''"
:label="trans('admin::app.common.custom-attributes.address')"
::rules="attribute.is_required ? 'required|' + validations : validations"
/>
<x-admin::form.control-group.error ::name="attribute['code'] + '[address]'" />
<x-admin::form.control-group.error ::name="attribute['code'] + '.address'" />
</x-admin::form.control-group>
</div>
<div class="grid w-full">
<!-- Country Field -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="select"
::name="attribute['code'] + '[country]'"
::rules="attribute.is_required ? 'required|' + validations : validations"
:label="trans('admin::app.common.custom-attributes.country')"
v-model="country"
>
<option value="">@lang('admin::app.common.custom-attributes.select-country')</option>
@foreach (core()->countries() as $country)
<option value="{{ $country->code }}">{{ $country->name }}</option>
@endforeach
</x-admin::form.control-group.control>
<x-admin::form.control-group.error ::name="attribute['code'] + '[country]'" />
<x-admin::form.control-group.error ::name="attribute['code'] + '.country'" />
</x-admin::form.control-group>
<!-- State Field -->
<template v-if="haveStates()">
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="select"
::name="attribute['code'] + '[state]'"
v-model="state"
:label="trans('admin::app.common.custom-attributes.state')"
::rules="attribute.is_required ? 'required|' + validations : validations"
>
<option value="">@lang('admin::app.common.custom-attributes.select-state')</option>
<option
v-for='(state, index) in countryStates[country]'
:value="state.code"
>
@{{ state.name }}
</option>
</x-admin::form.control-group.control>
<x-admin::form.control-group.error ::name="attribute['code'] + '[state]'" />
<x-admin::form.control-group.error ::name="attribute['code'] + '.state'" />
</x-admin::form.control-group>
</template>
<template v-else>
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="text"
::name="attribute['code'] + '[state]'"
:placeholder="trans('admin::app.common.custom-attributes.state')"
:label="trans('admin::app.common.custom-attributes.state')"
::rules="attribute.is_required ? 'required|' + validations : validations"
v-model="state"
>
</x-admin::form.control-group.control>
<x-admin::form.control-group.error ::name="attribute['code'] + '[state]'" />
<x-admin::form.control-group.error ::name="attribute['code'] + '.state'" />
</x-admin::form.control-group>
</template>
<!-- City Field -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="text"
::name="attribute['code'] + '[city]'"
::value="data && data['city'] ? data['city'] : ''"
:placeholder="trans('admin::app.common.custom-attributes.city')"
:label="trans('admin::app.common.custom-attributes.city')"
::rules="attribute.is_required ? 'required|' + validations : validations"
/>
<x-admin::form.control-group.error ::name="attribute['code'] + '[city]'"/>
<x-admin::form.control-group.error ::name="attribute['code'] + '.city'" />
</x-admin::form.control-group>
<!-- Postcode Field -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="text"
::name="attribute['code'] + '[postcode]'"
::value="data && data['postcode'] ? data['postcode'] : ''"
:placeholder="trans('admin::app.common.custom-attributes.postcode')"
:label="trans('admin::app.common.custom-attributes.postcode')"
::rules="attribute.is_required ? 'required|postcode' : 'postcode'"
/>
<x-admin::form.control-group.error ::name="attribute['code'] + '[postcode]'" />
<x-admin::form.control-group.error ::name="attribute['code'] + '.postcode'" />
</x-admin::form.control-group>
</div>
</div>
</script>
<script type="module">
app.component('v-address-component', {
template: '#v-address-component-template',
props: ['attribute', 'data', 'validations'],
data() {
return {
country: this.data?.country || '',
state: this.data?.state || '',
countryStates: @json(core()->groupedStatesByCountries()),
};
},
methods: {
haveStates() {
/*
* The double negation operator is used to convert the value to a boolean.
* It ensures that the final result is a boolean value,
* true if the array has a length greater than 0, and otherwise false.
*/
return !!this.countryStates[this.country]?.length;
},
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,20 @@
<?php $selectedOption = old($attribute->code) ?: $value ?>
<input
type="hidden"
name="{{ $attribute->code }}"
value="0"
>
<label class="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
name="{{ $attribute->code }}"
value="1"
id="{{ $attribute->code }}"
class="peer sr-only"
{{ $selectedOption ? 'checked' : '' }}
>
<div class="peer h-5 w-9 cursor-pointer rounded-full bg-gray-200 after:absolute after:top-0.5 after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-brandColor peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-blue-300 dark:bg-gray-800 dark:after:border-white dark:after:bg-white dark:peer-checked:bg-gray-950 after:ltr:left-0.5 peer-checked:after:ltr:translate-x-full after:rtl:right-0.5 peer-checked:after:rtl:-translate-x-full"></div>
</label>

View File

@@ -0,0 +1,32 @@
@php
$options = $attribute->lookup_type
? app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpOptions($attribute->lookup_type)
: $attribute->options()->orderBy('sort_order')->get();
$selectedOption = old($attribute->code, $value);
$selectedOption = is_array($selectedOption) ? $selectedOption : explode(',', $selectedOption);
@endphp
<input type="hidden" name="{{ $attribute->code }}" />
@foreach ($options as $option)
<x-admin::form.control-group class="!mb-2 flex items-center gap-2.5">
<x-admin::form.control-group.control
type="checkbox"
:id="$option->id"
name="{{ $attribute->code }}[]"
:value="$option->id"
:for="$option->id"
:label="$option->name"
:checked="in_array($option->id, $selectedOption)"
/>
<label
class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-300"
for="{{ $option->id }}"
>
{{ $option->name }}
</label>
</x-admin::form.control-group>
@endforeach

View File

@@ -0,0 +1,18 @@
@php
if (! empty($value)) {
if ($value instanceof \Carbon\Carbon) {
$value = $value->format('Y-m-d');
} elseif (is_string($value)) {
$value = \Carbon\Carbon::parse($value)->format('Y-m-d');
}
}
@endphp
<x-admin::form.control-group.control
type="date"
:id="$attribute->code"
:name="$attribute->code"
:value="$value"
:rules="$validations.'|regex:^\d{4}-\d{2}-\d{2}$'"
:label="$attribute->name"
/>

View File

@@ -0,0 +1,8 @@
<x-admin::form.control-group.control
type="datetime"
:id="$attribute->code"
:name="$attribute->code"
:value="old($attribute->code) ?? $value"
:rules="$validations.'|regex:^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$'"
:label="$attribute->name"
/>

View File

@@ -0,0 +1,179 @@
@if (isset($attribute))
<v-email-component
:attribute="{{ json_encode($attribute) }}"
:validations="'{{ $validations }}'"
:value="{{ json_encode(old($attribute->code) ?? $value) }}"
>
<div class="mb-2 flex items-center">
<input
type="text"
class="w-full rounded rounded-r-none border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
>
<div class="relative">
<select class="custom-select w-full rounded rounded-l-none border bg-white px-2.5 py-2 text-sm font-normal text-gray-800 hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 ltr:mr-6 ltr:pr-8 rtl:ml-6 rtl:pl-8">
<option value="work" selected>@lang('admin::app.common.custom-attributes.work')</option>
<option value="home">@lang('admin::app.common.custom-attributes.home')</option>
</select>
</div>
</div>
<span class="flex cursor-pointer items-center gap-2 text-brandColor">
<i class="icon-add text-md !text-brandColor"></i>
@lang("admin::app.common.custom-attributes.add-more")
</span>
</v-email-component>
@endif
@pushOnce('scripts')
<script
type="text/x-template"
id="v-email-component-template"
>
<template v-for="(email, index) in emails">
<div class="mb-2 flex items-center">
<x-admin::form.control-group.control
type="text"
::id="attribute.code"
::name="`${attribute['code']}[${index}][value]`"
class="rounded-r-none"
::rules="getValidation"
::label="attribute.name"
v-model="email['value']"
::disabled="isDisabled"
/>
<div class="relative">
<x-admin::form.control-group.control
type="select"
::id="attribute.code"
::name="`${attribute['code']}[${index}][label]`"
class="rounded-l-none ltr:mr-6 ltr:pr-8 rtl:ml-6 rtl:pl-8"
rules="required"
::label="attribute.name"
v-model="email['label']"
::disabled="isDisabled"
>
<option value="work">@lang('admin::app.common.custom-attributes.work')</option>
<option value="home">@lang('admin::app.common.custom-attributes.home')</option>
</x-admin::form.control-group.control>
</div>
<i
v-if="emails.length > 1"
class="icon-delete ml-1 cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-100 dark:hover:bg-gray-950"
@click="remove(email)"
></i>
</div>
<x-admin::form.control-group.error ::name="`${attribute['code']}[${index}][value]`"/>
<x-admin::form.control-group.error ::name="`${attribute['code']}[${index}].value`"/>
</template>
<span
class="flex w-fit cursor-pointer items-center gap-2 text-brandColor"
@click="add"
v-if="! isDisabled"
>
<i class="icon-add text-md !text-brandColor"></i>
@lang("admin::app.common.custom-attributes.add-more")
</span>
</script>
<script type="module">
app.component('v-email-component', {
template: '#v-email-component-template',
props: ['validations', 'isDisabled', 'attribute', 'value'],
data() {
return {
emails: this.value || [{'value': '', 'label': 'work'}],
};
},
watch: {
value(newValue, oldValue) {
if (
JSON.stringify(newValue)
!== JSON.stringify(oldValue)
) {
this.emails = newValue || [{'value': '', 'label': 'work'}];
}
},
},
computed: {
getValidation() {
return {
email: true,
unique_email: this.emails ?? [],
...(this.validations === 'required' ? { required: true } : {}),
};
},
},
created() {
this.extendValidations();
},
methods: {
add() {
this.emails.push({
'value': '',
'label': 'work'
});
},
remove(email) {
this.emails = this.emails.filter(item => item !== email);
},
extendValidations() {
defineRule('unique_email', async (value, emails) => {
if (! value || ! value.length) {
return true;
}
const foundEmails = emails.filter(email => email.value === value).length;
if (foundEmails > 1) {
return 'This email is already in use.';
}
/**
* Check if the email is unique. This support is only for person emails only.
*/
if (this.attribute.code === 'person[emails]') {
try {
const { data } = await this.$axios.get('{{ route('admin.settings.attributes.check_unique_validation') }}', {
params: {
entity_id: this.attribute.id,
entity_type: 'persons',
attribute_code: 'emails',
attribute_value: value
}
});
if (! data.validated) {
return 'This email is already in use.';
}
return true;
} catch (error) {
console.error('Error checking email: ', error);
return 'Error validating email. Please try again.';
}
} else {
return true;
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,39 @@
<div class="flex items-center gap-2">
@if ($value)
<a
href="{{ route('admin.settings.attributes.download', ['path' => $value]) }}"
target="_blank"
>
<div class="w-full max-w-max cursor-pointer rounded-md p-1.5 text-gray-600 hover:bg-gray-200 active:border-gray-300 dark:text-gray-300 dark:hover:bg-gray-800">
<i class="icon-download text-2xl"></i>
</div>
</a>
@endif
<x-admin::form.control-group.control
type="file"
:id="$attribute->code"
:name="$attribute->code"
:rules="$validations"
:label="$attribute->name"
/>
</div>
@if ($value)
<div class="flex cursor-pointer items-center gap-2.5">
<x-admin::form.control-group.control
type="checkbox"
name="{{ $attribute->code }}[delete]"
id="{{ $attribute->code }}[delete]"
for="{{ $attribute->code }}[delete]"
value="1"
/>
<label
class="cursor-pointer !text-gray-600 dark:!text-gray-300"
for="{{ $attribute->code }}[delete]"
>
@lang('admin::app.components.attributes.edit.delete')
</label>
</div>
@endif

View File

@@ -0,0 +1,42 @@
<div class="flex items-center gap-2">
@if ($value)
<a
href="{{ route('admin.settings.attributes.download', ['path' => $value]) }}"
target="_blank"
>
<img
src="{{ Storage::url($value) }}"
alt="{{ $attribute->code }}"
class="top-15 rounded-3 border-3 relative h-[33px] w-[33px] border-gray-500"
/>
</a>
@endif
<x-admin::form.control-group.control
type="file"
:id="$attribute->code"
:name="$attribute->code"
class="!w-full"
:rules="$validations"
:label="$attribute->name"
/>
</div>
@if ($value)
<div class="flex cursor-pointer items-center gap-2.5">
<x-admin::form.control-group.control
type="checkbox"
name="{{ $attribute->code }}[delete]"
id="{{ $attribute->code }}[delete]"
for="{{ $attribute->code }}[delete]"
value="1"
/>
<label
class="cursor-pointer !text-gray-600 dark:!text-gray-300"
for="{{ $attribute->code }}[delete]"
>
@lang('admin::app.components.attributes.edit.delete')
</label>
</div>
@endif

View File

@@ -0,0 +1,143 @@
@props([
'attribute' => '',
'value' => '',
'validations' => '',
])
@switch($attribute->type)
@case('text')
<x-admin::attributes.edit.text
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('email')
<x-admin::attributes.edit.email
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('phone')
<x-admin::attributes.edit.phone
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('lookup')
<x-admin::attributes.edit.lookup
:attribute="$attribute"
:value="$value"
:validations="$validations"
can-add-new="true"
/>
@break
@case('select')
<x-admin::attributes.edit.select
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('multiselect')
<x-admin::attributes.edit.multiselect
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('price')
<x-admin::attributes.edit.price
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('image')
<x-admin::attributes.edit.image
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('file')
<x-admin::attributes.edit.file
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('textarea')
<x-admin::attributes.edit.textarea
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('address')
<x-admin::attributes.edit.address
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('date')
<x-admin::attributes.edit.date
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('datetime')
<x-admin::attributes.edit.datetime
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('boolean')
<x-admin::attributes.edit.boolean
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@case('checkbox')
<x-admin::attributes.edit.checkbox
:attribute="$attribute"
:value="$value"
:validations="$validations"
/>
@break
@endswitch

View File

@@ -0,0 +1,290 @@
@if (isset($attribute))
@php
$lookUpEntityData = app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpEntity($attribute->lookup_type, old($attribute->code) ?: $value);
@endphp
<v-lookup-component
:attribute="{{ json_encode($attribute) }}"
:validations="'{{ $validations }}'"
:value="{{ json_encode($lookUpEntityData)}}"
can-add-new="{{ $canAddNew ?? false }}"
@lookup-added="handleLookupAdded"
@lookup-removed="handleLookupRemoved"
>
<div class="relative inline-block w-full">
<!-- Input Container -->
<div class="relative flex items-center justify-between rounded border border-gray-200 p-2 hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:text-gray-300">
@lang('admin::app.components.attributes.lookup.click-to-add')
<!-- Icons Container -->
<div class="flex items-center gap-2">
<!-- Arrow Icon -->
<i class="icon-down-arrow text-2xl text-gray-600"></i>
</div>
</div>
</div>
</v-lookup-component>
@endif
@pushOnce('scripts')
<script
type="text/x-template"
id="v-lookup-component-template"
>
<div
class="relative"
ref="lookup"
>
<div
class="relative inline-block w-full"
@click="toggle"
>
<!-- Input Container -->
<div
class="relative flex items-center justify-between rounded border border-gray-200 p-2 hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:text-gray-300"
:class="{
'bg-gray-50': isDisabled,
}"
>
<!-- Selected Item or Placeholder Text -->
<span
class="overflow-hidden text-ellipsis"
:title="selectedItem?.name"
>
@{{ selectedItem?.name !== "" ? selectedItem?.name : "@lang('admin::app.components.attributes.lookup.click-to-add')" }}
</span>
<!-- Icons Container -->
<div class="flex items-center gap-2">
<!-- Close Icon -->
<i
v-if="
! isDisabled
&& (
selectedItem?.name
&& ! isSearching
)"
class="icon-cross-large cursor-pointer text-2xl text-gray-600"
@click="remove"
></i>
<!-- Arrow Icon -->
<i
class="text-2xl text-gray-600"
:class="showPopup ? 'icon-up-arrow' : 'icon-down-arrow'"
></i>
</div>
</div>
</div>
<!-- Hidden Input Entity Value -->
<x-admin::form.control-group.control
type="hidden"
::name="attribute['code']"
v-model="selectedItem.id"
::rules="validations"
::label="attribute['name']"
/>
<!-- Popup Box -->
<div
v-if="showPopup"
class="absolute top-full z-10 mt-1 flex w-full origin-top transform flex-col gap-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg transition-transform dark:border-gray-900 dark:bg-gray-800"
>
<!-- Search Bar -->
<div class="relative flex items-center">
<!-- Input Box -->
<input
type="text"
v-model.lazy="searchTerm"
v-debounce="500"
class="w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
placeholder="@lang('admin::app.components.attributes.lookup.search')"
ref="searchInput"
@keyup="search"
/>
<!-- Search Icon (absolute positioned) -->
<span class="absolute flex items-center ltr:right-2 rtl:left-2">
<!-- Loader (optional, based on condition) -->
<div
class="relative"
v-if="isSearching"
>
<x-admin::spinner />
</div>
</span>
</div>
<!-- Results List -->
<ul class="max-h-40 divide-y divide-gray-100 overflow-y-auto">
<li
v-for="item in filteredResults"
:key="item.id"
class="flex cursor-pointer gap-2 p-2 transition-colors hover:bg-blue-100 dark:text-gray-300 dark:hover:bg-gray-900"
@click="handleResult(item)"
>
<!-- Entity Name -->
<span>@{{ item.name }}</span>
</li>
<template v-if="filteredResults.length === 0">
<li class="px-4 py-2 text-center text-gray-500">
@lang('admin::app.components.attributes.lookup.no-result-found')
</li>
<li
v-if="searchTerm.length > 2 && canAddNew"
@click="handleResult({ id: '', name: searchTerm })"
class="cursor-pointer border-t border-gray-800 px-4 py-2 text-gray-500 hover:bg-brandColor hover:text-white dark:border-gray-300 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-white"
>
<i class="icon-add text-md"></i>
@lang('admin::app.components.lookup.add-as-new')
</li>
</template>
</ul>
</div>
</div>
</script>
<script type="module">
app.component('v-lookup-component', {
template: '#v-lookup-component-template',
props: ['validations', 'isDisabled', 'attribute', 'value', 'canAddNew'],
emits: ['lookup-added', 'lookup-removed'],
data() {
return {
showPopup: false,
searchTerm: '',
searchedResults: [],
selectedItem: {
id: '',
name: ''
},
searchRoute: `{{ route('admin.settings.attributes.lookup') }}/${this.attribute.lookup_type}`,
lookupEntityRoute: `{{ route('admin.settings.attributes.lookup_entity') }}/${this.attribute.lookup_type}`,
isSearching: false,
};
},
mounted() {
if (this.value) {
this.getLookUpEntity();
}
window.addEventListener('click', this.handleFocusOut);
},
watch: {
searchTerm(newVal, oldVal) {
this.search();
},
},
computed: {
/**
* Filter the searchedResults based on the search query.
*
* @return {Array}
*/
filteredResults() {
return this.searchedResults.filter(item =>
item.name.toLowerCase().includes(this.searchTerm.toLowerCase())
);
}
},
methods: {
toggle() {
if (this.isDisabled) {
this.showPopup = false;
return;
}
this.showPopup = ! this.showPopup;
if (this.showPopup) {
this.$nextTick(() => this.$refs.searchInput.focus());
}
},
search() {
if (this.searchTerm.length <= 2) {
this.searchedResults = [];
this.isSearching = false;
return;
}
this.isSearching = true;
this.$axios.get(this.searchRoute, {
params: { query: this.searchTerm }
})
.then (response => {
this.searchedResults = response.data;
})
.catch (error => {})
.finally(() => this.isSearching = false);
},
getLookUpEntity() {
this.$axios.get(this.lookupEntityRoute, {
params: { query: this.value?.id ?? ""}
})
.then (response => {
this.selectedItem = Object.keys(response.data).length
? response.data
: {
id: '',
name: ''
};
})
.catch (error => {});
},
handleResult(result) {
this.showPopup = false;
this.selectedItem = result;
this.searchTerm = '';
this.$emit('lookup-added', this.selectedItem);
},
handleFocusOut(e) {
const lookup = this.$refs.lookup;
if (
lookup &&
! lookup.contains(event.target)
) {
this.showPopup = false;
}
},
remove() {
this.selectedItem = {
id: '',
name: ''
};
this.$emit('lookup-removed', this.selectedItem);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,32 @@
@php
$options = $attribute->lookup_type
? app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpOptions($attribute->lookup_type)
: $attribute->options()->orderBy('sort_order')->get();
$selectedOption = old($attribute->code) ?: $value;
@endphp
<v-field
type="select"
id="{{ $attribute->code }}"
name="{{ $attribute->code }}[]"
rules="{{ $validations }}"
label="{{ $attribute->name }}"
placeholder="{{ $attribute->name }}"
multiple
>
<select
name="{{ $attribute->code }}[]"
class="flex min-h-[39px] w-full rounded-md border px-3 py-2 text-sm text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
multiple
>
@foreach ($options as $option)
<option
value="{{ $option->id }}"
{{ in_array($option->id, is_array($selectedOption) ? $selectedOption : explode(',', $selectedOption)) ? 'selected' : ''}}
>
{{ $option->name }}
</option>
@endforeach
</select>
</v-field>

View File

@@ -0,0 +1,186 @@
@if (isset($attribute))
<v-phone-component
:attribute="{{ json_encode($attribute) }}"
:validations="'{{ $validations }}'"
:value="{{ json_encode(old($attribute->code) ?? $value) }}"
>
<div class="mb-2 flex items-center">
<input
type="text"
class="w-full rounded rounded-r-none border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
>
<div class="relative">
<select class="custom-select w-full rounded rounded-l-none border bg-white px-2.5 py-2 text-sm font-normal text-gray-800 hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 ltr:mr-6 ltr:pr-8 rtl:ml-6 rtl:pl-8">
<option value="work" selected>@lang('admin::app.common.custom-attributes.work')</option>
<option value="home">@lang('admin::app.common.custom-attributes.home')</option>
</select>
</div>
</div>
<span class="flex cursor-pointer items-center gap-2 text-brandColor">
<i class="icon-add text-md !text-brandColor"></i>
@lang("admin::app.common.custom-attributes.add-more")
</span>
</v-phone-component>
@endif
@pushOnce('scripts')
<script
type="text/x-template"
id="v-phone-component-template"
>
<template v-for="(contactNumber, index) in contactNumbers">
<div class="mb-2 flex items-center">
<x-admin::form.control-group.control
type="text"
::id="attribute.code"
::name="`${attribute['code']}[${index}][value]`"
class="rounded-r-none"
::rules="getValidation"
::label="attribute.name"
v-model="contactNumber['value']"
::disabled="isDisabled"
/>
<div class="relative">
<x-admin::form.control-group.control
type="select"
::id="attribute.code"
::name="`${attribute['code']}[${index}][label]`"
class="rounded-l-none ltr:mr-6 ltr:pr-8 rtl:ml-6 rtl:pl-8"
rules="required"
::label="attribute.name"
v-model="contactNumber['label']"
::disabled="isDisabled"
>
<option value="work">@lang('admin::app.common.custom-attributes.work')</option>
<option value="home">@lang('admin::app.common.custom-attributes.home')</option>
</x-admin::form.control-group.control>
</div>
<i
v-if="contactNumbers.length > 1"
class="icon-delete ml-1 cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-100 dark:hover:bg-gray-950"
@click="remove(contactNumber)"
></i>
</div>
<x-admin::form.control-group.error ::name="`${attribute['code']}[${index}][value]`"/>
<x-admin::form.control-group.error ::name="`${attribute['code']}[${index}].value`"/>
</template>
<span
class="flex w-fit cursor-pointer items-center gap-2 text-brandColor"
@click="add"
v-if="! isDisabled"
>
<i class="icon-add text-md !text-brandColor"></i>
@lang("admin::app.common.custom-attributes.add-more")
</span>
</script>
<script type="module">
app.component('v-phone-component', {
template: '#v-phone-component-template',
props: ['validations', 'isDisabled', 'attribute', 'value'],
data() {
return {
contactNumbers: this.value || [{'value': '', 'label': 'work'}],
};
},
watch: {
value(newValue, oldValue) {
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
this.contactNumbers = newValue || [{'value': '', 'label': 'work'}];
}
},
},
computed: {
getValidation() {
return {
phone: true,
unique_contact_number: this.contactNumbers ?? [],
...(this.validations === 'required' ? { required: true } : {}),
};
},
},
created() {
this.extendValidations();
if (! this.contactNumbers || ! this.contactNumbers.length) {
this.contactNumbers = [{
'value': '',
'label': 'work'
}];
}
},
methods: {
add() {
this.contactNumbers.push({
'value': '',
'label': 'work'
});
},
remove(contactNumber) {
this.contactNumbers = this.contactNumbers.filter(number => number !== contactNumber);
},
extendValidations() {
defineRule('unique_contact_number', async (value, contactNumbers) => {
if (
! value
|| ! value.length
) {
return true;
}
const phoneOccurrences = contactNumbers.filter(contactNumber => contactNumber.value === value).length;
if (phoneOccurrences > 1) {
return 'This phone number is already in use.';
}
/**
* Check if the phone number is unique. This support is only for person phone numbers only.
*/
if (this.attribute.code === 'person[contact_numbers]') {
try {
const { data } = await this.$axios.get('{{ route('admin.settings.attributes.check_unique_validation') }}', {
params: {
entity_id: this.attribute.id,
entity_type: 'persons',
attribute_code: 'contact_numbers',
attribute_value: value
}
});
if (! data.validated) {
return 'This phone number is already in use.';
}
return true;
} catch (error) {
console.error('Error checking email: ', error);
return 'Error validating email. Please try again.';
}
} else {
return true;
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,32 @@
@if (isset($attribute))
<v-price-component
:attribute="{{ json_encode($attribute) }}"
:validations="'{{ $validations }}'"
:value="{{ json_encode(old($attribute->code) ?? $value) }}"
>
</v-price-component>
@endif
@pushOnce('scripts')
<script
type="text/x-template"
id="v-price-component-template"
>
<x-admin::form.control-group.control
type="text"
::id="attribute.code"
::value="value"
::name="attribute.code"
::rules="validations"
::label="attribute.name"
/>
</script>
<script type="module">
app.component('v-price-component', {
template: '#v-price-component-template',
props: ['validations', 'attribute', 'value'],
});
</script>
@endPushOnce

View File

@@ -0,0 +1,21 @@
@php
$options = $attribute->lookup_type
? app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpOptions($attribute->lookup_type)
: $attribute->options()->orderBy('sort_order')->get();
@endphp
<x-admin::form.control-group.control
type="select"
id="{{ $attribute->code }}"
name="{{ $attribute->code }}"
rules="{{ $validations }}"
:label="$attribute->name"
:placeholder="$attribute->name"
:value="old($attribute->code) ?? $value"
>
@foreach ($options as $option)
<option value="{{ $option->id }}">
{{ $option->name }}
</option>
@endforeach
</x-admin::form.control-group.control>

View File

@@ -0,0 +1,8 @@
<x-admin::form.control-group.control
type="text"
:id="$attribute->code"
:name="$attribute->code"
:value="old($attribute->code) ?? $value"
:rules="$validations"
:label="$attribute->name"
/>

View File

@@ -0,0 +1,8 @@
<x-admin::form.control-group.control
type="textarea"
:id="$attribute->code"
:name="$attribute->code"
:value="old($attribute->code) ?? $value"
:rules="$validations"
:label="$attribute->name"
/>

View File

@@ -0,0 +1,40 @@
@foreach ($customAttributes as $attribute)
@php
$validations = [];
if ($attribute->is_required) {
$validations[] = 'required';
}
if ($attribute->type == 'price') {
$validations[] = 'decimal';
}
$validations[] = $attribute->validation;
$validations = implode('|', array_filter($validations));
@endphp
<x-admin::form.control-group class="mb-2.5 w-full">
<x-admin::form.control-group.label
for="{{ $attribute->code }}"
:class="$attribute->is_required ? 'required' : ''"
>
{{ $attribute->name }}
@if ($attribute->type == 'price')
<span class="currency-code">({{ core()->currencySymbol(config('app.currency')) }})</span>
@endif
</x-admin::form.control-group.label>
@if (isset($attribute))
<x-admin::attributes.edit.index
:attribute="$attribute"
:validations="$validations"
:value="isset($entity) ? $entity[$attribute->code] : null"
/>
@endif
<x-admin::form.control-group.error :control-name="$attribute->code" />
</x-admin::form.control-group>
@endforeach

View File

@@ -0,0 +1,25 @@
@props([
'customAttributes' => [],
'entity' => null,
'allowEdit' => false,
'url' => null,
])
<div class="flex flex-col gap-1">
@foreach ($customAttributes as $attribute)
@if (view()->exists($typeView = 'admin::components.attributes.view.' . $attribute->type))
<div class="grid grid-cols-[1fr_2fr] items-center gap-1">
<div class="label dark:text-white">{{ $attribute->name }}</div>
<div class="font-medium dark:text-white">
@include ($typeView, [
'attribute' => $attribute,
'value' => isset($entity) ? $entity[$attribute->code] : null,
'allowEdit' => $allowEdit,
'url' => $url,
])
</div>
</div>
@endif
@endforeach
</div>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.address
::name="'{{ $attribute->code }}'"
:value="$value"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.boolean
::name="'{{ $attribute->code }}'"
:value="json_encode($value)"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,20 @@
@php
$options = $attribute->lookup_type
? app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpOptions($attribute->lookup_type)
: $attribute->options()->orderBy('sort_order')->get();
$selectedOption = old($attribute->code) ?: $value;
@endphp
<x-admin::form.control-group.controls.inline.multiselect
::name="'{{ $attribute->code }}'"
::value="'{{ $selectedOption }}'"
:data="$options"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,21 @@
@php
if (! empty($value)) {
if ($value instanceof \Carbon\Carbon) {
$value = $value->format('Y-m-d');
} elseif (is_string($value)) {
$value = \Carbon\Carbon::parse($value)->format('Y-m-d');
}
}
@endphp
<x-admin::form.control-group.controls.inline.date
::name="'{{ $attribute->code }}'"
::value="'{{ $value }}'"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.datetime
::name="'{{ $attribute->code }}'"
::value="'{{ $value }}'"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.email
::name="'{{ $attribute->code }}'"
:value="$value"
rules="required|decimal:4"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.file
::name="'{{ $attribute->code }}'"
::value="'{{ $value ? route('admin.settings.attributes.download', ['path' => $value]) : '' }}'"
rules="required|mimes:jpeg,jpg,png,gif"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.image
::name="'{{ $attribute->code }}'"
::value="'{{ route('admin.settings.attributes.download', ['path' => $value]) }}'"
rules="required|mimes:jpeg,jpg,png,gif"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,16 @@
@php
$lookUpEntity = app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpEntity($attribute->lookup_type, $value);
@endphp
<x-admin::form.control-group.controls.inline.lookup
::name="'{{ $attribute->code }}'"
::value="'{{ $lookUpEntity?->name }}'"
:attribute="$attribute"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
:value-label="$lookUpEntity?->name ?? '--'"
/>

View File

@@ -0,0 +1,20 @@
@php
$options = $attribute->lookup_type
? app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpOptions($attribute->lookup_type)
: $attribute->options()->orderBy('sort_order')->get();
$selectedOption = old($attribute->code) ?: $value;
@endphp
<x-admin::form.control-group.controls.inline.multiselect
::name="'{{ $attribute->code }}'"
::value="'{{ $selectedOption }}'"
:data="$options"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,11 @@
<x-admin::form.control-group.controls.inline.phone
::name="'{{ $attribute->code }}'"
:value="$value"
rules="required|decimal:4"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,12 @@
<x-admin::form.control-group.controls.inline.text
type="inline"
::name="'{{ $attribute->code }}'"
::value="'{{ $value }}'"
position="left"
rules="required"
:label="$attribute->name"
:placeholder="$attribute->name"
::errors="errors"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,18 @@
@php
$options = $attribute->lookup_type
? app('Webkul\Attribute\Repositories\AttributeRepository')->getLookUpOptions($attribute->lookup_type)
: $attribute->options()->orderBy('sort_order')->get();
@endphp
<x-admin::form.control-group.controls.inline.select
::name="'{{ $attribute->code }}'"
:value="$value"
:options="$options"
rules="required"
position="left"
:label="$attribute->name"
::errors="errors"
:placeholder="$attribute->name"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,13 @@
<x-admin::form.control-group.controls.inline.text
type="inline"
::name="'{{ $attribute->code }}'"
:value="$value ?? ''"
:value-label="$value == '' ? '--' : $value"
position="left"
rules="required|{{ $attribute->validation }}"
:label="$attribute->name"
:placeholder="$attribute->name"
::errors="errors"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,12 @@
<x-admin::form.control-group.controls.inline.text
type="inline"
::name="'{{ $attribute->code }}'"
:value="$value"
position="left"
rules="required"
:label="$attribute->name"
:placeholder="$attribute->name"
::errors="errors"
:url="$url"
:allow-edit="$allowEdit"
/>

View File

@@ -0,0 +1,69 @@
<v-avatar {{ $attributes }}>
<div class="shimmer h-9 w-9 rounded-full"></div>
</v-avatar>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-avatar-template"
>
<div
class="flex h-9 min-w-9 items-center justify-center rounded-full text-xs font-medium"
:class="colorClasses()"
>
@{{ name.split(' ').slice(0, 2).map(word => word[0].toUpperCase()).join('') }}
</div>
</script>
<script type="module">
app.component('v-avatar', {
template: '#v-avatar-template',
props: {
name: {
type: String,
default: '',
},
},
data() {
return {
colors: {
background: [
'bg-yellow-200',
'bg-red-200',
'bg-lime-200',
'bg-blue-200',
'bg-orange-200',
'bg-green-200',
'bg-pink-200',
'bg-yellow-400'
],
text: [
'text-yellow-900',
'text-red-900',
'text-lime-900',
'text-blue-900',
'text-orange-900',
'text-green-900',
'text-pink-900',
'text-yellow-900',
],
},
}
},
methods: {
colorClasses() {
let index = Math.floor(Math.random() * this.colors.background.length);
return [
this.colors.background[index],
this.colors.text[index],
];
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,15 @@
@props([
'name' => '',
'entity' => null,
'route' => null,
])
<div class="flex justify-start max-lg:hidden">
<div class="flex items-center gap-x-3.5">
@if($route)
{{ Breadcrumbs::view('admin::partials.breadcrumbs', $name, $route, $entity) }}
@else
{{ Breadcrumbs::view('admin::partials.breadcrumbs', $name, $entity) }}
@endif
</div>
</div>

View File

@@ -0,0 +1,40 @@
<v-button {{ $attributes }}></v-button>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-button-template"
>
<button
v-if="! loading"
:class="[buttonClass, '']"
>
@{{ title }}
</button>
<button
v-else
:class="[buttonClass, '']"
>
<!-- Spinner -->
<x-admin::spinner class="absolute" />
<span class="relative h-full w-full opacity-0">
@{{ title }}
</span>
</button>
</script>
<script type="module">
app.component('v-button', {
template: '#v-button-template',
props: {
loading: Boolean,
buttonType: String,
title: String,
buttonClass: String,
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,100 @@
<v-charts-bar {{ $attributes }}></v-charts-bar>
@pushOnce('scripts')
<!-- SEO Vue Component Template -->
<script
type="text/x-template"
id="v-charts-bar-template"
>
<canvas
:id="$.uid + '_chart'"
class="flex w-full max-w-full items-end"
:style="'aspect-ratio:' + aspectRatio + '/1'"
style=""
></canvas>
</script>
<script type="module">
app.component('v-charts-bar', {
template: '#v-charts-bar-template',
props: {
labels: {
type: Array,
default: [],
},
datasets: {
type: Array,
default: [],
},
aspectRatio: {
type: Number,
default: 3.23,
},
},
data() {
return {
chart: undefined,
}
},
mounted() {
this.prepare();
},
methods: {
prepare() {
const barCount = this.datasets.length;
this.datasets.forEach((dataset) => {
dataset.barThickness = Math.max(4, 36 / barCount);
});
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(document.getElementById(this.$.uid + '_chart'), {
type: 'bar',
data: {
labels: this.labels,
datasets: this.datasets,
},
options: {
aspectRatio: this.aspectRatio,
plugins: {
legend: {
display: false
},
},
scales: {
x: {
beginAtZero: true,
border: {
dash: [8, 4],
}
},
y: {
beginAtZero: true,
border: {
dash: [8, 4],
}
}
}
}
});
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,68 @@
<v-charts-doughnut {{ $attributes }}></v-charts-doughnut>
@pushOnce('scripts')
<!-- SEO Vue Component Template -->
<script
type="text/x-template"
id="v-charts-doughnut-template"
>
<canvas
:id="$.uid + '_chart'"
class="flex w-full max-w-full items-end"
></canvas>
</script>
<script type="module">
app.component('v-charts-doughnut', {
template: '#v-charts-doughnut-template',
props: {
labels: {
type: Array,
default: [],
},
datasets: {
type: Array,
default: true,
},
},
data() {
return {
chart: undefined,
}
},
mounted() {
this.prepare();
},
methods: {
prepare() {
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(document.getElementById(this.$.uid + '_chart'), {
type: 'doughnut',
data: {
labels: this.labels,
datasets: this.datasets,
},
options: {
plugins: {
legend: {
display: false
},
},
}
});
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,97 @@
<v-charts-line {{ $attributes }}></v-charts-line>
@pushOnce('scripts')
<!-- SEO Vue Component Template -->
<script
type="text/x-template"
id="v-charts-line-template"
>
<canvas
:id="$.uid + '_chart'"
class="flex w-full items-end"
:style="'aspect-ratio:' + aspectRatio + '/1'"
></canvas>
</script>
<script type="module">
app.component('v-charts-line', {
template: '#v-charts-line-template',
props: {
labels: {
type: Array,
default: [],
},
datasets: {
type: Array,
default: true,
},
aspectRatio: {
type: Number,
default: 3.23,
},
},
data() {
return {
chart: undefined,
}
},
mounted() {
this.prepare();
},
methods: {
prepare() {
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(document.getElementById(this.$.uid + '_chart'), {
type: 'line',
data: {
labels: this.labels,
datasets: this.datasets,
},
options: {
aspectRatio: this.aspectRatio,
plugins: {
legend: {
display: false
},
{{-- tooltip: {
enabled: false,
} --}}
},
scales: {
x: {
beginAtZero: true,
border: {
dash: [8, 4],
}
},
y: {
beginAtZero: true,
border: {
dash: [8, 4],
}
}
}
}
});
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,170 @@
<v-datagrid-export {{ $attributes }}>
<div class="transparent-button hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800">
<span class="icon-export text-xl text-gray-600"></span>
@lang('admin::app.export.export')
</div>
</v-datagrid-export>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datagrid-export-template"
>
<div>
<x-admin::modal ref="exportModal">
<x-slot:toggle>
<button class="transparent-button hover:bg-gray-200 dark:text-white dark:hover:bg-gray-800">
<span class="icon-export text-xl text-gray-600"></span>
@lang('admin::app.export.export')
</button>
</x-slot>
<x-slot:header>
<p class="text-lg font-bold text-gray-800 dark:text-white">
@lang('admin::app.export.download')
</p>
</x-slot>
<x-slot:content>
<x-admin::form action="">
<x-admin::form.control-group class="!mb-0">
<x-admin::form.control-group.control
type="select"
name="format"
v-model="format"
>
<option value="csv">
@lang('admin::app.export.csv')
</option>
<option value="xls">
@lang('admin::app.export.xls')
</option>
<option value="xlsx">
@lang('admin::app.export.xlsx')
</option>
</x-admin::form.control-group.control>
</x-admin::form.control-group>
</x-admin::form>
</x-slot>
<x-slot:footer>
<button
type="button"
class="primary-button"
@click="download"
>
@lang('admin::app.export.export')
</button>
</x-slot>
</x-admin::modal>
</div>
</script>
<script type="module">
app.component('v-datagrid-export', {
template: '#v-datagrid-export-template',
props: ['src'],
data() {
return {
format: 'xls',
available: null,
applied: null,
};
},
mounted() {
this.registerEvents();
},
methods: {
/**
* Registers events to update properties and trigger the download process.
*
* @returns {void}
*/
registerEvents() {
this.$emitter.on('change-datagrid', this.updateProperties);
},
/**
* Updates the available and applied properties with new values.
*
* @param {object} data - Object containing available and applied properties.
* @returns {void}
*/
updateProperties({ available, applied }) {
this.available = available;
this.applied = applied;
},
/**
* Initiates the download process for exporting data.
*
* @returns {void}
*/
download() {
if (! this.available?.records?.length) {
this.$emitter.emit('add-flash', { type: 'warning', message: '@lang('admin::app.export.no-records')' });
this.$refs.exportModal.toggle();
} else {
let params = {
export: 1,
format: this.format,
sort: {},
filters: {},
};
if (
this.applied.sort.column &&
this.applied.sort.order
) {
params.sort = this.applied.sort;
}
this.applied.filters.columns.forEach(column => {
params.filters[column.index] = column.value;
});
this.$axios
.get(this.src, {
params,
responseType: 'blob',
})
.then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
/**
* Link generation.
*/
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${(Math.random() + 1).toString(36).substring(7)}.${this.format}`);
/**
* Adding a link to a document, clicking on the link, and then removing the link.
*/
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.$refs.exportModal.toggle();
});
}
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,23 @@
<table>
<thead>
<tr>
@foreach ($columns as $column)
<th>{{ $column->getLabel() }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($records as $record)
<tr>
@foreach($columns as $column)
@if ($closure = $column->getClosure())
<td>{!! $closure($record) !!}</td>
@else
<td>{{ $record->{$column->getIndex()} }}</td>
@endif
@endforeach
</tr>
@endforeach
</tbody>
</table>

View File

@@ -0,0 +1,572 @@
@props([
'isMultiRow' => false,
'toolbarLeftBefore' => null,
'toolbarLeftAfter' => null,
'toolbarRightBefore' => null,
'toolbarRightAfter' => null,
])
<v-datagrid {{ $attributes }}>
{{ $slot }}
</v-datagrid>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datagrid-template"
>
<div>
<!-- Toolbar -->
<x-admin::datagrid.toolbar>
<x-slot:toolbar-left-before>
{{ $toolbarLeftBefore }}
</x-slot>
<x-slot:toolbar-left-after>
{{ $toolbarLeftAfter }}
</x-slot>
<x-slot:toolbar-right-before>
{{ $toolbarRightBefore }}
</x-slot>
<x-slot:toolbar-right-after>
{{ $toolbarRightAfter }}
</x-slot>
</x-admin::datagrid.toolbar>
<div class="flex">
<x-admin::datagrid.table :isMultiRow="$isMultiRow">
<template #header="{
isLoading,
available,
applied,
selectAll,
sort,
performAction
}">
<slot
name="header"
:is-loading="isLoading"
:available="available"
:applied="applied"
:select-all="selectAll"
:sort="sort"
:perform-action="performAction"
>
</slot>
</template>
<template #body="{
isLoading,
available,
applied,
selectAll,
sort,
performAction
}">
<slot
name="body"
:is-loading="isLoading"
:available="available"
:applied="applied"
:select-all="selectAll"
:sort="sort"
:perform-action="performAction"
>
</slot>
</template>
</x-admin::datagrid.table>
</div>
</div>
</script>
<script type="module">
app.component('v-datagrid', {
template: '#v-datagrid-template',
props: ['src'],
data() {
return {
isLoading: false,
available: {
id: null,
columns: [],
actions: [],
massActions: [],
records: [],
meta: {},
},
applied: {
massActions: {
meta: {
mode: 'none',
action: null,
},
indices: [],
value: null,
},
pagination: {
page: 1,
perPage: 10,
},
sort: {
column: null,
order: null,
},
filters: {
columns: [
{
index: 'all',
value: [],
},
],
},
savedFilterId: null,
},
};
},
watch: {
'available.records': function (newRecords, oldRecords) {
this.setCurrentSelectionMode();
this.updateDatagrids();
this.updateExportComponent();
},
'applied.savedFilterId': function (newSavedFilterId, oldSavedFilterId) {
this.updateDatagrids();
},
'applied.massActions.indices': function (newIndices, oldIndices) {
this.setCurrentSelectionMode();
},
},
mounted() {
this.boot();
},
methods: {
/**
* Initialization: This function checks for any previously saved filters in local storage and applies them as needed.
*
* @returns {void}
*/
boot() {
let datagrids = this.getDatagrids();
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('search')) {
let searchAppliedColumn = this.applied.filters.columns.find(column => column.index === 'all');
searchAppliedColumn.value = [urlParams.get('search')];
}
if (datagrids?.length) {
const currentDatagrid = datagrids.find(({ src }) => src === this.src);
if (currentDatagrid) {
this.applied.pagination = currentDatagrid.applied.pagination;
this.applied.sort = currentDatagrid.applied.sort;
this.applied.filters = currentDatagrid.applied.filters;
this.applied.savedFilterId = currentDatagrid.applied.savedFilterId;
if (urlParams.has('search')) {
let searchAppliedColumn = this.applied.filters.columns.find(column => column.index === 'all');
searchAppliedColumn.value = [urlParams.get('search')];
}
this.get();
return;
}
}
this.get();
},
/**
* Get. This will prepare params from the `applied` props and fetch the data from the backend.
*
* @returns {void}
*/
get(extraParams = {}) {
let params = {
pagination: {
page: this.applied.pagination.page,
per_page: this.applied.pagination.perPage,
},
sort: {},
filters: {},
};
if (
this.applied.sort.column &&
this.applied.sort.order
) {
params.sort = this.applied.sort;
}
this.applied.filters.columns.forEach(column => {
params.filters[column.index] = column.value;
});
const urlParams = new URLSearchParams(window.location.search);
urlParams.forEach((param, key) => params[key] = param);
this.isLoading = true;
this.$axios
.get(this.src, {
params: { ...params, ...extraParams }
})
.then((response) => {
/**
* Precisely taking all the keys to the data prop to avoid adding any extra keys from the response.
*/
const {
id,
columns,
actions,
mass_actions,
records,
meta
} = response.data;
this.available.id = id;
this.available.columns = columns;
this.available.actions = actions;
this.available.massActions = mass_actions;
this.available.records = records;
this.available.meta = meta;
this.isLoading = false;
});
},
/**
* Change Page. When the child component has handled all the cases, it will send the
* valid new page; otherwise, it will block. Here, we are certain that we have
* a new page, so the parent will simply call the AJAX based on the new page.
*
* @param {integer} newPage
* @returns {void}
*/
changePage(newPage) {
this.applied.pagination.page = newPage;
this.get();
},
/**
* Change per page option.
*
* @param {integer} option
* @returns {void}
*/
changePerPageOption(option) {
this.applied.pagination.perPage = option;
/**
* When the total records are less than the number of data per page, we need to reset the page.
*/
if (this.available.meta.last_page >= this.applied.pagination.page) {
this.applied.pagination.page = 1;
}
this.get();
},
/**
* Sort results.
*
* @param {object} column
* @returns {void}
*/
sort(column) {
if (column.sortable) {
this.applied.sort = {
column: column.index,
order: this.applied.sort.order === 'asc' ? 'desc' : 'asc',
};
/**
* When the sorting changes, we need to reset the page.
*/
this.applied.pagination.page = 1;
this.get();
}
},
/**
* Search results.
*
* @param {object} filters
* @returns {void}
*/
search(filters) {
this.applied.filters.columns = [
...(this.applied.filters.columns.filter((column) => column.index !== 'all')),
...filters.columns,
];
/**
* We need to reset the page on filtering.
*/
this.applied.pagination.page = 1;
this.get();
},
/**
* Filter results.
*
* @param {object} filters
* @returns {void}
*/
filter(filters) {
this.applied.filters.columns = [
...(this.applied.filters.columns.filter((column) => column.index === 'all')),
...filters.columns,
];
/**
* This will check for empty column values and reset the saved filter ID to ensure the saved filter is not highlighted.
*/
const isEmptyColumnValue = this.applied.filters.columns
.filter((column) => column.index !== 'all')
.every((column) => column.value.length === 0);
if (isEmptyColumnValue) {
this.applied.savedFilterId = null;
}
/**
* We need to reset the page on filtering.
*/
this.applied.pagination.page = 1;
this.get();
},
/**
* Filter results by the saved filter.
*
* @param {Object} filter
* @returns {void}
*/
applySavedFilter(filter) {
if (! filter) {
this.applied.savedFilterId = null;
return;
}
this.applied = filter.applied;
this.applied.savedFilterId = filter.id;
this.get();
},
/**
* This will analyze the current selection mode based on the mass action indices.
*
* @returns {void}
*/
setCurrentSelectionMode() {
this.applied.massActions.meta.mode = 'none';
if (! this.available.records.length) {
return;
}
let selectionCount = 0;
this.available.records.forEach(record => {
const id = record[this.available.meta.primary_column];
if (this.applied.massActions.indices.includes(id)) {
this.applied.massActions.meta.mode = 'partial';
++selectionCount;
}
});
if (this.available.records.length === selectionCount) {
this.applied.massActions.meta.mode = 'all';
}
},
/**
* This will select all records and update the mass action indices.
*
* @returns {void}
*/
selectAll() {
if (['all', 'partial'].includes(this.applied.massActions.meta.mode)) {
this.available.records.forEach(record => {
const id = record[this.available.meta.primary_column];
this.applied.massActions.indices = this.applied.massActions.indices.filter(selectedId => selectedId !== id);
});
this.applied.massActions.meta.mode = 'none';
} else {
this.available.records.forEach(record => {
const id = record[this.available.meta.primary_column];
let found = this.applied.massActions.indices.find(selectedId => selectedId === id);
if (! found) {
this.applied.massActions.indices = [
...this.applied.massActions.indices,
id,
];
}
});
this.applied.massActions.meta.mode = 'all';
}
},
/**
* Updates the export component properties whenever new results appear in the datagrid.
*
* @returns {void}
*/
updateExportComponent() {
/**
* This event should be fired whenever new results appear. This allows the export feature to
* listen to it and update its properties accordingly.
*/
this.$emitter.emit('change-datagrid', {
available: this.available,
applied: this.applied
});
},
//=======================================================================================
// Support for previous applied values in datagrids. All code is based on local storage.
//=======================================================================================
/**
* Updates the datagrids stored in local storage with the latest data.
*
* @returns {void}
*/
updateDatagrids() {
let datagrids = this.getDatagrids();
if (datagrids?.length) {
const currentDatagrid = datagrids.find(({ src }) => src === this.src);
if (currentDatagrid) {
datagrids = datagrids.map(datagrid => {
if (datagrid.src === this.src) {
return {
...datagrid,
requestCount: ++datagrid.requestCount,
available: this.available,
applied: this.applied,
};
}
return datagrid;
});
} else {
datagrids.push(this.getDatagridInitialProperties());
}
} else {
datagrids = [this.getDatagridInitialProperties()];
}
this.setDatagrids(datagrids);
},
/**
* Returns the initial properties for a datagrid.
*
* @returns {object} Initial properties for a datagrid.
*/
getDatagridInitialProperties() {
return {
src: this.src,
requestCount: 0,
available: this.available,
applied: this.applied,
};
},
/**
* Returns the storage key for datagrids in local storage.
*
* @returns {string} Storage key for datagrids.
*/
getDatagridsStorageKey() {
return 'datagrids';
},
/**
* Retrieves the datagrids stored in local storage.
*
* @returns {Array} Datagrids stored in local storage.
*/
getDatagrids() {
let datagrids = localStorage.getItem(
this.getDatagridsStorageKey()
);
return JSON.parse(datagrids) ?? [];
},
/**
* Sets the datagrids in local storage.
*
* @param {Array} datagrids - Datagrids to be stored in local storage.
* @returns {void}
*/
setDatagrids(datagrids) {
localStorage.setItem(
this.getDatagridsStorageKey(),
JSON.stringify(datagrids)
);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,377 @@
@props(['isMultiRow' => false])
<v-datagrid-table
:is-loading="isLoading"
:available="available"
:applied="applied"
@selectAll="selectAll"
@sort="sort"
@actionSuccess="get"
>
{{ $slot }}
</v-datagrid-table>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datagrid-table-template"
>
<div class="w-full">
<!-- Table view for larger screens, Card view for mobile -->
<div class="table-responsive box-shadow rounded-t-0 grid w-full overflow-hidden border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<!-- Table Header - Always visible on all screens -->
<slot
name="header"
:is-loading="isLoading"
:available="available"
:applied="applied"
:select-all="selectAll"
:sort="sort"
:perform-action="performAction"
>
<template v-if="isLoading">
<x-admin::shimmer.datagrid.table.head :isMultiRow="$isMultiRow" />
</template>
<template v-else>
<div
class="row grid min-h-[47px] items-center gap-2.5 border-b bg-gray-50 px-4 py-2.5 text-black dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 max-lg:hidden"
:style="`grid-template-columns: repeat(${gridsCount}, minmax(0, 1fr))`"
>
<!-- Mass Actions -->
<p v-if="available.massActions.length">
<label for="mass_action_select_all_records">
<input
type="checkbox"
name="mass_action_select_all_records"
id="mass_action_select_all_records"
class="peer hidden"
:checked="['all', 'partial'].includes(applied.massActions.meta.mode)"
@change="selectAll"
>
<span
class="icon-checkbox-outline cursor-pointer rounded-md text-2xl text-gray-500 peer-checked:text-brandColor"
:class="[
applied.massActions.meta.mode === 'all' ? 'peer-checked:icon-checkbox-select peer-checked:text-brandColor ' : (
applied.massActions.meta.mode === 'partial' ? 'peer-checked:icon-checkbox-multiple peer-checked:brandColor' : ''
),
]"
>
</span>
</label>
</p>
<!-- Columns -->
<template v-for="column in available.columns">
<div
class="flex items-center gap-1.5 break-words"
:class="{'cursor-pointer select-none hover:text-gray-800 dark:hover:text-white': column.sortable}"
@click="sort(column)"
v-if="column.visibility"
>
<p v-html="column.label"></p>
<i
class="align-text-bottom text-base text-gray-600 dark:text-gray-300"
:class="[applied.sort.order === 'asc' ? 'icon-stats-down': 'icon-stats-up']"
v-if="column.index == applied.sort.column"
></i>
</div>
</template>
<!-- Actions -->
<p
class="text-end"
v-if="available.actions.length"
>
@lang('admin::app.components.datagrid.table.actions')
</p>
</div>
<!-- Mobile Sort/Filter Header -->
<div class="hidden border-b bg-gray-50 px-4 py-3 text-black dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 max-lg:block">
<div class="flex items-center justify-between">
<!-- Mass Actions for Mobile -->
<div v-if="available.massActions.length">
<label for="mass_action_select_all_records">
<input
type="checkbox"
name="mass_action_select_all_records"
id="mass_action_select_all_records"
class="peer hidden"
:checked="['all', 'partial'].includes(applied.massActions.meta.mode)"
@change="selectAll"
>
<span
class="icon-checkbox-outline cursor-pointer rounded-md text-2xl text-gray-500 peer-checked:text-brandColor"
:class="[
applied.massActions.meta.mode === 'all' ? 'peer-checked:icon-checkbox-select peer-checked:text-brandColor ' : (
applied.massActions.meta.mode === 'partial' ? 'peer-checked:icon-checkbox-multiple peer-checked:brandColor' : ''
),
]"
>
</span>
</label>
</div>
<!-- Mobile Sort Dropdown -->
<div class="flex w-full justify-end" v-if="available.columns.some(column => column.sortable)">
<x-admin::dropdown position="bottom-{{ in_array(app()->getLocale(), ['fa', 'ar']) ? 'left' : 'right' }}">
<x-slot:toggle>
<div class="flex items-center gap-1">
<button
type="button"
class="inline-flex w-full max-w-max cursor-pointer appearance-none items-center justify-between gap-x-2 rounded-md border bg-white px-2.5 py-1.5 text-center leading-6 text-gray-600 transition-all marker:shadow hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
>
<span>
Sort
</span>
<span class="icon-down-arrow text-2xl"></span>
</button>
</div>
</x-slot>
<x-slot:menu>
<x-admin::dropdown.menu.item
v-for="column in available.columns.filter(column => column.sortable && column.visibility)"
@click="sort(column)"
>
<div class="flex items-center gap-2">
<span v-html="column.label"></span>
<i
class="align-text-bottom text-base text-gray-600 dark:text-gray-300"
:class="[applied.sort.order === 'asc' ? 'icon-stats-down': 'icon-stats-up']"
v-if="column.index == applied.sort.column"
></i>
</div>
</x-admin::dropdown.menu.item>
</x-slot>
</x-admin::dropdown>
</div>
</div>
</div>
</template>
</slot>
<slot
name="body"
:is-loading="isLoading"
:available="available"
:applied="applied"
:select-all="selectAll"
:sort="sort"
:perform-action="performAction"
>
<template v-if="isLoading">
<x-admin::shimmer.datagrid.table.body :isMultiRow="$isMultiRow" />
</template>
<template v-else>
<template v-if="available.records.length">
<!-- Desktop View -->
<div
class="row grid items-center gap-2.5 border-b px-4 py-4 text-black transition-all hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-950 max-lg:hidden"
v-for="record in available.records"
:style="`grid-template-columns: repeat(${gridsCount}, minmax(0, 1fr))`"
>
<!-- Mass Actions -->
<p v-if="available.massActions.length">
<label :for="`mass_action_select_record_${record[available.meta.primary_column]}`">
<input
type="checkbox"
:name="`mass_action_select_record_${record[available.meta.primary_column]}`"
:value="record[available.meta.primary_column]"
:id="`mass_action_select_record_${record[available.meta.primary_column]}`"
class="peer hidden"
v-model="applied.massActions.indices"
>
<span class="icon-checkbox-outline peer-checked:icon-checkbox-select cursor-pointer rounded-md text-2xl text-gray-500 peer-checked:text-brandColor">
</span>
</label>
</p>
<!-- Columns -->
<template v-for="column in available.columns">
<p
class="break-words"
v-html="record[column.index]"
v-if="column.visibility"
>
</p>
</template>
<!-- Actions -->
<p
class="flex h-full items-center place-self-end"
v-if="available.actions.length"
>
<span
class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
:class="action.icon"
v-text="! action.icon ? action.title : ''"
v-for="action in record.actions"
@click="performAction(action)"
>
</span>
</p>
</div>
<!-- Mobile Card View -->
<div
class="hidden border-b px-4 py-4 text-black dark:border-gray-800 dark:text-gray-300 max-lg:block"
v-for="record in available.records"
>
<div class="mb-2 flex items-center justify-between">
<!-- Mass Actions for Mobile Cards -->
<div class="flex w-full items-center justify-between gap-2">
<p v-if="available.massActions.length">
<label :for="`mass_action_select_record_${record[available.meta.primary_column]}`">
<input
type="checkbox"
:name="`mass_action_select_record_${record[available.meta.primary_column]}`"
:value="record[available.meta.primary_column]"
:id="`mass_action_select_record_${record[available.meta.primary_column]}`"
class="peer hidden"
v-model="applied.massActions.indices"
>
<span class="icon-checkbox-outline peer-checked:icon-checkbox-select cursor-pointer rounded-md text-2xl text-gray-500 peer-checked:text-brandColor">
</span>
</label>
</p>
<!-- Actions for Mobile -->
<div
class="flex w-full items-center justify-end"
v-if="available.actions.length"
>
<span
class="dark:hover:bg-gray-80 cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200"
:class="action.icon"
v-text="! action.icon ? action.title : ''"
v-for="action in record.actions"
@click="performAction(action)"
>
</span>
</div>
</div>
</div>
<!-- Card Content -->
<div class="grid gap-2">
<template v-for="column in available.columns">
<div class="flex flex-wrap items-baseline gap-x-2">
<span class="text-slate-600 dark:text-gray-300" v-html="column.label + ':'"></span>
<span class="break-words font-medium text-slate-900 dark:text-white" v-html="record[column.index]"></span>
</div>
</template>
</div>
</div>
</template>
<template v-else>
<div class="row grid border-b px-4 py-4 text-center text-gray-600 dark:border-gray-800 dark:text-gray-300">
<p>
@lang('admin::app.components.datagrid.table.no-records-available')
</p>
</div>
</template>
</template>
</slot>
</div>
</div>
</script>
<script type="module">
app.component('v-datagrid-table', {
template: '#v-datagrid-table-template',
props: ['isLoading', 'available', 'applied'],
computed: {
gridsCount() {
let count = this.available.columns.filter((column) => column.visibility).length;
if (this.available.actions.length) {
++count;
}
if (this.available.massActions.length) {
++count;
}
return count;
},
},
methods: {
/**
* Select all records in the datagrid.
*
* @returns {void}
*/
selectAll() {
this.$emit('selectAll');
},
/**
* Perform a sorting operation on the specified column.
*
* @param {object} column
* @returns {void}
*/
sort(column) {
this.$emit('sort', column);
},
/**
* Perform the specified action.
*
* @param {object} action
* @returns {void}
*/
performAction(action) {
const method = action.method.toLowerCase();
switch (method) {
case 'get':
window.location.href = action.url;
break;
case 'post':
case 'put':
case 'patch':
case 'delete':
this.$emitter.emit('open-confirm-modal', {
agree: () => {
this.$axios[method](action.url)
.then(response => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
this.$emit('actionSuccess', response.data);
})
.catch((error) => {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
this.$emit('actionError', error.response.data);
});
}
});
break;
default:
console.error('Method not supported.');
break;
}
},
},
});
</script>
@endpushOnce

View File

@@ -0,0 +1,94 @@
<template v-if="isLoading">
<x-admin::shimmer.datagrid.toolbar />
</template>
<template v-else>
<div class="flex items-center justify-between gap-4 rounded-t-lg border border-b-0 border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 max-md:flex-wrap">
<!-- Left Toolbar -->
<div class="toolbarLeft flex gap-x-1">
{{ $toolbarLeftBefore }}
<!-- Mass Actions Panel -->
<transition-group
tag='div'
name="flash-group"
enter-from-class="ltr:translate-y-full rtl:-translate-y-full"
enter-active-class="transform transition duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
enter-to-class="ltr:translate-y-0 rtl:-translate-y-0"
leave-from-class="ltr:translate-y-0 rtl:-translate-y-0"
leave-active-class="transform transition duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
leave-to-class="ltr:translate-y-full rtl:-translate-y-full"
class='fixed bottom-10 left-1/2 z-[10003] grid -translate-x-1/2 justify-items-end gap-2.5'
>
<div v-if="applied.massActions.indices.length">
<x-admin::datagrid.toolbar.mass-action>
<template #mass-action="{
available,
applied,
massActions,
validateMassAction,
performMassAction
}">
<slot
name="mass-action"
:available="available"
:applied="applied"
:mass-actions="massActions"
:validate-mass-action="validateMassAction"
:perform-mass-action="performMassAction"
>
</slot>
</template>
</x-admin::datagrid.toolbar.mass-action>
</div>
</transition-group>
<!-- Search Panel -->
<x-admin::datagrid.toolbar.search>
<template #search="{
available,
applied,
search,
getSearchedValues,
}">
<slot
name="search"
:available="available"
:applied="applied"
:search="search"
:get-searched-values="getSearchedValues"
>
</slot>
</template>
</x-admin::datagrid.toolbar.search>
{{ $toolbarLeftAfter }}
</div>
<!-- Right Toolbar -->
<div class="toolbarRight flex gap-x-4">
{{ $toolbarRightBefore }}
<!-- Pagination Panel -->
<x-admin::datagrid.toolbar.pagination>
<template #pagination="{
available,
applied,
changePage,
changePerPageOption
}">
<slot
name="pagination"
:available="available"
:applied="applied"
:change-page="changePage"
:change-per-page-option="changePerPageOption"
>
</slot>
</template>
</x-admin::datagrid.toolbar.pagination>
{{ $toolbarRightAfter }}
</div>
</div>
</template>

View File

@@ -0,0 +1,220 @@
<v-datagrid-mass-action
:available="available"
:applied="applied"
>
{{ $slot }}
</v-datagrid-mass-action>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datagrid-mass-action-template"
>
<slot
name="mass-action"
:available="available"
:applied="applied"
:mass-actions="massActions"
:validate-mass-action="validateMassAction"
:perform-mass-action="performMassAction"
>
<div class="flex max-w-max items-center justify-center gap-2 rounded-lg bg-white p-2 px-4 shadow-[0px_10px_20px_0px_rgba(0,0,0,0.12)] dark:border-gray-800 dark:bg-gray-700 dark:text-gray-300">
<div>
<p class="text-sm font-light text-gray-800 dark:text-white">
@{{ "@lang('admin::app.components.datagrid.toolbar.selected')".replace(':total', applied.massActions.indices.length) }}
</p>
</div>
<template v-if="available.massActions.some(action => action.icon !== 'icon-delete' && action.options.length)">
<template v-for="massAction in available.massActions.filter(action => action.icon !== 'icon-delete')">
<x-admin::dropdown class="rounded-lg dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400">
<x-slot:toggle>
<button
type="button"
class="inline-flex w-full max-w-max cursor-pointer appearance-none items-center justify-between gap-x-2 rounded-md border bg-white px-2.5 py-1.5 text-center leading-6 text-gray-600 transition-all marker:shadow hover:border-gray-400 focus:border-gray-400 focus:ring-black dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
@click="showPopup = ! showPopup"
>
<span class="text-sm font-normal">
@{{ massAction.title }}
</span>
<span
class="text-2xl"
:class="showPopup ? 'icon-up-arrow' : 'icon-down-arrow'"
></span>
</button>
</x-slot>
<x-slot:menu class="!bottom-12 !top-auto !p-0 shadow-[0_5px_20px_rgba(0,0,0,0.15)] dark:border-gray-800">
<li v-for="option in massAction?.options">
<a
class="whitespace-no-wrap block rounded-t px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-950"
href="javascript:void(0);"
@click="performMassAction(massAction, option)"
>
@{{ option.label }}
</a>
</li>
</x-slot>
</x-admin::dropdown>
</template>
</template>
<button
type="button"
class="primary-button border-red-500 !bg-red-500"
@click="performMassAction(available.massActions.find(action => action.icon === 'icon-delete'))"
>
@{{ available.massActions.find(action => action.icon === 'icon-delete')?.title }}
</button>
<i
class="icon-cross-large cursor-pointer rounded-md p-1 text-2xl text-gray-600 transition-all hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-950"
@click="massActions.indices = []"
></i>
</div>
</slot>
</script>
<script type="module">
app.component('v-datagrid-mass-action', {
template: '#v-datagrid-mass-action-template',
props: ['available', 'applied'],
data() {
return {
showPopup: false,
massActions: {
meta: {
mode: 'none',
action: null,
},
indices: [],
value: null,
},
};
},
mounted() {
this.massActions = this.applied.massActions;
},
methods: {
/**
* Validate mass action.
*
* @param {object} filters
* @returns {void}
*/
validateMassAction() {
if (! this.massActions.indices.length) {
this.$emitter.emit('add-flash', { type: 'warning', message: "@lang('admin::app.components.datagrid.index.no-records-selected')" });
return false;
}
if (! this.massActions.meta.action) {
this.$emitter.emit('add-flash', { type: 'warning', message: "@lang('admin::app.components.datagrid.index.must-select-a-mass-action')" });
return false;
}
if (
this.massActions.meta.action?.options?.length &&
this.massActions.value === null
) {
this.$emitter.emit('add-flash', { type: 'warning', message: "@lang('admin::app.components.datagrid.index.must-select-a-mass-action-option')" });
return false;
}
return true;
},
/**
* Perform mass action.
*
* @param {object} currentAction
* @param {object} currentOption
* @returns {void}
*/
performMassAction(currentAction, currentOption = null) {
this.massActions.meta.action = currentAction;
if (currentOption) {
this.massActions.value = currentOption.value;
}
if (! this.validateMassAction()) {
return;
}
const { action } = this.massActions.meta;
const method = action.method.toLowerCase();
this.$emitter.emit('open-confirm-modal', {
agree: () => {
switch (method) {
case 'post':
case 'put':
case 'patch':
this.$axios[method](action.url, {
indices: this.massActions.indices,
value: this.massActions.value,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
this.$parent.$parent.get();
})
.catch((error) => {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
this.$parent.$parent.get();
});
break;
case 'delete':
this.$axios[method](action.url, {
indices: this.massActions.indices
})
.then(response => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
/**
* Need to check reason why this.$emit('massActionSuccess') not emitting.
*/
this.$parent.$parent.get();
})
.catch((error) => {
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
/**
* Need to check reason why this.$emit('massActionSuccess') not emitting.
*/
this.$parent.$parent.get();
});
break;
default:
console.error('Method not supported.');
break;
}
this.massActions.indices = [];
},
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,157 @@
<v-datagrid-pagination
:is-loading="isLoading"
:available="available"
:applied="applied"
@changePage="changePage"
@changePerPageOption="changePerPageOption"
>
{{ $slot }}
</v-datagrid-pagination>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datagrid-pagination-template"
>
<slot
name="pagination"
:available="available"
:applied="applied"
:change-page="changePage"
:change-per-page-option="changePerPageOption"
>
<template v-if="isLoading">
<x-admin::shimmer.datagrid.toolbar.pagination />
</template>
<template v-else>
<div class="flex items-center gap-x-2">
<p class="whitespace-nowrap text-gray-600 dark:text-gray-300 max-sm:hidden">
@lang('admin::app.components.datagrid.toolbar.per-page')
</p>
<x-admin::dropdown>
<x-slot:toggle>
<div class="flex items-center gap-1">
<button
type="button"
class="inline-flex w-full max-w-max cursor-pointer appearance-none items-center justify-between gap-x-2 rounded-md border bg-white px-2.5 py-1.5 text-center leading-6 text-gray-600 transition-all marker:shadow hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
>
<span>
@{{ applied.pagination.perPage }}
</span>
<span class="icon-down-arrow text-2xl"></span>
</button>
</div>
</x-slot>
<x-slot:menu>
<x-admin::dropdown.menu.item
v-for="perPageOption in available.meta.per_page_options"
@click="changePerPageOption(perPageOption)"
>
@{{ perPageOption }}
</x-admin::dropdown.menu.item>
</x-slot>
</x-admin::dropdown>
<div class="whitespace-nowrap text-gray-600 dark:text-gray-300">
<span>
@{{ available.meta.from ?? 0 }} - @{{ available.meta.to ?? 0 }}
</span>
<span>
@lang('admin::app.components.datagrid.toolbar.of')
</span>
<span>
@{{ available.meta.total }}
</span>
</div>
<div class="flex items-center gap-1">
<div
class="inline-flex w-full max-w-max cursor-pointer appearance-none items-center justify-between gap-x-1 rounded-md border border-transparent p-1.5 text-center text-gray-600 transition-all marker:shadow hover:bg-gray-200 active:border-gray-300 dark:text-gray-300 dark:hover:bg-gray-800"
@click="changePage('previous')"
>
<span class="icon-left-arrow rtl:icon-right-arrow text-2xl"></span>
</div>
<div
class="inline-flex w-full max-w-max cursor-pointer appearance-none items-center justify-between gap-x-1 rounded-md border border-transparent p-1.5 text-center text-gray-600 transition-all marker:shadow hover:bg-gray-200 active:border-gray-300 dark:text-gray-300 dark:hover:bg-gray-800"
@click="changePage('next')"
>
<span class="icon-right-arrow rtl:icon-left-arrow text-2xl"></span>
</div>
</div>
</div>
</template>
</slot>
</script>
<script type="module">
app.component('v-datagrid-pagination', {
template: '#v-datagrid-pagination-template',
props: ['isLoading', 'available', 'applied'],
emits: ['changePage', 'changePerPageOption'],
methods: {
/**
* Change Page.
*
* The reason for choosing the numeric approach over the URL approach is to prevent any conflicts with our existing
* URLs. If we were to use the URL approach, it would introduce additional arguments in the `get` method, necessitating
* the addition of a `url` prop. Instead, by using the numeric approach, we can let Axios handle all the query parameters
* using the `applied` prop. This allows for a cleaner and more straightforward implementation.
*
* @param {string|integer} directionOrPageNumber
* @returns {void}
*/
changePage(directionOrPageNumber) {
let newPage;
if (typeof directionOrPageNumber === 'string') {
if (directionOrPageNumber === 'previous') {
newPage = this.available.meta.current_page - 1;
} else if (directionOrPageNumber === 'next') {
newPage = this.available.meta.current_page + 1;
} else {
console.warn('Invalid Direction Provided : ' + directionOrPageNumber);
return;
}
} else if (typeof directionOrPageNumber === 'number') {
newPage = directionOrPageNumber;
} else {
console.warn('Invalid Input Provided: ' + directionOrPageNumber);
return;
}
/**
* Check if the `newPage` is within the valid range.
*/
if (newPage >= 1 && newPage <= this.available.meta.last_page) {
this.$emit('changePage', newPage);
} else {
console.warn('Invalid Page Provided: ' + newPage);
}
},
/**
* Change per page option.
*
* @param {integer} option
* @returns {void}
*/
changePerPageOption(option) {
this.$emit('changePerPageOption', option);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,157 @@
<v-datagrid-search
:is-loading="isLoading"
:available="available"
:applied="applied"
:src="src"
@search="search"
@filter="filter"
@applySavedFilter="applySavedFilter"
>
{{ $slot }}
</v-datagrid-search>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datagrid-search-template"
>
<slot
name="search"
:available="available"
:applied="applied"
:search="search"
:get-searched-values="getSearchedValues"
>
<template v-if="isLoading">
<x-admin::shimmer.datagrid.toolbar.search />
</template>
<template v-else>
<div class="flex w-full items-center gap-x-1.5">
<!-- Search Panel -->
<div class="flex max-w-[445px] items-center max-sm:w-full max-sm:max-w-full">
<div class="relative w-full">
<div class="icon-search absolute top-1.5 flex items-center text-2xl ltr:left-3 rtl:right-3"></div>
<input
type="text"
name="search"
:value="getSearchedValues('all')"
class="block w-full rounded-lg border bg-white py-1.5 leading-6 text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400 ltr:pl-10 ltr:pr-3 rtl:pl-3 rtl:pr-10"
placeholder="@lang('admin::app.components.datagrid.toolbar.search.title')"
autocomplete="off"
@keyup.enter="search"
>
</div>
</div>
<!-- Filter Panel -->
<x-admin::datagrid.toolbar.filter>
<template #filter="{
available,
applied,
filters,
applyFilter,
applyColumnValues,
findAppliedColumn,
hasAnyAppliedColumnValues,
getAppliedColumnValues,
removeAppliedColumnValue,
removeAppliedColumnAllValues
}">
<slot
name="filter"
:available="available"
:applied="applied"
:filters="filters"
:apply-filter="applyFilter"
:apply-column-values="applyColumnValues"
:find-applied-column="findAppliedColumn"
:has-any-applied-column-values="hasAnyAppliedColumnValues"
:get-applied-column-values="getAppliedColumnValues"
:remove-applied-column-value="removeAppliedColumnValue"
:remove-applied-column-all-values="removeAppliedColumnAllValues"
>
</slot>
</template>
</x-admin::datagrid.toolbar.filter>
</div>
</template>
</slot>
</script>
<script type="module">
app.component('v-datagrid-search', {
template: '#v-datagrid-search-template',
props: ['isLoading', 'available', 'applied', 'src'],
emits: ['search', 'filter', 'applySavedFilter'],
data() {
return {
filters: {
columns: [],
},
};
},
mounted() {
this.filters.columns = this.applied.filters.columns.filter((column) => column.index === 'all');
},
methods: {
/**
* Perform a search operation based on the input value.
*
* @param {Event} $event
* @returns {void}
*/
search($event) {
let requestedValue = $event.target.value;
let appliedColumn = this.filters.columns.find(column => column.index === 'all');
if (! requestedValue) {
appliedColumn.value = [];
this.$emit('search', this.filters);
return;
}
if (appliedColumn) {
appliedColumn.value = [requestedValue];
} else {
this.filters.columns.push({
index: 'all',
value: [requestedValue]
});
}
this.$emit('search', this.filters);
},
filter(filter) {
this.$emit('filter', filter);
},
applySavedFilter(filter) {
this.$emit('applySavedFilter', filter);
},
/**
* Get the searched values for a specific column.
*
* @param {string} columnIndex
* @returns {Array}
*/
getSearchedValues(columnIndex) {
let appliedColumn = this.filters.columns.find(column => column.index === 'all');
return appliedColumn?.value ?? [];
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,201 @@
@props([
'isActive' => false,
'position' => 'right',
'width' => '500px',
])
<v-drawer
{{ $attributes }}
is-active="{{ $isActive }}"
position="{{ $position }}"
width="{{ $width }}"
>
@isset($toggle)
<template v-slot:toggle>
{{ $toggle }}
</template>
@endisset
@isset($header)
<template v-slot:header="{ close }">
<div {{ $header->attributes->merge(['class' => 'flex justify-between items-center gap-y-2.5 border-b p-3 dark:border-gray-800 max-sm:px-4']) }}>
{{ $header }}
<div class="w-full flex-1 ltr:right-3 ltr:text-right rtl:left-3 rtl:text-left">
<span
class="icon-cross-large cursor-pointer text-3xl hover:rounded-md hover:bg-gray-100 dark:hover:bg-gray-950"
@click="close"
>
</span>
</div>
</div>
</template>
@endisset
@isset($content)
<template v-slot:content>
<div {{ $content->attributes->merge(['class' => 'flex-1 overflow-auto p-3 max-sm:px-4']) }}>
{{ $content }}
</div>
</template>
@endisset
@isset($footer)
<template v-slot:footer>
<div {{ $footer->attributes->merge(['class' => 'pb-8']) }}>
{{ $footer }}
</div>
</template>
@endisset
</v-drawer>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-drawer-template"
>
<div>
<!-- Toggler -->
<div @click="open">
<slot name="toggle">
</slot>
</div>
<!-- Overlay -->
<transition
tag="div"
name="drawer-overlay"
enter-class="ease-out duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-class="ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
class="fixed inset-0 z-[10002] bg-gray-500 bg-opacity-50 transition-opacity"
v-show="isOpen"
></div>
</transition>
<!-- Content -->
<transition
tag="div"
name="drawer"
:enter-from-class="enterFromLeaveToClasses"
enter-active-class="transform transition duration-200 ease-in-out"
enter-to-class="translate-x-0"
leave-from-class="translate-x-0"
leave-active-class="transform transition duration-200 ease-in-out"
:leave-to-class="enterFromLeaveToClasses"
>
<div
class="fixed z-[10003] m-3 rounded-lg bg-white dark:bg-gray-900 max-sm:!w-[calc(100%-24px)]"
:class="{
'inset-x-0 top-0': position == 'top',
'inset-x-0 bottom-0': position == 'bottom',
'inset-y-0 ltr:right-0 rtl:left-0': position == 'right',
'inset-y-0 ltr:left-0 rtl:right-0': position == 'left'
}"
:style="'width:' + width"
v-if="isOpen"
>
<div class="pointer-events-auto h-full w-full overflow-auto rounded-lg bg-white dark:bg-gray-900">
<div class="flex h-full w-full flex-col">
<div class="min-h-0 min-w-0 flex-1 overflow-auto">
<div class="flex h-full flex-col">
<!-- Header Slot-->
<slot
name="header"
:close="close"
>
</slot>
<!-- Content Slot -->
<slot name="content"></slot>
<!-- Footer Slot -->
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</script>
<script type="module">
app.component('v-drawer', {
template: '#v-drawer-template',
props: [
'isActive',
'position',
'width'
],
emits: [
'toggle',
'open',
'close',
],
data() {
return {
isOpen: this.isActive,
};
},
watch: {
isActive: function(newVal, oldVal) {
this.isOpen = newVal;
}
},
computed: {
enterFromLeaveToClasses() {
if (this.position == 'top') {
return '-translate-y-full';
} else if (this.position == 'bottom') {
return 'translate-y-full';
} else if (this.position == 'left') {
return 'ltr:-translate-x-full rtl:translate-x-full';
} else if (this.position == 'right') {
return 'ltr:translate-x-full rtl:-translate-x-full';
}
}
},
methods: {
toggle() {
this.isOpen = ! this.isOpen;
if (this.isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow ='auto';
}
this.$emit('toggle', { isActive: this.isOpen });
},
open() {
this.isOpen = true;
document.body.style.overflow = 'hidden';
this.$emit('open', { isActive: this.isOpen });
},
close() {
this.isOpen = false;
document.body.style.overflow = 'auto';
this.$emit('close', { isActive: this.isOpen });
}
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,170 @@
@props(['position' => 'bottom-left'])
<v-dropdown position="{{ $position }}" {{ $attributes->merge(['class' => 'relative']) }}>
@isset($toggle)
{{ $toggle }}
<template v-slot:toggle>
{{ $toggle }}
</template>
@endisset
@isset($content)
<template #content="{ isActive, positionStyles }">
<div
{{ $content->attributes->merge(['class' => 'absolute z-10 w-max rounded bg-white py-5 shadow-[0px_10px_20px_0px_#0000001F] dark:bg-gray-900 border border-gray-300 dark:border-gray-800']) }}
:style="positionStyles"
v-show="isActive"
>
{{ $content }}
</div>
</template>
@endisset
@isset($menu)
<template #menu="{ isActive, positionStyles }">
<ul
{{ $menu->attributes->merge(['class' => 'absolute z-10 w-max rounded bg-white py-4 shadow-[0px_10px_20px_0px_#0000001F] dark:bg-gray-900']) }}
:style="positionStyles"
v-show="isActive"
>
{{ $menu }}
</ul>
</template>
@endisset
</v-dropdown>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-dropdown-template"
>
<div>
<div
class="flex select-none items-center"
ref="toggleBlock"
@click="toggle()"
>
<slot name="toggle"></slot>
</div>
<transition
tag="div"
name="dropdown"
enter-active-class="transition duration-100 ease-out"
enter-from-class="scale-95 transform opacity-0"
enter-to-class="scale-100 transform opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="scale-100 transform opacity-100"
leave-to-class="scale-95 transform opacity-0"
>
<div>
<slot
name="content"
:position-styles="positionStyles"
:is-active="isActive"
></slot>
<slot
name="menu"
:position-styles="positionStyles"
:is-active="isActive"
></slot>
</div>
</transition>
</div>
</script>
<script type="module">
app.component('v-dropdown', {
template: '#v-dropdown-template',
props: {
position: String,
closeOnClick: {
type: Boolean,
required: false,
default: true
},
},
data() {
return {
toggleBlockWidth: 0,
toggleBlockHeight: 0,
isActive: false,
};
},
created() {
window.addEventListener('click', this.handleFocusOut);
},
mounted() {
this.toggleBlockWidth = this.$refs.toggleBlock.clientWidth;
this.toggleBlockHeight = this.$refs.toggleBlock.clientHeight;
},
beforeDestroy() {
window.removeEventListener('click', this.handleFocusOut);
},
computed: {
positionStyles() {
switch (this.position) {
case 'bottom-left':
return [
`min-width: ${this.toggleBlockWidth}px`,
`top: ${this.toggleBlockHeight}px`,
'left: 0',
];
case 'bottom-right':
return [
`min-width: ${this.toggleBlockWidth}px`,
`top: ${this.toggleBlockHeight}px`,
'right: 0',
];
case 'top-left':
return [
`min-width: ${this.toggleBlockWidth}px`,
`bottom: ${this.toggleBlockHeight*2}px`,
'left: 0',
];
case 'top-right':
return [
`min-width: ${this.toggleBlockWidth}px`,
`bottom: ${this.toggleBlockHeight*2}px`,
'right: 0',
];
default:
return [
`min-width: ${this.toggleBlockWidth}px`,
`top: ${this.toggleBlockHeight}px`,
'left: 0',
];
}
},
},
methods: {
toggle() {
this.isActive = ! this.isActive;
},
handleFocusOut(e) {
if (! this.$el.contains(e.target) || (this.closeOnClick && this.$el.children[1].contains(e.target))) {
this.isActive = false;
}
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,3 @@
<li {{ $attributes->merge(['class' => 'cursor-pointer px-5 py-2 text-sm text-gray-800 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-950 ']) }}>
{{ $slot }}
</li>

View File

@@ -0,0 +1,12 @@
@php
$bgColors = ['bg-orange-100', 'bg-red-100', 'bg-green-100', 'bg-blue-100', 'bg-purple-100'];
$textColors = ['text-orange-800', 'text-red-800', 'text-green-800', 'text-blue-800', 'text-purple-800'];
@endphp
@foreach ($bgColors as $bgColor)
<div class="{{ $bgColor }}"></div>
@endforeach
@foreach ($textColors as $textColor)
<div class="{{ $textColor }}"></div>
@endforeach

View File

@@ -0,0 +1,64 @@
<v-flash-group ref='flashes'></v-flash-group>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-flash-group-template"
>
<transition-group
tag='div'
name="flash-group"
enter-from-class="ltr:translate-y-full rtl:-translate-y-full"
enter-active-class="transform transition duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
enter-to-class="ltr:translate-y-0 rtl:-translate-y-0"
leave-from-class="ltr:translate-y-0 rtl:-translate-y-0"
leave-active-class="transform transition duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
leave-to-class="ltr:translate-y-full rtl:-translate-y-full"
class='fixed bottom-5 left-1/2 z-[10003] grid -translate-x-1/2 justify-items-end gap-2.5'
>
<x-admin::flash-group.item />
</transition-group>
</script>
<script type="module">
app.component('v-flash-group', {
template: '#v-flash-group-template',
data() {
return {
uid: 0,
flashes: []
}
},
created() {
@foreach (['success', 'warning', 'error', 'info'] as $key)
@if (session()->has($key))
this.flashes.push({'type': '{{ $key }}', 'message': "{{ session($key) }}", 'uid': this.uid++});
@endif
@endforeach
this.registerGlobalEvents();
},
methods: {
add(flash) {
flash.uid = this.uid++;
this.flashes.push(flash);
},
remove(flash) {
let index = this.flashes.indexOf(flash);
this.flashes.splice(index, 1);
},
registerGlobalEvents() {
this.$emitter.on('add-flash', this.add);
},
}
});
</script>
@endpushOnce

View File

@@ -0,0 +1,205 @@
<v-flash-item
v-for='flash in flashes'
:key='flash.uid'
:flash="flash"
@onRemove="remove($event)"
>
</v-flash-item>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-flash-item-template"
>
<div
class="flex w-max items-start justify-between gap-2 rounded-lg bg-white p-3 shadow-[0px_10px_20px_0px_rgba(0,0,0,0.12)] dark:bg-gray-950"
:style="typeStyles[flash.type]['container']"
@mouseenter="pauseTimer"
@mouseleave="resumeTimer"
>
<!-- Icon -->
<span
class="icon-toast-done rounded-full bg-white text-2xl dark:bg-gray-900"
:class="iconClasses[flash.type]"
:style="typeStyles[flash.type]['icon']"
></span>
<div class="flex flex-col gap-1.5">
<!-- Heading -->
<p class="text-base font-semibold dark:text-white">
@{{ typeHeadings[flash.type] }}
</p>
<!-- Message -->
<p
class="flex items-center break-all text-sm dark:text-white"
:style="typeStyles[flash.type]['message']"
>
@{{ flash.message }}
</p>
</div>
<button
class="relative ml-4 inline-flex rounded-full bg-white p-1.5 text-gray-400 dark:bg-gray-950"
@click="remove"
>
<span class="icon-cross-large text-2xl text-slate-800"></span>
<svg class="absolute inset-0 h-full w-full -rotate-90" viewBox="0 0 24 24">
<circle
class="text-gray-200"
stroke-width="1.5"
stroke="#D0D4DA"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
<circle
class="text-blue-600 transition-all duration-100 ease-out"
stroke-width="1.5"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
:stroke="typeStyles[flash.type]['stroke']"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
</svg>
</button>
</div>
</script>
<script type="module">
app.component('v-flash-item', {
template: '#v-flash-item-template',
props: ['flash'],
data() {
return {
iconClasses: {
success: 'icon-success',
error: 'icon-error',
warning: 'icon-warning',
info: 'icon-info',
},
typeHeadings: {
success: "@lang('admin::app.components.flash-group.success')",
error: "@lang('admin::app.components.flash-group.error')",
warning: "@lang('admin::app.components.flash-group.warning')",
info: "@lang('admin::app.components.flash-group.info')",
},
typeStyles: {
success: {
container: 'border-left: 8px solid #16A34A',
icon: 'color: #16A34A',
stroke: '#16A34A',
},
error: {
container: 'border-left: 8px solid #FF4D50',
icon: 'color: #FF4D50',
stroke: '#FF4D50',
},
warning: {
container: 'border-left: 8px solid #FBAD15',
icon: 'color: #FBAD15',
stroke: '#FBAD15',
},
info: {
container: 'border-left: 8px solid #0E90D9',
icon: 'color: #0E90D9',
stroke: '#0E90D9',
},
},
duration: 5000,
progress: 0,
circumference: 2 * Math.PI * 10,
timer: null,
isPaused: false,
remainingTime: 5000,
};
},
computed: {
strokeDashoffset() {
return this.circumference - (this.progress / 100) * this.circumference;
}
},
created() {
this.startTimer();
},
beforeUnmount() {
this.stopTimer();
},
methods: {
remove() {
this.$emit('onRemove', this.flash)
},
startTimer() {
const interval = 100;
const step = (100 / (this.duration / interval));
this.timer = setInterval(() => {
if (! this.isPaused) {
this.progress += step;
this.remainingTime -= interval;
if (this.progress >= 100) {
this.stopTimer();
this.remove();
}
}
}, interval);
},
stopTimer() {
clearInterval(this.timer);
},
pauseTimer() {
this.isPaused = true;
},
resumeTimer() {
this.isPaused = false;
},
}
});
</script>
@endpushOnce

View File

@@ -0,0 +1,81 @@
<v-date-picker {{ $attributes }}>
{{ $slot }}
</v-date-picker>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-date-picker-template"
>
<span class="relative inline-block w-full">
<slot></slot>
<i class="icon-calendar pointer-events-none absolute top-1/2 -translate-y-1/2 text-2xl text-gray-400 ltr:right-2 rtl:left-2"></i>
</span>
</script>
<script type="module">
app.component('v-date-picker', {
template: '#v-date-picker-template',
props: {
name: String,
value: String,
allowInput: {
type: Boolean,
default: true,
},
disable: Array,
minDate: String,
maxDate: String,
},
data: function() {
return {
datepicker: null
};
},
mounted: function() {
let options = this.setOptions();
this.activate(options);
},
methods: {
setOptions: function() {
let self = this;
return {
allowInput: this.allowInput ?? true,
disable: this.disable ?? [],
minDate: this.minDate ?? '',
maxDate: this.maxDate ?? '',
altFormat: "Y-m-d",
dateFormat: "Y-m-d",
weekNumbers: true,
onChange: function(selectedDates, dateStr, instance) {
self.$emit("onChange", dateStr);
}
};
},
activate: function(options) {
let element = this.$el.getElementsByTagName("input")[0];
this.datepicker = new Flatpickr(element, options);
},
clear: function() {
this.datepicker.clear();
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,83 @@
<v-datetime-picker {{ $attributes }}>
{{ $slot }}
</v-datetime-picker>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-datetime-picker-template"
>
<span class="relative inline-block w-full">
<slot></slot>
<i class="icon-calendar pointer-events-none absolute top-1/2 -translate-y-1/2 text-2xl text-gray-400 ltr:right-2 rtl:left-2"></i>
</span>
</script>
<script type="module">
app.component('v-datetime-picker', {
template: '#v-datetime-picker-template',
props: {
name: String,
value: String,
allowInput: {
type: Boolean,
default: true,
},
disable: Array,
minDate: String,
maxDate: String,
},
data: function() {
return {
datepicker: null
};
},
mounted: function() {
let options = this.setOptions();
this.activate(options);
},
methods: {
setOptions: function() {
let self = this;
return {
allowInput: this.allowInput ?? true,
disable: this.disable ?? [],
minDate: this.minDate ?? '',
maxDate: this.maxDate ?? '',
altFormat: "Y-m-d H:i:S",
dateFormat: "Y-m-d H:i:S",
enableTime: true,
time_24hr: true,
weekNumbers: true,
onChange: function(selectedDates, dateStr, instance) {
self.$emit("onChange", dateStr);
}
};
},
activate: function(options) {
let element = this.$el.getElementsByTagName("input")[0];
this.datepicker = new Flatpickr(element, options);
},
clear: function() {
this.datepicker.clear();
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,353 @@
@props([
'type' => 'text',
'name' => '',
])
@switch($type)
@case('hidden')
@case('text')
@case('email')
@case('password')
@case('number')
<v-field
v-slot="{ field, errors }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label']) }}
name="{{ $name }}"
>
<input
type="{{ $type }}"
name="{{ $name }}"
v-bind="field"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['class' => 'w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400']) }}
/>
</v-field>
@break
@case('price')
<v-field
v-slot="{ field, errors }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label']) }}
name="{{ $name }}"
>
<div
class="flex w-full items-center overflow-hidden rounded-md border text-sm text-gray-600 transition-all focus-within:border-gray-400 hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
>
@if (isset($currency))
<span {{ $currency->attributes->merge(['class' => 'py-2.5 text-gray-500 ltr:pl-4 rtl:pr-4']) }}>
{{ $currency }}
</span>
@else
<span class="py-2.5 text-gray-500 ltr:pl-4 rtl:pr-4">
{{ config('app.currency') }}
</span>
@endif
<input
type="text"
name="{{ $name }}"
v-bind="field"
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['class' => 'w-full p-2.5 text-sm text-gray-600 dark:bg-gray-900 dark:text-gray-300']) }}
/>
</div>
</v-field>
@break
@case('file')
<v-field
v-slot="{ field, errors, handleChange, handleBlur }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label']) }}
name="{{ $name }}"
>
<input
type="{{ $type }}"
v-bind="{ name: field.name }"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['class' => 'w-full dark:file:bg-gray-800 dark:file:dark:text-white rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400']) }}
@change="handleChange"
@blur="handleBlur"
/>
</v-field>
@break
@case('color')
<v-field
name="{{ $name }}"
v-slot="{ field, errors }"
{{ $attributes->except('class') }}
>
<input
type="{{ $type }}"
:class="[errors.length ? 'border border-red-500' : '']"
v-bind="field"
{{ $attributes->except(['value'])->merge(['class' => 'w-full appearance-none rounded-md border text-sm text-gray-600 transition-all hover:border-gray-400 dark:text-gray-300 dark:hover:border-gray-400']) }}
>
</v-field>
@break
@case('textarea')
<v-field
v-slot="{ field, errors }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label']) }}
name="{{ $name }}"
>
@php
$defaultAttributes = [
'class' => 'w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400'
];
if ($attributes->get('tinymce', false) || $attributes->get(':tinymce', false)) {
$defaultAttributes['id'] = $attributes->get(':id', 'id');
}
@endphp
<textarea
type="{{ $type }}"
name="{{ $name }}"
v-bind="field"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
{{
$attributes
->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])
->merge($defaultAttributes)
}}
>
</textarea>
@if ($attributes->get('tinymce', false) || $attributes->get(':tinymce', false))
<x-admin::tinymce
:selector="'textarea#' . ($attributes->get('id') ?? $attributes->get(':id'))"
::field="field"
/>
@endif
</v-field>
@break
@case('date')
<v-field
v-slot="{ field, errors }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['rules' => 'regex:^\d{4}-\d{2}-\d{2}$']) }}
name="{{ $name }}"
>
<x-admin::flat-picker.date>
<input
name="{{ $name }}"
v-bind="field"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['class' => 'w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400']) }}
autocomplete="off"
/>
</x-admin::flat-picker.date>
</v-field>
@break
@case('datetime')
<v-field
v-slot="{ field, errors }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['rules' => 'regex:^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$']) }}
name="{{ $name }}"
>
<x-admin::flat-picker.datetime>
<input
name="{{ $name }}"
v-bind="field"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['class' => 'w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400']) }}
autocomplete="off"
>
</x-admin::flat-picker.datetime>
</v-field>
@break
@case('select')
<v-field
v-slot="{ field, errors }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label']) }}
name="{{ $name }}"
>
<select
name="{{ $name }}"
v-bind="field"
:class="[errors.length ? 'border border-red-500' : '']"
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label'])->merge(['class' => 'custom-select w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400']) }}
>
{{ $slot }}
</select>
</v-field>
@break
@case('multiselect')
<v-field
as="select"
v-slot="{ value }"
:class="[errors && errors['{{ $name }}'] ? 'border !border-red-600 hover:border-red-600' : '']"
{{ $attributes->except([])->merge(['class' => 'flex w-full flex-col rounded-md border bg-white px-3 py-2.5 text-sm font-normal text-gray-600 transition-all hover:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400']) }}
name="{{ $name }}"
multiple
>
{{ $slot }}
</v-field>
@break
@case('checkbox')
<v-field
v-slot="{ field }"
type="checkbox"
class="hidden"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label', 'key', ':key']) }}
name="{{ $name }}"
>
<input
type="checkbox"
name="{{ $name }}"
v-bind="field"
class="peer sr-only"
{{ $attributes->except(['rules', 'label', ':label', 'key', ':key']) }}
/>
<v-checked-handler
:field="field"
checked="{{ $attributes->get('checked') }}"
>
</v-checked-handler>
</v-field>
<label
{{
$attributes
->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label', 'key', ':key'])
->merge(['class' => 'text-gray-500 icon-checkbox-outline peer-checked:icon-checkbox-select text-2xl peer-checked:text-brandColor'])
->merge(['class' => $attributes->get('disabled') ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'])
}}
>
</label>
@break
@case('radio')
<v-field
type="radio"
class="hidden"
v-slot="{ field }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label', 'key', ':key']) }}
name="{{ $name }}"
>
<input
type="radio"
name="{{ $name }}"
v-bind="field"
{{ $attributes->except(['rules', 'label', ':label', 'key', ':key'])->merge(['class' => 'peer sr-only']) }}
/>
<v-checked-handler
class="hidden"
:field="field"
checked="{{ $attributes->get('checked') }}"
>
</v-checked-handler>
</v-field>
<label
{{ $attributes->except(['value', ':value', 'v-model', 'rules', ':rules', 'label', ':label', 'key', ':key'])->merge(['class' => 'icon-radio-normal peer-checked:icon-radio-selected cursor-pointer text-2xl peer-checked:text-brandColor']) }}
>
</label>
@break
@case('switch')
<label class="relative inline-flex cursor-pointer items-center">
<v-field
type="checkbox"
class="hidden"
v-slot="{ field }"
{{ $attributes->only(['name', ':name', 'value', ':value', 'v-model', 'rules', ':rules', 'label', ':label', 'key', ':key']) }}
name="{{ $name }}"
>
<input
type="checkbox"
name="{{ $name }}"
id="{{ $name }}"
class="peer sr-only"
v-bind="field"
{{ $attributes->except(['v-model', 'rules', ':rules', 'label', ':label', 'key', ':key']) }}
/>
<v-checked-handler
class="hidden"
:field="field"
checked="{{ $attributes->get('checked') }}"
>
</v-checked-handler>
</v-field>
<label
class="peer h-5 w-9 cursor-pointer rounded-full bg-gray-200 after:absolute after:top-0.5 after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-brandColor peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-blue-300 dark:bg-gray-800 dark:after:border-white dark:after:bg-white dark:peer-checked:bg-gray-950 after:ltr:left-0.5 peer-checked:after:ltr:translate-x-full after:rtl:right-0.5 peer-checked:after:rtl:-translate-x-full"
for="{{ $name }}"
></label>
</label>
@break
@case('image')
<x-admin::media.images
name="{{ $name }}"
::class="[errors && errors['{{ $name }}'] ? 'border !border-red-600 hover:border-red-600' : '']"
{{ $attributes }}
/>
@break
@case('inline')
<x-admin::form.control-group.controls.inline.text {{ $attributes }} />
@break
@case('custom')
<v-field {{ $attributes }}>
{{ $slot }}
</v-field>
@break
@case('tags')
<x-admin::form.control-group.controls.tags
:name="$name"
:data="$attributes->get(':data') ?? $attributes->get('data')"
{{ $attributes}}
/>
@break
@endswitch
@pushOnce('scripts')
<script
type="text/x-template"
id="v-checked-handler-template"
>
</script>
<script type="module">
app.component('v-checked-handler', {
template: '#v-checked-handler-template',
props: ['field', 'checked'],
mounted() {
if (this.checked == '') {
return;
}
this.field.checked = true;
this.field.onChange();
},
});
</script>
@endpushOnce

View File

@@ -0,0 +1,370 @@
@props([
'allowEdit' => true,
])
<v-inline-address-edit
{{ $attributes->except('value') }}
:value='@json($attributes->get('value'))'
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center ltr:pl-2.5 ltr:text-left rtl:pr-2.5 rtl:text-right">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-address-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-address-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : `${inputValue?.address} ${inputValue?.city} ${inputValue?.state} ${inputValue?.postcode} ${inputValue?.country}`.length > 20 ? `${inputValue?.address} ${inputValue?.city} ${inputValue?.state} ${inputValue?.postcode} ${inputValue?.country}`.substring(0, 20) + '...' : `${inputValue?.address} ${inputValue?.city} ${inputValue?.state} ${inputValue?.postcode} ${inputValue?.country}` }}
</span>
<div class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex">
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue?.address }}<br>
@{{ `${inputValue?.city}, ${inputValue?.state}, ${inputValue?.postcode}` }}<br>
@{{ `${inputValue?.country}` }}<br>
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<Teleport to="body">
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
as="div"
ref="modalForm"
>
<form @submit="handleSubmit($event, updateOrCreate)">
<!-- Editing view -->
<x-admin::modal ref="emailModal">
<!-- Modal Header -->
<x-slot:header>
<p class="text-lg font-bold text-gray-800 dark:text-white">
Update Address
</p>
</x-slot>
<!-- Modal Content -->
<x-slot:content>
<div class="flex gap-4">
<div class="w-full">
<!-- Address (Textarea field) -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="textarea"
::name="`${name}.address`"
rows="10"
::value="inputValue?.address"
/>
<x-admin::form.control-group.error ::name="name" />
</x-admin::form.control-group>
</div>
<div class="grid w-full">
<!-- Country Field -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="select"
::name="`${name}.country`"
v-model="inputValue.country"
>
<option value="">@lang('admin::app.common.custom-attributes.select-country')</option>
@foreach (core()->countries() as $country)
<option value="{{ $country->code }}">{{ $country->name }}</option>
@endforeach
</x-admin::form.control-group.control>
<x-admin::form.control-group.error name="country" />
</x-admin::form.control-group>
<!-- State Field -->
<template v-if="haveStates()">
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="select"
::name="`${name}.state`"
v-model="inputValue.state"
>
<option value="">@lang('admin::app.common.custom-attributes.select-state')</option>
<option v-for='(state, index) in countryStates[inputValue?.country]' :value="state.code">
@{{ state.name }}
</option>
</x-admin::form.control-group.control>
<x-admin::form.control-group.error name="country" />
</x-admin::form.control-group>
</template>
<template v-else>
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="text"
::name="`${name}.state`"
v-model="inputValue.state"
/>
<x-admin::form.control-group.error name="state" />
</x-admin::form.control-group>
</template>
<!-- City Field -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="text"
::name="`${name}.city`"
::value="inputValue?.city"
/>
<x-admin::form.control-group.error name="city" />
</x-admin::form.control-group>
<!-- Postcode Field -->
<x-admin::form.control-group>
<x-admin::form.control-group.control
type="text"
::name="`${name}.postcode`"
::value="inputValue?.postcode"
:placeholder="trans('admin::app.common.custom-attributes.postcode')"
/>
<x-admin::form.control-group.error name="postcode" />
</x-admin::form.control-group>
</div>
</div>
</x-slot>
<!-- Modal Footer -->
<x-slot:footer>
<!-- Save Button -->
<x-admin::button
button-type="submit"
class="primary-button justify-center"
:title="trans('Save')"
::loading="isProcessing"
::disabled="isProcessing"
/>
</x-slot>
</x-admin::modal>
</form>
</x-admin::form>
</Teleport>
</div>
</script>
<script type="module">
app.component('v-inline-address-edit', {
template: '#v-inline-address-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
emails: JSON.parse(JSON.stringify(this.value || [{'value': '', 'label': 'work'}])),
isProcessing: false,
countryStates: @json(core()->groupedStatesByCountries()),
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue, oldValue) {
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
this.emails = newValue || [{'value': '', 'label': 'work'}];
}
},
},
created() {
this.extendValidations();
if (! this.emails || ! this.emails.length) {
this.emails = [{
'value': '',
'label': 'work'
}];
}
},
computed: {
/**
* Get the validation rules.
*
* @return {Object}
*/
getValidation() {
return {
email: true,
unique_contact_email: this.emails ?? [],
required: true,
};
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
this.$refs.emailModal.toggle();
},
add() {
this.emails.push({
'value': '',
'label': 'work'
});
},
remove(email) {
this.emails = this.emails.filter(email => email !== email);
},
extendValidations() {
defineRule('unique_contact_email', (value, emails) => {
if (
! value
|| ! value.length
) {
return true;
}
const emailOccurrences = emails.filter(email => email.value === value).length;
if (emailOccurrences > 1) {
return 'This email email is already used';
}
return true;
});
},
updateOrCreate(params) {
this.inputValue = params[this.name];
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
this.$refs.emailModal.toggle();
},
haveStates() {
/*
* The double negation operator is used to convert the value to a boolean.
* It ensures that the final result is a boolean value,
* true if the array has a length greater than 0, and otherwise false.
*/
return !!this.countryStates[this.inputValue.country]?.length;
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,250 @@
@props([
'allowEdit' => true,
])
<v-inline-boolean-edit
{{ $attributes->except('options') }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-boolean-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-boolean-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative h-[18px] !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : options[inputValue].name }}
</span>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative flex w-full flex-col"
v-else
>
<x-admin::form.control-group.control
type="select"
::id="name"
::name="name"
class="text-normal py-1 ltr:pr-16 rtl:pl-16"
::rules="rules"
::label="label"
::placeholder="placeholder"
::style="inputPositionStyle"
v-model="inputValue"
>
<option
v-for="(option, index) in options"
:key="option.id"
:value="option.id"
>
@{{ option.name }}
</option>
</x-admin::form.control-group.control>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-boolean-edit', {
template: '#v-inline-boolean-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
isRTL: document.documentElement.dir === 'rtl',
options: [
{
id: 0,
name: 'No',
},
{
id: 1,
name: 'Yes',
},
]
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,245 @@
@props([
'allowEdit' => true,
])
<v-inline-date-edit
{{ $attributes }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-date-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-date-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : inputValue.length > 20 ? inputValue.substring(0, 20) + '...' : inputValue }}
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="inputValue.length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative flex w-full flex-col ltr:[&>span>i]:right-14 rtl:[&>span>i]:left-14"
v-else
>
<x-admin::form.control-group.control
type="date"
::id="name"
::name="name"
class="text-normal py-1 ltr:pr-20 rtl:pl-20"
::rules="rules"
::label="label"
::placeholder="placeholder"
::style="inputPositionStyle"
v-model="inputValue"
ref="input"
readonly
/>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-date-edit', {
template: '#v-inline-date-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,245 @@
@props([
'allowEdit' => true,
])
<v-inline-datetime-edit
{{ $attributes }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center ltr:pl-2.5 ltr:text-left rtl:pr-2.5 rtl:text-right">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-datetime-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-datetime-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : inputValue.length > 20 ? inputValue.substring(0, 20) + '...' : inputValue }}
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="inputValue.length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative flex w-full flex-col ltr:[&>span>i]:right-14 rtl:[&>span>i]:left-14"
v-else
>
<x-admin::form.control-group.control
type="datetime"
::id="name"
::name="name"
class="text-normal py-1 ltr:pr-16 rtl:pl-16"
::rules="rules"
::label="label"
::placeholder="placeholder"
::style="inputPositionStyle"
v-model="inputValue"
ref="input"
readonly
/>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 bg-white ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-datetime-edit', {
template: '#v-inline-datetime-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,320 @@
@props([
'allowEdit' => true,
])
<v-inline-email-edit
{{ $attributes->except('value') }}
:value={{ json_encode($attributes->get('value')) }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center ltr:pl-2.5 ltr:text-left rtl:pr-2.5 rtl:text-right">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-email-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-email-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : inputValue?.map(item => `${item.value}(${item.label})`).join(', ').length > 20 ? inputValue?.map(item => `${item.value}(${item.label})`).join(', ').substring(0, 20) + '...' : inputValue?.map(item => `${item.value}(${item.label})`).join(', ') }}
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="inputValue?.map(item => `${item.value}(${item.label})`).join(', ').length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue?.map(item => `${item.value}(${item.label})`).join(', \n') }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<Teleport to="body">
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
as="div"
ref="modalForm"
>
<form @submit="handleSubmit($event, updateOrCreate)">
<!-- Editing view -->
<x-admin::modal ref="emailModal">
<!-- Modal Header -->
<x-slot:header>
<p class="text-lg font-bold text-gray-800 dark:text-white">
@lang("admin::app.common.custom-attributes.update-emails-title")
</p>
</x-slot>
<!-- Modal Content -->
<x-slot:content>
<template v-for="(email, index) in emails">
<div class="mb-2 flex items-center">
<x-admin::form.control-group.control
type="text"
::id="`${name}[${index}].value`"
::name="`${name}[${index}].value`"
class="!rounded-r-none"
::rules="getValidation"
v-model="email.value"
:label="trans('admin::app.common.custom-attributes.email')"
/>
<div class="relative">
<x-admin::form.control-group.control
type="select"
::id="`${name}[${index}].label`"
::name="`${name}[${index}].label`"
class="!w-24 !rounded-l-none"
::value="email.label"
>
<option value="work">@lang('admin::app.common.custom-attributes.work')</option>
<option value="home">@lang('admin::app.common.custom-attributes.home')</option>
</x-admin::form.control-group.control>
</div>
<i
v-if="emails.length > 1"
class="icon-delete ml-1 cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-100 dark:hover:bg-gray-950"
@click="remove(email)"
></i>
</div>
<x-admin::form.control-group.error ::name="`${name}[${index}].value`"/>
</template>
<button
type="button"
class="flex max-w-max items-center gap-2 text-brandColor"
@click="add"
>
<i class="icon-add text-md !text-brandColor"></i>
@lang("admin::app.common.custom-attributes.add-more")
</button>
</x-slot>
<!-- Modal Footer -->
<x-slot:footer>
<!-- Save Button -->
<x-admin::button
button-type="submit"
class="primary-button justify-center"
:title="trans('admin::app.common.custom-attributes.save')"
::loading="isProcessing"
::disabled="isProcessing"
/>
</x-slot>
</x-admin::modal>
</form>
</x-admin::form>
</Teleport>
</div>
</script>
<script type="module">
app.component('v-inline-email-edit', {
template: '#v-inline-email-edit-template',
emits: ['on-save'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
emails: JSON.parse(JSON.stringify(this.value || [{'value': '', 'label': 'work'}])),
isProcessing: false,
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue, oldValue) {
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
this.emails = newValue || [{'value': '', 'label': 'work'}];
}
},
},
created() {
this.extendValidations();
if (! this.emails || ! this.emails.length) {
this.emails = [{
'value': '',
'label': 'work'
}];
}
},
computed: {
/**
* Get the validation rules.
*
* @return {Object}
*/
getValidation() {
return {
email: true,
unique_contact_email: this.emails ?? [],
required: true,
};
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
this.$refs.emailModal.toggle();
},
add() {
this.emails.push({
'value': '',
'label': 'work'
});
},
remove(contactEmail) {
this.emails = this.emails.filter(email => email !== contactEmail);
},
extendValidations() {
defineRule('unique_contact_email', (value, emails) => {
if (
! value
|| ! value.length
) {
return true;
}
const emailOccurrences = emails.filter(email => email.value === value).length;
if (emailOccurrences > 1) {
return 'This email is already used';
}
return true;
});
},
updateOrCreate(params) {
this.inputValue = params.contact_emails ?? params.emails;
if (this.url) {
this.isProcessing = true;
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.emails = response.data.data.emails || this.emails;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
})
.finally(() => {
this.isProcessing = false;
});
}
this.$emit('on-save', params);
this.$refs.emailModal.toggle();
}
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,245 @@
@props([
'allowEdit' => true,
])
<v-inline-file-edit
{{ $attributes }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-file-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-file-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center justify-between rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<a
:href="inputValue"
v-if="inputValue"
target="_blank"
>
<span class="icon-download pl-[2px] text-2xl font-normal"></span>
</a>
<span
v-else
class="icon-download cursor-pointer pl-[2px] text-2xl font-normal"
>
</span>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative flex w-full flex-col"
v-else
>
<input
type="file"
:name="name"
:id="name"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
class="w-full rounded-md border px-3 py-2.5 text-sm text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:file:bg-gray-800 dark:file:dark:text-white dark:hover:border-gray-400 dark:focus:border-gray-400"
@change="handleChange"
ref="input"
/>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-file-edit', {
template: '#v-inline-file-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
file: null,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
let formData = new FormData();
formData.append(this.name, this.file);
formData.append('_method', 'PUT');
if (this.url) {
this.$axios.post(this.url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
handleChange(event) {
this.file = event.target.files[0];
this.inputValue = URL.createObjectURL(this.file);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,242 @@
@props([
'allowEdit' => true,
])
<v-inline-image-edit
{{ $attributes }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-image-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-image-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<img
:src="inputValue"
class="h-5 w-5"
:alt="name"
/>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative w-full"
v-else
>
<input
type="file"
:name="name"
:id="name"
:class="[errors.length ? 'border !border-red-600 hover:border-red-600' : '']"
class="!h-[34px] w-full cursor-pointer rounded-md border !py-0 px-3 text-sm text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-white dark:file:bg-gray-800 dark:file:dark:text-white dark:hover:border-gray-400 dark:focus:border-gray-400 ltr:pr-20 rtl:pl-20"
@change="handleChange"
ref="input"
/>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-image-edit', {
template: '#v-inline-image-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
file: null,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
let formData = new FormData();
formData.append(this.name, this.file);
formData.append('_method', 'PUT');
if (this.url) {
this.$axios.post(this.url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
handleChange(event) {
this.file = event.target.files[0];
this.inputValue = URL.createObjectURL(this.file);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,448 @@
@props([
'allowEdit' => true,
'attribute' => [],
])
<v-inline-look-edit
{{ $attributes }}
:attribute="{{ json_encode($attribute) }}"
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-look-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-look-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative h-[18px] !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
<template v-if="isDirty">
@{{ inputValue.length > 20 ? inputValue.substring(0, 20) + '...' : inputValue }}
</template>
<template v-else>
@{{ valueLabel ? valueLabel : inputValue.length > 20 ? inputValue.substring(0, 20) + '...' : inputValue }}
</template>
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="inputValue.length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative flex w-full flex-col"
ref="dropdownContainer"
v-else
>
<x-admin::form.control-group.control
type="text"
::name="name"
class="!h-[34px] w-full cursor-pointer !py-0 text-gray-800 dark:text-white ltr:pr-20 rtl:pl-20"
::placeholder="placeholder"
v-model="selectedItem.name"
@click="toggleEditor"
readonly
/>
<span class="pointer-events-none absolute inset-y-0 flex items-center ltr:right-0 ltr:pr-14 rtl:left-0 rtl:pl-14">
<div class="flex items-center justify-center space-x-1">
<div
class="relative"
v-if="isSearching"
>
<x-admin::spinner />
</div>
<i
class="text-2xl"
:class="showPopup ? 'icon-up-arrow': 'icon-down-arrow'"
></i>
</div>
</span>
<!-- Popup Box -->
<div
v-if="showPopup"
class="absolute z-10 mt-1 w-full origin-top transform rounded-lg border border-gray-200 bg-white p-2 shadow-lg transition-transform dark:border-gray-800 dark:bg-gray-800"
:class="dropdownPosition === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1'"
>
<!-- Search Bar -->
<input
type="text"
v-model.lazy="searchTerm"
v-debounce="200"
class="!mb-2 w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
placeholder="@lang('admin::app.components.lookup.search')"
ref="searchInput"
/>
<!-- Results List -->
<ul class="max-h-40 divide-y divide-gray-100 overflow-y-auto">
<li
v-for="item in filteredResults"
:key="item.id"
class="cursor-pointer px-4 py-2 text-gray-800 transition-colors hover:bg-blue-100 dark:text-white dark:hover:bg-gray-950"
@click="selectItem(item)"
>
@{{ item.name }}
</li>
<li v-if="filteredResults.length === 0" class="px-4 py-2 text-center text-gray-500 dark:text-gray-300">
@lang('admin::app.components.lookup.no-results')
</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 bg-white dark:bg-gray-900 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="item-center flex justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-look-edit', {
template: '#v-inline-look-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
position: {
type: String,
default: 'right',
},
errors: {
type: Object,
default: {},
},
attribute: {
type: Object,
default: () => ({}),
},
allowEdit: {
type: Boolean,
default: true,
},
placeholder: {
type: String,
default: 'Search...',
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value ?? '',
isEditing: false,
isDirty: false,
showPopup: false,
searchTerm: '',
selectedItem: {},
searchedResults: [],
isSearching: false,
cancelToken: null,
isDropdownOpen: false,
dropdownPosition: "bottom",
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
searchTerm(newVal, oldVal) {
this.search();
},
},
mounted() {
window.addEventListener("resize", this.setDropdownPosition);
this.$emitter.on('show-pop', this.handleShowPop);
},
computed: {
src() {
return `{{ route('admin.settings.attributes.lookup') }}/${this.attribute.lookup_type}`;
},
/**
* Filter the searchedResults based on the search query.
*
* @return {Array}
*/
filteredResults() {
return this.searchedResults.filter(item =>
item.name.toLowerCase().includes(this.searchTerm.toLowerCase())
);
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
this.searchTerm = '';
this.selectedItem.name = this.inputValue;
this.isDropdownOpen = ! this.isDropdownOpen;
if (this.isDropdownOpen) {
this.setDropdownPosition();
}
},
toggleEditor() {
this.$emitter.emit('show-pop', this.$.uid);
},
handleShowPop(uid) {
this.showPopup = (uid === this.$.uid);
if (this.showPopup) {
this.$nextTick(() => this.$refs.searchInput?.focus());
} else {
this.isEditing = false;
}
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.selectedItem.id === undefined) {
return;
}
this.isDirty = true;
this.inputValue = this.selectedItem.name;
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.selectedItem.id,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.isDirty = false;
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.selectedItem.id,
});
},
/**
* Select an item from the list.
*
* @param {Object} item
*
* @return {void}
*/
selectItem(item) {
this.showPopup = false;
this.searchTerm = '';
this.selectedItem = item;
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
if (this.selectItem) {
this.inputValue = this.selectedItem.name;
}
this.isEditing = false;
this.$emit('on-cancelled', this.inputValue);
},
/**
* Initialize the items.
*
* @return {void}
*/
search() {
if (this.searchTerm.length <= 2) {
this.searchedResults = [];
this.isSearching = false;
return;
}
this.isSearching = true;
if (this.cancelToken) {
this.cancelToken.cancel();
}
this.cancelToken = this.$axios.CancelToken.source();
this.$axios.get(this.src, {
params: {
...this.params,
query: this.searchTerm
},
cancelToken: this.cancelToken.token,
})
.then(response => {
this.searchedResults = response.data;
})
.catch(error => {
if (! this.$axios.isCancel(error)) {
console.error("Search request failed:", error);
}
this.isSearching = false;
})
.finally(() => this.isSearching = false);
},
setDropdownPosition() {
this.$nextTick(() => {
const dropdownContainer = this.$refs.dropdownContainer;
if (! dropdownContainer) {
return;
}
const dropdownRect = dropdownContainer.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (dropdownRect.bottom + 250 > viewportHeight) {
this.dropdownPosition = "top";
} else {
this.dropdownPosition = "bottom";
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,367 @@
@props([
'allowEdit' => true,
'data' => [],
])
<v-inline-multi-select-edit
{{ $attributes->except('data') }}
:data="{{ json_encode($data) }}"
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-multi-select-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-multi-select-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative h-[18px] !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : selectedValue?.length > 20 ? selectedValue.substring(0, 20) + '...' : selectedValue }}
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="selectedValue?.length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ selectedValue }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit phttp://192.168.15.43/test/-0.5 cursor-pointer rounded text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
<!-- Editing view -->
<div
class="relative flex w-full flex-col"
ref="dropdownContainer"
v-if="isEditing"
>
<div class="flex min-h-[38px] w-full items-center rounded border border-gray-200 px-2.5 py-1.5 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400 ltr:pr-16 rtl:pl-16">
<ul class="flex flex-wrap items-center gap-1">
<li
v-for="option in tempOptions"
:key="option.id"
class="flex items-center gap-1 rounded-md bg-slate-100 pl-2 dark:bg-gray-800 dark:text-white"
>
<input
type="hidden"
:name="name"
:value="option"
/>
<div
class="relative h-[18px] pl-2.5"
:style="{ 'text-align': position }"
>
<!-- Wrap the text and tooltip in a group class for hover tracking -->
<div class="group">
<!-- Truncated text -->
<p class="max-w-[110px] cursor-pointer truncate">@{{ option.name }}</p>
<!-- Tooltip that shows on hover over the truncated text -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="option.name?.length > 20"
>
<span
class="whitespace-no-wrap relative z-20 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900"
>
@{{ option.name }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
</div>
<!-- Cross icon for removing option -->
<span
class="icon-cross-large cursor-pointer p-0.5 text-xl"
@click="removeOption(option)"
></span>
</li>
</ul>
</div>
<!-- Dropdown (position dynamic based on space) -->
<div
class="absolute z-10 w-full origin-top transform rounded-lg border bg-white shadow-lg dark:border-gray-800 dark:bg-gray-800"
:class="dropdownPosition === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1'"
v-if="options.length > 0"
>
<!-- Results List -->
<ul class="max-h-40 divide-gray-100 overflow-y-auto p-0.5">
<li
v-for="option in options"
:key="option.id"
class="cursor-pointer rounded px-4 py-2 text-gray-800 transition-colors hover:bg-blue-100 dark:text-white dark:hover:bg-gray-950 ltr:pr-16 rtl:pl-16"
@click="addOption(option)"
>
@{{ option.name }}
</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform gap-0.5 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-multi-select-edit', {
template: '#v-inline-multi-select-edit-template',
emits: ['options-updated'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
data: {
type: Array,
required: true,
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
options: this.data ?? [],
tempOptions: [],
isRTL: document.documentElement.dir === 'rtl',
isDropdownOpen: false,
dropdownPosition: "bottom",
};
},
mounted() {
this.tempOptions = this.options.filter((data) => this.value.includes(data.id));
this.options = this.options.filter((data) => !this.value.includes(data.id));
window.addEventListener("resize", this.setDropdownPosition);
},
computed: {
/**
* Get the selected value.
*
* @return {Object}
*/
selectedValue() {
if (this.tempOptions.length === 0) {
return null;
}
return this.tempOptions.map((data) => data.name).join(', ');
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
this.isDropdownOpen = ! this.isDropdownOpen;
if (this.isDropdownOpen) {
this.setDropdownPosition();
}
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.tempOptions.map((data) => data.id),
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('options-updated', {
name: this.name,
value: this.tempOptions.map((data) => data.id),
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.isEditing = false;
this.$emit('options-updated', {
name: this.name,
value: this.tempOptions.map((data) => data.id),
});
},
addOption(option) {
if (!this.tempOptions.some((data) => data.id === option.id)) {
this.tempOptions.push(option);
this.options = this.options.filter((data) => data.id !== option.id);
this.input = '';
}
},
removeOption(option) {
if (!this.options.some((data) => data.id === option.id)) {
this.options.push(option);
this.tempOptions = this.tempOptions.filter((data) => data.id !== option.id);
}
},
setDropdownPosition() {
this.$nextTick(() => {
const dropdownContainer = this.$refs.dropdownContainer;
if (! dropdownContainer) {
return;
}
const dropdownRect = dropdownContainer.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (dropdownRect.bottom + 250 > viewportHeight) {
this.dropdownPosition = "top";
} else {
this.dropdownPosition = "bottom";
}
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,321 @@
@props([
'allowEdit' => true,
])
<v-inline-phone-edit
{{ $attributes->except('value') }}
:value={{ json_encode($attributes->get('value')) }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-phone-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-phone-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : inputValue.map(item => `${item.value}(${item.label})`).join(', ').length > 20 ? inputValue.map(item => `${item.value}(${item.label})`).join(', ').substring(0, 20) + '...' : inputValue.map(item => `${item.value}(${item.label})`).join(', ') }}
</span>
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="inputValue.map(item => `${item.value}(${item.label})`).join(', ').length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue.map(item => `${item.value}(${item.label})`).join(', \n') }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<Teleport to="body">
<x-admin::form
v-slot="{ meta, errors, handleSubmit }"
as="div"
ref="modalForm"
>
<form @submit="handleSubmit($event, updateOrCreate)">
<!-- Editing view -->
<x-admin::modal ref="phoneNumberModal">
<!-- Modal Header -->
<x-slot:header>
<p class="text-lg font-bold text-gray-800 dark:text-white">
@lang("admin::app.common.custom-attributes.update-contact-title")
</p>
</x-slot>
<!-- Modal Content -->
<x-slot:content>
<template v-for="(contactNumber, index) in contactNumbers">
<div class="mb-2 flex items-center">
<x-admin::form.control-group.control
type="text"
::id="`${name}[${index}].value`"
::name="`${name}[${index}].value`"
class="!rounded-r-none"
::rules="getValidation"
:label="trans('admin::app.common.custom-attributes.contact')"
v-model="contactNumber.value"
/>
<div class="relative">
<x-admin::form.control-group.control
type="select"
::id="`${name}[${index}].label`"
::name="`${name}[${index}].label`"
class="!w-24 !rounded-l-none"
::value="contactNumber.label"
>
<option value="work">@lang('admin::app.common.custom-attributes.work')</option>
<option value="home">@lang('admin::app.common.custom-attributes.home')</option>
</x-admin::form.control-group.control>
</div>
<i
v-if="contactNumbers.length > 1"
class="icon-delete ml-1 cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-100 dark:hover:bg-gray-950"
@click="remove(contactNumber)"
></i>
</div>
<x-admin::form.control-group.error ::name="`${name}[${index}].value`"/>
</template>
<button
type="button"
class="flex max-w-max items-center gap-2 text-brandColor"
@click="add"
>
<i class="icon-add text-md !text-brandColor"></i>
@lang("admin::app.common.custom-attributes.add-more")
</button>
</x-slot>
<!-- Modal Footer -->
<x-slot:footer>
<!-- Save Button -->
<x-admin::button
button-type="submit"
class="primary-button justify-center"
:title="trans('admin::app.common.custom-attributes.save')"
::loading="isProcessing"
::disabled="isProcessing"
/>
</x-slot>
</x-admin::modal>
</form>
</x-admin::form>
</Teleport>
</div>
</script>
<script type="module">
app.component('v-inline-phone-edit', {
template: '#v-inline-phone-edit-template',
emits: ['on-save'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
contactNumbers: JSON.parse(JSON.stringify(this.value || [{'value': '', 'label': 'work'}])),
isProcessing: false,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue, oldValue) {
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
this.contactNumbers = newValue || [{'value': '', 'label': 'work'}];
}
},
},
created() {
this.extendValidations();
if (! this.contactNumbers || ! this.contactNumbers.length) {
this.contactNumbers = [{
'value': '',
'label': 'work'
}];
}
},
computed: {
/**
* Get the validation rules.
*
* @return {Object}
*/
getValidation() {
return {
phone: true,
unique_contact_number: this.contactNumbers ?? [],
required: true,
};
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
this.$refs.phoneNumberModal.toggle();
},
add() {
this.contactNumbers.push({
'value': '',
'label': 'work'
});
},
remove(contactNumber) {
this.contactNumbers = this.contactNumbers.filter(number => number !== contactNumber);
},
extendValidations() {
defineRule('unique_contact_number', (value, contactNumbers) => {
if (
! value
|| ! value.length
) {
return true;
}
const phoneOccurrences = contactNumbers.filter(contactNumber => contactNumber.value === value).length;
if (phoneOccurrences > 1) {
return 'This phone number is already used';
}
return true;
});
},
updateOrCreate(params) {
this.inputValue = params.contact_numbers || this.inputValue;
if (this.url) {
this.isProcessing = true;
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.contactNumbers = response.data.data.contact_numbers || this.contactNumbers;
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
})
.finally(() => {
this.isProcessing = false;
});
}
this.$emit('on-save', params);
this.$refs.phoneNumberModal.toggle();
}
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,270 @@
@props([
'allowEdit' => true,
'options' => [],
])
<v-inline-select-edit
{{ $attributes->except('options') }}
:options="{{ json_encode($options) }}"
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-select-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-select-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
@{{ valueLabel ? valueLabel : selectedValue?.name.length > 20 ? selectedValue?.name.substring(0, 20) + '...' : selectedValue?.name }}
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="selectedValue?.name.length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ selectedValue?.name }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative w-full"
v-else
>
<x-admin::form.control-group.control
type="select"
::id="name"
::name="name"
::rules="rules"
::label="label"
class="!h-[34px] !py-0 ltr:pr-20 rtl:pl-20"
::placeholder="placeholder"
v-model="inputValue"
>
<option
v-for="(option, index) in options"
:key="option.id"
:value="option.id"
>
@{{ option.name }}
</option>
</x-admin::form.control-group.control>
<!-- Action Buttons -->
<div class="absolute top-1/2 flex -translate-y-1/2 transform items-center gap-0.5 ltr:right-2 rtl:left-2">
<i class="icon-down-arrow text-2xl" />
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</script>
<script type="module">
app.component('v-inline-select-edit', {
template: '#v-inline-select-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
options: {
type: Array,
required: true,
},
url: {
type: String,
default: '',
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
computed: {
/**
* Get the selected value.
*
* @return {Object}
*/
selectedValue() {
return this.options.find((option, index) => option.id == this.inputValue);
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.url) {
this.$axios.put(this.url, {
[this.name]: this.inputValue,
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,277 @@
@props([
'allowEdit' => true,
])
<v-inline-text-edit
{{ $attributes }}
:allow-edit="{{ $allowEdit ? 'true' : 'false' }}"
>
<div class="group w-full max-w-full hover:rounded-sm">
<div class="rounded-xs flex h-[34px] items-center pl-2.5 text-left">
<div class="shimmer h-5 w-48 rounded border border-transparent"></div>
</div>
</div>
</v-inline-text-edit>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-inline-text-edit-template"
>
<div class="group w-full max-w-full hover:rounded-sm">
<!-- Non-editing view -->
<div
v-if="! isEditing"
class="flex h-[34px] items-center rounded border border-transparent transition-all"
:class="allowEdit ? 'hover:bg-gray-100 dark:hover:bg-gray-800' : ''"
>
<x-admin::form.control-group.control
type="hidden"
::id="name"
::name="name"
v-model="inputValue"
/>
<div
class="group relative h-[18px] !w-full pl-2.5"
:style="{ 'text-align': position }"
>
<span class="cursor-pointer truncate rounded">
<template v-if="isDirty">
@{{
inputValue.length > 20
? inputValue.substring(0, 20) + '...'
: inputValue
}}
</template>
<template v-else>
@{{
(valueLabel || inputValue || '').length > 20
? (valueLabel || inputValue).substring(0, 20) + '...'
: (valueLabel || inputValue)
}}
</template>
</span>
<!-- Tooltip -->
<div
class="absolute bottom-0 mb-5 hidden flex-col group-hover:flex"
v-if="inputValue?.length > 20"
>
<span class="whitespace-no-wrap relative z-10 rounded-md bg-black px-4 py-2 text-xs leading-none text-white shadow-lg dark:bg-white dark:text-gray-900">
@{{ inputValue }}
</span>
<div class="-mt-2 ml-4 h-3 w-3 rotate-45 bg-black dark:bg-white"></div>
</div>
</div>
<template v-if="allowEdit">
<i
@click="toggle"
class="icon-edit cursor-pointer rounded p-0.5 text-2xl opacity-0 hover:bg-gray-200 group-hover:opacity-100 dark:hover:bg-gray-950 ltr:mr-1 rtl:ml-1"
></i>
</template>
</div>
<!-- Editing view -->
<div
class="relative w-full"
v-else
>
<x-admin::form.control-group.control
type="text"
::id="name"
::name="name"
class="!h-[34px] !py-0 ltr:pr-16 rtl:pl-16"
::rules="rules"
::label="label"
::placeholder="placeholder"
v-model="inputValue"
ref="input"
/>
<!-- Action Buttons -->
<div class="absolute top-[6px] flex gap-0.5 ltr:right-2 rtl:left-2">
<button
type="button"
class="flex items-center justify-center bg-green-100 p-1 hover:bg-green-200 ltr:rounded-l-md rtl:rounded-r-md"
@click="save"
>
<i class="icon-tick text-md cursor-pointer font-bold text-green-600 dark:!text-green-600" />
</button>
<button
type="button"
class="flex items-center justify-center bg-red-100 p-1 hover:bg-red-200 ltr:rounded-r-md rtl:rounded-l-md"
@click="cancel"
>
<i class="icon-cross-large text-md cursor-pointer font-bold text-red-600 dark:!text-red-600" />
</button>
</div>
<x-admin::form.control-group.error ::name="name"/>
</div>
</div>
</script>
<script type="module">
app.component('v-inline-text-edit', {
template: '#v-inline-text-edit-template',
emits: ['on-change', 'on-cancelled'],
props: {
name: {
type: String,
required: true,
},
value: {
required: true,
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
position: {
type: String,
default: 'right',
},
allowEdit: {
type: Boolean,
default: true,
},
errors: {
type: Object,
default: {},
},
url: {
type: String,
default: '',
},
params: {
type: Object,
default: () => ({}),
},
valueLabel: {
type: String,
default: '',
},
},
data() {
return {
inputValue: this.value,
isEditing: false,
isDirty: false,
isRTL: document.documentElement.dir === 'rtl',
};
},
watch: {
/**
* Watch the value prop.
*
* @param {String} newValue
*/
value(newValue) {
this.inputValue = newValue;
},
},
methods: {
/**
* Toggle the input.
*
* @return {void}
*/
toggle() {
this.isEditing = true;
this.$nextTick(() => this.$refs.input.focus());
},
/**
* Save the input value.
*
* @return {void}
*/
save() {
if (this.errors[this.name]) {
return;
}
this.isEditing = false;
if (this.url) {
let formData = new FormData();
formData.append(this.name, this.inputValue);
formData.append('_method', 'PUT');
this.isDirty = true;
this.$axios.post(this.url, {
...this.params,
...Object.fromEntries(formData),
})
.then((response) => {
this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
})
.catch((error) => {
this.isDirty = false;
this.inputValue = this.value;
this.$emitter.emit('add-flash', { type: 'error', message: error.response.data.message });
});
}
this.$emit('on-change', {
name: this.name,
value: this.inputValue,
});
},
/**
* Cancel the input value.
*
* @return {void}
*/
cancel() {
this.inputValue = this.value;
this.isEditing = false;
this.$emit('on-cancelled', {
name: this.name,
value: this.inputValue,
});
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,183 @@
<v-control-tags
:errors="errors"
{{ $attributes }}
v-bind="$attrs"
></v-control-tags>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-control-tags-template"
>
<div
class="flex min-h-[38px] w-full items-center rounded border border-gray-200 px-2.5 py-1.5 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 dark:border-gray-800 dark:text-white dark:hover:border-gray-400"
:class="[errors[`temp-${name}`] ? 'border !border-red-600 hover:border-red-600' : '']"
>
<ul
class="flex flex-wrap items-center gap-1"
v-bind="$attrs"
>
<li
v-for="(tag, index) in tags"
:key="index"
class="flex items-center gap-1 rounded-md bg-gray-100 dark:bg-gray-950 ltr:pl-2 rtl:pr-2"
>
<x-admin::form.control-group.control
type="hidden"
::name="name + '[' + index + ']'"
::value="tag"
/>
@{{ tag }}
<span
class="icon-cross-large cursor-pointer p-0.5 text-xl"
@click="removeTag(tag)"
></span>
</li>
<li :class="['w-full', tags.length && 'mt-1.5']">
<v-field
v-slot="{ field, errors }"
:name="'temp-' + name"
v-model="input"
:rules="tags.length ? inputRules : [inputRules, rules].filter(Boolean).join('|')"
:label="label"
>
<input
type="text"
:name="'temp-' + name"
v-bind="field"
class="w-full dark:!bg-gray-900"
:placeholder="placeholder"
:label="label"
@keydown.enter.prevent="addTag"
autocomplete="new-email"
@blur="addTag"
/>
</v-field>
<template v-if="! tags.length && input != ''">
<v-field
v-slot="{ field, errors }"
:name="name + '[' + 0 +']'"
:value="input"
:rules="inputRules"
:label="label"
>
<input
type="hidden"
:name="name + '[0]'"
v-bind="field"
/>
</v-field>
</template>
</li>
</ul>
</div>
<v-error-message
:name="'temp-' + name"
v-slot="{ message }"
>
<p
class="mt-1 text-xs italic text-red-600"
v-text="message"
>
</p>
</v-error-message>
</script>
<script type="module">
app.component('v-control-tags', {
template: '#v-control-tags-template',
props: {
name: {
type: String,
required: true,
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
rules: {
type: String,
default: '',
},
inputRules: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
errors: {
type: Object,
default: () => {},
},
allowDuplicates: {
type: Boolean,
default: true,
},
},
data() {
return {
tags: this.data ? this.data : [],
input: '',
}
},
methods: {
addTag() {
if (this.errors['temp-' + this.name]) {
return;
}
const tag = this.input.trim();
if (! tag) {
return;
}
if (
! this.allowDuplicates
&& this.tags.includes(tag)
) {
this.input = '';
return;
}
this.tags.push(tag);
this.$emit('tags-updated', this.tags);
this.input = '';
},
removeTag: function(tag) {
this.tags = this.tags.filter(function (tempTag) {
return tempTag != tag;
});
this.$emit('tags-updated', this.tags);
},
}
});
</script>
@endpushOnce

View File

@@ -0,0 +1,16 @@
@props([
'name' => null,
'controlName' => '',
])
<v-error-message
{{ $attributes }}
name="{{ $name ?? $controlName }}"
v-slot="{ message }"
>
<p
{{ $attributes->merge(['class' => 'mt-1 text-xs italic text-red-600']) }}
v-text="message"
>
</p>
</v-error-message>

View File

@@ -0,0 +1,3 @@
<div {{ $attributes->merge(['class' => 'mb-4']) }}>
{{ $slot }}
</div>

View File

@@ -0,0 +1,3 @@
<label {{ $attributes->merge(['class' => 'mb-1.5 flex items-center gap-1 text-sm font-normal text-gray-800 dark:text-white']) }}>
{{ $slot }}
</label>

View File

@@ -0,0 +1,40 @@
<!--
If a component has the `as` attribute, it indicates that it uses
the ajaxified form or some customized slot form.
-->
@if ($attributes->has('as'))
<v-form {{ $attributes }}>
{{ $slot }}
</v-form>
<!--
Otherwise, a traditional form will be provided with a minimal
set of configurations.
-->
@else
@props([
'method' => 'POST',
])
@php
$method = strtoupper($method);
@endphp
<v-form
method="{{ $method === 'GET' ? 'GET' : 'POST' }}"
:initial-errors="{{ json_encode($errors->getMessages()) }}"
v-slot="{ meta, errors, setValues }"
@invalid-submit="onInvalidSubmit"
{{ $attributes }}
>
@unless(in_array($method, ['HEAD', 'GET', 'OPTIONS']))
@csrf
@endunless
@if (! in_array($method, ['GET', 'POST']))
@method($method)
@endif
{{ $slot }}
</v-form>
@endif

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html
lang="{{ app()->getLocale() }}"
dir="{{ in_array(app()->getLocale(), ['fa', 'ar']) ? 'rtl' : 'ltr' }}"
>
<head>
<title>{{ $title ?? '' }}</title>
<meta charset="UTF-8">
<meta
http-equiv="X-UA-Compatible"
content="IE=edge"
>
<meta
http-equiv="content-language"
content="{{ app()->getLocale() }}"
>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
<meta
name="base-url"
content="{{ url()->to('/') }}"
>
<meta
name="currency-code"
{{-- content="{{ core()->getCurrentCurrencyCode() }}" --}}
>
@stack('meta')
{{
vite()->set(['src/Resources/assets/css/app.css', 'src/Resources/assets/js/app.js'])
}}
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&display=swap"
rel="stylesheet"
/>
@if ($favicon = core()->getConfigData('general.design.admin_logo.favicon'))
<link
type="image/x-icon"
href="{{ Storage::url($favicon) }}"
rel="shortcut icon"
sizes="16x16"
>
@else
<link
type="image/x-icon"
href="{{ vite()->asset('images/favicon.ico') }}"
rel="shortcut icon"
sizes="16x16"
/>
@endif
@php
$brandColor = core()->getConfigData('general.settings.menu_color.brand_color') ?? '#0E90D9';
@endphp
@stack('styles')
<style>
:root {
--brand-color: {{ $brandColor }};
}
{!! core()->getConfigData('general.content.custom_scripts.custom_css') !!}
</style>
{!! view_render_event('admin.layout.head') !!}
</head>
<body>
{!! view_render_event('admin.layout.body.before') !!}
<div id="app">
<!-- Flash Message Blade Component -->
<x-admin::flash-group />
{!! view_render_event('admin.layout.content.before') !!}
<!-- Page Content Blade Component -->
{{ $slot }}
{!! view_render_event('admin.layout.content.after') !!}
</div>
{!! view_render_event('admin.layout.body.after') !!}
@stack('scripts')
{!! view_render_event('admin.layout.vue-app-mount.before') !!}
<script>
/**
* Load event, the purpose of using the event is to mount the application
* after all of our `Vue` components which is present in blade file have
* been registered in the app. No matter what `app.mount()` should be
* called in the last.
*/
window.addEventListener("load", function(event) {
app.mount("#app");
});
</script>
{!! view_render_event('admin.layout.vue-app-mount.after') !!}
<script type="text/javascript">
{!! core()->getConfigData('general.content.custom_scripts.custom_javascript') !!}
</script>
</body>
</html>

View File

@@ -0,0 +1,567 @@
<v-mega-search>
<div class="relative flex w-[550px] max-w-[550px] items-center max-lg:w-[400px] ltr:ml-2.5 rtl:mr-2.5">
<i class="icon-search absolute top-2 flex items-center text-2xl ltr:left-3 rtl:right-3"></i>
<input
type="text"
class="block w-full rounded-3xl border bg-white px-10 py-1.5 leading-6 text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
placeholder="@lang('admin::app.components.layouts.header.mega-search.title')"
>
</div>
</v-mega-search>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-mega-search-template"
>
<div class="relative flex w-[550px] max-w-[550px] items-center max-lg:w-[400px] ltr:ml-2.5 rtl:mr-2.5">
<i class="icon-search absolute top-2 flex items-center text-2xl ltr:left-3 rtl:right-3"></i>
<input
type="text"
class="peer block w-full rounded-3xl border bg-white px-10 py-1.5 leading-6 text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
:class="{'border-gray-400': isDropdownOpen}"
placeholder="@lang('admin::app.components.layouts.header.mega-search.title')"
v-model.lazy="searchTerm"
@click="searchTerm.length >= 2 ? isDropdownOpen = true : {}"
v-debounce="500"
>
<div
class="absolute top-10 z-10 w-full rounded-lg border bg-white shadow-[0px_0px_0px_0px_rgba(0,0,0,0.10),0px_1px_3px_0px_rgba(0,0,0,0.10),0px_5px_5px_0px_rgba(0,0,0,0.09),0px_12px_7px_0px_rgba(0,0,0,0.05),0px_22px_9px_0px_rgba(0,0,0,0.01),0px_34px_9px_0px_rgba(0,0,0,0.00)] dark:border-gray-800 dark:bg-gray-900"
v-if="isDropdownOpen"
>
<!-- Search Tabs -->
<div class="flex overflow-x-auto border-b text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
<div
class="cursor-pointer p-4 hover:bg-gray-100 dark:hover:bg-gray-950"
:class="{ 'border-b-2 border-brandColor': activeTab == tab.key }"
v-for="tab in tabs"
@click="activeTab = tab.key; search();"
>
@{{ tab.title }}
</div>
</div>
<!-- Searched Results -->
<template v-if="activeTab == 'products'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.products />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="product in searchedResults.products">
<a
:href="'{{ route('admin.products.view', ':id') }}'.replace(':id', product.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ product.name }}
</p>
<p class="text-gray-500">
@{{ "@lang(':sku')".replace(':sku', product.sku) }}
</p>
</div>
</div>
<!-- Right Information -->
<div class="grid place-content-center gap-1 text-right">
<!-- Formatted Price -->
<p class="font-semibold text-gray-600 dark:text-gray-300">
@{{ $admin.formatPrice(product.price) }}
</p>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.products.length">
<a
:href="'{{ route('admin.products.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-products')`.replace(':query', searchTerm).replace(':count', searchedResults.products.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.products.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-products')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'leads'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.leads />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="lead in searchedResults.leads">
<a
:href="'{{ route('admin.leads.view', ':id') }}'.replace(':id', lead.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ lead.title }}
</p>
<p class="text-gray-500">
@{{ lead.description }}
</p>
</div>
</div>
<!-- Right Information -->
<div class="grid place-content-center gap-1 text-right">
<!-- Formatted Price -->
<p class="font-semibold text-gray-600 dark:text-gray-300">
@{{ $admin.formatPrice(lead.lead_value) }}
</p>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.leads.length">
<a
:href="'{{ route('admin.leads.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-leads')`.replace(':query', searchTerm).replace(':count', searchedResults.leads.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.leads.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-leads')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'persons'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.persons />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="person in searchedResults.persons">
<a
:href="'{{ route('admin.contacts.persons.view', ':id') }}'.replace(':id', person.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ person.name }}
</p>
<p class="text-gray-500">
@{{ person.emails.map((item) => `${item.value}(${item.label})`).join(', ') }}
</p>
</div>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.persons.length">
<a
:href="'{{ route('admin.contacts.persons.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-contacts')`.replace(':query', searchTerm).replace(':count', searchedResults.persons.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.contacts.persons.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-contacts')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'quotes'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.quotes />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="quote in searchedResults.quotes">
<a
:href="'{{ route('admin.quotes.edit', ':id') }}'.replace(':id', quote.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ quote.subject }}
</p>
<p class="text-gray-500">
@{{ quote.description }}
</p>
</div>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.quotes.length">
<a
:href="'{{ route('admin.quotes.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-quotes')`.replace(':query', searchTerm).replace(':count', searchedResults.quotes.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.quotes.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-quotes')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'settings'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.settings />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="setting in searchedResults.settings">
<a
:href="setting.url"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ setting.name }}
</p>
</div>
</div>
</a>
</template>
</div>
<template v-if="! searchedResults.settings.length">
<div class="flex border-t p-3 dark:border-gray-800">
<a
href="{{ route('admin.settings.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-settings')
</a>
</div>
</template>
</template>
</template>
<template v-if="activeTab == 'configurations'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.configurations />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="configuration in searchedResults.configurations">
<a
:href="configuration.url"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ configuration.title }}
</p>
</div>
</div>
</a>
</template>
</div>
<template v-if="! searchedResults.configurations.length">
<div class="flex border-t p-3 dark:border-gray-800">
<a
href="{{ route('admin.configuration.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-configurations')
</a>
</div>
</template>
</template>
</template>
</div>
</div>
</script>
<script type="module">
app.component('v-mega-search', {
template: '#v-mega-search-template',
data() {
return {
activeTab: 'leads',
isDropdownOpen: false,
tabs: {
leads: {
key: 'leads',
title: '@lang('admin::app.components.layouts.header.mega-search.tabs.leads')',
is_active: true,
endpoint: '{{ route('admin.leads.search') }}',
query_params: [
{
search: 'title',
searchFields: 'title:like',
},
{
search: 'user.name',
searchFields: 'user.name:like',
},
{
search: 'person.name',
searchFields: 'person.name:like',
},
],
},
quotes: {
key: 'quotes',
title: '@lang('admin::app.components.layouts.header.mega-search.tabs.quotes')',
is_active: false,
endpoint: '{{ route('admin.quotes.search') }}',
query_params: [
{
search: 'subject',
searchFields: 'subject:like',
},
{
search: 'description',
searchFields: 'description:like',
},
{
search: 'user.name',
searchFields: 'user.name:like',
},
{
search: 'person.name',
searchFields: 'person.name:like',
},
],
},
products: {
key: 'products',
title: '@lang('admin::app.components.layouts.header.mega-search.tabs.products')',
is_active: false,
endpoint: '{{ route('admin.products.search') }}',
query_params: [
{
search: 'name',
searchFields: 'name:like',
},
{
search: 'sku',
searchFields: 'sku:like',
},
{
search: 'description',
searchFields: 'description:like',
},
],
},
persons: {
key: 'persons',
title: '@lang('admin::app.components.layouts.header.mega-search.tabs.persons')',
is_active: false,
endpoint: '{{ route('admin.contacts.persons.search') }}',
query_params: [
{
search: 'name',
searchFields: 'name:like',
},
{
search: 'job_title',
searchFields: 'job_title:like',
},
{
search: 'user.name',
searchFields: 'user.name:like',
},
{
search: 'organization.name',
searchFields: 'organization.name:like',
},
],
},
settings: {
key: 'settings',
title: "@lang('admin::app.layouts.settings')",
is_active: false,
endpoint: '{{ route('admin.settings.search') }}',
query: '',
},
configurations: {
key: 'configurations',
title: "@lang('admin::app.layouts.configuration')",
is_active: false,
endpoint: '{{ route('admin.configuration.search') }}',
query: '',
},
},
isLoading: false,
searchTerm: '',
searchedResults: {
leads: [],
quotes: [],
products: [],
persons: [],
settings: [],
configurations: [],
},
params: {
search: '',
searchFields: '',
},
};
},
watch: {
searchTerm: 'updateSearchParams',
activeTab: 'updateSearchParams',
},
created() {
window.addEventListener('click', this.handleFocusOut);
},
beforeDestroy() {
window.removeEventListener('click', this.handleFocusOut);
},
methods: {
search(endpoint = null) {
if (! endpoint) {
return;
}
if (this.searchTerm.length <= 1) {
this.searchedResults[this.activeTab] = [];
this.isDropdownOpen = false;
return;
}
this.isDropdownOpen = true;
this.$axios.get(endpoint, {
params: {
...this.params,
},
})
.then((response) => {
this.searchedResults[this.activeTab] = response.data.data;
})
.catch((error) => {})
.finally(() => this.isLoading = false);
},
handleFocusOut(e) {
if (! this.$el.contains(e.target)) {
this.isDropdownOpen = false;
}
},
updateSearchParams() {
const newTerm = this.searchTerm;
this.params = {
search: '',
searchFields: '',
};
const tab = this.tabs[this.activeTab];
if (
tab.key === 'settings'
|| tab.key === 'configurations'
) {
this.params = null;
this.search(`${tab.endpoint}?query=${newTerm}`);
return;
}
this.params.search += tab.query_params.map((param) => `${param.search}:${newTerm};`).join('');
this.params.searchFields += tab.query_params.map((param) => `${param.searchFields};`).join('');
this.search(tab.endpoint);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,191 @@
<header class="sticky top-0 z-[10001] flex items-center justify-between gap-1 border-b border-gray-200 bg-white px-4 py-2.5 transition-all dark:border-gray-800 dark:bg-gray-900">
<!-- logo -->
<div class="flex items-center gap-1.5">
<!-- Sidebar Menu -->
<x-admin::layouts.sidebar.mobile />
<a href="{{ route('admin.dashboard.index') }}">
@if ($logo = core()->getConfigData('general.general.admin_logo.logo_image'))
<img
class="h-7"
src="{{ Storage::url($logo) }}"
alt="{{ config('app.name') }}"
/>
@else
<img
class="h-7 max-sm:hidden"
src="{{ request()->cookie('dark_mode') ? vite()->asset('images/dark-logo.svg') : vite()->asset('images/logo.svg') }}"
id="logo-image"
alt="{{ config('app.name') }}"
/>
<img
class="h-7 sm:hidden"
src="{{ request()->cookie('dark_mode') ? vite()->asset('images/mobile-dark-logo.svg') : vite()->asset('images/mobile-light-logo.svg') }}"
id="logo-image"
alt="{{ config('app.name') }}"
/>
@endif
</a>
</div>
<div class="flex items-center gap-1.5 max-md:hidden">
<!-- Mega Search Bar -->
@include('admin::components.layouts.header.desktop.mega-search')
<!-- Quick Creation Bar -->
@include('admin::components.layouts.header.quick-creation')
</div>
<div class="flex items-center gap-2.5">
<div class="md:hidden">
<!-- Mega Search Bar -->
@include('admin::components.layouts.header.mobile.mega-search')
</div>
<!-- Dark mode -->
<v-dark>
<div class="flex">
<span
class="{{ request()->cookie('dark_mode') ? 'icon-light' : 'icon-dark' }} p-1.5 rounded-md text-2xl cursor-pointer transition-all hover:bg-gray-100 dark:hover:bg-gray-950"
></span>
</div>
</v-dark>
<div class="md:hidden">
<!-- Quick Creation Bar -->
@include('admin::components.layouts.header.quick-creation')
</div>
<!-- Admin profile -->
<x-admin::dropdown position="bottom-{{ in_array(app()->getLocale(), ['fa', 'ar']) ? 'left' : 'right' }}">
<x-slot:toggle>
@php($user = auth()->guard('user')->user())
@if ($user->image)
<button class="flex h-9 w-9 cursor-pointer overflow-hidden rounded-full hover:opacity-80 focus:opacity-80">
<img
src="{{ $user->image_url }}"
class="h-full w-full object-cover"
/>
</button>
@else
<button class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-pink-400 font-semibold leading-6 text-white">
{{ substr($user->name, 0, 1) }}
</button>
@endif
</x-slot>
<!-- Admin Dropdown -->
<x-slot:content class="mt-2 border-t-0 !p-0">
<div class="flex items-center gap-1.5 border border-x-0 border-b-gray-300 px-5 py-2.5 dark:border-gray-800">
<img
src="{{ asset('favicon.ico') }}"
width="24"
height="24"
class="object-contain"
/>
<!-- Version -->
<p class="text-gray-400">
@lang('admin::app.layouts.app-version', ['version' => core()->version()])
</p>
</div>
<div class="grid gap-1 pb-2.5">
<a
class="cursor-pointer px-5 py-2 text-base text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-950"
href="{{ route('admin.user.account.edit') }}"
>
@lang('admin::app.layouts.my-account')
</a>
<!--Admin logout-->
<x-admin::form
method="DELETE"
action="{{ route('admin.session.destroy') }}"
id="adminLogout"
>
</x-admin::form>
<a
class="cursor-pointer px-5 py-2 text-base text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-950"
href="{{ route('admin.session.destroy') }}"
onclick="event.preventDefault(); document.getElementById('adminLogout').submit();"
>
@lang('admin::app.layouts.sign-out')
</a>
</div>
</x-slot>
</x-admin::dropdown>
</div>
</header>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-dark-template"
>
<div class="flex">
<span
class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-100 dark:hover:bg-gray-950"
:class="[isDarkMode ? 'icon-light' : 'icon-dark']"
@click="toggle"
></span>
</div>
</script>
<script type="module">
app.component('v-dark', {
template: '#v-dark-template',
data() {
return {
isDarkMode: {{ request()->cookie('dark_mode') ?? 0 }},
logo: "{{ vite()->asset('images/logo.svg') }}",
dark_logo: "{{ vite()->asset('images/dark-logo.svg') }}",
};
},
methods: {
toggle() {
this.isDarkMode = parseInt(this.isDarkModeCookie()) ? 0 : 1;
var expiryDate = new Date();
expiryDate.setMonth(expiryDate.getMonth() + 1);
document.cookie = 'dark_mode=' + this.isDarkMode + '; path=/; expires=' + expiryDate.toGMTString();
document.documentElement.classList.toggle('dark', this.isDarkMode === 1);
if (this.isDarkMode) {
this.$emitter.emit('change-theme', 'dark');
document.getElementById('logo-image').src = this.dark_logo;
} else {
this.$emitter.emit('change-theme', 'light');
document.getElementById('logo-image').src = this.logo;
}
},
isDarkModeCookie() {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'dark_mode') {
return value;
}
}
return 0;
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,593 @@
<v-mobile-mega-search>
<i class="icon-search flex items-center text-2xl"></i>
</v-mobile-mega-search>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-mobile-mega-search-template"
>
<div>
<i
class="icon-search flex items-center text-2xl"
@click="toggleSearchInput"
v-show="!isSearchVisible"
></i>
<div
v-show="isSearchVisible"
class="absolute left-1/2 top-3 z-[10002] flex w-full max-w-full -translate-x-1/2 items-center px-2"
>
<i class="icon-search absolute top-2 flex items-center text-2xl ltr:left-4 rtl:right-4"></i>
<input
type="text"
class="peer block w-full rounded-3xl border bg-white px-10 py-1.5 leading-6 text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
:class="{'border-gray-400': isDropdownOpen}"
placeholder="@lang('admin::app.components.layouts.header.mega-search.title')"
v-model.lazy="searchTerm"
@click="searchTerm.length >= 2 ? isDropdownOpen = true : {}"
v-debounce="500"
ref="searchInput"
>
<i class="icon-cross-large absolute top-2 flex items-center text-2xl ltr:right-4 rtl:left-4"></i>
<div
class="absolute left-[6px] right-[6px] top-10 z-10 max-h-[80vh] overflow-y-auto rounded-lg border bg-white shadow-[0px_0px_0px_0px_rgba(0,0,0,0.10),0px_1px_3px_0px_rgba(0,0,0,0.10),0px_5px_5px_0px_rgba(0,0,0,0.09),0px_12px_7px_0px_rgba(0,0,0,0.05),0px_22px_9px_0px_rgba(0,0,0,0.01),0px_34px_9px_0px_rgba(0,0,0,0.00)] dark:border-gray-800 dark:bg-gray-900"
v-if="isDropdownOpen"
>
<!-- Search Tabs -->
<div class="flex overflow-x-auto border-b text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300 sm:text-base">
<div
class="flex-shrink-0 cursor-pointer whitespace-nowrap px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-950 sm:px-4 sm:py-3"
:class="{ 'border-b-2 border-brandColor': activeTab == tab.key }"
v-for="tab in tabs"
@click="activeTab = tab.key; search();"
>
@{{ tab.title }}
</div>
</div>
<!-- Searched Results -->
<template v-if="activeTab == 'products'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.products />
</template>
<template v-else>
<div class="grid max-h-[400px] divide-y divide-slate-200 overflow-y-auto dark:divide-gray-800">
<template v-for="product in searchedResults.products">
<a
:href="'{{ route('admin.products.view', ':id') }}'.replace(':id', product.id)"
class="flex flex-col justify-between gap-2.5 p-4 hover:bg-gray-100 dark:hover:bg-gray-950 sm:flex-row"
>
<!-- Left Information -->
<div class="flex flex-col gap-2.5 sm:flex-row sm:items-center">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-sm font-semibold text-gray-600 dark:text-gray-300 sm:text-base">
@{{ product.name }}
</p>
<p class="text-sm text-gray-500">
@{{ "@lang(':sku')".replace(':sku', product.sku) }}
</p>
</div>
</div>
<!-- Right Information -->
<div class="mt-2 text-right sm:mt-0 sm:text-right">
<p class="text-gray-600 dark:text-gray-300 sm:text-base">
@{{ $admin.formatPrice(product.price) }}
</p>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.products.length">
<a
:href="'{{ route('admin.products.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-products')`.replace(':query', searchTerm).replace(':count', searchedResults.products.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.products.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-products')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'leads'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.leads />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="lead in searchedResults.leads">
<a
:href="'{{ route('admin.leads.view', ':id') }}'.replace(':id', lead.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-gray-600 dark:text-gray-300">
@{{ lead.title }}
</p>
<p class="text-gray-500">
@{{ lead.description }}
</p>
</div>
</div>
<!-- Right Information -->
<div class="grid place-content-center gap-1 text-right">
<!-- Formatted Price -->
<p class="font-semibold text-gray-600 dark:text-gray-300">
@{{ $admin.formatPrice(lead.lead_value) }}
</p>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.leads.length">
<a
:href="'{{ route('admin.leads.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-leads')`.replace(':query', searchTerm).replace(':count', searchedResults.leads.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.leads.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-leads')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'persons'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.persons />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="person in searchedResults.persons">
<a
:href="'{{ route('admin.contacts.persons.view', ':id') }}'.replace(':id', person.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-gray-600 dark:text-gray-300">
@{{ person.name }}
</p>
<p class="text-gray-500">
@{{ person.emails.map((item) => `${item.value}(${item.label})`).join(', ') }}
</p>
</div>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.persons.length">
<a
:href="'{{ route('admin.contacts.persons.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-contacts')`.replace(':query', searchTerm).replace(':count', searchedResults.persons.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.contacts.persons.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-contacts')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'quotes'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.quotes />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="quote in searchedResults.quotes">
<a
:href="'{{ route('admin.quotes.edit', ':id') }}'.replace(':id', quote.id)"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-base font-semibold text-gray-600 dark:text-gray-300">
@{{ quote.subject }}
</p>
<p class="text-gray-500">
@{{ quote.description }}
</p>
</div>
</div>
</a>
</template>
</div>
<div class="flex border-t p-3 dark:border-gray-800">
<template v-if="searchedResults.quotes.length">
<a
:href="'{{ route('admin.quotes.index') }}?search=:query'.replace(':query', searchTerm)"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@{{ `@lang('admin::app.components.layouts.header.mega-search.explore-all-matching-quotes')`.replace(':query', searchTerm).replace(':count', searchedResults.quotes.length) }}
</a>
</template>
<template v-else>
<a
href="{{ route('admin.quotes.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-quotes')
</a>
</template>
</div>
</template>
</template>
<template v-if="activeTab == 'settings'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.settings />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="setting in searchedResults.settings">
<a
:href="setting.url"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-gray-600 dark:text-gray-300">
@{{ setting.name }}
</p>
</div>
</div>
</a>
</template>
</div>
<template v-if="! searchedResults.settings.length">
<div class="flex border-t p-3 dark:border-gray-800">
<a
href="{{ route('admin.settings.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-settings')
</a>
</div>
</template>
</template>
</template>
<template v-if="activeTab == 'configurations'">
<template v-if="isLoading">
<x-admin::shimmer.header.mega-search.configurations />
</template>
<template v-else>
<div class="grid max-h-[400px] overflow-y-auto">
<template v-for="configuration in searchedResults.configurations">
<a
:href="configuration.url"
class="flex cursor-pointer justify-between gap-2.5 border-b border-slate-300 p-4 last:border-b-0 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-950"
>
<!-- Left Information -->
<div class="flex gap-2.5">
<!-- Details -->
<div class="grid place-content-start gap-1.5">
<p class="text-gray-600 dark:text-gray-300">
@{{ configuration.title }}
</p>
</div>
</div>
</a>
</template>
</div>
<template v-if="! searchedResults.configurations.length">
<div class="flex border-t p-3 dark:border-gray-800">
<a
href="{{ route('admin.configuration.index') }}"
class="cursor-pointer text-xs font-semibold text-brandColor transition-all hover:underline"
>
@lang('admin::app.components.layouts.header.mega-search.explore-all-configurations')
</a>
</div>
</template>
</template>
</template>
</div>
</div>
</div>
</script>
<script type="module">
app.component('v-mobile-mega-search', {
template: '#v-mobile-mega-search-template',
data() {
return {
activeTab: 'leads',
isSearchVisible: false,
isDropdownOpen: false,
tabs: {
leads: {
key: 'leads',
title: "@lang('admin::app.components.layouts.header.mega-search.tabs.leads')",
is_active: true,
endpoint: "{{ route('admin.leads.search') }}",
query_params: [
{
search: 'title',
searchFields: 'title:like',
},
{
search: 'user.name',
searchFields: 'user.name:like',
},
{
search: 'person.name',
searchFields: 'person.name:like',
},
],
},
quotes: {
key: 'quotes',
title: "@lang('admin::app.components.layouts.header.mega-search.tabs.quotes')",
is_active: false,
endpoint: "{{ route('admin.quotes.search') }}",
query_params: [
{
search: 'subject',
searchFields: 'subject:like',
},
{
search: 'description',
searchFields: 'description:like',
},
{
search: 'user.name',
searchFields: 'user.name:like',
},
{
search: 'person.name',
searchFields: 'person.name:like',
},
],
},
products: {
key: 'products',
title: "@lang('admin::app.components.layouts.header.mega-search.tabs.products')",
is_active: false,
endpoint: "{{ route('admin.products.search') }}",
query_params: [
{
search: 'name',
searchFields: 'name:like',
},
{
search: 'sku',
searchFields: 'sku:like',
},
{
search: 'description',
searchFields: 'description:like',
},
],
},
persons: {
key: 'persons',
title: "@lang('admin::app.components.layouts.header.mega-search.tabs.persons')",
is_active: false,
endpoint: "{{ route('admin.contacts.persons.search') }}",
query_params: [
{
search: 'name',
searchFields: 'name:like',
},
{
search: 'job_title',
searchFields: 'job_title:like',
},
{
search: 'user.name',
searchFields: 'user.name:like',
},
{
search: 'organization.name',
searchFields: 'organization.name:like',
},
],
},
settings: {
key: 'settings',
title: "@lang('admin::app.layouts.settings')",
is_active: false,
endpoint: '{{ route('admin.settings.search') }}',
query: '',
},
configurations: {
key: 'configurations',
title: "@lang('admin::app.layouts.configuration')",
is_active: false,
endpoint: '{{ route('admin.configuration.search') }}',
query: '',
},
},
isLoading: false,
searchTerm: '',
searchedResults: {
leads: [],
quotes: [],
products: [],
persons: [],
settings: [],
configurations: [],
},
params: {
search: '',
searchFields: '',
},
};
},
watch: {
searchTerm: 'updateSearchParams',
activeTab: 'updateSearchParams',
},
created() {
window.addEventListener('click', this.handleFocusOut);
},
beforeDestroy() {
window.removeEventListener('click', this.handleFocusOut);
},
methods: {
toggleSearchInput() {
this.isSearchVisible = ! this.isSearchVisible;
this.isDropdownOpen = false;
if (this.isSearchVisible) {
this.$nextTick(() => {
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
});
} else {
this.searchTerm = '';
}
},
search(endpoint) {
if (! endpoint) {
return;
}
if (this.searchTerm.length <= 1) {
this.searchedResults[this.activeTab] = [];
this.isDropdownOpen = false;
return;
}
this.isDropdownOpen = true;
this.$axios.get(endpoint, {
params: {
...this.params,
},
})
.then((response) => {
this.searchedResults[this.activeTab] = response.data.data;
})
.catch((error) => {})
.finally(() => this.isLoading = false);
},
handleFocusOut(e) {
if (! this.$el.contains(e.target) || e.target.classList.contains('icon-cross-large')) {
this.isDropdownOpen = false;
if (! this.isDropdownOpen) {
this.isSearchVisible = false;
this.searchTerm = '';
}
}
},
updateSearchParams() {
const newTerm = this.searchTerm;
this.params = {
search: '',
searchFields: '',
};
const tab = this.tabs[this.activeTab];
if (
tab.key === 'settings'
|| tab.key === 'configurations'
) {
this.params = null;
this.search(`${tab.endpoint}?query=${newTerm}`);
return;
}
this.params.search += tab.query_params.map((param) => `${param.search}:${newTerm};`).join('');
this.params.searchFields += tab.query_params.map((param) => `${param.searchFields};`).join('');
this.search(tab.endpoint);
},
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,145 @@
<div>
@if (bouncer()->hasPermission('leads.create')
|| bouncer()->hasPermission('quotes.create')
|| bouncer()->hasPermission('mail.create')
|| bouncer()->hasPermission('contacts.persons.create')
|| bouncer()->hasPermission('contacts.organizations.create')
|| bouncer()->hasPermission('products.create')
|| bouncer()->hasPermission('settings.automation.attributes.create')
|| bouncer()->hasPermission('settings.user.roles.create')
|| bouncer()->hasPermission('settings.user.users.create')
)
<x-admin::dropdown position="bottom-right">
<x-slot:toggle>
<!-- Toggle Button -->
<button class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-brandColor text-white">
<i class="icon-add text-2xl"></i>
</button>
</x-slot>
<!-- Dropdown Content -->
<x-slot:content class="mt-2 !p-0">
<div class="relative px-2 py-4">
<div class="grid grid-cols-3 gap-2 text-center max-sm:grid-cols-2">
<!-- Link to create new Lead -->
@if (bouncer()->hasPermission('leads.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.leads.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-leads text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.lead')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new Quotes -->
@if (bouncer()->hasPermission('quotes.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.quotes.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-quote text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.quote')</span>
</div>
</a>
</div>
@endif
<!-- Link to send new Mail-->
@if (bouncer()->hasPermission('mail.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.mail.index', ['route' => 'inbox', 'openModal' => 'true']) }}">
<div class="flex flex-col gap-1">
<i class="icon-mail text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.email')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new Person-->
@if (bouncer()->hasPermission('contacts.persons.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.contacts.persons.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-settings-user text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.person')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new Organizations -->
@if (bouncer()->hasPermission('contacts.organizations.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.contacts.organizations.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-organization text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.organization')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new Products -->
@if (bouncer()->hasPermission('products.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.products.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-product text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.product')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new Attributes -->
@if (bouncer()->hasPermission('settings.automation.attributes.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.settings.attributes.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-attribute text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.attribute')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new Role -->
@if (bouncer()->hasPermission('settings.user.roles.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.settings.roles.create') }}">
<div class="flex flex-col gap-1">
<i class="icon-role text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.role')</span>
</div>
</a>
</div>
@endif
<!-- Link to create new User-->
@if (bouncer()->hasPermission('settings.user.users.create'))
<div class="rounded-lg bg-white p-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-950">
<a href="{{ route('admin.settings.users.index', ['action' => 'create']) }}">
<div class="flex flex-col gap-1">
<i class="icon-user text-2xl text-gray-600"></i>
<span class="font-medium dark:text-gray-300">@lang('admin::app.layouts.user')</span>
</div>
</a>
</div>
@endif
</div>
</div>
</x-slot>
</x-admin::dropdown>
@endif
</div>

View File

@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html
class="{{ request()->cookie('dark_mode') ? 'dark' : '' }}"
lang="{{ app()->getLocale() }}"
dir="{{ in_array(app()->getLocale(), ['fa', 'ar']) ? 'rtl' : 'ltr' }}"
>
<head>
{!! view_render_event('admin.layout.head.before') !!}
<title>{{ $title ?? '' }}</title>
<meta charset="UTF-8">
<meta
http-equiv="X-UA-Compatible"
content="IE=edge"
>
<meta
http-equiv="content-language"
content="{{ app()->getLocale() }}"
>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
<meta
name="base-url"
content="{{ url()->to('/') }}"
>
<meta
name="currency"
content="{{
json_encode([
'code' => config('app.currency'),
'symbol' => core()->currencySymbol(config('app.currency'))])
}}
"
>
@stack('meta')
{{
vite()->set(['src/Resources/assets/css/app.css', 'src/Resources/assets/js/app.js'])
}}
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
rel="stylesheet"
/>
<link
rel="preload"
as="image"
href="{{ url('cache/logo/bagisto.png') }}"
>
@if ($favicon = core()->getConfigData('general.design.admin_logo.favicon'))
<link
type="image/x-icon"
href="{{ Storage::url($favicon) }}"
rel="shortcut icon"
sizes="16x16"
>
@else
<link
type="image/x-icon"
href="{{ vite()->asset('images/favicon.ico') }}"
rel="shortcut icon"
sizes="16x16"
/>
@endif
@php
$brandColor = core()->getConfigData('general.settings.menu_color.brand_color') ?? '#0E90D9';
@endphp
@stack('styles')
<style>
:root {
--brand-color: {{ $brandColor }};
}
{!! core()->getConfigData('general.content.custom_scripts.custom_css') !!}
</style>
{!! view_render_event('admin.layout.head.after') !!}
</head>
<body class="h-full font-inter dark:bg-gray-950">
{!! view_render_event('admin.layout.body.before') !!}
<div
id="app"
class="h-full"
>
<!-- Flash Message Blade Component -->
<x-admin::flash-group />
<!-- Confirm Modal Blade Component -->
<x-admin::modal.confirm />
{!! view_render_event('admin.layout.content.before') !!}
<!-- Page Header Blade Component -->
<x-admin::layouts.header />
<div
class="group/container sidebar-collapsed flex gap-4"
ref="appLayout"
>
<!-- Page Sidebar Blade Component -->
<x-admin::layouts.sidebar.desktop />
<div class="flex min-h-[calc(100vh-62px)] max-w-full flex-1 flex-col bg-gray-100 pt-3 transition-all duration-300 dark:bg-gray-950">
<!-- Page Content Blade Component -->
<div class="px-4 pb-6 ltr:lg:pl-[85px] rtl:lg:pr-[85px]">
{{ $slot }}
</div>
<!-- Powered By -->
<div class="mt-auto pt-6">
<div class="border-t bg-white py-5 text-center text-sm font-normal dark:border-gray-800 dark:bg-gray-900 dark:text-white max-md:py-3">
<p>Desenvolvido por <a href="https://blyzer.com.br" style="color: #0E90D9;" class="hover:underline" target="_blank">Blyzer</a></p>
</div>
</div>
</div>
</div>
{!! view_render_event('admin.layout.content.after') !!}
</div>
{!! view_render_event('admin.layout.body.after') !!}
@stack('scripts')
{!! view_render_event('admin.layout.vue-app-mount.before') !!}
<script>
/**
* Load event, the purpose of using the event is to mount the application
* after all of our `Vue` components which is present in blade file have
* been registered in the app. No matter what `app.mount()` should be
* called in the last.
*/
window.addEventListener("load", function(event) {
app.mount("#app");
});
</script>
{!! view_render_event('admin.layout.vue-app-mount.after') !!}
</body>
</html>

View File

@@ -0,0 +1,63 @@
<div
ref="sidebar"
class="duration-80 fixed top-[60px] z-[10002] h-full w-[200px] border-gray-200 bg-white pt-4 transition-all group-[.sidebar-collapsed]/container:w-[70px] dark:border-gray-800 dark:bg-gray-900 max-lg:hidden ltr:border-r rtl:border-l"
@mouseover="handleMouseOver"
@mouseleave="handleMouseLeave"
>
<div class="journal-scroll h-[calc(100vh-100px)] overflow-hidden group-[.sidebar-collapsed]/container:overflow-visible">
<nav class="sidebar-rounded grid w-full gap-2">
<!-- Navigation Menu -->
@foreach (menu()->getItems('admin') as $menuItem)
<div class="px-4 group/item {{ $menuItem->isActive() ? 'active' : 'inactive' }}">
<a
class="flex gap-2 p-1.5 items-center cursor-pointer hover:rounded-lg {{ $menuItem->isActive() == 'active' ? 'bg-brandColor rounded-lg' : ' hover:bg-gray-100 hover:dark:bg-gray-950' }} peer"
href="{{ ! in_array($menuItem->getKey(), ['settings', 'configuration']) && $menuItem->haveChildren() ? 'javascript:void(0)' : $menuItem->getUrl() }}"
@mouseleave="!isMenuActive ? hoveringMenu = '' : {}"
@mouseover="hoveringMenu='{{$menuItem->getKey()}}'"
@click="isMenuActive = !isMenuActive"
>
<span class="{{ $menuItem->getIcon() }} text-2xl {{ $menuItem->isActive() ? 'text-white' : ''}}"></span>
<div class="flex-1 flex justify-between items-center text-gray-600 dark:text-gray-300 font-medium whitespace-nowrap group-[.sidebar-collapsed]/container:hidden {{ $menuItem->isActive() ? 'text-white' : ''}} group">
<p>{{ $menuItem->getName() }}</p>
@if ( ! in_array($menuItem->getKey(), ['settings', 'configuration']) && $menuItem->haveChildren())
<i class="icon-right-arrow rtl:icon-left-arrow invisible text-2xl group-hover/item:visible {{ $menuItem->isActive() ? 'text-white' : ''}}"></i>
@endif
</div>
</a>
<!-- Submenu -->
@if (
! in_array($menuItem->getKey(), ['settings', 'configuration'])
&& $menuItem->haveChildren()
)
<div
class="absolute top-0 hidden flex-col bg-gray-100 ltr:left-[200px] rtl:right-[199px]"
:class="[isMenuActive && (hoveringMenu == '{{$menuItem->getKey()}}') ? '!flex' : 'hidden']"
>
<div class="sidebar-rounded fixed z-[1000] h-full min-w-[140px] max-w-max bg-white pt-4 after:-right-[30px] dark:border-gray-800 dark:bg-gray-900 max-lg:hidden ltr:border-r rtl:border-x">
<div class="journal-scroll h-[calc(100vh-100px)] overflow-hidden">
<nav class="grid w-full gap-2">
@foreach ($menuItem->getChildren() as $subMenuItem)
<div class="px-4 group/item {{ $menuItem->isActive() ? 'active' : 'inactive' }}">
<a
href="{{ $subMenuItem->getUrl() }}"
class="flex gap-2.5 p-2 items-center cursor-pointer hover:rounded-lg {{ $subMenuItem->isActive() == 'active' ? 'bg-brandColor rounded-lg' : ' hover:bg-gray-100 hover:dark:bg-gray-950' }} peer"
>
<p class="text-gray-600 dark:text-gray-300 font-medium whitespace-nowrap {{ $subMenuItem->isActive() ? 'text-white' : ''}}">
{{ $subMenuItem->getName() }}
</p>
</a>
</div>
@endforeach
</nav>
</div>
</div>
</div>
@endif
</div>
@endforeach
</nav>
</div>
</div>

View File

@@ -0,0 +1,120 @@
<v-sidebar-drawer>
<i class="icon-menu lg:hidden cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-100 dark:hover:bg-gray-950 max-lg:block"></i>
</v-sidebar-drawer>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-sidebar-drawer-template"
>
<x-admin::drawer
position="left"
width="280px"
class="lg:hidden [&>:nth-child(3)]:!m-0 [&>:nth-child(3)]:!rounded-l-none [&>:nth-child(3)]:max-sm:!w-[80%]"
>
<x-slot:toggle>
<i class="icon-menu lg:hidden cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-100 dark:hover:bg-gray-950 max-lg:block"></i>
</x-slot>
<x-slot:header>
@if ($logo = core()->getConfigData('general.design.admin_logo.logo_image'))
<img
class="h-10"
src="{{ Storage::url($logo) }}"
alt="{{ config('app.name') }}"
/>
@else
<img
class="h-10"
src="{{ request()->cookie('dark_mode') ? vite()->asset('images/dark-logo.svg') : vite()->asset('images/logo.svg') }}"
id="logo-image"
alt="{{ config('app.name') }}"
/>
@endif
</x-slot>
<x-slot:content class="p-4">
<div class="journal-scroll h-[calc(100vh-100px)] overflow-auto">
<nav class="grid w-full gap-2">
@foreach (menu()->getItems('admin') as $menuItem)
@php
$hasActiveChild = $menuItem->haveChildren() && collect($menuItem->getChildren())->contains(fn($child) => $child->isActive());
$isMenuActive = $menuItem->isActive() == 'active' || $hasActiveChild;
$menuKey = $menuItem->getKey();
@endphp
<div
class="menu-item relative"
data-menu-key="{{ $menuKey }}"
>
<a
href="{{ ! in_array($menuItem->getKey(), ['settings', 'configuration']) && $menuItem->haveChildren() ? 'javascript:void(0)' : $menuItem->getUrl() }}"
class="menu-link flex items-center justify-between rounded-lg p-2 transition-colors duration-200"
@if ($menuItem->haveChildren() && !in_array($menuKey, ['settings', 'configuration']))
@click.prevent="toggleMenu('{{ $menuKey }}')"
@endif
:class="{ 'bg-brandColor text-white': activeMenu === '{{ $menuKey }}' || {{ $isMenuActive ? 'true' : 'false' }}, 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-950': !(activeMenu === '{{ $menuKey }}' || {{ $isMenuActive ? 'true' : 'false' }}) }"
>
<div class="flex items-center gap-3">
<span class="{{ $menuItem->getIcon() }} text-2xl"></span>
<p class="whitespace-nowrap font-semibold">{{ $menuItem->getName() }}</p>
</div>
@if ($menuItem->haveChildren())
<span
class="transform text-lg transition-transform duration-300"
:class="{ 'icon-arrow-up': activeMenu === '{{ $menuKey }}', 'icon-arrow-down': activeMenu !== '{{ $menuKey }}' }"
></span>
@endif
</a>
@if ($menuItem->haveChildren() && !in_array($menuKey, ['settings', 'configuration']))
<div
class="submenu ml-1 mt-1 overflow-hidden rounded-b-lg border-l-2 transition-all duration-300 dark:border-gray-700"
:class="{ 'max-h-[500px] py-2 border-l-brandColor bg-gray-50 dark:bg-gray-900': activeMenu === '{{ $menuKey }}' || {{ $hasActiveChild ? 'true' : 'false' }}, 'max-h-0 py-0 border-transparent bg-transparent': activeMenu !== '{{ $menuKey }}' && !{{ $hasActiveChild ? 'true' : 'false' }} }"
>
@foreach ($menuItem->getChildren() as $subMenuItem)
<a
href="{{ $subMenuItem->getUrl() }}"
class="submenu-link block whitespace-nowrap p-2 pl-10 text-sm transition-colors duration-200"
:class="{ 'text-brandColor font-medium bg-gray-100 dark:bg-gray-800': '{{ $subMenuItem->isActive() }}' === '1', 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800': '{{ $subMenuItem->isActive() }}' !== '1' }">
{{ $subMenuItem->getName() }}
</a>
@endforeach
</div>
@endif
</div>
@endforeach
</nav>
</div>
</x-slot>
</x-admin::drawer>
</script>
<script type="module">
app.component('v-sidebar-drawer', {
template: '#v-sidebar-drawer-template',
data() {
return { activeMenu: null };
},
mounted() {
const activeElement = document.querySelector('.menu-item .menu-link.bg-brandColor');
if (activeElement) {
this.activeMenu = activeElement.closest('.menu-item').getAttribute('data-menu-key');
}
},
methods: {
toggleMenu(menuKey) {
this.activeMenu = this.activeMenu === menuKey ? null : menuKey;
}
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,20 @@
@php
$tabs = menu()->getCurrentActiveMenu('admin')?->getChildren();
@endphp
@if (
$tabs
&& $tabs->isNotEmpty()
)
<div class="tabs">
<div class="mb-4 flex gap-4 border-b-2 pt-2 dark:border-gray-800 max-sm:hidden">
@foreach ($tabs as $tab)
<a href="{{ $tab->getUrl() }}">
<div class="{{ $tab->isActive() ? "-mb-px border-blue-600 border-b-2 transition" : '' }} pb-3.5 px-2.5 text-base font-medium text-gray-600 dark:text-gray-300 cursor-pointer">
{{ $tab->getName() }}
</div>
</a>
@endforeach
</div>
</div>
@endif

View File

@@ -0,0 +1,328 @@
<v-lookup {{ $attributes }}></v-lookup>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-lookup-template"
>
<div
class="relative"
ref="lookup"
>
<!-- Input Box (Button) -->
<div
class="relative inline-block w-full"
@click="toggle"
>
<!-- Input Container -->
<div class="relative flex cursor-pointer items-center justify-between rounded border border-gray-200 p-2 hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:text-gray-300">
<!-- Selected Item or Placeholder Text -->
<span
class="overflow-hidden text-ellipsis"
:title="selectedItem?.name"
>
@{{ selectedItem?.name !== "" ? selectedItem?.name : "@lang('admin::app.components.lookup.click-to-add')" }}
</span>
<!-- Icons Container -->
<div class="flex items-center gap-2">
<!-- Close Icon -->
<i
v-if="(selectedItem?.name) && ! isSearching"
class="icon-cross-large cursor-pointer text-xl text-gray-600"
@click="remove"
></i>
<!-- Arrow Icon -->
<i
class="text-2xl text-gray-600"
:class="showPopup ? 'icon-up-arrow' : 'icon-down-arrow'"
></i>
</div>
</div>
</div>
<!-- Hidden Input Box -->
<x-admin::form.control-group.control
type="hidden"
::name="name"
::rules="rules"
::label="label"
v-model="selectedItem.id"
/>
<!-- Popup Box -->
<div
v-if="showPopup"
class="absolute top-full z-10 mt-1 flex w-full origin-top transform flex-col gap-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg transition-transform dark:border-gray-900 dark:bg-gray-800"
>
<!-- Search Bar -->
<div class="relative flex items-center">
<input
type="text"
v-model.lazy="searchTerm"
v-debounce="500"
class="w-full rounded border border-gray-200 px-2.5 py-2 text-sm font-normal text-gray-800 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400"
placeholder="@lang('admin::app.components.lookup.search')"
ref="searchInput"
@keyup="search"
/>
<!-- Search Icon (absolute positioned) -->
<span class="absolute flex items-center ltr:right-2 rtl:left-2">
<!-- Loader (optional, based on condition) -->
<div
class="relative"
v-if="isSearching"
>
<x-admin::spinner />
</div>
</span>
</div>
<!-- Results List -->
<ul class="max-h-40 divide-y divide-gray-100 overflow-y-auto">
<li
v-for="item in filteredResults"
:key="item.id"
class="cursor-pointer px-4 py-2 text-gray-800 transition-colors hover:bg-blue-100 dark:text-white dark:hover:bg-gray-900"
@click="selectItem(item)"
>
@{{ item.name }}
</li>
<template v-if="filteredResults.length === 0">
<li class="px-4 py-2 text-gray-500">
@lang('admin::app.components.lookup.no-results')
</li>
<li
v-if="searchTerm.length > 2 && canAddNew"
@click="selectItem({ id: '', name: searchTerm })"
class="cursor-pointer border-t border-gray-800 px-4 py-2 text-gray-500 hover:bg-brandColor hover:text-white dark:border-gray-300 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-white"
>
<i class="icon-add text-md"></i>
@lang('admin::app.components.lookup.add-as-new')
</li>
</template>
</ul>
</div>
</div>
</script>
<script type="module">
app.component('v-lookup', {
template: '#v-lookup-template',
props: {
src: {
type: String,
required: true,
},
params: {
type: Object,
default: () => ({}),
},
name: {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
value: {
type: Object,
default: () => ({}),
},
rules: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
canAddNew: {
type: Boolean,
default: false,
},
preload: {
type: Boolean,
default: false,
}
},
emits: ['on-selected'],
data() {
return {
showPopup: false,
searchTerm: '',
selectedItem: {},
searchedResults: [],
isSearching: false,
cancelToken: null,
};
},
mounted() {
if (this.value) {
this.selectedItem = this.value;
}
this.search(this.preload);
},
created() {
window.addEventListener('click', this.handleFocusOut);
},
beforeDestroy() {
window.removeEventListener('click', this.handleFocusOut);
},
watch: {
searchTerm(newVal, oldVal) {
this.search(this.preload);
},
},
computed: {
/**
* Filter the searchedResults based on the search query.
*
* @return {Array}
*/
filteredResults() {
return this.searchedResults.filter(item =>
item.name.toLowerCase().includes(this.searchTerm.toLowerCase())
);
}
},
methods: {
/**
* Toggle the popup.
*
* @return {void}
*/
toggle() {
this.showPopup = ! this.showPopup;
if (this.showPopup) {
this.$nextTick(() => this.$refs.searchInput.focus());
}
},
/**
* Select an item from the list.
*
* @param {Object} item
*
* @return {void}
*/
selectItem(item) {
this.showPopup = false;
this.searchTerm = '';
this.selectedItem = item;
this.$emit('on-selected', item);
},
/**
* Initialize the items.
*
* @return {void}
*/
search(preload = false) {
if (
! preload
&& this.searchTerm.length <= 2
) {
this.searchedResults = [];
this.isSearching = false;
return;
}
this.isSearching = true;
if (this.cancelToken) {
this.cancelToken.cancel();
}
this.cancelToken = this.$axios.CancelToken.source();
this.$axios.get(this.src, {
params: {
...this.params,
query: this.searchTerm
},
cancelToken: this.cancelToken.token,
})
.then(response => {
this.searchedResults = response.data.data;
})
.catch(error => {
if (! this.$axios.isCancel(error)) {
console.error("Search request failed:", error);
}
this.isSearching = false;
})
.finally(() => this.isSearching = false);
},
/**
* Handle the focus out event.
*
* @param {Event} event
*
* @return {void}
*/
handleFocusOut(event) {
const lookup = this.$refs.lookup;
if (
lookup &&
! lookup.contains(event.target)
) {
this.showPopup = false;
}
},
/**
* Remove the selected item.
*
* @return {void}
*/
remove() {
this.selectedItem = {
id: '',
name: '',
};
this.$emit('on-selected', {});
}
},
});
</script>
@endPushOnce

View File

@@ -0,0 +1,334 @@
@props([
'name' => 'images',
'allowMultiple' => false,
'showPlaceholders' => false,
'uploadedImages' => [],
'width' => '120px',
'height' => '120px'
])
<v-media-images
name="{{ $name }}"
v-bind:allow-multiple="{{ $allowMultiple ? 'true' : 'false' }}"
v-bind:show-placeholders="{{ $showPlaceholders ? 'true' : 'false' }}"
:uploaded-images='{{ json_encode($uploadedImages) }}'
width="{{ $width }}"
height="{{ $height }}"
:errors="errors"
>
<x-admin::shimmer.image class="h-[120px] w-[120px] rounded" />
</v-media-images>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-media-images-template"
>
<!-- Panel Content -->
<div class="grid">
<div class="flex flex-wrap gap-1">
<!-- Upload Image Button -->
<template v-if="allowMultiple || images.length == 0">
<!-- Upload Image Button -->
<label
class="grid h-[120px] max-h-[120px] min-h-[110px] w-full min-w-[110px] max-w-[120px] cursor-pointer items-center justify-items-center rounded border border-dashed border-gray-300 transition-all hover:border-gray-400 dark:border-gray-800 dark:mix-blend-exclusion dark:invert"
:class="[(errors?.['images.files[0]'] ?? false) ? 'border border-red-500' : 'border-gray-300']"
:style="{'max-width': this.width, 'max-height': this.height}"
:for="$.uid + '_imageInput'"
>
<div class="flex flex-col items-center">
<span class="icon-image text-2xl"></span>
<p class="grid text-center text-sm font-semibold text-gray-600 dark:text-gray-300">
@lang('admin::app.components.media.images.add-image-btn')
<span class="text-xs">
@lang('admin::app.components.media.images.allowed-types')
</span>
</p>
<input
type="file"
class="hidden"
:id="$.uid + '_imageInput'"
accept="image/*"
:multiple="allowMultiple"
:ref="$.uid + '_imageInput'"
@change="add"
/>
</div>
</label>
</template>
<!-- Uploaded Images -->
<draggable
class="flex flex-wrap gap-1"
ghost-class="draggable-ghost"
v-bind="{animation: 200}"
:list="images"
item-key="id"
>
<template #item="{ element, index }">
<v-media-image-item
:name="name"
:index="index"
:image="element"
:width="width"
:height="height"
@onRemove="remove($event)"
>
</v-media-image-item>
</template>
</draggable>
<!-- Placeholders -->
<template v-if="showPlaceholders && ! images.length">
<!-- Front Placeholder -->
<div
class="relative h-[120px] max-h-[120px] w-full min-w-[120px] max-w-[120px] rounded border border-dashed border-gray-300 dark:border-gray-800 dark:mix-blend-exclusion dark:invert"
v-for="placeholder in placeholders"
>
<img :src="placeholder.image">
<p class="absolute bottom-4 w-full text-center text-xs font-semibold text-gray-400">
@{{ placeholder.label }}
</p>
</div>
</template>
</div>
</div>
</script>
<script type="text/x-template" id="v-media-image-item-template">
<div class="group relative grid max-h-[120px] min-w-[120px] justify-items-center overflow-hidden rounded transition-all hover:border-gray-400">
<!-- Image Preview -->
<img
:src="image.url"
:style="{'width': this.width, 'height': this.height}"
/>
<div class="invisible absolute bottom-0 top-0 flex w-full flex-col justify-between bg-white p-3 opacity-80 transition-all group-hover:visible dark:bg-gray-900">
<!-- Image Name -->
<p class="break-all text-xs font-semibold text-gray-600 dark:text-gray-300"></p>
<!-- Actions -->
<div class="flex justify-between">
<span
class="icon-delete cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-200 dark:hover:bg-gray-800"
@click="remove"
></span>
<label
class="icon-edit cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-200 dark:hover:bg-gray-800"
:for="$.uid + '_imageInput_' + index"
></label>
<input
type="hidden"
:name="name + '[' + image.id + ']'"
v-if="! image.is_new"
/>
<input
type="file"
:name="name + '[]'"
class="hidden"
accept="image/*"
:id="$.uid + '_imageInput_' + index"
:ref="$.uid + '_imageInput_' + index"
@change="edit"
/>
</div>
</div>
</div>
</script>
<script type="module">
app.component('v-media-images', {
template: '#v-media-images-template',
props: {
name: {
type: String,
default: 'images',
},
allowMultiple: {
type: Boolean,
default: false,
},
showPlaceholders: {
type: Boolean,
default: false,
},
uploadedImages: {
type: Array,
default: () => []
},
width: {
type: String,
default: '120px'
},
height: {
type: String,
default: '120px'
},
errors: {
type: Object,
default: () => {}
}
},
data() {
return {
images: [],
placeholders: [
{
label: "@lang('admin::app.components.media.images.placeholders.front')",
image: "{{ asset('images/product-placeholders/front.svg') }}"
}, {
label: "@lang('admin::app.components.media.images.placeholders.next')",
image: "{{ asset('images/product-placeholders/next-1.svg') }}"
}, {
label: "@lang('admin::app.components.media.images.placeholders.next')",
image: "{{ asset('images/product-placeholders/next-2.svg') }}"
}, {
label: "@lang('admin::app.components.media.images.placeholders.zoom')",
image: "{{ asset('images/product-placeholders/zoom.svg') }}"
}, {
label: "@lang('admin::app.components.media.images.placeholders.use-cases')",
image: "{{ asset('images/product-placeholders/use-cases.svg') }}"
}, {
label: "@lang('admin::app.components.media.images.placeholders.size')",
image: "{{ asset('images/product-placeholders/size.svg') }}"
}
],
isLoading: false,
}
},
mounted() {
this.images = this.uploadedImages;
},
methods: {
add() {
let imageInput = this.$refs[this.$.uid + '_imageInput'];
if (imageInput.files == undefined) {
return;
}
const validFiles = Array.from(imageInput.files).every(file => file.type.includes('image/'));
if (! validFiles) {
this.$emitter.emit('add-flash', {
type: 'warning',
message: "@lang('admin::app.components.media.images.not-allowed-error')"
});
return;
}
imageInput.files.forEach((file, index) => {
this.images.push({
id: 'image_' + this.images.length,
url: '',
file: file
});
});
},
remove(image) {
let index = this.images.indexOf(image);
this.images.splice(index, 1);
},
getBase64ToFile(base64, filename) {
var arr = base64.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[arr.length - 1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, {type:mime});
},
}
});
app.component('v-media-image-item', {
template: '#v-media-image-item-template',
props: ['index', 'image', 'name', 'width', 'height'],
mounted() {
if (this.image.file instanceof File) {
this.setFile(this.image.file);
this.readFile(this.image.file);
}
},
methods: {
edit() {
let imageInput = this.$refs[this.$.uid + '_imageInput_' + this.index];
if (imageInput.files == undefined) {
return;
}
const validFiles = Array.from(imageInput.files).every(file => file.type.includes('image/'));
if (! validFiles) {
this.$emitter.emit('add-flash', {
type: 'warning',
message: "@lang('admin::app.components.media.images.not-allowed-error')"
});
return;
}
this.setFile(imageInput.files[0]);
this.readFile(imageInput.files[0]);
},
remove() {
this.$emit('onRemove', this.image)
},
setFile(file) {
this.image.is_new = 1;
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.$refs[this.$.uid + '_imageInput_' + this.index].files = dataTransfer.files;
},
readFile(file) {
let reader = new FileReader();
reader.onload = (e) => {
this.image.url = e.target.result;
}
reader.readAsDataURL(file);
},
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,307 @@
@props([
'name' => 'images',
'allowMultiple' => false,
'uploadedVideos' => [],
'width' => '210px',
'height' => '120px'
])
<v-media-videos
name="{{ $name }}"
v-bind:allow-multiple="{{ $allowMultiple ? 'true' : 'false' }}"
:uploaded-videos='{{ json_encode($uploadedVideos) }}'
width="{{ $width }}"
height="{{ $height }}"
:errors="errors"
{{ $attributes->get('class') }}
>
</v-media-videos>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-media-videos-template"
>
<!-- Panel Content -->
<div class="grid">
<div class="flex gap-1">
<!-- Upload Video Button -->
<label
class="grid h-[120px] max-h-[120px] w-full max-w-[210px] cursor-pointer items-center justify-items-center rounded border border-dashed transition-all hover:border-gray-400 dark:border-gray-800 dark:mix-blend-exclusion dark:invert"
:class="[errors['videos.files[0]'] ? 'border border-red-500' : 'border-gray-300']"
:for="$.uid + '_videoInput'"
v-if="allowMultiple || videos.length == 0"
>
<div class="flex flex-col items-center">
<span class="icon-image text-2xl"></span>
<p class="grid text-center text-sm font-semibold text-gray-600 dark:text-gray-300">
@lang('admin::app.components.media.videos.add-video-btn')
<span class="text-xs">
@lang('admin::app.components.media.videos.allowed-types')
</span>
</p>
<input
type="file"
class="hidden"
:id="$.uid + '_videoInput'"
accept="video/*"
:multiple="allowMultiple"
:ref="$.uid + '_videoInput'"
@change="add"
/>
</div>
</label>
<!-- Uploaded Videos -->
<draggable
class="flex gap-1"
ghost-class="draggable-ghost"
v-bind="{animation: 200}"
:list="videos"
item-key="id"
>
<template #item="{ element, index }">
<v-media-video-item
:name="name"
:index="index"
:video="element"
:width="width"
:height="height"
@onRemove="remove($event)"
>
</v-media-video-item>
</template>
</draggable>
</div>
</div>
</script>
<script
type="text/x-template"
id="v-media-video-item-template"
>
<div class="group relative grid h-[120px] max-h-[120px] min-w-[210px] max-w-[210px] justify-items-center overflow-hidden rounded border border-dashed border-gray-300 transition-all hover:border-gray-400 dark:border-gray-800">
<!-- Video Preview -->
<video
class="h-[120px] w-[210px] object-cover"
ref="videoPreview"
v-if="video.url.length > 0"
>
<source :src="video.url" type="video/mp4">
</video>
<div class="invisible absolute bottom-0 top-0 flex w-full flex-col justify-between bg-white p-3 opacity-80 transition-all group-hover:visible dark:bg-gray-900">
<!-- Video Name -->
<p class="break-all text-xs font-semibold text-gray-600 dark:text-gray-300"></p>
<!-- Actions -->
<div class="flex justify-between">
<!-- Remove Button -->
<span
class="icon-delete cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-200 dark:hover:bg-gray-800"
@click="remove"
></span>
<!-- Play Pause Button -->
<span
class="cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-200 dark:hover:bg-gray-800"
:class="[isPlaying ? 'icon-pause': 'icon-play']"
@click="playPause"
></span>
<!-- Edit Button -->
<label
class="icon-edit cursor-pointer rounded-md p-1.5 text-2xl hover:bg-gray-200 dark:hover:bg-gray-800"
:for="$.uid + '_videoInput_' + index"
></label>
<input
type="hidden"
:name="name + '[' + video.id + ']'"
v-if="! video.is_new"
/>
<input
type="file"
:name="name + '[]'"
class="hidden"
accept="video/*"
:id="$.uid + '_videoInput_' + index"
:ref="$.uid + '_videoInput_' + index"
@change="edit"
/>
</div>
</div>
</div>
</script>
<script type="module">
app.component('v-media-videos', {
template: '#v-media-videos-template',
props: {
name: {
type: String,
default: 'videos',
},
allowMultiple: {
type: Boolean,
default: false,
},
uploadedVideos: {
type: Array,
default: () => []
},
width: {
type: String,
default: '210px'
},
height: {
type: String,
default: '120px'
},
errors: {
type: Object,
default: () => {}
}
},
data() {
return {
videos: [],
}
},
mounted() {
this.videos = this.uploadedVideos;
},
methods: {
add() {
let videoInput = this.$refs[this.$.uid + '_videoInput'];
if (videoInput.files == undefined) {
return;
}
const validFiles = Array.from(videoInput.files).every(file => file.type.includes('video/'));
if (! validFiles) {
this.$emitter.emit('add-flash', {
type: 'warning',
message: "@lang('admin::app.components.media.videos.not-allowed-error')"
});
return;
}
videoInput.files.forEach((file, index) => {
this.videos.push({
id: 'video_' + this.videos.length,
url: '',
file: file
});
});
},
remove(video) {
let index = this.videos.indexOf(video);
this.videos.splice(index, 1);
},
}
});
app.component('v-media-video-item', {
template: '#v-media-video-item-template',
props: ['index', 'video', 'name', 'width', 'height'],
data() {
return {
isPlaying: false
}
},
mounted() {
if (this.video.file instanceof File) {
this.setFile(this.video.file);
this.readFile(this.video.file);
}
},
methods: {
edit() {
let videoInput = this.$refs[this.$.uid + '_videoInput_' + this.index];
if (videoInput.files == undefined) {
return;
}
const validFiles = Array.from(videoInput.files).every(file => file.type.includes('video/'));
if (! validFiles) {
this.$emitter.emit('add-flash', {
type: 'warning',
message: "@lang('admin::app.components.media.videos.not-allowed-error')"
});
return;
}
this.setFile(videoInput.files[0]);
this.readFile(videoInput.files[0]);
},
remove() {
this.$emit('onRemove', this.video)
},
setFile(file) {
this.video.is_new = 1;
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
this.$refs[this.$.uid + '_videoInput_' + this.index].files = dataTransfer.files;
},
readFile(file) {
let reader = new FileReader();
reader.onload = (e) => {
this.video.url = e.target.result;
}
reader.readAsDataURL(file);
},
playPause() {
let videoPreview = this.$refs.videoPreview;
if (videoPreview.paused == true) {
this.isPlaying = true;
videoPreview.play();
} else {
this.isPlaying = false;
videoPreview.pause();
}
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,140 @@
<v-modal-confirm ref="confirmModal"></v-modal-confirm>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-modal-confirm-template"
>
<div>
<transition
tag="div"
name="modal-overlay"
enter-class="duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
class="fixed inset-0 z-[10003] bg-gray-500 bg-opacity-50 transition-opacity"
v-show="isOpen"
></div>
</transition>
<transition
tag="div"
name="modal-content"
enter-class="duration-300 ease-out"
enter-from-class="translate-y-4 opacity-0 md:translate-y-0 md:scale-95"
enter-to-class="translate-y-0 opacity-100 md:scale-100"
leave-class="duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100 md:scale-100"
leave-to-class="translate-y-4 opacity-0 md:translate-y-0 md:scale-95"
>
<div
class="fixed inset-0 z-[10004] transform overflow-y-auto transition"
v-if="isOpen"
>
<div class="flex min-h-full items-end justify-center p-5 sm:items-center sm:p-0">
<div class="box-shadow absolute left-1/2 top-1/2 z-[999] w-full max-w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white dark:bg-gray-900 max-md:w-[90%]">
<div class="flex items-center justify-between gap-2.5 border-b px-4 py-3 text-lg font-bold text-gray-800 dark:border-gray-800 dark:text-white">
@{{ title }}
</div>
<div class="px-4 py-3 text-left text-gray-600 dark:text-gray-300">
@{{ message }}
</div>
<div class="flex justify-end gap-2.5 px-4 py-2.5">
<button type="button" class="transparent-button" @click="disagree">
@{{ options.btnDisagree }}
</button>
<button type="button" class="primary-button" @click="agree">
@{{ options.btnAgree }}
</button>
</div>
</div>
</div>
</div>
</transition>
</div>
</script>
<script type="module">
app.component('v-modal-confirm', {
template: '#v-modal-confirm-template',
data() {
return {
isOpen: false,
title: '',
message: '',
options: {
btnDisagree: '',
btnAgree: '',
},
agreeCallback: null,
disagreeCallback: null,
};
},
created() {
this.registerGlobalEvents();
},
methods: {
open({
title = "@lang('admin::app.components.modal.confirm.title')",
message = "@lang('admin::app.components.modal.confirm.message')",
options = {
btnDisagree: "@lang('admin::app.components.modal.confirm.disagree-btn')",
btnAgree: "@lang('admin::app.components.modal.confirm.agree-btn')",
},
agree = () => {},
disagree = () => {},
}) {
this.isOpen = true;
document.body.style.overflow = 'hidden';
this.title = title;
this.message = message;
this.options = options;
this.agreeCallback = agree;
this.disagreeCallback = disagree;
},
disagree() {
this.isOpen = false;
document.body.style.overflow = 'auto';
this.disagreeCallback();
},
agree() {
this.isOpen = false;
document.body.style.overflow = 'auto';
this.agreeCallback();
},
registerGlobalEvents() {
this.$emitter.on('open-confirm-modal', this.open);
},
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,225 @@
@props([
'isActive' => false,
'position' => 'center',
'size' => 'normal',
])
<v-modal
is-active="{{ $isActive }}"
position="{{ $position }}"
size="{{ $size }}"
{{ $attributes }}
>
@isset($toggle)
<template v-slot:toggle>
{{ $toggle }}
</template>
@endisset
@isset($header)
<template v-slot:header="{ toggle, isOpen }">
<div {{ $header->attributes->merge(['class' => 'flex items-center justify-between gap-2.5 border-b px-4 py-3 dark:border-gray-800']) }}>
{{ $header }}
<span
class="icon-cross-large cursor-pointer text-3xl hover:rounded-md hover:bg-gray-100 dark:hover:bg-gray-950"
@click="toggle"
>
</span>
</div>
</template>
@endisset
@isset($content)
<template v-slot:content>
<div {{ $content->attributes->merge(['class' => 'border-b px-4 py-2.5 dark:border-gray-800']) }}>
{{ $content }}
</div>
</template>
@endisset
@isset($footer)
<template v-slot:footer>
<div {{ $content->attributes->merge(['class' => 'flex justify-end px-4 py-2.5']) }}>
{{ $footer }}
</div>
</template>
@endisset
</v-modal>
@pushOnce('scripts')
<script
type="text/x-template"
id="v-modal-template"
>
<div>
<div @click="toggle">
<slot name="toggle">
</slot>
</div>
<transition
tag="div"
name="modal-overlay"
enter-class="duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-class="duration-200 ease-[cubic-bezier(.4,0,.2,1)]"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
class="fixed inset-0 z-[10002] bg-gray-500 bg-opacity-50 transition-opacity"
v-show="isOpen"
></div>
</transition>
<transition
tag="div"
name="modal-content"
enter-class="duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
:enter-from-class="enterFromLeaveToClasses"
enter-to-class="translate-y-0 opacity-100"
leave-class="duration-300 ease-[cubic-bezier(.4,0,.2,1)]"
leave-from-class="translate-y-0 opacity-100"
:leave-to-class="enterFromLeaveToClasses"
>
<div
class="fixed inset-0 z-[10003] transform overflow-y-auto transition"
v-if="isOpen"
>
<div class="flex min-h-full items-center justify-center max-md:p-4">
<div
class="box-shadow z-[999] w-full overflow-hidden rounded-lg bg-white dark:bg-gray-900 sm:absolute"
:class="[finalPositionClass, sizeClass]"
>
<!-- Header Slot -->
<slot
name="header"
:toggle="toggle"
:isOpen="isOpen"
>
</slot>
<!-- Content Slot -->
<slot name="content"></slot>
<!-- Footer Slot -->
<slot name="footer"></slot>
</div>
</div>
</div>
</transition>
</div>
</script>
<script type="module">
app.component('v-modal', {
template: '#v-modal-template',
props: [
'isActive',
'position',
'size'
],
emits: [
'toggle',
'open',
'close',
],
data() {
return {
isOpen: this.isActive,
isMobile: window.innerWidth < 640,
};
},
created() {
window.addEventListener('resize', this.checkScreenSize);
},
beforeUnmount() {
window.removeEventListener('resize', this.checkScreenSize);
},
computed: {
positionClass() {
return {
'center': 'items-center justify-center',
'top-center': 'top-4',
'bottom-center': 'bottom-4',
'bottom-right': 'bottom-4 right-4',
'bottom-left': 'bottom-4 left-4',
'top-right': 'top-4 right-4',
'top-left': 'top-4 left-4',
}[this.position];
},
finalPositionClass() {
return this.isMobile
? 'items-center justify-center'
: this.positionClass;
},
sizeClass() {
return {
'normal': 'max-w-[525px]',
'medium': 'max-w-[768px]',
'large': 'max-w-[950px]',
}[this.size] || 'max-w-[525px]';
},
enterFromLeaveToClasses() {
const effectivePosition = this.isMobile ? 'center' : this.position;
return {
'center': '-translate-y-4 opacity-0',
'top-center': '-translate-y-4 opacity-0',
'bottom-center': 'translate-y-4 opacity-0',
'bottom-right': 'translate-y-4 opacity-0',
'bottom-left': 'translate-y-4 opacity-0',
'top-right': '-translate-y-4 opacity-0',
'top-left': '-translate-y-4 opacity-0',
}[effectivePosition];
}
},
methods: {
checkScreenSize() {
this.isMobile = window.innerWidth < 640;
},
toggle() {
this.isOpen = ! this.isOpen;
if (this.isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow ='auto';
}
this.$emit('toggle', { isActive: this.isOpen });
},
open() {
this.isOpen = true;
document.body.style.overflow = 'hidden';
this.$emit('open', { isActive: this.isOpen });
},
close() {
this.isOpen = false;
document.body.style.overflow = 'auto';
this.$emit('close', { isActive: this.isOpen });
}
}
});
</script>
@endPushOnce

View File

@@ -0,0 +1,17 @@
<div class="!rounded-lg bg-white dark:bg-gray-900">
<div class="flex items-center justify-between gap-x-5 p-4">
<p class="shimmer w-[200px] p-2.5"></p>
<p class="shimmer w-5 p-2.5"></p>
</div>
<div class="px-4 pb-4">
<div class="grid gap-y-2.5">
<p class="shimmer w-[200px] p-2.5"></p>
<p class="shimmer w-[200px] p-2.5"></p>
<p class="shimmer w-40 p-2.5"></p>
<p class="shimmer w-40 p-2.5"></p>
<p class="shimmer w-40 p-2.5"></p>
<p class="shimmer w-40 p-2.5"></p>
</div>
</div>
</div>

View File

@@ -0,0 +1,41 @@
<div class="w-full rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<!-- Tabs -->
<div class="flex gap-2 overflow-x-auto border-b border-gray-200 dark:border-gray-800">
@for ($i = 0; $i < 5; $i++)
<div class="px-3 py-[11px]">
<div class="shimmer h-5 w-24"></div>
</div>
@endfor
</div>
<!-- Tab Content -->
<div class="p-4">
<!-- Activity List -->
<div class="flex flex-col gap-4">
@for ($i = 0; $i < 5; $i++)
<!-- Activity Item -->
<div class="flex gap-2">
<!-- Icon -->
<div class="shimmer mt-2 flex h-9 w-9 rounded-full"></div>
<!-- Details -->
<div class="flex w-full justify-between gap-4 p-4">
<div class="flex w-full flex-col gap-2">
<div class="shimmer h-[17px] w-48"></div>
<div class="flex flex-col gap-1">
<div class="shimmer h-[17px] w-full"></div>
<div class="shimmer h-[17px] w-1/2"></div>
</div>
<div class="shimmer h-5 w-48"></div>
</div>
<!-- Menu -->
<div class="shimmer h-7 w-7 rounded-md"></div>
</div>
</div>
@endfor
</div>
</div>
</div>

View File

@@ -0,0 +1,28 @@
@props(['count' => 30])
<div class="flex gap-1.5">
<div class="grid">
@foreach (range(1, 10) as $i)
<div class="shimmer h-[15px] w-[39px]"></div>
@endforeach
</div>
<div class="grid w-full gap-1.5">
<div class="flex aspect-[3.23/1] w-full items-end border-b border-l pl-2.5 dark:border-gray-800">
<div class="flex aspect-[3.23/1] w-full items-end justify-between gap-5 max-lg:gap-4 max-sm:gap-2.5">
@foreach (range(1, $count) as $i)
<div
class="shimmer flex w-full"
style="height: {{ rand(10, 100) }}%"
></div>
@endforeach
</div>
</div>
<div class="flex justify-between gap-5 pl-2.5 max-lg:gap-4 max-sm:gap-2.5">
@foreach (range(1, $count) as $i)
<div class="shimmer flex h-[15px] w-full"></div>
@endforeach
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
<div class="flex gap-4">
<div class="w-full">
<div class="shimmer w-50% h-56"></div>
</div>
<div class="grid w-full gap-3">
<div class="shimmer w-50% h-10"></div>
<div class="shimmer w-50% h-10"></div>
<div class="shimmer w-50% h-10"></div>
<div class="shimmer w-50% h-10"></div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More