mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 17:24:10 +00:00
feat(modules): relocate marketplace browser to super-admin context
The module marketplace browser UI (ModuleIndexView, ModuleDetailView,
ModuleCard, the four-step installer store) was filed under
features/company/modules/ only by historical accident — it's authorized via
the manage modules ability (super-admin-only) and conceptually belongs in the
admin context, not the company context.
- Move features/company/modules/{store.ts, views/ModuleIndexView.vue,
views/ModuleDetailView.vue, components/ModuleCard.vue} to
features/admin/modules/.
- Update hardcoded /admin/modules/... paths in the moved files to
/admin/administration/modules/... so the breadcrumbs and ModuleCard
navigation target the new admin-context routes.
- Tighten the four-step installer's silent catch {} blocks in the moved
store.ts: errors were being swallowed, now they dispatch through the
global notification store instead.
- New features/admin/modules/routes.ts declares admin.modules.index +
admin.modules.view as children of /admin/administration with
meta.isSuperAdmin: true.
- features/admin/{index,routes}.ts re-export and mount the relocated routes.
- config/invoiceshelf.php gains a new AdminModules entry in admin_menu
pointing at /admin/administration/modules with super_admin_only: true.
- The dev-gated navigation.modules entry in main_menu is replaced (not
deleted) with a non-gated entry pointing at the new company-context
Active Modules index page that lands in the next commit. The
ability is set to manage modules so non-owners can't see it.
The new company-context Active Modules index, schema-driven settings page,
and dynamic sidebar group are introduced in subsequent commits.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative shadow-md border-2 border-line-default/60 rounded-lg cursor-pointer overflow-hidden h-100"
|
||||
@click="$router.push(`/admin/administration/modules/${data.slug}`)"
|
||||
>
|
||||
<div
|
||||
v-if="data.purchased"
|
||||
class="absolute mt-5 px-6 w-full flex justify-end"
|
||||
>
|
||||
<label
|
||||
v-if="data.purchased"
|
||||
class="bg-white/75 text-xs px-3 py-1 font-semibold tracking-wide rounded"
|
||||
>
|
||||
{{ $t('modules.purchased') }}
|
||||
</label>
|
||||
<label
|
||||
v-if="data.installed"
|
||||
class="ml-2 bg-white/75 text-xs px-3 py-1 font-semibold tracking-wide rounded"
|
||||
>
|
||||
<span v-if="data.update_available">
|
||||
{{ $t('modules.update_available') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('modules.installed') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<img
|
||||
class="lg:h-64 md:h-48 w-full object-cover object-center"
|
||||
:src="data.cover ?? ''"
|
||||
alt="cover"
|
||||
/>
|
||||
|
||||
<div class="px-6 py-5 flex flex-col bg-surface-secondary flex-1 justify-between">
|
||||
<span class="text-lg sm:text-2xl font-medium whitespace-nowrap truncate text-primary-500">
|
||||
{{ data.name }}
|
||||
</span>
|
||||
|
||||
<div v-if="data.author_avatar" class="flex items-center mt-2">
|
||||
<img
|
||||
class="hidden h-10 w-10 rounded-full sm:inline-block mr-2"
|
||||
:src="data.author_avatar"
|
||||
alt=""
|
||||
/>
|
||||
<span>by</span>
|
||||
<span class="ml-2 text-base font-semibold truncate">
|
||||
{{ data.author_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<base-text
|
||||
:text="data.short_description ?? ''"
|
||||
class="pt-4 text-muted h-16 line-clamp-2"
|
||||
/>
|
||||
|
||||
<div class="flex justify-between mt-4 flex-col space-y-2 sm:space-y-0 sm:flex-row">
|
||||
<div>
|
||||
<BaseRating :rating="averageRating" />
|
||||
</div>
|
||||
<div class="text-xl md:text-2xl font-semibold whitespace-nowrap text-primary-500">
|
||||
$
|
||||
{{ data.monthly_price ? data.monthly_price / 100 : (data.yearly_price ?? 0) / 100 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Module } from '../../../../types/domain/module'
|
||||
|
||||
interface Props {
|
||||
data: Module
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const averageRating = computed<number>(() => {
|
||||
return parseInt(String(props.data.average_rating ?? 0), 10)
|
||||
})
|
||||
</script>
|
||||
17
resources/scripts/features/admin/modules/index.ts
Normal file
17
resources/scripts/features/admin/modules/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { adminModuleRoutes } from './routes'
|
||||
|
||||
export { useModuleStore } from './store'
|
||||
export type {
|
||||
ModuleState,
|
||||
ModuleStore,
|
||||
ModuleDetailResponse,
|
||||
ModuleDetailMeta,
|
||||
InstallationStep,
|
||||
} from './store'
|
||||
|
||||
// Views
|
||||
export { default as ModuleIndexView } from './views/ModuleIndexView.vue'
|
||||
export { default as ModuleDetailView } from './views/ModuleDetailView.vue'
|
||||
|
||||
// Components
|
||||
export { default as ModuleCard } from './components/ModuleCard.vue'
|
||||
34
resources/scripts/features/admin/modules/routes.ts
Normal file
34
resources/scripts/features/admin/modules/routes.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const ModuleIndexView = () => import('./views/ModuleIndexView.vue')
|
||||
const ModuleDetailView = () => import('./views/ModuleDetailView.vue')
|
||||
|
||||
/**
|
||||
* Super-admin marketplace browser routes.
|
||||
*
|
||||
* These are mounted as children of `/admin/administration` in features/admin/routes.ts,
|
||||
* meaning they require `meta.isSuperAdmin` and the admin-mode bootstrap.
|
||||
*
|
||||
* Company-context module routes (the read-only Active Modules index and the
|
||||
* schema-rendered settings page) live in features/company/modules/routes.ts.
|
||||
*/
|
||||
export const adminModuleRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'admin.modules.index',
|
||||
component: ModuleIndexView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
title: 'modules.title',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'modules/:slug',
|
||||
name: 'admin.modules.view',
|
||||
component: ModuleDetailView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
title: 'modules.title',
|
||||
},
|
||||
},
|
||||
]
|
||||
193
resources/scripts/features/admin/modules/store.ts
Normal file
193
resources/scripts/features/admin/modules/store.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { moduleService } from '../../../api/services/module.service'
|
||||
import type {
|
||||
Module,
|
||||
ModuleReview,
|
||||
ModuleFaq,
|
||||
ModuleLink,
|
||||
ModuleScreenshot,
|
||||
} from '../../../types/domain/module'
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export interface ModuleDetailMeta {
|
||||
modules: Module[]
|
||||
}
|
||||
|
||||
export interface ModuleDetailResponse {
|
||||
data: Module
|
||||
meta: ModuleDetailMeta
|
||||
}
|
||||
|
||||
export interface InstallationStep {
|
||||
translationKey: string
|
||||
stepUrl: string
|
||||
time: string | null
|
||||
started: boolean
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Store
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export interface ModuleState {
|
||||
currentModule: ModuleDetailResponse | null
|
||||
modules: Module[]
|
||||
apiToken: string | null
|
||||
currentUser: {
|
||||
api_token: string | null
|
||||
}
|
||||
enableModules: string[]
|
||||
}
|
||||
|
||||
export const useModuleStore = defineStore('modules', {
|
||||
state: (): ModuleState => ({
|
||||
currentModule: null,
|
||||
modules: [],
|
||||
apiToken: null,
|
||||
currentUser: {
|
||||
api_token: null,
|
||||
},
|
||||
enableModules: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
salesTaxUSEnabled: (state): boolean =>
|
||||
state.enableModules.includes('SalesTaxUS'),
|
||||
|
||||
installedModules: (state): Module[] =>
|
||||
state.modules.filter((m) => m.installed),
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchModules(): Promise<void> {
|
||||
const response = await moduleService.list()
|
||||
this.modules = response.data
|
||||
},
|
||||
|
||||
async fetchModule(slug: string): Promise<ModuleDetailResponse> {
|
||||
const response = await moduleService.get(slug)
|
||||
const data = response as unknown as ModuleDetailResponse
|
||||
|
||||
if ((data as Record<string, unknown>).error === 'invalid_token') {
|
||||
this.currentModule = null
|
||||
this.modules = []
|
||||
this.apiToken = null
|
||||
this.currentUser.api_token = null
|
||||
return data
|
||||
}
|
||||
|
||||
this.currentModule = data
|
||||
return data
|
||||
},
|
||||
|
||||
async checkApiToken(token: string): Promise<{ success: boolean; error?: string }> {
|
||||
const response = await moduleService.checkToken(token)
|
||||
return {
|
||||
success: response.success ?? false,
|
||||
error: response.error,
|
||||
}
|
||||
},
|
||||
|
||||
async disableModule(moduleName: string): Promise<{ success: boolean }> {
|
||||
return moduleService.disable(moduleName)
|
||||
},
|
||||
|
||||
async enableModule(moduleName: string): Promise<{ success: boolean }> {
|
||||
return moduleService.enable(moduleName)
|
||||
},
|
||||
|
||||
async installModule(
|
||||
moduleName: string,
|
||||
version: string,
|
||||
onStepUpdate?: (step: InstallationStep) => void,
|
||||
): Promise<boolean> {
|
||||
const steps: InstallationStep[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
let path: string | null = null
|
||||
|
||||
for (const step of steps) {
|
||||
step.started = true
|
||||
onStepUpdate?.(step)
|
||||
|
||||
try {
|
||||
const stepFns: Record<string, () => Promise<Record<string, unknown>>> = {
|
||||
'/api/v1/modules/download': () =>
|
||||
moduleService.download({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/unzip': () =>
|
||||
moduleService.unzip({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/copy': () =>
|
||||
moduleService.copy({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/complete': () =>
|
||||
moduleService.complete({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
}
|
||||
|
||||
const result = await stepFns[step.stepUrl]()
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
|
||||
if ((result as Record<string, unknown>).path) {
|
||||
path = (result as Record<string, unknown>).path as string
|
||||
}
|
||||
|
||||
if (!(result as Record<string, unknown>).success) {
|
||||
const message = (result as Record<string, unknown>).error
|
||||
if (typeof message === 'string') {
|
||||
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||
useNotificationStore().showNotification({
|
||||
type: 'error',
|
||||
message,
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||
useNotificationStore().showNotification({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : 'Module installation failed',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type ModuleStore = ReturnType<typeof useModuleStore>
|
||||
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<div v-if="isFetchingInitialData" class="p-8">
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div class="h-8 bg-surface-tertiary rounded w-1/3" />
|
||||
<div class="h-64 bg-surface-tertiary rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BasePage v-else-if="moduleData" class="bg-surface">
|
||||
<BasePageHeader :title="moduleData.name">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/administration/modules" />
|
||||
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<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">
|
||||
<!-- Image Gallery -->
|
||||
<div class="lg:row-end-1 lg:col-span-4">
|
||||
<div class="flex flex-col-reverse">
|
||||
<!-- Thumbnails -->
|
||||
<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" role="tablist">
|
||||
<button
|
||||
v-if="thumbnail && videoUrl"
|
||||
:class="[
|
||||
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
|
||||
{ 'outline-hidden ring-3 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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="(screenshot, ssIdx) in displayImages"
|
||||
:key="ssIdx"
|
||||
:class="[
|
||||
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
|
||||
{ 'outline-hidden ring-3 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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Main Image -->
|
||||
<div
|
||||
v-else
|
||||
class="aspect-w-4 aspect-h-3 rounded-lg bg-surface-tertiary overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="displayImage ?? ''"
|
||||
alt="Module Images"
|
||||
class="w-full h-full object-center object-cover sm:rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<!-- Rating -->
|
||||
<div class="flex items-center">
|
||||
<BaseRating :rating="averageRating" />
|
||||
</div>
|
||||
|
||||
<!-- Name & Version -->
|
||||
<div class="flex flex-col-reverse">
|
||||
<div class="mt-4">
|
||||
<h1 class="text-2xl font-extrabold tracking-tight text-heading sm:text-3xl">
|
||||
{{ moduleData.name }}
|
||||
</h1>
|
||||
<p v-if="moduleData.latest_module_version" class="text-sm text-muted mt-2">
|
||||
{{ $t('modules.version') }}
|
||||
{{ moduleVersion }} ({{ $t('modules.last_updated') }}
|
||||
{{ updatedAt }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div
|
||||
class="prose prose-sm max-w-none text-muted text-sm my-10"
|
||||
v-html="moduleData.long_description"
|
||||
/>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div v-if="!moduleData.purchased">
|
||||
<a
|
||||
:href="buyLink"
|
||||
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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Not installed yet -->
|
||||
<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"
|
||||
:loading="isInstalling"
|
||||
:disabled="isInstalling"
|
||||
class="mr-4 flex items-center justify-center text-base"
|
||||
@click="handleInstall"
|
||||
>
|
||||
<BaseIcon v-if="!isInstalling" name="ArrowDownTrayIcon" class="mr-2" />
|
||||
{{ $t('modules.install') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Already installed -->
|
||||
<div v-else-if="isModuleInstalled" 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="handleInstall"
|
||||
>
|
||||
{{ $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="handleDisable"
|
||||
>
|
||||
<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="handleEnable"
|
||||
>
|
||||
<BaseIcon v-if="!isEnabling" name="CheckIcon" class="mr-2" />
|
||||
{{ $t('modules.enable') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlights -->
|
||||
<div v-if="moduleData.highlights" class="border-t border-line-default mt-10 pt-10">
|
||||
<h3 class="text-sm font-medium text-heading">
|
||||
{{ $t('modules.what_you_get') }}
|
||||
</h3>
|
||||
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="moduleData.highlights" />
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div v-if="moduleData.links?.length" class="border-t border-line-default mt-10 pt-10">
|
||||
<div
|
||||
v-for="(link, key) in moduleData.links"
|
||||
:key="key"
|
||||
class="mb-4 last:mb-0 flex"
|
||||
>
|
||||
<BaseIcon :name="(link as ModuleLinkItem).icon ?? ''" class="mr-4" />
|
||||
<a :href="(link as ModuleLinkItem).link" class="text-primary-500" target="_blank">
|
||||
{{ (link as ModuleLinkItem).label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation Steps -->
|
||||
<div v-if="isInstalling" class="border-t border-line-default mt-10 pt-10">
|
||||
<ul class="w-full p-0 list-none">
|
||||
<li
|
||||
v-for="step in installationSteps"
|
||||
:key="step.translationKey"
|
||||
class="flex justify-between w-full py-3 border-b border-line-default border-solid last:border-b-0"
|
||||
>
|
||||
<p class="m-0 text-sm leading-8">{{ $t(step.translationKey) }}</p>
|
||||
<span
|
||||
:class="stepStatusClass(step)"
|
||||
class="block py-1 text-sm text-center uppercase rounded-full"
|
||||
style="width: 88px"
|
||||
>
|
||||
{{ stepStatusLabel(step) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Reviews, FAQ, License -->
|
||||
<div class="w-full max-w-2xl mx-auto mt-16 lg:max-w-none lg:mt-0 lg:col-span-4">
|
||||
<!-- Simple tab implementation -->
|
||||
<div class="-mb-px flex space-x-8 border-b border-line-default">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:class="[
|
||||
activeTab === tab.key
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-body hover:text-heading hover:border-line-strong',
|
||||
'whitespace-nowrap py-6 border-b-2 font-medium text-sm',
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reviews -->
|
||||
<div v-if="activeTab === 'reviews'" class="-mb-10">
|
||||
<div v-if="moduleData.reviews?.length">
|
||||
<div
|
||||
v-for="(review, reviewIdx) in moduleData.reviews"
|
||||
:key="reviewIdx"
|
||||
class="flex text-sm text-muted space-x-4"
|
||||
>
|
||||
<div class="flex-none py-10">
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full bg-surface-secondary">
|
||||
<span class="text-lg font-medium leading-none text-white uppercase">
|
||||
{{ review.user?.[0] ?? '?' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div :class="[reviewIdx === 0 ? '' : 'border-t border-line-default', 'py-10']">
|
||||
<h3 class="font-medium text-heading">{{ review.user }}</h3>
|
||||
<p>{{ formatDate(review.created_at) }}</p>
|
||||
<div class="flex items-center mt-4">
|
||||
<BaseRating :rating="review.rating" />
|
||||
</div>
|
||||
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="review.comment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex w-full items-center justify-center">
|
||||
<p class="text-muted mt-10 text-sm">{{ $t('modules.no_reviews_found') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ -->
|
||||
<dl v-if="activeTab === 'faq'" class="text-sm text-muted">
|
||||
<template v-for="faq in moduleData.faq" :key="faq.question">
|
||||
<dt class="mt-10 font-medium text-heading">{{ faq.question }}</dt>
|
||||
<dd class="mt-2 prose prose-sm max-w-none text-muted">
|
||||
<p>{{ faq.answer }}</p>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<!-- License -->
|
||||
<div v-if="activeTab === 'license'" class="pt-10">
|
||||
<div class="prose prose-sm max-w-none text-muted" v-html="moduleData.license" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Modules -->
|
||||
<div v-if="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-heading">{{ $t('modules.other_modules') }}</h2>
|
||||
<a
|
||||
href="/admin/administration/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">
|
||||
<ModuleCard :data="other" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6" />
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModuleStore } from '../store'
|
||||
import type { InstallationStep } from '../store'
|
||||
import ModuleCard from '../components/ModuleCard.vue'
|
||||
import { useDialogStore } from '../../../../stores/dialog.store'
|
||||
import type { Module, ModuleLink } from '../../../../types/domain/module'
|
||||
|
||||
interface ModuleLinkItem {
|
||||
icon: string
|
||||
link: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TabItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const moduleStore = useModuleStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isFetchingInitialData = ref<boolean>(true)
|
||||
const isInstalling = ref<boolean>(false)
|
||||
const isEnabling = ref<boolean>(false)
|
||||
const isDisabling = ref<boolean>(false)
|
||||
const displayImage = ref<string | null>('')
|
||||
const displayVideo = ref<boolean>(false)
|
||||
const thumbnail = ref<string | null>(null)
|
||||
const videoUrl = ref<string | null>(null)
|
||||
const activeTab = ref<string>('reviews')
|
||||
|
||||
const installationSteps = reactive<InstallationStep[]>([])
|
||||
|
||||
const tabs = computed<TabItem[]>(() => [
|
||||
{ key: 'reviews', label: t('modules.customer_reviews') },
|
||||
{ key: 'faq', label: t('modules.faq') },
|
||||
{ key: 'license', label: t('modules.license') },
|
||||
])
|
||||
|
||||
const moduleData = computed<Module | undefined>(() => {
|
||||
return moduleStore.currentModule?.data
|
||||
})
|
||||
|
||||
const otherModules = computed<Module[] | undefined>(() => {
|
||||
return moduleStore.currentModule?.meta?.modules
|
||||
})
|
||||
|
||||
const averageRating = computed<number>(() => {
|
||||
return parseInt(String(moduleData.value?.average_rating ?? 0), 10)
|
||||
})
|
||||
|
||||
const isModuleInstalled = computed<boolean>(() => {
|
||||
return !!(moduleData.value?.installed && moduleData.value?.latest_module_version)
|
||||
})
|
||||
|
||||
const moduleVersion = computed<string>(() => {
|
||||
return moduleData.value?.installed_module_version ?? moduleData.value?.latest_module_version ?? ''
|
||||
})
|
||||
|
||||
const updatedAt = computed<string>(() => {
|
||||
const date =
|
||||
moduleData.value?.installed_module_version_updated_at ??
|
||||
moduleData.value?.latest_module_version_updated_at
|
||||
return date ? formatDate(date) : ''
|
||||
})
|
||||
|
||||
const displayImages = computed<Array<{ url: string }>>(() => {
|
||||
const images: Array<{ url: string }> = []
|
||||
if (moduleData.value?.cover) {
|
||||
images.push({ url: moduleData.value.cover })
|
||||
}
|
||||
if (moduleData.value?.screenshots) {
|
||||
moduleData.value.screenshots.forEach((s) => images.push({ url: s.url }))
|
||||
}
|
||||
return images
|
||||
})
|
||||
|
||||
const buyLink = computed<string>(() => {
|
||||
return `/modules/${moduleData.value?.slug ?? ''}`
|
||||
})
|
||||
|
||||
watch(() => route.params.slug, () => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const slug = route.params.slug as string
|
||||
if (!slug) return
|
||||
|
||||
isFetchingInitialData.value = true
|
||||
await moduleStore.fetchModule(slug)
|
||||
|
||||
videoUrl.value = moduleData.value?.video_link ?? null
|
||||
thumbnail.value = moduleData.value?.video_thumbnail ?? null
|
||||
|
||||
if (videoUrl.value) {
|
||||
setDisplayVideo()
|
||||
} else {
|
||||
displayImage.value = moduleData.value?.cover ?? null
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
|
||||
async function handleInstall(): Promise<void> {
|
||||
if (!moduleData.value) return
|
||||
|
||||
installationSteps.length = 0
|
||||
isInstalling.value = true
|
||||
|
||||
const success = await moduleStore.installModule(
|
||||
moduleData.value.module_name,
|
||||
moduleData.value.latest_module_version,
|
||||
(step) => {
|
||||
const existing = installationSteps.find(
|
||||
(s) => s.translationKey === step.translationKey,
|
||||
)
|
||||
if (existing) {
|
||||
Object.assign(existing, step)
|
||||
} else {
|
||||
installationSteps.push({ ...step })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
isInstalling.value = false
|
||||
|
||||
if (success) {
|
||||
setTimeout(() => location.reload(), 1500)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisable(): void {
|
||||
if (!moduleData.value) return
|
||||
|
||||
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: boolean) => {
|
||||
if (res) {
|
||||
isDisabling.value = true
|
||||
const response = await moduleStore.disableModule(moduleData.value!.module_name)
|
||||
isDisabling.value = false
|
||||
|
||||
if (response.success) {
|
||||
setTimeout(() => location.reload(), 1500)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleEnable(): Promise<void> {
|
||||
if (!moduleData.value) return
|
||||
|
||||
isEnabling.value = true
|
||||
const res = await moduleStore.enableModule(moduleData.value.module_name)
|
||||
isEnabling.value = false
|
||||
|
||||
if (res.success) {
|
||||
setTimeout(() => location.reload(), 1500)
|
||||
}
|
||||
}
|
||||
|
||||
function setDisplayImage(url: string): void {
|
||||
displayVideo.value = false
|
||||
displayImage.value = url
|
||||
}
|
||||
|
||||
function setDisplayVideo(): void {
|
||||
displayVideo.value = true
|
||||
displayImage.value = null
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function stepStatusClass(step: InstallationStep): string {
|
||||
const status = stepStatusLabel(step)
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'text-primary-800 bg-surface-muted'
|
||||
case 'finished':
|
||||
return 'text-teal-500 bg-teal-100'
|
||||
case 'running':
|
||||
return 'text-blue-400 bg-blue-100'
|
||||
default:
|
||||
return 'text-danger bg-red-200'
|
||||
}
|
||||
}
|
||||
|
||||
function stepStatusLabel(step: InstallationStep): string {
|
||||
if (step.started && step.completed) return 'finished'
|
||||
if (step.started && !step.completed) return 'running'
|
||||
return 'pending'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<BasePage>
|
||||
<BasePageHeader :title="$t('modules.title')">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||
<BaseBreadcrumbItem :title="$t('modules.module', 2)" to="#" active />
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<!-- Connected: module listing -->
|
||||
<div v-if="hasApiToken && moduleStore.modules">
|
||||
<BaseTabGroup @change="setStatusFilter">
|
||||
<BaseTab :title="$t('general.all')" filter="" />
|
||||
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
|
||||
</BaseTabGroup>
|
||||
|
||||
<!-- Placeholder -->
|
||||
<div
|
||||
v-if="isFetchingModule"
|
||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div v-for="n in 3" :key="n" class="h-80 bg-surface-tertiary rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- Module Cards -->
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="filteredModules.length"
|
||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div v-for="(mod, idx) in filteredModules" :key="idx">
|
||||
<ModuleCard :data="mod" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-24">
|
||||
<label class="flex items-center justify-center text-muted">
|
||||
{{ $t('modules.no_modules_installed') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not connected: API token form -->
|
||||
<BaseCard v-else class="mt-6">
|
||||
<h6 class="text-heading text-lg font-medium">
|
||||
{{ $t('modules.connect_installation') }}
|
||||
</h6>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ $t('modules.api_token_description', { url: baseUrlDisplay }) }}
|
||||
</p>
|
||||
|
||||
<div class="grid lg:grid-cols-2 mt-6">
|
||||
<form class="mt-6" @submit.prevent="submitApiToken">
|
||||
<BaseInputGroup
|
||||
:label="$t('modules.api_token')"
|
||||
required
|
||||
:error="v$.api_token.$error ? String(v$.api_token.$errors[0]?.$message) : undefined"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="moduleStore.currentUser.api_token"
|
||||
:invalid="v$.api_token.$error"
|
||||
@input="v$.api_token.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<BaseButton class="mt-6" :loading="isSaving" type="submit">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
|
||||
<a
|
||||
:href="signUpUrl"
|
||||
class="mt-6 block"
|
||||
target="_blank"
|
||||
>
|
||||
<BaseButton variant="primary-outline" type="button">
|
||||
{{ $t('modules.sign_up_and_get_token') }}
|
||||
</BaseButton>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { useModuleStore } from '../store'
|
||||
import ModuleCard from '../components/ModuleCard.vue'
|
||||
import type { Module } from '../../../../types/domain/module'
|
||||
|
||||
interface Props {
|
||||
baseUrl?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
baseUrl: '',
|
||||
})
|
||||
|
||||
const moduleStore = useModuleStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<string>('')
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingModule = ref<boolean>(false)
|
||||
|
||||
const rules = computed(() => ({
|
||||
api_token: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(
|
||||
rules,
|
||||
computed(() => moduleStore.currentUser),
|
||||
)
|
||||
|
||||
const hasApiToken = computed<boolean>(() => !!moduleStore.apiToken)
|
||||
|
||||
const filteredModules = computed<Module[]>(() => {
|
||||
if (activeTab.value === 'INSTALLED') {
|
||||
return moduleStore.installedModules
|
||||
}
|
||||
return moduleStore.modules
|
||||
})
|
||||
|
||||
const baseUrlDisplay = computed<string>(() => {
|
||||
return props.baseUrl.replace(/^http:\/\//, '')
|
||||
})
|
||||
|
||||
const signUpUrl = computed<string>(() => {
|
||||
return `${props.baseUrl}/auth/customer/register`
|
||||
})
|
||||
|
||||
watch(hasApiToken, (val) => {
|
||||
if (val) fetchModulesData()
|
||||
}, { immediate: true })
|
||||
|
||||
async function fetchModulesData(): Promise<void> {
|
||||
isFetchingModule.value = true
|
||||
await moduleStore.fetchModules()
|
||||
isFetchingModule.value = false
|
||||
}
|
||||
|
||||
async function submitApiToken(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const response = await moduleStore.checkApiToken(
|
||||
moduleStore.currentUser.api_token ?? '',
|
||||
)
|
||||
if (response.success) {
|
||||
moduleStore.apiToken = moduleStore.currentUser.api_token
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setStatusFilter(data: { filter: string }): void {
|
||||
activeTab.value = data.filter
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user