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:
Darko Gjorgjijoski
2026-04-10 19:00:00 +02:00
parent 23d1476870
commit 3d79fe1abc
4 changed files with 525 additions and 410 deletions

View File

@@ -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>

View File

@@ -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]()

View File

@@ -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"> &rarr;</span> <span aria-hidden="true"> &rarr;</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 {

View File

@@ -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
} }