mirror of
https://github.com/crater-invoice/crater.git
synced 2025-10-28 04:01:10 -04:00
1043 lines
32 KiB
Vue
1043 lines
32 KiB
Vue
<template>
|
|
<ModulePlaceholder v-if="isFetchingInitialData" />
|
|
<BasePage v-else class="bg-white">
|
|
<BasePageHeader :title="moduleData.name">
|
|
<BaseBreadcrumb>
|
|
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
|
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/modules" />
|
|
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
|
|
</BaseBreadcrumb>
|
|
</BasePageHeader>
|
|
<!-- Main Content -->
|
|
<div
|
|
class="
|
|
lg:grid lg:grid-rows-1 lg:grid-cols-7 lg:gap-x-8 lg:gap-y-10
|
|
xl:gap-x-16
|
|
mt-6
|
|
"
|
|
>
|
|
<!-- Product image -->
|
|
<div class="lg:row-end-1 lg:col-span-4">
|
|
<div class="flex flex-col-reverse">
|
|
<div
|
|
class="hidden mt-6 w-full max-w-2xl mx-auto sm:block lg:max-w-none"
|
|
>
|
|
<div
|
|
class="grid grid-cols-3 xl:grid-cols-4 gap-6"
|
|
aria-orientation="horizontal"
|
|
role="tablist"
|
|
>
|
|
<button
|
|
v-if="thumbnail && videoUrl"
|
|
:class="[
|
|
'relative md:h-24 lg:h-36 rounded hover:bg-gray-50',
|
|
{
|
|
'outline-none ring ring-offset-1 ring-primary-500':
|
|
displayVideo,
|
|
},
|
|
]"
|
|
type="button"
|
|
@click="setDisplayVideo"
|
|
>
|
|
<span class="absolute inset-0 rounded-md overflow-hidden">
|
|
<img
|
|
:src="thumbnail"
|
|
alt=""
|
|
class="w-full h-full object-center object-cover"
|
|
/>
|
|
</span>
|
|
<span
|
|
class="
|
|
ring-transparent
|
|
absolute
|
|
inset-0
|
|
rounded-md
|
|
ring-2 ring-offset-2
|
|
pointer-events-none
|
|
"
|
|
aria-hidden="true"
|
|
></span>
|
|
</button>
|
|
|
|
<button
|
|
v-for="(screenshot, ssIndx) in displayImages"
|
|
id="tabs-1-tab-1"
|
|
:key="ssIndx"
|
|
:class="[
|
|
'relative md:h-24 lg:h-36 rounded hover:bg-gray-50',
|
|
{
|
|
'outline-none ring ring-offset-1 ring-primary-500':
|
|
displayImage === screenshot.url,
|
|
},
|
|
]"
|
|
type="button"
|
|
@click="setDisplayImage(screenshot.url)"
|
|
>
|
|
<span class="absolute inset-0 rounded-md overflow-hidden">
|
|
<img
|
|
:src="screenshot.url"
|
|
alt=""
|
|
class="w-full h-full object-center object-cover"
|
|
/>
|
|
</span>
|
|
<span
|
|
class="
|
|
ring-transparent
|
|
absolute
|
|
inset-0
|
|
rounded-md
|
|
ring-2 ring-offset-2
|
|
pointer-events-none
|
|
"
|
|
aria-hidden="true"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="displayVideo" class="aspect-w-4 aspect-h-3">
|
|
<iframe
|
|
:src="videoUrl"
|
|
class="sm:rounded-lg"
|
|
frameborder="0"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowfullscreen
|
|
>
|
|
</iframe>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="aspect-w-4 aspect-h-3 rounded-lg bg-gray-100 overflow-hidden"
|
|
>
|
|
<img
|
|
:src="displayImage"
|
|
alt="Module Images"
|
|
class="w-full h-full object-center object-cover sm:rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Product details -->
|
|
<div
|
|
class="
|
|
max-w-2xl
|
|
mx-auto
|
|
mt-10
|
|
lg:max-w-none lg:mt-0 lg:row-end-2 lg:row-span-2 lg:col-span-3
|
|
w-full
|
|
"
|
|
>
|
|
<!-- Average Rating -->
|
|
|
|
<h3 class="sr-only">Reviews</h3>
|
|
|
|
<div class="flex items-center">
|
|
<BaseRating :rating="averageRating" />
|
|
</div>
|
|
<p class="sr-only">4 out of 5 stars</p>
|
|
|
|
<!-- Module Name and Version -->
|
|
<div class="flex flex-col-reverse">
|
|
<div class="mt-4">
|
|
<h1
|
|
class="
|
|
text-2xl
|
|
font-extrabold
|
|
tracking-tight
|
|
text-gray-900
|
|
sm:text-3xl
|
|
"
|
|
>
|
|
{{ moduleData.name }}
|
|
</h1>
|
|
|
|
<h2 id="information-heading" class="sr-only">
|
|
Product information
|
|
</h2>
|
|
|
|
<p
|
|
v-if="moduleData.latest_module_version"
|
|
class="text-sm text-gray-500 mt-2"
|
|
>
|
|
{{ $t('modules.version') }}
|
|
{{ moduleVersion }} ({{ $t('modules.last_updated') }}
|
|
{{ updatedAt }})
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Module Description -->
|
|
<div
|
|
class="prose prose-sm max-w-none text-gray-500 text-sm my-10"
|
|
v-html="moduleData.long_description"
|
|
/>
|
|
|
|
<!-- Module Pricing -->
|
|
<div v-if="!moduleData.purchased">
|
|
<RadioGroup v-model="selectedPlan">
|
|
<RadioGroupLabel class="sr-only"> Pricing plans </RadioGroupLabel>
|
|
<div class="relative bg-white rounded-md -space-y-px">
|
|
<RadioGroupOption
|
|
v-for="(size, sizeIdx) in modulePrice"
|
|
:key="size.name"
|
|
v-slot="{ checked, active }"
|
|
as="template"
|
|
:value="size"
|
|
>
|
|
<div
|
|
:class="[
|
|
sizeIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
|
|
sizeIdx === modulePrice.length - 1
|
|
? 'rounded-bl-md rounded-br-md'
|
|
: '',
|
|
checked
|
|
? 'bg-primary-50 border-primary-200 z-10'
|
|
: 'border-gray-200',
|
|
'relative border p-4 flex flex-col cursor-pointer md:pl-4 md:pr-6 md:grid md:grid-cols-2 focus:outline-none',
|
|
]"
|
|
>
|
|
<div class="flex items-center text-sm">
|
|
<span
|
|
:class="[
|
|
checked
|
|
? 'bg-primary-600 border-transparent'
|
|
: 'bg-white border-gray-300',
|
|
active ? 'ring-2 ring-offset-2 ring-primary-500' : '',
|
|
'h-4 w-4 rounded-full border flex items-center justify-center',
|
|
]"
|
|
aria-hidden="true"
|
|
>
|
|
<span class="rounded-full bg-white w-1.5 h-1.5" />
|
|
</span>
|
|
<RadioGroupLabel
|
|
as="span"
|
|
:class="[
|
|
checked ? 'text-primary-900' : 'text-gray-900',
|
|
'ml-3 font-medium',
|
|
]"
|
|
>
|
|
{{ size.name }}
|
|
</RadioGroupLabel>
|
|
</div>
|
|
<RadioGroupDescription
|
|
class="ml-6 pl-1 text-base md:ml-0 md:pl-0 md:text-center"
|
|
>
|
|
<span
|
|
:class="[
|
|
checked ? 'text-primary-900' : 'text-gray-900',
|
|
'font-medium',
|
|
]"
|
|
>
|
|
$ {{ size.price }}
|
|
</span>
|
|
</RadioGroupDescription>
|
|
</div>
|
|
</RadioGroupOption>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<!-- Button Section -->
|
|
|
|
<!-- If Module is not purchased -->
|
|
<a
|
|
v-if="!moduleData.purchased"
|
|
:href="`${globalStore.config.base_url}/modules/${moduleData.slug}`"
|
|
target="_blank"
|
|
class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
|
|
>
|
|
<BaseButton
|
|
size="xl"
|
|
class="items-center flex justify-center text-base mt-10"
|
|
>
|
|
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
|
|
{{ $t('modules.buy_now') }}
|
|
</BaseButton>
|
|
</a>
|
|
|
|
<!-- When module is Purchased -->
|
|
<div v-else>
|
|
<!-- Module not installed -->
|
|
<div
|
|
v-if="!moduleData.installed"
|
|
class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
|
|
>
|
|
<BaseButton
|
|
v-if="moduleData.latest_module_version"
|
|
size="xl"
|
|
variant="primary-outline"
|
|
outline
|
|
:loading="isInstalling"
|
|
:disabled="isInstalling"
|
|
class="mr-4 flex items-center justify-center text-base"
|
|
@click="installModule()"
|
|
>
|
|
<BaseIcon v-if="!isInstalling" name="DownloadIcon" class="mr-2" />
|
|
{{ $t('modules.install') }}
|
|
</BaseButton>
|
|
</div>
|
|
|
|
<!-- Module already installed -->
|
|
<div v-else-if="isModuleInstalled">
|
|
<!-- When new module version is available -->
|
|
|
|
<div class="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
|
<BaseButton
|
|
v-if="moduleData.update_available"
|
|
variant="primary"
|
|
size="xl"
|
|
:loading="isInstalling"
|
|
:disabled="isInstalling"
|
|
class="mr-4 flex items-center justify-center text-base"
|
|
@click="installModule()"
|
|
>
|
|
{{ $t('modules.update_to') }}
|
|
<span class="ml-2">{{ moduleData.latest_module_version }}</span>
|
|
</BaseButton>
|
|
|
|
<BaseButton
|
|
v-if="moduleData.enabled"
|
|
variant="danger"
|
|
size="xl"
|
|
:loading="isDisabling"
|
|
:disabled="isDisabling"
|
|
class="mr-4 flex items-center justify-center text-base"
|
|
@click="disableModule"
|
|
>
|
|
<BaseIcon v-if="!isDisabling" name="BanIcon" class="mr-2" />
|
|
{{ $t('modules.disable') }}
|
|
</BaseButton>
|
|
<BaseButton
|
|
v-else
|
|
variant="primary-outline"
|
|
size="xl"
|
|
:loading="isEnabling"
|
|
:disabled="isEnabling"
|
|
class="mr-4 flex items-center justify-center text-base"
|
|
@click="enableModule"
|
|
>
|
|
<BaseIcon v-if="!isEnabling" name="CheckIcon" class="mr-2" />
|
|
{{ $t('modules.enable') }}
|
|
</BaseButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-10"></div>
|
|
|
|
<!-- HighLights -->
|
|
<div class="border-t border-gray-200 mt-10 pt-10">
|
|
<h3 class="text-sm font-medium text-gray-900">
|
|
{{ $t('modules.what_you_get') }}
|
|
</h3>
|
|
<div class="mt-4 prose prose-sm max-w-none text-gray-500">
|
|
<div
|
|
class="prose prose-sm max-w-none text-gray-500 text-sm"
|
|
v-html="moduleData.highlights"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="border-t border-gray-200 mt-10 pt-10">
|
|
<div
|
|
v-for="(link, key) in moduleData.links"
|
|
:key="key"
|
|
class="mb-4 last:mb-0 flex"
|
|
>
|
|
<BaseIcon :name="link.icon" class="mr-4" />
|
|
<a :href="link.link" class="text-primary-500" target="_blank">
|
|
{{ link.label }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<!-- Installation Steps -->
|
|
<div v-if="isInstalling" class="border-t border-gray-200 mt-10 pt-10">
|
|
<ul class="w-full p-0 list-none">
|
|
<li
|
|
v-for="step in installationSteps"
|
|
:key="step.stepUrl"
|
|
class="
|
|
flex
|
|
justify-between
|
|
w-full
|
|
py-3
|
|
border-b border-gray-200 border-solid
|
|
last:border-b-0
|
|
"
|
|
>
|
|
<p class="m-0 text-sm leading-8">
|
|
{{ $t(step.translationKey) }}
|
|
</p>
|
|
<div class="flex flex-row items-center">
|
|
<span v-if="step.time" class="mr-3 text-xs text-gray-500">
|
|
{{ step.time }}
|
|
</span>
|
|
<span
|
|
:class="statusClass(step)"
|
|
class="block py-1 text-sm text-center uppercase rounded-full"
|
|
style="width: 88px"
|
|
>
|
|
{{ getStatus(step) }}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Social Share -->
|
|
<!-- <div class="border-t border-gray-200 mt-10 pt-10">
|
|
<h3 class="text-sm font-medium text-gray-900">Share</h3>
|
|
<ul role="list" class="flex items-center space-x-6 mt-4">
|
|
<li>
|
|
<a
|
|
href="#"
|
|
class="
|
|
flex
|
|
items-center
|
|
justify-center
|
|
w-6
|
|
h-6
|
|
text-gray-400
|
|
hover:text-gray-500
|
|
"
|
|
>
|
|
<span class="sr-only">Share on Facebook</span>
|
|
<svg
|
|
class="w-5 h-5"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M20 10c0-5.523-4.477-10-10-10S0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.988C16.343 19.128 20 14.991 20 10z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
class="
|
|
flex
|
|
items-center
|
|
justify-center
|
|
w-6
|
|
h-6
|
|
text-gray-400
|
|
hover:text-gray-500
|
|
"
|
|
>
|
|
<span class="sr-only">Share on Instagram</span>
|
|
<svg
|
|
class="w-6 h-6"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
href="#"
|
|
class="
|
|
flex
|
|
items-center
|
|
justify-center
|
|
w-6
|
|
h-6
|
|
text-gray-400
|
|
hover:text-gray-500
|
|
"
|
|
>
|
|
<span class="sr-only">Share on Twitter</span>
|
|
<svg
|
|
class="w-5 h-5"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div> -->
|
|
</div>
|
|
|
|
<div
|
|
class="
|
|
w-full
|
|
max-w-2xl
|
|
mx-auto
|
|
mt-16
|
|
lg:max-w-none lg:mt-0 lg:col-span-4
|
|
"
|
|
>
|
|
<TabGroup as="div">
|
|
<TabList class="-mb-px flex space-x-8 border-b border-gray-200">
|
|
<Tab v-slot="{ selected }" as="template">
|
|
<button
|
|
:class="[
|
|
selected
|
|
? 'border-primary-600 text-primary-600'
|
|
: 'border-transparent text-gray-700 hover:text-gray-800 hover:border-gray-300',
|
|
'whitespace-nowrap py-6 border-b-2 font-medium text-sm',
|
|
]"
|
|
>
|
|
{{ $t('modules.customer_reviews') }}
|
|
</button>
|
|
</Tab>
|
|
<Tab v-slot="{ selected }" as="template">
|
|
<button
|
|
:class="[
|
|
selected
|
|
? 'border-primary-600 text-primary-600'
|
|
: 'border-transparent text-gray-700 hover:text-gray-800 hover:border-gray-300',
|
|
'whitespace-nowrap py-6 border-b-2 font-medium text-sm',
|
|
]"
|
|
>
|
|
{{ $t('modules.faq') }}
|
|
</button>
|
|
</Tab>
|
|
<Tab v-slot="{ selected }" as="template">
|
|
<button
|
|
:class="[
|
|
selected
|
|
? 'border-primary-600 text-primary-600'
|
|
: 'border-transparent text-gray-700 hover:text-gray-800 hover:border-gray-300',
|
|
'whitespace-nowrap py-6 border-b-2 font-medium text-sm',
|
|
]"
|
|
>
|
|
{{ $t('modules.license') }}
|
|
</button>
|
|
</Tab>
|
|
</TabList>
|
|
<TabPanels as="template">
|
|
<!-- Customer Reviews -->
|
|
<TabPanel class="-mb-10">
|
|
<h3 class="sr-only">Customer Reviews</h3>
|
|
<div v-if="moduleData.reviews.length">
|
|
<div
|
|
v-for="(review, reviewIdx) in moduleData.reviews"
|
|
:key="reviewIdx"
|
|
class="flex text-sm text-gray-500 space-x-4"
|
|
>
|
|
<div class="flex-none py-10">
|
|
<span
|
|
class="
|
|
inline-flex
|
|
items-center
|
|
justify-center
|
|
h-12
|
|
w-12
|
|
rounded-full
|
|
bg-gray-500
|
|
"
|
|
>
|
|
<span
|
|
class="
|
|
text-lg
|
|
font-medium
|
|
leading-none
|
|
text-white
|
|
uppercase
|
|
"
|
|
>{{ review.customer.name[0] }}</span
|
|
>
|
|
</span>
|
|
</div>
|
|
<div
|
|
:class="[
|
|
reviewIdx === 0 ? '' : 'border-t border-gray-200',
|
|
'py-10',
|
|
]"
|
|
>
|
|
<h3 class="font-medium text-gray-900">
|
|
{{ review.customer.name }}
|
|
</h3>
|
|
<p>
|
|
{{ moment(review.created_at).format('MMMM Do YYYY') }}
|
|
</p>
|
|
|
|
<div class="flex items-center mt-4">
|
|
<BaseRating :rating="review.rating" />
|
|
</div>
|
|
|
|
<div
|
|
class="mt-4 prose prose-sm max-w-none text-gray-500"
|
|
v-html="review.feedback"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="flex w-full items-center justify-center">
|
|
<p class="text-gray-500 mt-10 text-sm">
|
|
{{ $t('modules.no_reviews_found') }}
|
|
</p>
|
|
</div>
|
|
</TabPanel>
|
|
|
|
<!-- FAQs -->
|
|
<TabPanel as="dl" class="text-sm text-gray-500">
|
|
<h3 class="sr-only">Frequently Asked Questions</h3>
|
|
|
|
<template v-for="faq in moduleData.faq" :key="faq.question">
|
|
<dt class="mt-10 font-medium text-gray-900">
|
|
{{ faq.question }}
|
|
</dt>
|
|
<dd class="mt-2 prose prose-sm max-w-none text-gray-500">
|
|
<p>{{ faq.answer }}</p>
|
|
</dd>
|
|
</template>
|
|
</TabPanel>
|
|
|
|
<!-- License -->
|
|
<TabPanel class="pt-10">
|
|
<h3 class="sr-only">License</h3>
|
|
|
|
<div
|
|
class="prose prose-sm max-w-none text-gray-500"
|
|
v-html="moduleData.license"
|
|
/>
|
|
</TabPanel>
|
|
</TabPanels>
|
|
</TabGroup>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Other Modules -->
|
|
<div
|
|
v-if="otherModules && otherModules.length"
|
|
class="mt-24 sm:mt-32 lg:max-w-none"
|
|
>
|
|
<div class="flex items-center justify-between space-x-4">
|
|
<h2 class="text-lg font-medium text-gray-900">
|
|
{{ $t('modules.other_modules') }}
|
|
</h2>
|
|
<a
|
|
href="/admin/modules"
|
|
class="
|
|
whitespace-nowrap
|
|
text-sm
|
|
font-medium
|
|
text-primary-600
|
|
hover:text-primary-500
|
|
"
|
|
>{{ $t('modules.view_all')
|
|
}}<span aria-hidden="true"> →</span></a
|
|
>
|
|
</div>
|
|
<div
|
|
class="
|
|
mt-6
|
|
grid grid-cols-1
|
|
gap-x-8 gap-y-8
|
|
sm:grid-cols-2 sm:gap-y-10
|
|
lg:grid-cols-4
|
|
"
|
|
>
|
|
<div v-for="(other, moduleIdx) in otherModules" :key="moduleIdx">
|
|
<RecentModuleCard :data="other" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6"></div>
|
|
</BasePage>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/vue'
|
|
import {
|
|
RadioGroup,
|
|
RadioGroupDescription,
|
|
RadioGroupLabel,
|
|
RadioGroupOption,
|
|
} from '@headlessui/vue'
|
|
import { useModuleStore } from '@/scripts/admin/stores/module'
|
|
import { computed, onMounted, ref, watch, reactive } from 'vue'
|
|
import { required, minLength, maxLength, helpers } from '@vuelidate/validators'
|
|
import { useVuelidate } from '@vuelidate/core'
|
|
import { useRoute } from 'vue-router'
|
|
import { useDialogStore } from '@/scripts/stores/dialog'
|
|
import { useI18n } from 'vue-i18n'
|
|
import moment from 'moment'
|
|
import axios from 'axios'
|
|
import ModulePlaceholder from './partials/ModulePlaceholder.vue'
|
|
import RecentModuleCard from './partials/RecentModuleCard.vue'
|
|
import { useNotificationStore } from '@/scripts/stores/notification'
|
|
import { useGlobalStore } from '@/scripts/admin/stores/global'
|
|
const globalStore = useGlobalStore()
|
|
|
|
const moduleStore = useModuleStore()
|
|
const notificationStore = useNotificationStore()
|
|
const dialogStore = useDialogStore()
|
|
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
let isInstalling = ref(false)
|
|
let isFetchingInitialData = ref(true)
|
|
let displayImage = ref('')
|
|
let isEnabling = ref(false)
|
|
let isDisabling = ref(false)
|
|
let isUpdating = ref(false)
|
|
|
|
loadData()
|
|
|
|
watch(
|
|
() => route.params.slug,
|
|
async (newSlug) => {
|
|
loadData()
|
|
}
|
|
)
|
|
|
|
const moduleData = computed(() => {
|
|
return moduleStore.currentModule.data
|
|
})
|
|
|
|
const modulePrice = computed(() => {
|
|
let priceList = []
|
|
|
|
let monthlyPrice = reactive({
|
|
name: t('modules.monthly'),
|
|
price: moduleData?.value?.monthly_price / 100,
|
|
})
|
|
|
|
let yearlyPrice = reactive({
|
|
name: t('modules.yearly'),
|
|
price: moduleData?.value?.yearly_price / 100,
|
|
})
|
|
|
|
if (typeYearly.value) {
|
|
priceList.push(yearlyPrice)
|
|
} else if (typeMonthly.value) {
|
|
priceList.push(monthlyPrice)
|
|
} else {
|
|
priceList.push(monthlyPrice)
|
|
priceList.push(yearlyPrice)
|
|
}
|
|
|
|
return priceList
|
|
})
|
|
|
|
const typeYearly = computed(() => {
|
|
if (moduleData.value) {
|
|
return moduleData.value.type === 'YEARLY'
|
|
}
|
|
|
|
return false
|
|
})
|
|
|
|
const typeMonthly = computed(() => {
|
|
if (moduleData.value) {
|
|
return moduleData.value.type === 'MONTHLY'
|
|
}
|
|
|
|
return false
|
|
})
|
|
|
|
const isModuleInstalled = computed(() => {
|
|
if (moduleData.value.installed && moduleData.value.latest_module_version) {
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
|
|
const otherModules = computed(() => {
|
|
return moduleStore.currentModule.meta.modules
|
|
})
|
|
|
|
let updatedAt = computed(() => {
|
|
let latest = ref(moduleData.value.latest_module_version_updated_at)
|
|
let installed = ref(moduleData.value.installed_module_version_updated_at)
|
|
|
|
const date = installed.value ? installed.value : latest.value
|
|
|
|
return moment(date).format('MMMM Do YYYY')
|
|
})
|
|
|
|
let moduleVersion = computed(() => {
|
|
let latest = ref(moduleData.value.latest_module_version)
|
|
let installed = ref(moduleData.value.installed_module_version)
|
|
|
|
let data = installed.value ? installed.value : latest.value
|
|
|
|
return data
|
|
})
|
|
|
|
let averageRating = computed(() => {
|
|
return parseInt(moduleData.value.average_rating)
|
|
})
|
|
|
|
const displayImages = computed(() => {
|
|
let images = reactive([])
|
|
|
|
let cover = reactive({
|
|
id: null,
|
|
url: moduleData.value.cover,
|
|
})
|
|
|
|
images.push(cover)
|
|
|
|
if (moduleData.value.screenshots) {
|
|
moduleData.value.screenshots.forEach((image) => {
|
|
images.push(image)
|
|
})
|
|
}
|
|
|
|
return images
|
|
})
|
|
|
|
const displayVideo = ref(false)
|
|
|
|
const thumbnail = ref(null)
|
|
|
|
const videoUrl = ref(null)
|
|
|
|
const selectedPlan = ref(modulePrice.value[0])
|
|
|
|
const installationSteps = reactive([
|
|
{
|
|
translationKey: 'modules.download_zip_file',
|
|
stepUrl: '/api/v1/modules/download',
|
|
time: null,
|
|
started: false,
|
|
completed: false,
|
|
},
|
|
{
|
|
translationKey: 'modules.unzipping_package',
|
|
stepUrl: '/api/v1/modules/unzip',
|
|
time: null,
|
|
started: false,
|
|
completed: false,
|
|
},
|
|
{
|
|
translationKey: 'modules.copying_files',
|
|
stepUrl: '/api/v1/modules/copy',
|
|
time: null,
|
|
started: false,
|
|
completed: false,
|
|
},
|
|
{
|
|
translationKey: 'modules.completing_installation',
|
|
stepUrl: '/api/v1/modules/complete',
|
|
time: null,
|
|
started: false,
|
|
completed: false,
|
|
},
|
|
])
|
|
|
|
async function installModule() {
|
|
let path = null
|
|
|
|
for (let index = 0; index < installationSteps.length; index++) {
|
|
let currentStep = installationSteps[index]
|
|
|
|
try {
|
|
isInstalling.value = true
|
|
currentStep.started = true
|
|
let updateParams = {
|
|
version: moduleData.value.latest_module_version,
|
|
path: path || null,
|
|
module: moduleData.value.module_name,
|
|
}
|
|
|
|
let requestResponse = await axios.post(currentStep.stepUrl, updateParams)
|
|
|
|
currentStep.completed = true
|
|
if (requestResponse.data) {
|
|
path = requestResponse.data.path
|
|
}
|
|
|
|
if (!requestResponse.data.success) {
|
|
let displayMsg = ref('')
|
|
|
|
if (
|
|
requestResponse.data.message === 'crater_version_is_not_supported'
|
|
) {
|
|
displayMsg.value = t('modules.version_not_supported', {
|
|
version: requestResponse.data.min_crater_version,
|
|
})
|
|
} else {
|
|
displayMsg.value = getErrorMessage(requestResponse.data.message)
|
|
}
|
|
|
|
notificationStore.showNotification({
|
|
type: 'error',
|
|
message: displayMsg.value,
|
|
})
|
|
|
|
isInstalling.value = false
|
|
currentStep.started = false
|
|
currentStep.completed = true
|
|
return false
|
|
}
|
|
if (currentStep.translationKey == 'modules.completing_installation') {
|
|
isInstalling.value = false
|
|
notificationStore.showNotification({
|
|
type: 'success',
|
|
message: t('modules.install_success'),
|
|
})
|
|
|
|
setTimeout(() => {
|
|
location.reload()
|
|
}, 1500)
|
|
}
|
|
} catch (error) {
|
|
isInstalling.value = false
|
|
currentStep.started = false
|
|
currentStep.completed = true
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
function getErrorMessage(message) {
|
|
let msg = ref('')
|
|
|
|
switch (message) {
|
|
case 'module_not_found':
|
|
msg = t('modules.module_not_found')
|
|
break
|
|
|
|
case 'module_not_purchased':
|
|
msg = t('modules.module_not_purchased')
|
|
break
|
|
|
|
case 'version_not_supported':
|
|
msg = t('modules.version_not_supported')
|
|
break
|
|
|
|
default:
|
|
msg = message
|
|
break
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
async function loadData() {
|
|
if (!route.params.slug) {
|
|
return
|
|
}
|
|
|
|
isFetchingInitialData.value = true
|
|
await moduleStore.fetchModule(route.params.slug).then((response) => {
|
|
selectedPlan.value = modulePrice.value[0]
|
|
|
|
videoUrl.value = moduleData.value.video_link
|
|
thumbnail.value = moduleData.value.video_thumbnail
|
|
|
|
if (videoUrl.value) {
|
|
setDisplayVideo()
|
|
isFetchingInitialData.value = false
|
|
return
|
|
}
|
|
displayImage.value = moduleData.value.cover
|
|
isFetchingInitialData.value = false
|
|
return
|
|
})
|
|
}
|
|
|
|
function statusClass(step) {
|
|
const status = getStatus(step)
|
|
|
|
switch (status) {
|
|
case 'pending':
|
|
return 'text-primary-800 bg-gray-200'
|
|
case 'finished':
|
|
return 'text-teal-500 bg-teal-100'
|
|
case 'running':
|
|
return 'text-blue-400 bg-blue-100'
|
|
case 'error':
|
|
return 'text-danger bg-red-200'
|
|
default:
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function disableModule() {
|
|
dialogStore
|
|
.openDialog({
|
|
title: t('general.are_you_sure'),
|
|
message: t('modules.disable_warning'),
|
|
yesLabel: t('general.ok'),
|
|
noLabel: t('general.cancel'),
|
|
variant: 'danger',
|
|
hideNoButton: false,
|
|
size: 'lg',
|
|
})
|
|
.then(async (res) => {
|
|
if (res) {
|
|
isDisabling.value = true
|
|
await moduleStore
|
|
.disableModule(moduleData.value.module_name)
|
|
.then((res) => {
|
|
if (res.data.success) {
|
|
moduleData.value.enabled = 0
|
|
isDisabling.value = false
|
|
|
|
setTimeout(() => {
|
|
location.reload()
|
|
}, 1500)
|
|
return
|
|
}
|
|
})
|
|
isDisabling.value = false
|
|
return
|
|
}
|
|
})
|
|
}
|
|
|
|
async function enableModule() {
|
|
isEnabling.value = true
|
|
|
|
await moduleStore.enableModule(moduleData.value.module_name).then((res) => {
|
|
if (res.data.success) {
|
|
moduleData.value.enabled = 1
|
|
|
|
setTimeout(() => {
|
|
location.reload()
|
|
}, 1500)
|
|
}
|
|
isEnabling.value = false
|
|
return
|
|
})
|
|
isEnabling.value = false
|
|
return
|
|
}
|
|
|
|
function getStatus(step) {
|
|
if (step.started && step.completed) {
|
|
return 'finished'
|
|
} else if (step.started && !step.completed) {
|
|
return 'running'
|
|
} else if (!step.started && !step.completed) {
|
|
return 'pending'
|
|
} else {
|
|
return 'error'
|
|
}
|
|
}
|
|
|
|
function setDisplayImage(url) {
|
|
displayVideo.value = false
|
|
displayImage.value = url
|
|
}
|
|
|
|
function setDisplayVideo() {
|
|
displayVideo.value = true
|
|
displayImage.value = null
|
|
}
|
|
</script>
|