916 lines
26 KiB
Vue
916 lines
26 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
PlusOutlined,
|
|
ThunderboltOutlined,
|
|
} from '@ant-design/icons-vue'
|
|
import { ApiClientError } from '@geo/http-client'
|
|
import type {
|
|
Brand,
|
|
BrandLibrarySummary,
|
|
Competitor,
|
|
Question,
|
|
} from '@geo/shared-types'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
import type { TableColumnsType } from 'ant-design-vue'
|
|
import { message } from 'ant-design-vue'
|
|
import { computed, reactive, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
import { brandsApi } from '@/lib/api'
|
|
import { formatDateTime } from '@/lib/display'
|
|
import { formatError } from '@/lib/errors'
|
|
import { useCompanyStore } from '@/stores/company'
|
|
|
|
const queryClient = useQueryClient()
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const companyStore = useCompanyStore()
|
|
|
|
const activeTab = ref<'questions' | 'competitors'>('questions')
|
|
const selectedBrandId = computed<number | null>({
|
|
get: () => companyStore.currentBrandId,
|
|
set: (brandId) => companyStore.setCurrentBrand(brandId),
|
|
})
|
|
|
|
const brandModalOpen = ref(false)
|
|
const editingBrandId = ref<number | null>(null)
|
|
const singleQuestionModalOpen = ref(false)
|
|
const questionEditOpen = ref(false)
|
|
const editingQuestionId = ref<number | null>(null)
|
|
const competitorModalOpen = ref(false)
|
|
const editingCompetitorId = ref<number | null>(null)
|
|
|
|
const brandForm = reactive({
|
|
name: '',
|
|
website: '',
|
|
description: '',
|
|
})
|
|
|
|
const questionEditForm = reactive({
|
|
question_text: '',
|
|
})
|
|
|
|
const singleQuestionForm = reactive({
|
|
question_text: '',
|
|
})
|
|
|
|
const competitorForm = reactive({
|
|
name: '',
|
|
website: '',
|
|
description: '',
|
|
product_lines: '',
|
|
})
|
|
|
|
const brandListQuery = useQuery({
|
|
queryKey: ['brands', 'list'],
|
|
queryFn: () => brandsApi.list(),
|
|
})
|
|
|
|
const brandLibrarySummaryQuery = useQuery({
|
|
queryKey: ['brands', 'library-summary'],
|
|
queryFn: () => brandsApi.getLibrarySummary(),
|
|
})
|
|
|
|
const questionsQuery = useQuery({
|
|
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'all']),
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
|
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
|
})
|
|
|
|
const competitorsQuery = useQuery({
|
|
queryKey: computed(() => ['brands', selectedBrandId.value, 'competitors']),
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
|
queryFn: () => brandsApi.listCompetitors(selectedBrandId.value as number),
|
|
})
|
|
|
|
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
|
|
() => brandLibrarySummaryQuery.data.value ?? null,
|
|
)
|
|
|
|
const selectedBrand = computed(
|
|
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
|
)
|
|
|
|
const currentQuestions = computed(() => questionsQuery.data.value ?? [])
|
|
|
|
const maxQuestions = computed(
|
|
() =>
|
|
brandLibrarySummary.value?.max_questions ??
|
|
brandLibrarySummary.value?.max_questions_per_brand ??
|
|
0,
|
|
)
|
|
const usedQuestions = computed(
|
|
() => brandLibrarySummary.value?.used_questions ?? currentQuestions.value.length,
|
|
)
|
|
|
|
const brandLimitReached = computed(() => {
|
|
if (!brandLibrarySummary.value) {
|
|
return false
|
|
}
|
|
return brandLibrarySummary.value.remaining_brands <= 0
|
|
})
|
|
|
|
const questionLimitReached = computed(() => {
|
|
if (!maxQuestions.value) {
|
|
return false
|
|
}
|
|
return usedQuestions.value >= maxQuestions.value
|
|
})
|
|
|
|
const questionColumns = computed<TableColumnsType<Question>>(() => [
|
|
{
|
|
title: t('brands.questions.table.question'),
|
|
dataIndex: 'question_text',
|
|
key: 'question_text',
|
|
},
|
|
{
|
|
title: t('common.createdAt'),
|
|
dataIndex: 'created_at',
|
|
key: 'created_at',
|
|
width: 180,
|
|
},
|
|
{
|
|
title: t('common.actions'),
|
|
key: 'actions',
|
|
width: 120,
|
|
align: 'right',
|
|
},
|
|
])
|
|
|
|
const brandMutations = {
|
|
create: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.create({
|
|
name: brandForm.name.trim(),
|
|
website: brandForm.website.trim() || null,
|
|
description: brandForm.description.trim() || null,
|
|
}),
|
|
onSuccess: async (brand) => {
|
|
message.success(t('brands.messages.createBrand'))
|
|
brandModalOpen.value = false
|
|
selectedBrandId.value = brand.id
|
|
companyStore.setCurrentBrand(brand.id)
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
update: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.update(editingBrandId.value as number, {
|
|
name: brandForm.name.trim(),
|
|
website: brandForm.website.trim() || null,
|
|
description: brandForm.description.trim() || null,
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.updateBrand'))
|
|
brandModalOpen.value = false
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
remove: useMutation({
|
|
mutationFn: (brandId: number) => brandsApi.remove(brandId),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.deleteBrand'))
|
|
selectedBrandId.value = null
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
}
|
|
|
|
const questionMutations = {
|
|
createSingle: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.materializeQuestions(selectedBrandId.value as number, {
|
|
source: 'manual',
|
|
questions: [{ text: singleQuestionForm.question_text.trim() }],
|
|
}),
|
|
onSuccess: async (result) => {
|
|
if (result.created_questions <= 0) {
|
|
message.warning(t('brands.questions.errors.noValidQuestions'))
|
|
return
|
|
}
|
|
message.success(t('brands.messages.createQuestion'))
|
|
singleQuestionModalOpen.value = false
|
|
singleQuestionForm.question_text = ''
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => {
|
|
if (error instanceof ApiClientError && error.message === 'no_valid_questions') {
|
|
message.warning(t('brands.questions.errors.noValidQuestions'))
|
|
return
|
|
}
|
|
message.error(formatError(error))
|
|
},
|
|
}),
|
|
update: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.updateQuestion(selectedBrandId.value as number, editingQuestionId.value as number, {
|
|
question_text: questionEditForm.question_text.trim(),
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.updateQuestion'))
|
|
questionEditOpen.value = false
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
remove: useMutation({
|
|
mutationFn: (questionId: number) =>
|
|
brandsApi.removeQuestion(selectedBrandId.value as number, questionId),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.deleteQuestion'))
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
}
|
|
|
|
const competitorMutations = {
|
|
create: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.createCompetitor(selectedBrandId.value as number, {
|
|
name: competitorForm.name.trim(),
|
|
website: competitorForm.website.trim() || null,
|
|
description: competitorForm.description.trim() || null,
|
|
product_lines_json: parseProductLines(competitorForm.product_lines),
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.createCompetitor'))
|
|
competitorModalOpen.value = false
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
update: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.updateCompetitor(
|
|
selectedBrandId.value as number,
|
|
editingCompetitorId.value as number,
|
|
{
|
|
name: competitorForm.name.trim(),
|
|
website: competitorForm.website.trim() || null,
|
|
description: competitorForm.description.trim() || null,
|
|
product_lines_json: parseProductLines(competitorForm.product_lines),
|
|
},
|
|
),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.updateCompetitor'))
|
|
competitorModalOpen.value = false
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
remove: useMutation({
|
|
mutationFn: (competitorId: number) =>
|
|
brandsApi.removeCompetitor(selectedBrandId.value as number, competitorId),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.deleteCompetitor'))
|
|
await invalidateBrandQueries()
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
}
|
|
|
|
watch(
|
|
() => brandListQuery.data.value,
|
|
(brands) => {
|
|
const routeBrandId = Number(route.query.brand_id)
|
|
if (!selectedBrandId.value && !Number.isNaN(routeBrandId) && routeBrandId > 0) {
|
|
selectedBrandId.value = routeBrandId
|
|
}
|
|
if (!selectedBrandId.value && brands?.length) {
|
|
selectedBrandId.value = brands[0].id
|
|
return
|
|
}
|
|
if (selectedBrandId.value && brands && !brands.some((item) => item.id === selectedBrandId.value)) {
|
|
selectedBrandId.value = brands[0]?.id ?? null
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
function parseProductLines(value: string): string[] | undefined {
|
|
const lines = value
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
return lines.length ? lines : undefined
|
|
}
|
|
|
|
function stringifyProductLines(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return value.filter((item) => typeof item === 'string').join(', ')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function openBrandModal(brand?: Brand): void {
|
|
if (!brand && brandLimitReached.value) {
|
|
message.warning(
|
|
t('brands.messages.brandLimitReached', {
|
|
limit: brandLibrarySummary.value?.max_brands ?? 0,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
editingBrandId.value = brand?.id ?? null
|
|
brandForm.name = brand?.name ?? ''
|
|
brandForm.website = brand?.website ?? ''
|
|
brandForm.description = brand?.description ?? ''
|
|
brandModalOpen.value = true
|
|
}
|
|
|
|
function selectBrand(brandId: number): void {
|
|
if (!Number.isFinite(brandId) || brandId <= 0) {
|
|
return
|
|
}
|
|
selectedBrandId.value = brandId
|
|
void queryClient.invalidateQueries({ queryKey: ['brands'] })
|
|
}
|
|
|
|
function ensureQuestionCreatable(): boolean {
|
|
if (!selectedBrandId.value) {
|
|
message.warning(t('brands.messages.chooseBrand'))
|
|
return false
|
|
}
|
|
if (questionLimitReached.value) {
|
|
message.warning(
|
|
t('brands.questions.messages.limitReached', {
|
|
limit: maxQuestions.value,
|
|
}),
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function openSingleQuestionModal(): void {
|
|
if (!ensureQuestionCreatable()) {
|
|
return
|
|
}
|
|
singleQuestionForm.question_text = ''
|
|
singleQuestionModalOpen.value = true
|
|
}
|
|
|
|
function openQuestionExpansionPage(): void {
|
|
if (!ensureQuestionCreatable()) {
|
|
return
|
|
}
|
|
void router.push({
|
|
name: 'brand-question-create',
|
|
params: { brandId: String(selectedBrandId.value) },
|
|
})
|
|
}
|
|
|
|
function openQuestionEditModal(question: Question): void {
|
|
editingQuestionId.value = question.id
|
|
questionEditForm.question_text = question.question_text
|
|
questionEditOpen.value = true
|
|
}
|
|
|
|
function openCompetitorModal(competitor?: Competitor): void {
|
|
if (!selectedBrandId.value) {
|
|
message.warning(t('brands.messages.chooseBrand'))
|
|
return
|
|
}
|
|
editingCompetitorId.value = competitor?.id ?? null
|
|
competitorForm.name = competitor?.name ?? ''
|
|
competitorForm.website = competitor?.website ?? ''
|
|
competitorForm.description = competitor?.description ?? ''
|
|
competitorForm.product_lines = stringifyProductLines(competitor?.product_lines_json)
|
|
competitorModalOpen.value = true
|
|
}
|
|
|
|
async function submitBrand(): Promise<void> {
|
|
if (!brandForm.name.trim()) {
|
|
return
|
|
}
|
|
if (editingBrandId.value) {
|
|
await brandMutations.update.mutateAsync()
|
|
return
|
|
}
|
|
await brandMutations.create.mutateAsync()
|
|
}
|
|
|
|
async function submitQuestionEdit(): Promise<void> {
|
|
if (!questionEditForm.question_text.trim()) {
|
|
return
|
|
}
|
|
await questionMutations.update.mutateAsync()
|
|
}
|
|
|
|
async function submitSingleQuestion(): Promise<void> {
|
|
if (!singleQuestionForm.question_text.trim()) {
|
|
return
|
|
}
|
|
await questionMutations.createSingle.mutateAsync()
|
|
}
|
|
|
|
async function submitCompetitor(): Promise<void> {
|
|
if (!competitorForm.name.trim()) {
|
|
return
|
|
}
|
|
if (editingCompetitorId.value) {
|
|
await competitorMutations.update.mutateAsync()
|
|
return
|
|
}
|
|
await competitorMutations.create.mutateAsync()
|
|
}
|
|
|
|
async function invalidateBrandQueries(): Promise<void> {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
|
])
|
|
await companyStore.refreshBrands()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="brands-view">
|
|
<section class="brand-page-head">
|
|
<div>
|
|
<p class="eyebrow">{{ t('brands.eyebrow') }}</p>
|
|
<h2>{{ t('brands.title') }}</h2>
|
|
</div>
|
|
<div class="brand-page-head__quota">
|
|
<span>{{ t('brands.quota.questions') }}</span>
|
|
<strong>{{ usedQuestions }} / {{ maxQuestions || '--' }}</strong>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="brand-rail">
|
|
<div v-if="brandListQuery.isPending.value" class="brand-grid">
|
|
<a-skeleton v-for="item in 3" :key="item" active />
|
|
</div>
|
|
<div v-else-if="brandListQuery.data.value?.length" class="brand-grid">
|
|
<article
|
|
v-for="brand in brandListQuery.data.value"
|
|
:key="brand.id"
|
|
class="brand-card"
|
|
:class="{ 'brand-card--active': brand.id === selectedBrandId }"
|
|
@click="selectBrand(brand.id)"
|
|
>
|
|
<div class="brand-card__top">
|
|
<div>
|
|
<h3>{{ brand.name }}</h3>
|
|
<p>{{ brand.description || t('brands.empty.brandDescription') }}</p>
|
|
</div>
|
|
<div class="brand-card__actions">
|
|
<a-tooltip :title="t('common.edit')">
|
|
<a-button type="text" shape="circle" size="small" class="action-btn-edit" @click.stop="openBrandModal(brand)">
|
|
<EditOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-popconfirm @confirm="brandMutations.remove.mutate(brand.id)">
|
|
<template #title>{{ t('brands.deleteBrand') }}</template>
|
|
<a-button type="text" shape="circle" size="small" danger @click.stop>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</div>
|
|
<div class="brand-card__meta">
|
|
<span>{{ t('brands.meta.questionCount', { count: brand.question_count }) }}</span>
|
|
<span>{{ t('brands.meta.competitorCount', { count: brand.competitor_count ?? 0 }) }}</span>
|
|
</div>
|
|
</article>
|
|
<button type="button" class="brand-card brand-card--add" @click="openBrandModal()">
|
|
<PlusOutlined />
|
|
<span>{{ t('brands.actions.addBrand') }}</span>
|
|
</button>
|
|
</div>
|
|
<a-empty v-else :description="t('brands.empty.brands')">
|
|
<a-button type="primary" @click="openBrandModal()">
|
|
<template #icon><PlusOutlined /></template>
|
|
{{ t('brands.newBrand') }}
|
|
</a-button>
|
|
</a-empty>
|
|
</section>
|
|
|
|
<section v-if="selectedBrand" class="brand-detail">
|
|
<div class="brand-detail__head">
|
|
<div>
|
|
<p class="eyebrow">{{ selectedBrand?.name }}</p>
|
|
<h3>{{ activeTab === 'questions' ? t('brands.tabs.questions') : t('brands.tabs.competitors') }}</h3>
|
|
</div>
|
|
<div v-if="activeTab === 'questions'" class="brand-detail__actions">
|
|
<a-button :disabled="!selectedBrand" @click="openQuestionExpansionPage">
|
|
<template #icon><ThunderboltOutlined /></template>
|
|
{{ t('brands.questions.expand') }}
|
|
</a-button>
|
|
<a-button type="primary" :disabled="!selectedBrand" @click="openSingleQuestionModal">
|
|
<template #icon><PlusOutlined /></template>
|
|
{{ t('brands.questions.createSingle') }}
|
|
</a-button>
|
|
</div>
|
|
<a-button v-else type="primary" :disabled="!selectedBrand" @click="openCompetitorModal()">
|
|
<template #icon><PlusOutlined /></template>
|
|
{{ t('brands.actions.newCompetitor') }}
|
|
</a-button>
|
|
</div>
|
|
|
|
<a-tabs v-model:activeKey="activeTab" class="brand-tabs">
|
|
<a-tab-pane key="questions" :tab="t('brands.tabs.questions')">
|
|
<a-table
|
|
:columns="questionColumns"
|
|
:data-source="currentQuestions"
|
|
:loading="questionsQuery.isPending.value"
|
|
:pagination="{ pageSize: 10, showSizeChanger: false }"
|
|
row-key="id"
|
|
>
|
|
<template #emptyText>{{ t('brands.empty.questions') }}</template>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'question_text'">
|
|
<div class="question-cell">
|
|
<strong>{{ record.question_text }}</strong>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="column.key === 'created_at'">
|
|
<span class="datetime">{{ formatDateTime(record.created_at) }}</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'actions'">
|
|
<div class="table-actions-row">
|
|
<a-tooltip :title="t('common.edit')">
|
|
<a-button type="text" shape="circle" size="small" class="action-btn-edit" @click="openQuestionEditModal(record)">
|
|
<EditOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-popconfirm @confirm="questionMutations.remove.mutate(record.id)">
|
|
<template #title>{{ t('common.delete') }}</template>
|
|
<a-button type="text" shape="circle" size="small" danger>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</a-tab-pane>
|
|
|
|
<a-tab-pane key="competitors" :tab="t('brands.tabs.competitors')">
|
|
<div v-if="competitorsQuery.data.value?.length" class="competitor-grid">
|
|
<article v-for="competitor in competitorsQuery.data.value" :key="competitor.id" class="competitor-card">
|
|
<div class="competitor-card__head">
|
|
<div>
|
|
<h4>{{ competitor.name }}</h4>
|
|
<a :href="competitor.website || undefined" target="_blank" rel="noreferrer">
|
|
{{ competitor.website || t('common.optional') }}
|
|
</a>
|
|
</div>
|
|
<div class="inline-actions">
|
|
<a-button type="text" shape="circle" size="small" class="action-btn-edit" @click="openCompetitorModal(competitor)">
|
|
<EditOutlined />
|
|
</a-button>
|
|
<a-popconfirm @confirm="competitorMutations.remove.mutate(competitor.id)">
|
|
<template #title>{{ t('common.delete') }}</template>
|
|
<a-button type="text" shape="circle" size="small" danger>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</div>
|
|
<p>{{ competitor.description || t('brands.empty.competitorDescription') }}</p>
|
|
<div class="competitor-lines">
|
|
<span
|
|
v-for="line in Array.isArray(competitor.product_lines_json)
|
|
? competitor.product_lines_json
|
|
: []"
|
|
:key="String(line)"
|
|
>
|
|
{{ line }}
|
|
</span>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<a-empty v-else :description="t('brands.empty.competitors')" />
|
|
</a-tab-pane>
|
|
</a-tabs>
|
|
</section>
|
|
|
|
<a-modal
|
|
v-model:open="brandModalOpen"
|
|
:title="editingBrandId ? t('brands.editBrand') : t('brands.newBrand')"
|
|
centered
|
|
@ok="submitBrand"
|
|
>
|
|
<a-form layout="vertical">
|
|
<a-form-item :label="t('brands.form.brandName')">
|
|
<a-input v-model:value="brandForm.name" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('brands.form.brandWebsite')">
|
|
<a-input v-model:value="brandForm.website" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('brands.form.brandDescription')">
|
|
<a-textarea v-model:value="brandForm.description" :rows="4" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
|
|
<a-modal
|
|
v-model:open="singleQuestionModalOpen"
|
|
:title="t('brands.questions.createSingle')"
|
|
centered
|
|
:confirm-loading="questionMutations.createSingle.isPending.value"
|
|
@ok="submitSingleQuestion"
|
|
>
|
|
<a-form layout="vertical">
|
|
<a-form-item :label="t('brands.form.questionText')">
|
|
<a-textarea v-model:value="singleQuestionForm.question_text" :rows="4" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
|
|
<a-modal
|
|
v-model:open="questionEditOpen"
|
|
:title="t('common.edit')"
|
|
centered
|
|
@ok="submitQuestionEdit"
|
|
>
|
|
<a-form layout="vertical">
|
|
<a-form-item :label="t('brands.form.questionText')">
|
|
<a-textarea v-model:value="questionEditForm.question_text" :rows="4" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
|
|
<a-modal
|
|
v-model:open="competitorModalOpen"
|
|
:title="editingCompetitorId ? t('common.edit') : t('brands.actions.newCompetitor')"
|
|
centered
|
|
@ok="submitCompetitor"
|
|
>
|
|
<a-form layout="vertical">
|
|
<a-form-item :label="t('brands.form.competitorName')">
|
|
<a-input v-model:value="competitorForm.name" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('brands.form.competitorWebsite')">
|
|
<a-input v-model:value="competitorForm.website" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('brands.form.competitorDescription')">
|
|
<a-textarea v-model:value="competitorForm.description" :rows="3" />
|
|
</a-form-item>
|
|
<a-form-item :label="t('brands.form.competitorLines')">
|
|
<a-input
|
|
v-model:value="competitorForm.product_lines"
|
|
:placeholder="t('brands.form.competitorLinesHint')"
|
|
/>
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.brands-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
}
|
|
|
|
.eyebrow {
|
|
margin: 0;
|
|
color: #64748b;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: 0;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.brand-page-head,
|
|
.brand-rail,
|
|
.brand-detail {
|
|
background: #ffffff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.brand-page-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 22px 24px;
|
|
}
|
|
|
|
.brand-page-head h2,
|
|
.brand-detail__head h3 {
|
|
margin: 4px 0 0;
|
|
color: #111827;
|
|
font-size: 22px;
|
|
font-weight: 760;
|
|
}
|
|
|
|
.brand-page-head__quota {
|
|
min-width: 132px;
|
|
padding-left: 18px;
|
|
border-left: 1px solid #e2e8f0;
|
|
text-align: right;
|
|
}
|
|
|
|
.brand-page-head__quota span {
|
|
display: block;
|
|
color: #64748b;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.brand-page-head__quota strong {
|
|
color: #0f172a;
|
|
font-size: 22px;
|
|
}
|
|
|
|
.brand-rail,
|
|
.brand-detail {
|
|
padding: 20px;
|
|
}
|
|
|
|
.brand-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.brand-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 142px;
|
|
padding: 16px;
|
|
color: inherit;
|
|
text-align: left;
|
|
background: #ffffff;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition:
|
|
border-color 0.18s ease,
|
|
box-shadow 0.18s ease,
|
|
transform 0.18s ease;
|
|
}
|
|
|
|
.brand-card:hover {
|
|
border-color: #94a3b8;
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.06);
|
|
}
|
|
|
|
.brand-card--active {
|
|
border-color: #2563eb;
|
|
background: #f8fbff;
|
|
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
|
|
}
|
|
|
|
.brand-card--add {
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
color: #2563eb;
|
|
border-style: dashed;
|
|
}
|
|
|
|
.brand-card__top,
|
|
.brand-detail__head,
|
|
.competitor-card__head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.brand-detail__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
}
|
|
|
|
.brand-card__top h3,
|
|
.competitor-card h4 {
|
|
margin: 0;
|
|
color: #111827;
|
|
font-size: 16px;
|
|
font-weight: 720;
|
|
}
|
|
|
|
.brand-card__top p,
|
|
.competitor-card p {
|
|
display: -webkit-box;
|
|
margin: 8px 0 0;
|
|
overflow: hidden;
|
|
color: #64748b;
|
|
line-height: 1.55;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 2;
|
|
}
|
|
|
|
.brand-card__actions {
|
|
display: flex;
|
|
opacity: 0;
|
|
transition: opacity 0.18s ease;
|
|
}
|
|
|
|
.brand-card:hover .brand-card__actions,
|
|
.brand-card:focus-within .brand-card__actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
.brand-card__meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: auto;
|
|
padding-top: 16px;
|
|
}
|
|
|
|
.brand-card__meta span,
|
|
.competitor-lines span {
|
|
padding: 5px 9px;
|
|
color: #1e40af;
|
|
font-size: 12px;
|
|
font-weight: 650;
|
|
background: #eff6ff;
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.brand-tabs {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.question-cell strong {
|
|
color: #0f172a;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.datetime {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.table-actions-row,
|
|
.inline-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 4px;
|
|
}
|
|
|
|
.competitor-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.competitor-card {
|
|
padding: 16px;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.competitor-card a {
|
|
color: #2563eb;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.competitor-lines {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
min-height: 28px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
@media (max-width: 1180px) {
|
|
.brand-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.brand-page-head,
|
|
.brand-guide,
|
|
.brand-detail__head {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.brand-detail__actions {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.brand-grid,
|
|
.competitor-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.brand-guide__quota {
|
|
padding-left: 0;
|
|
border-left: 0;
|
|
}
|
|
}
|
|
.action-btn-edit:hover { color: #52c41a !important; background: #f6ffed !important; }
|
|
</style>
|