mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 19:24:03 +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>
|
<template>
|
||||||
<div
|
<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}`)"
|
@click="$router.push(`/admin/administration/modules/${data.slug}`)"
|
||||||
>
|
>
|
||||||
<div
|
<!-- Cover -->
|
||||||
v-if="data.purchased"
|
<div class="relative h-36 overflow-hidden">
|
||||||
class="absolute mt-5 px-6 w-full flex justify-end"
|
<img
|
||||||
>
|
v-if="data.cover"
|
||||||
<label
|
class="w-full h-full object-cover object-center transition-transform group-hover:scale-105"
|
||||||
v-if="data.purchased"
|
:src="data.cover"
|
||||||
class="bg-white/75 text-xs px-3 py-1 font-semibold tracking-wide rounded"
|
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') }}
|
<BaseIcon name="PuzzlePieceIcon" class="h-10 w-10 text-primary-400" />
|
||||||
</label>
|
</div>
|
||||||
<label
|
|
||||||
v-if="data.installed"
|
<!-- Badges -->
|
||||||
class="ml-2 bg-white/75 text-xs px-3 py-1 font-semibold tracking-wide rounded"
|
<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">
|
||||||
<span v-if="data.update_available">
|
{{ data.access_tier === 'premium' ? 'Premium' : 'Public' }}
|
||||||
{{ $t('modules.update_available') }}
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span
|
||||||
{{ $t('modules.installed') }}
|
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>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img
|
<!-- Info -->
|
||||||
class="lg:h-64 md:h-48 w-full object-cover object-center"
|
<div class="p-4">
|
||||||
:src="data.cover ?? ''"
|
<h3 class="text-base font-semibold text-heading truncate">
|
||||||
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 }}
|
{{ 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
|
<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"
|
:src="data.author_avatar"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<span>by</span>
|
<span>{{ data.author_name }}</span>
|
||||||
<span class="ml-2 text-base font-semibold truncate">
|
<span v-if="data.latest_module_version" class="ml-auto font-medium text-body">
|
||||||
{{ data.author_name }}
|
v{{ data.latest_module_version }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<base-text
|
<p class="mt-2.5 text-xs text-muted leading-relaxed line-clamp-2">
|
||||||
:text="data.short_description ?? ''"
|
{{ data.short_description }}
|
||||||
class="pt-4 text-muted h-16 line-clamp-2"
|
</p>
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { Module } from '../../../../types/domain/module'
|
import type { Module } from '../../../../types/domain/module'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: Module
|
data: Module
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const averageRating = computed<number>(() => {
|
|
||||||
return parseInt(String(props.data.average_rating ?? 0), 10)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,25 +2,17 @@ import { defineStore } from 'pinia'
|
|||||||
import { moduleService } from '../../../api/services/module.service'
|
import { moduleService } from '../../../api/services/module.service'
|
||||||
import type {
|
import type {
|
||||||
Module,
|
Module,
|
||||||
ModuleReview,
|
|
||||||
ModuleFaq,
|
|
||||||
ModuleLink,
|
|
||||||
ModuleScreenshot,
|
|
||||||
} from '../../../types/domain/module'
|
} from '../../../types/domain/module'
|
||||||
|
import type {
|
||||||
|
ModuleCheckResponse,
|
||||||
|
ModuleDetailResponse,
|
||||||
|
ModuleInstallPayload,
|
||||||
|
} from '../../../api/services/module.service'
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
export interface ModuleDetailMeta {
|
|
||||||
modules: Module[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModuleDetailResponse {
|
|
||||||
data: Module
|
|
||||||
meta: ModuleDetailMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstallationStep {
|
export interface InstallationStep {
|
||||||
translationKey: string
|
translationKey: string
|
||||||
stepUrl: string
|
stepUrl: string
|
||||||
@@ -40,6 +32,11 @@ export interface ModuleState {
|
|||||||
currentUser: {
|
currentUser: {
|
||||||
api_token: string | null
|
api_token: string | null
|
||||||
}
|
}
|
||||||
|
marketplaceStatus: {
|
||||||
|
authenticated: boolean
|
||||||
|
premium: boolean
|
||||||
|
invalidToken: boolean
|
||||||
|
}
|
||||||
enableModules: string[]
|
enableModules: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +48,11 @@ export const useModuleStore = defineStore('modules', {
|
|||||||
currentUser: {
|
currentUser: {
|
||||||
api_token: null,
|
api_token: null,
|
||||||
},
|
},
|
||||||
|
marketplaceStatus: {
|
||||||
|
authenticated: false,
|
||||||
|
premium: false,
|
||||||
|
invalidToken: false,
|
||||||
|
},
|
||||||
enableModules: [],
|
enableModules: [],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -70,25 +72,30 @@ export const useModuleStore = defineStore('modules', {
|
|||||||
|
|
||||||
async fetchModule(slug: string): Promise<ModuleDetailResponse> {
|
async fetchModule(slug: string): Promise<ModuleDetailResponse> {
|
||||||
const response = await moduleService.get(slug)
|
const response = await moduleService.get(slug)
|
||||||
const data = response as unknown as ModuleDetailResponse
|
this.currentModule = response
|
||||||
|
return response
|
||||||
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 }> {
|
async checkApiToken(token: string): Promise<ModuleCheckResponse> {
|
||||||
const response = await moduleService.checkToken(token)
|
const response = await moduleService.checkToken(token)
|
||||||
return {
|
this.marketplaceStatus = {
|
||||||
success: response.success ?? false,
|
authenticated: response.authenticated ?? false,
|
||||||
error: response.error,
|
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(
|
async installModule(
|
||||||
moduleName: string,
|
payload: ModuleInstallPayload,
|
||||||
version: string,
|
|
||||||
onStepUpdate?: (step: InstallationStep) => void,
|
onStepUpdate?: (step: InstallationStep) => void,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const steps: InstallationStep[] = [
|
const steps: InstallationStep[] = [
|
||||||
@@ -145,13 +151,25 @@ export const useModuleStore = defineStore('modules', {
|
|||||||
try {
|
try {
|
||||||
const stepFns: Record<string, () => Promise<Record<string, unknown>>> = {
|
const stepFns: Record<string, () => Promise<Record<string, unknown>>> = {
|
||||||
'/api/v1/modules/download': () =>
|
'/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': () =>
|
'/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': () =>
|
'/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': () =>
|
'/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]()
|
const result = await stepFns[step.stepUrl]()
|
||||||
|
|||||||
@@ -15,280 +15,300 @@
|
|||||||
</BaseBreadcrumb>
|
</BaseBreadcrumb>
|
||||||
</BasePageHeader>
|
</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">
|
<!-- Hero: Info + Action -->
|
||||||
<!-- Image Gallery -->
|
<div class="mt-8 lg:grid lg:grid-cols-3 lg:gap-12">
|
||||||
<div class="lg:row-end-1 lg:col-span-4">
|
<!-- Module Info (2/3) -->
|
||||||
<div class="flex flex-col-reverse">
|
<div class="lg:col-span-2">
|
||||||
<!-- Thumbnails -->
|
<div class="flex items-start gap-5">
|
||||||
<div class="hidden mt-6 w-full max-w-2xl mx-auto sm:block lg:max-w-none">
|
<!-- Icon / Cover thumbnail -->
|
||||||
<div class="grid grid-cols-3 xl:grid-cols-4 gap-6" role="tablist">
|
<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">
|
||||||
<button
|
<img v-if="moduleData.cover" :src="moduleData.cover" alt="" class="h-full w-full object-cover" />
|
||||||
v-if="thumbnail && videoUrl"
|
<BaseIcon v-else name="PuzzlePieceIcon" class="h-8 w-8 text-primary-400" />
|
||||||
:class="[
|
</div>
|
||||||
'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
|
<div class="min-w-0 flex-1">
|
||||||
v-for="(screenshot, ssIdx) in displayImages"
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
:key="ssIdx"
|
<h1 class="text-2xl font-bold tracking-tight text-heading">
|
||||||
:class="[
|
{{ moduleData.name }}
|
||||||
'relative md:h-24 lg:h-36 rounded hover:bg-hover',
|
</h1>
|
||||||
{ 'outline-hidden ring-3 ring-offset-1 ring-primary-500': displayImage === screenshot.url },
|
<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 }}
|
||||||
type="button"
|
</span>
|
||||||
@click="setDisplayImage(screenshot.url)"
|
</div>
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-md overflow-hidden">
|
<div class="mt-1.5 flex items-center gap-4 text-sm text-muted flex-wrap">
|
||||||
<img :src="screenshot.url" alt="" class="w-full h-full object-center object-cover" />
|
<span v-if="moduleData.author_name" class="flex items-center gap-1.5">
|
||||||
</span>
|
<img
|
||||||
</button>
|
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>
|
||||||
</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>
|
||||||
|
|
||||||
|
<p class="mt-6 text-sm text-muted leading-relaxed">
|
||||||
|
{{ moduleData.long_description }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Action Card (1/3) -->
|
||||||
<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">
|
<div class="mt-6 lg:mt-0">
|
||||||
<!-- Rating -->
|
<div class="rounded-xl border border-line-default bg-surface-secondary p-6">
|
||||||
<div class="flex items-center">
|
<!-- Not purchased -->
|
||||||
<BaseRating :rating="averageRating" />
|
<template v-if="!moduleData.purchased">
|
||||||
</div>
|
<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 -->
|
<!-- Purchased, not installed -->
|
||||||
<div class="flex flex-col-reverse">
|
<template v-else-if="!moduleData.installed">
|
||||||
<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
|
<BaseButton
|
||||||
v-if="moduleData.latest_module_version"
|
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"
|
variant="primary"
|
||||||
size="xl"
|
|
||||||
:loading="isInstalling"
|
:loading="isInstalling"
|
||||||
:disabled="isInstalling"
|
:disabled="isInstalling"
|
||||||
class="mr-4 flex items-center justify-center text-base"
|
class="w-full flex items-center justify-center"
|
||||||
@click="handleInstall"
|
@click="handleInstall"
|
||||||
>
|
>
|
||||||
{{ $t('modules.update_to') }}
|
<BaseIcon v-if="!isInstalling" name="ArrowDownTrayIcon" class="mr-2 h-4 w-4" />
|
||||||
<span class="ml-2">{{ moduleData.latest_module_version }}</span>
|
{{ $t('modules.install') }} v{{ moduleData.latest_module_version }}
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
<BaseButton
|
<!-- Installed -->
|
||||||
v-if="moduleData.enabled"
|
<template v-else-if="isModuleInstalled">
|
||||||
variant="danger"
|
<div class="flex items-center justify-between mb-4">
|
||||||
size="xl"
|
<div class="flex items-center gap-1.5">
|
||||||
:loading="isDisabling"
|
<BaseIcon name="CheckCircleIcon" class="h-4 w-4 text-green-500" />
|
||||||
:disabled="isDisabling"
|
<span class="text-sm font-medium text-heading">{{ $t('modules.installed') }}</span>
|
||||||
class="mr-4 flex items-center justify-center text-base"
|
</div>
|
||||||
@click="handleDisable"
|
<span class="text-xs text-muted">v{{ moduleData.installed_module_version }}</span>
|
||||||
>
|
</div>
|
||||||
<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 class="flex gap-2">
|
||||||
<div v-if="moduleData.highlights" class="border-t border-line-default mt-10 pt-10">
|
<BaseButton
|
||||||
<h3 class="text-sm font-medium text-heading">
|
v-if="moduleData.update_available"
|
||||||
{{ $t('modules.what_you_get') }}
|
variant="primary"
|
||||||
</h3>
|
:loading="isInstalling"
|
||||||
<div class="mt-4 prose prose-sm max-w-none text-muted" v-html="moduleData.highlights" />
|
:disabled="isInstalling"
|
||||||
</div>
|
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 -->
|
<BaseButton
|
||||||
<div v-if="moduleData.links?.length" class="border-t border-line-default mt-10 pt-10">
|
v-if="moduleData.enabled"
|
||||||
<div
|
variant="danger"
|
||||||
v-for="(link, key) in moduleData.links"
|
:loading="isDisabling"
|
||||||
:key="key"
|
:disabled="isDisabling"
|
||||||
class="mb-4 last:mb-0 flex"
|
:class="moduleData.update_available ? '' : 'flex-1'"
|
||||||
>
|
class="flex items-center justify-center"
|
||||||
<BaseIcon :name="(link as ModuleLinkItem).icon ?? ''" class="mr-4" />
|
@click="handleDisable"
|
||||||
<a :href="(link as ModuleLinkItem).link" class="text-primary-500" target="_blank">
|
>
|
||||||
{{ (link as ModuleLinkItem).label }}
|
<BaseIcon v-if="!isDisabling" name="NoSymbolIcon" class="h-4 w-4" :class="{ 'mr-1.5': !moduleData.update_available }" />
|
||||||
</a>
|
<span v-if="!moduleData.update_available">{{ $t('modules.disable') }}</span>
|
||||||
</div>
|
</BaseButton>
|
||||||
</div>
|
<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 -->
|
<!-- Installation Steps -->
|
||||||
<div v-if="isInstalling" class="border-t border-line-default mt-10 pt-10">
|
<ul v-if="isInstalling && installationSteps.length" class="mt-4 space-y-2">
|
||||||
<ul class="w-full p-0 list-none">
|
|
||||||
<li
|
<li
|
||||||
v-for="step in installationSteps"
|
v-for="step in installationSteps"
|
||||||
:key="step.translationKey"
|
: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
|
<span
|
||||||
:class="stepStatusClass(step)"
|
:class="stepStatusClass(step)"
|
||||||
class="block py-1 text-sm text-center uppercase rounded-full"
|
class="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||||
style="width: 88px"
|
|
||||||
>
|
>
|
||||||
{{ stepStatusLabel(step) }}
|
{{ stepStatusLabel(step) }}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs: Reviews, FAQ, License -->
|
<!-- Links -->
|
||||||
<div class="w-full max-w-2xl mx-auto mt-16 lg:max-w-none lg:mt-0 lg:col-span-4">
|
<div v-if="moduleData.links?.length" class="mt-5 pt-4 border-t border-line-light space-y-2">
|
||||||
<!-- Simple tab implementation -->
|
<a
|
||||||
<div class="-mb-px flex space-x-8 border-b border-line-default">
|
v-for="(link, key) in moduleData.links"
|
||||||
<button
|
:key="key"
|
||||||
v-for="tab in tabs"
|
:href="(link as ModuleLinkItem).link"
|
||||||
:key="tab.key"
|
target="_blank"
|
||||||
:class="[
|
class="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700"
|
||||||
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">
|
<BaseIcon :name="(link as ModuleLinkItem).icon ?? 'LinkIcon'" class="h-4 w-4" />
|
||||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full bg-surface-secondary">
|
{{ (link as ModuleLinkItem).label }}
|
||||||
<span class="text-lg font-medium leading-none text-white uppercase">
|
</a>
|
||||||
{{ 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>
|
||||||
<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>
|
</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 -->
|
<!-- Other Modules -->
|
||||||
<div v-if="otherModules?.length" class="mt-24 sm:mt-32 lg:max-w-none">
|
<div v-if="otherModules?.length" class="mt-16">
|
||||||
<div class="flex items-center justify-between space-x-4">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
|
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
|
||||||
<a
|
<a
|
||||||
href="/admin/administration/modules"
|
href="/admin/administration/modules"
|
||||||
@@ -298,7 +318,7 @@
|
|||||||
<span aria-hidden="true"> →</span>
|
<span aria-hidden="true"> →</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div v-for="(other, moduleIdx) in otherModules" :key="moduleIdx">
|
||||||
<ModuleCard :data="other" />
|
<ModuleCard :data="other" />
|
||||||
</div>
|
</div>
|
||||||
@@ -340,8 +360,8 @@ const isFetchingInitialData = ref<boolean>(true)
|
|||||||
const isInstalling = ref<boolean>(false)
|
const isInstalling = ref<boolean>(false)
|
||||||
const isEnabling = ref<boolean>(false)
|
const isEnabling = ref<boolean>(false)
|
||||||
const isDisabling = ref<boolean>(false)
|
const isDisabling = ref<boolean>(false)
|
||||||
const displayImage = ref<string | null>('')
|
|
||||||
const displayVideo = ref<boolean>(false)
|
const displayVideo = ref<boolean>(false)
|
||||||
|
const expandedImage = ref<string | null>(null)
|
||||||
const thumbnail = ref<string | null>(null)
|
const thumbnail = ref<string | null>(null)
|
||||||
const videoUrl = ref<string | null>(null)
|
const videoUrl = ref<string | null>(null)
|
||||||
const activeTab = ref<string>('reviews')
|
const activeTab = ref<string>('reviews')
|
||||||
@@ -413,12 +433,8 @@ async function loadData(): Promise<void> {
|
|||||||
|
|
||||||
videoUrl.value = moduleData.value?.video_link ?? null
|
videoUrl.value = moduleData.value?.video_link ?? null
|
||||||
thumbnail.value = moduleData.value?.video_thumbnail ?? null
|
thumbnail.value = moduleData.value?.video_thumbnail ?? null
|
||||||
|
displayVideo.value = false
|
||||||
if (videoUrl.value) {
|
expandedImage.value = null
|
||||||
setDisplayVideo()
|
|
||||||
} else {
|
|
||||||
displayImage.value = moduleData.value?.cover ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
isFetchingInitialData.value = false
|
isFetchingInitialData.value = false
|
||||||
}
|
}
|
||||||
@@ -430,8 +446,12 @@ async function handleInstall(): Promise<void> {
|
|||||||
isInstalling.value = true
|
isInstalling.value = true
|
||||||
|
|
||||||
const success = await moduleStore.installModule(
|
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) => {
|
(step) => {
|
||||||
const existing = installationSteps.find(
|
const existing = installationSteps.find(
|
||||||
(s) => s.translationKey === step.translationKey,
|
(s) => s.translationKey === step.translationKey,
|
||||||
@@ -491,12 +511,12 @@ async function handleEnable(): Promise<void> {
|
|||||||
|
|
||||||
function setDisplayImage(url: string): void {
|
function setDisplayImage(url: string): void {
|
||||||
displayVideo.value = false
|
displayVideo.value = false
|
||||||
displayImage.value = url
|
expandedImage.value = url
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDisplayVideo(): void {
|
function setDisplayVideo(): void {
|
||||||
displayVideo.value = true
|
displayVideo.value = true
|
||||||
displayImage.value = null
|
expandedImage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
|
|||||||
@@ -7,14 +7,70 @@
|
|||||||
</BaseBreadcrumb>
|
</BaseBreadcrumb>
|
||||||
</BasePageHeader>
|
</BasePageHeader>
|
||||||
|
|
||||||
<!-- Connected: module listing -->
|
<BaseCard class="mt-6">
|
||||||
<div v-if="hasApiToken && moduleStore.modules">
|
<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">
|
<BaseTabGroup @change="setStatusFilter">
|
||||||
<BaseTab :title="$t('general.all')" filter="" />
|
<BaseTab :title="$t('general.all')" filter="" />
|
||||||
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
|
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
|
||||||
</BaseTabGroup>
|
</BaseTabGroup>
|
||||||
|
|
||||||
<!-- Placeholder -->
|
|
||||||
<div
|
<div
|
||||||
v-if="isFetchingModule"
|
v-if="isFetchingModule"
|
||||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
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 v-for="n in 3" :key="n" class="h-80 bg-surface-tertiary rounded-lg animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Module Cards -->
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div
|
<div
|
||||||
v-if="filteredModules.length"
|
v-if="filteredModules.length"
|
||||||
@@ -39,72 +94,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</BasePage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
import { useModuleStore } from '../store'
|
import { useModuleStore } from '../store'
|
||||||
import ModuleCard from '../components/ModuleCard.vue'
|
import ModuleCard from '../components/ModuleCard.vue'
|
||||||
import type { Module } from '../../../../types/domain/module'
|
import type { Module } from '../../../../types/domain/module'
|
||||||
|
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||||
interface Props {
|
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||||
baseUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
baseUrl: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const moduleStore = useModuleStore()
|
const moduleStore = useModuleStore()
|
||||||
|
const globalStore = useGlobalStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const activeTab = ref<string>('')
|
const activeTab = ref<string>('')
|
||||||
@@ -126,8 +132,6 @@ const v$ = useVuelidate(
|
|||||||
computed(() => moduleStore.currentUser),
|
computed(() => moduleStore.currentUser),
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasApiToken = computed<boolean>(() => !!moduleStore.apiToken)
|
|
||||||
|
|
||||||
const filteredModules = computed<Module[]>(() => {
|
const filteredModules = computed<Module[]>(() => {
|
||||||
if (activeTab.value === 'INSTALLED') {
|
if (activeTab.value === 'INSTALLED') {
|
||||||
return moduleStore.installedModules
|
return moduleStore.installedModules
|
||||||
@@ -135,22 +139,72 @@ const filteredModules = computed<Module[]>(() => {
|
|||||||
return moduleStore.modules
|
return moduleStore.modules
|
||||||
})
|
})
|
||||||
|
|
||||||
const baseUrlDisplay = computed<string>(() => {
|
const statusLabel = computed<string>(() => {
|
||||||
return props.baseUrl.replace(/^http:\/\//, '')
|
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>(() => {
|
const statusClass = computed<string>(() => {
|
||||||
return `${props.baseUrl}/auth/customer/register`
|
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) => {
|
const baseUrl = computed<string>(() => {
|
||||||
if (val) fetchModulesData()
|
return String(globalStore.config?.base_url ?? '')
|
||||||
}, { immediate: true })
|
})
|
||||||
|
|
||||||
|
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> {
|
async function fetchModulesData(): Promise<void> {
|
||||||
isFetchingModule.value = true
|
isFetchingModule.value = true
|
||||||
await moduleStore.fetchModules()
|
try {
|
||||||
isFetchingModule.value = false
|
await moduleStore.fetchModules()
|
||||||
|
} finally {
|
||||||
|
isFetchingModule.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitApiToken(): Promise<void> {
|
async function submitApiToken(): Promise<void> {
|
||||||
@@ -160,17 +214,51 @@ async function submitApiToken(): Promise<void> {
|
|||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await moduleStore.checkApiToken(
|
const token = moduleStore.currentUser.api_token ?? ''
|
||||||
moduleStore.currentUser.api_token ?? '',
|
const response = await moduleStore.checkApiToken(token)
|
||||||
)
|
|
||||||
if (response.success) {
|
if (!response.success) {
|
||||||
moduleStore.apiToken = moduleStore.currentUser.api_token
|
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 {
|
} finally {
|
||||||
isSaving.value = false
|
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 {
|
function setStatusFilter(data: { filter: string }): void {
|
||||||
activeTab.value = data.filter
|
activeTab.value = data.filter
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user