mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 17:24:10 +00:00
feat(modules): redesign admin marketplace cards and detail view
ModuleCard moves badges to the top-right, shows a cover placeholder when art is missing, and drops the rating/pricing chrome that was never populated by the marketplace. ModuleDetailView splits into a hero row (cover + module info, two-thirds width) plus a sticky action card on the right (one-third) so install/update/purchase buttons stay visible when scrolling long descriptions. ModuleIndexView promotes the marketplace API token form to a persistent card at the top of the page and adds an authenticated/premium status pill so super-admins can see whether the current token unlocks premium listings. The tabs and empty state were reorganized so 'installed' and 'marketplace' feel like peers. The admin modules store tracks marketplace auth status, adds checkApiToken() and setApiToken() methods, and unifies the install-request shape into ModuleInstallPayload so both the free and paid install buttons route through the same code path.
This commit is contained in:
@@ -1,83 +1,72 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative shadow-md border-2 border-line-default/60 rounded-lg cursor-pointer overflow-hidden h-100"
|
||||
class="group relative rounded-xl border border-line-default bg-surface cursor-pointer overflow-hidden transition-shadow hover:shadow-lg"
|
||||
@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"
|
||||
<!-- Cover -->
|
||||
<div class="relative h-36 overflow-hidden">
|
||||
<img
|
||||
v-if="data.cover"
|
||||
class="w-full h-full object-cover object-center transition-transform group-hover:scale-105"
|
||||
:src="data.cover"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center"
|
||||
>
|
||||
{{ $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') }}
|
||||
<BaseIcon name="PuzzlePieceIcon" class="h-10 w-10 text-primary-400" />
|
||||
</div>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="absolute top-2.5 right-2.5 flex gap-1.5">
|
||||
<span class="bg-white/85 backdrop-blur-sm text-xs px-2 py-0.5 font-medium rounded-md text-heading">
|
||||
{{ data.access_tier === 'premium' ? 'Premium' : 'Public' }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('modules.installed') }}
|
||||
<span
|
||||
v-if="data.installed"
|
||||
class="text-xs px-2 py-0.5 font-medium rounded-md"
|
||||
:class="data.update_available
|
||||
? 'bg-amber-100/90 text-amber-700'
|
||||
: 'bg-green-100/90 text-green-700'"
|
||||
>
|
||||
{{ data.update_available ? $t('modules.update_available') : $t('modules.installed') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</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">
|
||||
<!-- Info -->
|
||||
<div class="p-4">
|
||||
<h3 class="text-base font-semibold text-heading truncate">
|
||||
{{ data.name }}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="data.author_avatar" class="flex items-center mt-2">
|
||||
<div class="flex items-center gap-1.5 mt-1 text-xs text-muted">
|
||||
<img
|
||||
class="hidden h-10 w-10 rounded-full sm:inline-block mr-2"
|
||||
v-if="data.author_avatar"
|
||||
class="h-4 w-4 rounded-full"
|
||||
:src="data.author_avatar"
|
||||
alt=""
|
||||
/>
|
||||
<span>by</span>
|
||||
<span class="ml-2 text-base font-semibold truncate">
|
||||
{{ data.author_name }}
|
||||
<span>{{ data.author_name }}</span>
|
||||
<span v-if="data.latest_module_version" class="ml-auto font-medium text-body">
|
||||
v{{ data.latest_module_version }}
|
||||
</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>
|
||||
<p class="mt-2.5 text-xs text-muted leading-relaxed line-clamp-2">
|
||||
{{ data.short_description }}
|
||||
</p>
|
||||
</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)
|
||||
})
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
@@ -2,25 +2,17 @@ import { defineStore } from 'pinia'
|
||||
import { moduleService } from '../../../api/services/module.service'
|
||||
import type {
|
||||
Module,
|
||||
ModuleReview,
|
||||
ModuleFaq,
|
||||
ModuleLink,
|
||||
ModuleScreenshot,
|
||||
} from '../../../types/domain/module'
|
||||
import type {
|
||||
ModuleCheckResponse,
|
||||
ModuleDetailResponse,
|
||||
ModuleInstallPayload,
|
||||
} from '../../../api/services/module.service'
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export interface ModuleDetailMeta {
|
||||
modules: Module[]
|
||||
}
|
||||
|
||||
export interface ModuleDetailResponse {
|
||||
data: Module
|
||||
meta: ModuleDetailMeta
|
||||
}
|
||||
|
||||
export interface InstallationStep {
|
||||
translationKey: string
|
||||
stepUrl: string
|
||||
@@ -40,6 +32,11 @@ export interface ModuleState {
|
||||
currentUser: {
|
||||
api_token: string | null
|
||||
}
|
||||
marketplaceStatus: {
|
||||
authenticated: boolean
|
||||
premium: boolean
|
||||
invalidToken: boolean
|
||||
}
|
||||
enableModules: string[]
|
||||
}
|
||||
|
||||
@@ -51,6 +48,11 @@ export const useModuleStore = defineStore('modules', {
|
||||
currentUser: {
|
||||
api_token: null,
|
||||
},
|
||||
marketplaceStatus: {
|
||||
authenticated: false,
|
||||
premium: false,
|
||||
invalidToken: false,
|
||||
},
|
||||
enableModules: [],
|
||||
}),
|
||||
|
||||
@@ -70,25 +72,30 @@ export const useModuleStore = defineStore('modules', {
|
||||
|
||||
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
|
||||
this.currentModule = response
|
||||
return response
|
||||
},
|
||||
|
||||
async checkApiToken(token: string): Promise<{ success: boolean; error?: string }> {
|
||||
async checkApiToken(token: string): Promise<ModuleCheckResponse> {
|
||||
const response = await moduleService.checkToken(token)
|
||||
return {
|
||||
success: response.success ?? false,
|
||||
error: response.error,
|
||||
this.marketplaceStatus = {
|
||||
authenticated: response.authenticated ?? false,
|
||||
premium: response.premium ?? false,
|
||||
invalidToken: response.error === 'invalid_token',
|
||||
}
|
||||
return response
|
||||
},
|
||||
|
||||
setApiToken(token: string | null): void {
|
||||
this.apiToken = token
|
||||
this.currentUser.api_token = token
|
||||
},
|
||||
|
||||
clearMarketplaceStatus(): void {
|
||||
this.marketplaceStatus = {
|
||||
authenticated: false,
|
||||
premium: false,
|
||||
invalidToken: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -101,8 +108,7 @@ export const useModuleStore = defineStore('modules', {
|
||||
},
|
||||
|
||||
async installModule(
|
||||
moduleName: string,
|
||||
version: string,
|
||||
payload: ModuleInstallPayload,
|
||||
onStepUpdate?: (step: InstallationStep) => void,
|
||||
): Promise<boolean> {
|
||||
const steps: InstallationStep[] = [
|
||||
@@ -145,13 +151,25 @@ export const useModuleStore = defineStore('modules', {
|
||||
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>>,
|
||||
moduleService.download({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/unzip': () =>
|
||||
moduleService.unzip({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
moduleService.unzip({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/copy': () =>
|
||||
moduleService.copy({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
moduleService.copy({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/complete': () =>
|
||||
moduleService.complete({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||
moduleService.complete({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
}
|
||||
|
||||
const result = await stepFns[step.stepUrl]()
|
||||
|
||||
@@ -15,280 +15,300 @@
|
||||
</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>
|
||||
<!-- Hero: Info + Action -->
|
||||
<div class="mt-8 lg:grid lg:grid-cols-3 lg:gap-12">
|
||||
<!-- Module Info (2/3) -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="flex items-start gap-5">
|
||||
<!-- Icon / Cover thumbnail -->
|
||||
<div class="shrink-0 h-16 w-16 rounded-xl overflow-hidden bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center">
|
||||
<img v-if="moduleData.cover" :src="moduleData.cover" alt="" class="h-full w-full object-cover" />
|
||||
<BaseIcon v-else name="PuzzlePieceIcon" class="h-8 w-8 text-primary-400" />
|
||||
</div>
|
||||
|
||||
<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 class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-heading">
|
||||
{{ moduleData.name }}
|
||||
</h1>
|
||||
<span class="rounded-full bg-primary-50 px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-primary-600">
|
||||
{{ moduleData.access_tier }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 flex items-center gap-4 text-sm text-muted flex-wrap">
|
||||
<span v-if="moduleData.author_name" class="flex items-center gap-1.5">
|
||||
<img
|
||||
v-if="moduleData.author_avatar"
|
||||
:src="moduleData.author_avatar"
|
||||
alt=""
|
||||
class="h-4 w-4 rounded-full"
|
||||
/>
|
||||
{{ moduleData.author_name }}
|
||||
</span>
|
||||
<span v-if="moduleData.latest_module_version">
|
||||
v{{ moduleVersion }}
|
||||
</span>
|
||||
<span v-if="updatedAt">
|
||||
{{ updatedAt }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<p class="mt-6 text-sm text-muted leading-relaxed">
|
||||
{{ moduleData.long_description }}
|
||||
</p>
|
||||
</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>
|
||||
<!-- Action Card (1/3) -->
|
||||
<div class="mt-6 lg:mt-0">
|
||||
<div class="rounded-xl border border-line-default bg-surface-secondary p-6">
|
||||
<!-- Not purchased -->
|
||||
<template v-if="!moduleData.purchased">
|
||||
<a :href="buyLink" target="_blank">
|
||||
<BaseButton size="lg" class="w-full flex items-center justify-center">
|
||||
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
|
||||
{{ $t('modules.buy_now') }}
|
||||
</BaseButton>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- 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">
|
||||
<!-- Purchased, not installed -->
|
||||
<template v-else-if="!moduleData.installed">
|
||||
<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"
|
||||
class="w-full flex items-center justify-center"
|
||||
@click="handleInstall"
|
||||
>
|
||||
{{ $t('modules.update_to') }}
|
||||
<span class="ml-2">{{ moduleData.latest_module_version }}</span>
|
||||
<BaseIcon v-if="!isInstalling" name="ArrowDownTrayIcon" class="mr-2 h-4 w-4" />
|
||||
{{ $t('modules.install') }} v{{ moduleData.latest_module_version }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- Installed -->
|
||||
<template v-else-if="isModuleInstalled">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<BaseIcon name="CheckCircleIcon" class="h-4 w-4 text-green-500" />
|
||||
<span class="text-sm font-medium text-heading">{{ $t('modules.installed') }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted">v{{ moduleData.installed_module_version }}</span>
|
||||
</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>
|
||||
<div class="flex gap-2">
|
||||
<BaseButton
|
||||
v-if="moduleData.update_available"
|
||||
variant="primary"
|
||||
:loading="isInstalling"
|
||||
:disabled="isInstalling"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
@click="handleInstall"
|
||||
>
|
||||
<BaseIcon v-if="!isInstalling" name="ArrowPathIcon" class="mr-1.5 h-4 w-4" />
|
||||
{{ $t('modules.update_to') }} {{ moduleData.latest_module_version }}
|
||||
</BaseButton>
|
||||
|
||||
<!-- 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>
|
||||
<BaseButton
|
||||
v-if="moduleData.enabled"
|
||||
variant="danger"
|
||||
:loading="isDisabling"
|
||||
:disabled="isDisabling"
|
||||
:class="moduleData.update_available ? '' : 'flex-1'"
|
||||
class="flex items-center justify-center"
|
||||
@click="handleDisable"
|
||||
>
|
||||
<BaseIcon v-if="!isDisabling" name="NoSymbolIcon" class="h-4 w-4" :class="{ 'mr-1.5': !moduleData.update_available }" />
|
||||
<span v-if="!moduleData.update_available">{{ $t('modules.disable') }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else
|
||||
variant="primary-outline"
|
||||
:loading="isEnabling"
|
||||
:disabled="isEnabling"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
@click="handleEnable"
|
||||
>
|
||||
<BaseIcon v-if="!isEnabling" name="CheckIcon" class="mr-1.5 h-4 w-4" />
|
||||
{{ $t('modules.enable') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Installation Steps -->
|
||||
<div v-if="isInstalling" class="border-t border-line-default mt-10 pt-10">
|
||||
<ul class="w-full p-0 list-none">
|
||||
<!-- Installation Steps -->
|
||||
<ul v-if="isInstalling && installationSteps.length" class="mt-4 space-y-2">
|
||||
<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"
|
||||
class="flex items-center justify-between text-sm py-1"
|
||||
>
|
||||
<p class="m-0 text-sm leading-8">{{ $t(step.translationKey) }}</p>
|
||||
<span class="text-muted">{{ $t(step.translationKey) }}</span>
|
||||
<span
|
||||
:class="stepStatusClass(step)"
|
||||
class="block py-1 text-sm text-center uppercase rounded-full"
|
||||
style="width: 88px"
|
||||
class="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||
>
|
||||
{{ 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"
|
||||
<!-- Links -->
|
||||
<div v-if="moduleData.links?.length" class="mt-5 pt-4 border-t border-line-light space-y-2">
|
||||
<a
|
||||
v-for="(link, key) in moduleData.links"
|
||||
:key="key"
|
||||
:href="(link as ModuleLinkItem).link"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<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>
|
||||
<BaseIcon :name="(link as ModuleLinkItem).icon ?? 'LinkIcon'" class="h-4 w-4" />
|
||||
{{ (link as ModuleLinkItem).label }}
|
||||
</a>
|
||||
</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>
|
||||
|
||||
<!-- Highlights -->
|
||||
<div v-if="moduleData.highlights?.length" class="mt-12 pt-8 border-t border-line-light">
|
||||
<h3 class="text-sm font-semibold text-heading mb-4">
|
||||
{{ $t('modules.what_you_get') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3">
|
||||
<div
|
||||
v-for="highlight in moduleData.highlights"
|
||||
:key="highlight"
|
||||
class="flex items-start gap-2.5 text-sm text-muted"
|
||||
>
|
||||
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
|
||||
{{ highlight }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshots Gallery -->
|
||||
<div v-if="displayImages.length" class="mt-12 pt-8 border-t border-line-light">
|
||||
<h3 class="text-sm font-semibold text-heading mb-5">
|
||||
{{ $t('modules.screenshots') }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Video thumbnail -->
|
||||
<button
|
||||
v-if="thumbnail && videoUrl"
|
||||
class="relative rounded-lg overflow-hidden bg-surface-tertiary aspect-video group"
|
||||
type="button"
|
||||
@click="setDisplayVideo"
|
||||
>
|
||||
<img :src="thumbnail" alt="" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
|
||||
<BaseIcon name="PlayIcon" class="h-12 w-12 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Screenshots -->
|
||||
<button
|
||||
v-for="(screenshot, ssIdx) in displayImages"
|
||||
:key="ssIdx"
|
||||
class="rounded-lg overflow-hidden bg-surface-tertiary aspect-video"
|
||||
type="button"
|
||||
@click="setDisplayImage(screenshot.url)"
|
||||
>
|
||||
<img :src="screenshot.url" alt="" class="w-full h-full object-cover" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox-style expanded view -->
|
||||
<div
|
||||
v-if="displayVideo || expandedImage"
|
||||
class="mt-4 rounded-lg overflow-hidden bg-surface-tertiary"
|
||||
>
|
||||
<div v-if="displayVideo" class="aspect-video">
|
||||
<iframe
|
||||
:src="videoUrl ?? ''"
|
||||
class="w-full h-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="expandedImage" class="relative">
|
||||
<img :src="expandedImage" alt="" class="w-full" />
|
||||
<button
|
||||
class="absolute top-3 right-3 rounded-full bg-black/50 hover:bg-black/70 p-1.5 text-white transition-colors"
|
||||
@click="expandedImage = null"
|
||||
>
|
||||
<BaseIcon name="XMarkIcon" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Reviews, FAQ, License -->
|
||||
<div class="mt-12 pt-8 border-t border-line-light">
|
||||
<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-4 border-b-2 font-medium text-sm',
|
||||
]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reviews -->
|
||||
<div v-if="activeTab === 'reviews'" class="py-6">
|
||||
<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-4">
|
||||
<span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-surface-secondary">
|
||||
<span class="text-sm font-medium leading-none text-muted uppercase">
|
||||
{{ review.user?.[0] ?? '?' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div :class="[reviewIdx === 0 ? '' : 'border-t border-line-default', 'py-4']">
|
||||
<h3 class="font-medium text-heading">{{ review.user }}</h3>
|
||||
<p>{{ formatDate(review.created_at) }}</p>
|
||||
<div class="flex items-center mt-2">
|
||||
<BaseRating :rating="review.rating" />
|
||||
</div>
|
||||
<div class="mt-2 prose prose-sm max-w-none text-muted" v-html="review.comment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-muted text-sm">{{ $t('modules.no_reviews_found') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- FAQ -->
|
||||
<dl v-if="activeTab === 'faq'" class="py-6 text-sm text-muted space-y-6">
|
||||
<div v-for="faq in moduleData.faq" :key="faq.question">
|
||||
<dt class="font-medium text-heading">{{ faq.question }}</dt>
|
||||
<dd class="mt-1">{{ faq.answer }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- License -->
|
||||
<div v-if="activeTab === 'license'" class="py-6">
|
||||
<div class="prose prose-sm max-w-none text-muted" v-html="moduleData.license" />
|
||||
</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">
|
||||
<div v-if="otherModules?.length" class="mt-16">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
|
||||
<a
|
||||
href="/admin/administration/modules"
|
||||
@@ -298,7 +318,7 @@
|
||||
<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 class="mt-6 grid grid-cols-1 gap-x-8 gap-y-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="(other, moduleIdx) in otherModules" :key="moduleIdx">
|
||||
<ModuleCard :data="other" />
|
||||
</div>
|
||||
@@ -340,8 +360,8 @@ 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 expandedImage = ref<string | null>(null)
|
||||
const thumbnail = ref<string | null>(null)
|
||||
const videoUrl = ref<string | null>(null)
|
||||
const activeTab = ref<string>('reviews')
|
||||
@@ -413,12 +433,8 @@ async function loadData(): Promise<void> {
|
||||
|
||||
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
|
||||
}
|
||||
displayVideo.value = false
|
||||
expandedImage.value = null
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
@@ -430,8 +446,12 @@ async function handleInstall(): Promise<void> {
|
||||
isInstalling.value = true
|
||||
|
||||
const success = await moduleStore.installModule(
|
||||
moduleData.value.module_name,
|
||||
moduleData.value.latest_module_version,
|
||||
{
|
||||
slug: moduleData.value.slug,
|
||||
module_name: moduleData.value.module_name,
|
||||
version: moduleData.value.latest_module_version,
|
||||
checksum_sha256: moduleData.value.latest_module_checksum_sha256,
|
||||
},
|
||||
(step) => {
|
||||
const existing = installationSteps.find(
|
||||
(s) => s.translationKey === step.translationKey,
|
||||
@@ -491,12 +511,12 @@ async function handleEnable(): Promise<void> {
|
||||
|
||||
function setDisplayImage(url: string): void {
|
||||
displayVideo.value = false
|
||||
displayImage.value = url
|
||||
expandedImage.value = url
|
||||
}
|
||||
|
||||
function setDisplayVideo(): void {
|
||||
displayVideo.value = true
|
||||
displayImage.value = null
|
||||
expandedImage.value = null
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
|
||||
@@ -7,14 +7,70 @@
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<!-- Connected: module listing -->
|
||||
<div v-if="hasApiToken && moduleStore.modules">
|
||||
<BaseCard class="mt-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h6 class="text-heading text-lg font-medium">Marketplace Access</h6>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Public modules are always available. Add your marketplace token to unlock premium modules tied to your website subscription.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-medium"
|
||||
:class="statusClass"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid mt-6 lg:grid-cols-2">
|
||||
<form class="space-y-4" @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 flex-wrap gap-3">
|
||||
<BaseButton :loading="isSaving" type="submit">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
Save Token
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="moduleStore.apiToken"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="clearApiToken"
|
||||
>
|
||||
Clear Token
|
||||
</BaseButton>
|
||||
|
||||
<a :href="tokenPageUrl" target="_blank" rel="noopener" class="inline-flex">
|
||||
<BaseButton variant="primary-outline" type="button">
|
||||
Manage Token
|
||||
</BaseButton>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<div class="mt-6">
|
||||
<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"
|
||||
@@ -22,7 +78,6 @@
|
||||
<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"
|
||||
@@ -39,72 +94,23 @@
|
||||
</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 { computed, onMounted, ref } 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: '',
|
||||
})
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
|
||||
const moduleStore = useModuleStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<string>('')
|
||||
@@ -126,8 +132,6 @@ const v$ = useVuelidate(
|
||||
computed(() => moduleStore.currentUser),
|
||||
)
|
||||
|
||||
const hasApiToken = computed<boolean>(() => !!moduleStore.apiToken)
|
||||
|
||||
const filteredModules = computed<Module[]>(() => {
|
||||
if (activeTab.value === 'INSTALLED') {
|
||||
return moduleStore.installedModules
|
||||
@@ -135,22 +139,72 @@ const filteredModules = computed<Module[]>(() => {
|
||||
return moduleStore.modules
|
||||
})
|
||||
|
||||
const baseUrlDisplay = computed<string>(() => {
|
||||
return props.baseUrl.replace(/^http:\/\//, '')
|
||||
const statusLabel = computed<string>(() => {
|
||||
if (moduleStore.marketplaceStatus.invalidToken) {
|
||||
return 'Invalid token'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.premium) {
|
||||
return 'Premium modules unlocked'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.authenticated) {
|
||||
return 'Connected'
|
||||
}
|
||||
|
||||
return 'Public modules only'
|
||||
})
|
||||
|
||||
const signUpUrl = computed<string>(() => {
|
||||
return `${props.baseUrl}/auth/customer/register`
|
||||
const statusClass = computed<string>(() => {
|
||||
if (moduleStore.marketplaceStatus.invalidToken) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.premium) {
|
||||
return 'bg-amber-100 text-amber-800'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.authenticated) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
}
|
||||
|
||||
return 'bg-surface-secondary text-muted'
|
||||
})
|
||||
|
||||
watch(hasApiToken, (val) => {
|
||||
if (val) fetchModulesData()
|
||||
}, { immediate: true })
|
||||
const baseUrl = computed<string>(() => {
|
||||
return String(globalStore.config?.base_url ?? '')
|
||||
})
|
||||
|
||||
const tokenPageUrl = computed<string>(() => {
|
||||
return `${baseUrl.value}/marketplace/token`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const savedToken = String(globalStore.globalSettings?.api_token ?? '').trim() || null
|
||||
moduleStore.setApiToken(savedToken)
|
||||
|
||||
if (savedToken) {
|
||||
const response = await moduleStore.checkApiToken(savedToken)
|
||||
if (response.error === 'invalid_token') {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: 'Saved marketplace token is invalid. Public modules are shown until you update it.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
moduleStore.clearMarketplaceStatus()
|
||||
}
|
||||
|
||||
await fetchModulesData()
|
||||
})
|
||||
|
||||
async function fetchModulesData(): Promise<void> {
|
||||
isFetchingModule.value = true
|
||||
await moduleStore.fetchModules()
|
||||
isFetchingModule.value = false
|
||||
try {
|
||||
await moduleStore.fetchModules()
|
||||
} finally {
|
||||
isFetchingModule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApiToken(): Promise<void> {
|
||||
@@ -160,17 +214,51 @@ async function submitApiToken(): Promise<void> {
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const response = await moduleStore.checkApiToken(
|
||||
moduleStore.currentUser.api_token ?? '',
|
||||
)
|
||||
if (response.success) {
|
||||
moduleStore.apiToken = moduleStore.currentUser.api_token
|
||||
const token = moduleStore.currentUser.api_token ?? ''
|
||||
const response = await moduleStore.checkApiToken(token)
|
||||
|
||||
if (!response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: response.error === 'invalid_token'
|
||||
? 'Invalid marketplace token'
|
||||
: 'Unable to validate marketplace token',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await globalStore.updateGlobalSettings({
|
||||
data: {
|
||||
settings: {
|
||||
api_token: token,
|
||||
},
|
||||
},
|
||||
message: 'Marketplace token saved',
|
||||
})
|
||||
|
||||
moduleStore.setApiToken(token)
|
||||
await fetchModulesData()
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearApiToken(): Promise<void> {
|
||||
await globalStore.updateGlobalSettings({
|
||||
data: {
|
||||
settings: {
|
||||
api_token: null,
|
||||
},
|
||||
},
|
||||
message: 'Marketplace token cleared',
|
||||
})
|
||||
|
||||
moduleStore.setApiToken(null)
|
||||
moduleStore.clearMarketplaceStatus()
|
||||
v$.value.$reset()
|
||||
await fetchModulesData()
|
||||
}
|
||||
|
||||
function setStatusFilter(data: { filter: string }): void {
|
||||
activeTab.value = data.filter
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user