mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-07 13:41:23 +00:00
* refactor: add HTTP client wrapper and upgrade axios to v1 Introduce a thin HTTP wrapper (resources/scripts/http) that centralizes axios configuration, interceptors, and auth header injection. All 43 files now import from the wrapper instead of axios directly, making future library swaps a single-file change. Upgrade axios from 0.30.0 to 1.14.0. * fix: restore window.Ls assignment removed during axios refactor company.js uses window.Ls.set() to persist selected company, which broke after the axios plugin (that set window.Ls) was deleted.
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="ArrowDownTrayIcon" 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="NoSymbolIcon" 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 http from '@/scripts/http'
|
|
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 http.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 === 'invoiceshelf_version_is_not_supported'
|
|
) {
|
|
displayMsg.value = t('modules.version_not_supported', {
|
|
version: requestResponse.data.min_invoiceshelf_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>
|