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

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