162abdc97c
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1273 lines
38 KiB
Vue
1273 lines
38 KiB
Vue
<script setup lang="ts">
|
|
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
|
import type { Brand, BrandLibrarySummary, Competitor, Keyword, 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 { brandsApi } from '@/lib/api'
|
|
import { formatDateTime } from '@/lib/display'
|
|
import { formatError } from '@/lib/errors'
|
|
|
|
const queryClient = useQueryClient()
|
|
const { t } = useI18n()
|
|
|
|
const activeTab = ref('keywords')
|
|
const selectedBrandId = ref<number | null>(null)
|
|
const selectedKeywordId = ref<number | null>(null)
|
|
|
|
const brandModalOpen = ref(false)
|
|
const editingBrandId = ref<number | null>(null)
|
|
const keywordModalOpen = ref(false)
|
|
const editingKeywordId = ref<number | null>(null)
|
|
const questionModalOpen = 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 keywordForm = reactive({
|
|
name: '',
|
|
})
|
|
|
|
const questionForm = reactive({
|
|
keyword_id: undefined as number | undefined,
|
|
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 keywordsQuery = useQuery({
|
|
queryKey: computed(() => ['brands', selectedBrandId.value, 'keywords']),
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
|
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
|
|
})
|
|
|
|
const allQuestionsQuery = useQuery({
|
|
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'all']),
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
|
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
|
})
|
|
|
|
const filteredQuestionsQuery = useQuery({
|
|
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', selectedKeywordId.value]),
|
|
enabled: computed(() => Boolean(selectedBrandId.value)),
|
|
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number, selectedKeywordId.value),
|
|
})
|
|
|
|
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 questionCountByKeyword = computed(() => {
|
|
const counts = new Map<number, number>()
|
|
for (const item of allQuestionsQuery.data.value ?? []) {
|
|
counts.set(item.keyword_id, (counts.get(item.keyword_id) ?? 0) + 1)
|
|
}
|
|
return counts
|
|
})
|
|
|
|
const selectedBrandKeywordCount = computed(() => selectedBrand.value?.keyword_count ?? 0)
|
|
const selectedKeywordQuestionCount = computed(() => {
|
|
if (!selectedKeywordId.value) {
|
|
return 0
|
|
}
|
|
return questionCountByKeyword.value.get(selectedKeywordId.value) ?? 0
|
|
})
|
|
|
|
const brandLimitReached = computed(() => {
|
|
if (!brandLibrarySummary.value) {
|
|
return false
|
|
}
|
|
return brandLibrarySummary.value.remaining_brands <= 0
|
|
})
|
|
|
|
const keywordLimitReached = computed(() => {
|
|
if (!brandLibrarySummary.value) {
|
|
return false
|
|
}
|
|
return brandLibrarySummary.value.remaining_keywords <= 0
|
|
})
|
|
|
|
const questionLimitReached = computed(() => {
|
|
if (!brandLibrarySummary.value || !selectedKeywordId.value) {
|
|
return false
|
|
}
|
|
return selectedKeywordQuestionCount.value >= brandLibrarySummary.value.max_questions_per_keyword
|
|
})
|
|
|
|
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
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
|
])
|
|
},
|
|
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 Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
|
])
|
|
},
|
|
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 Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
|
])
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
}
|
|
|
|
const keywordMutations = {
|
|
create: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.createKeyword(selectedBrandId.value as number, {
|
|
name: keywordForm.name.trim(),
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.createKeyword'))
|
|
keywordModalOpen.value = false
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'keywords'] }),
|
|
])
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
update: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.updateKeyword(selectedBrandId.value as number, editingKeywordId.value as number, {
|
|
name: keywordForm.name.trim(),
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.updateKeyword'))
|
|
keywordModalOpen.value = false
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'keywords'] }),
|
|
])
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
remove: useMutation({
|
|
mutationFn: (keywordId: number) =>
|
|
brandsApi.removeKeyword(selectedBrandId.value as number, keywordId),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.deleteKeyword'))
|
|
if (selectedKeywordId.value === editingKeywordId.value) {
|
|
selectedKeywordId.value = null
|
|
}
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'keywords'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'questions'] }),
|
|
])
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
}
|
|
|
|
const questionMutations = {
|
|
create: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.createQuestion(selectedBrandId.value as number, {
|
|
keyword_id: questionForm.keyword_id as number,
|
|
question_text: questionForm.question_text.trim(),
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.createQuestion'))
|
|
questionModalOpen.value = false
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'questions'] }),
|
|
])
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
update: useMutation({
|
|
mutationFn: () =>
|
|
brandsApi.updateQuestion(selectedBrandId.value as number, editingQuestionId.value as number, {
|
|
question_text: questionForm.question_text.trim(),
|
|
}),
|
|
onSuccess: async () => {
|
|
message.success(t('brands.messages.updateQuestion'))
|
|
questionModalOpen.value = false
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ['brands', selectedBrandId.value, 'questions'],
|
|
})
|
|
},
|
|
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 Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'questions'] }),
|
|
])
|
|
},
|
|
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 queryClient.invalidateQueries({
|
|
queryKey: ['brands', selectedBrandId.value, 'competitors'],
|
|
})
|
|
},
|
|
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 queryClient.invalidateQueries({
|
|
queryKey: ['brands', selectedBrandId.value, 'competitors'],
|
|
})
|
|
},
|
|
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 queryClient.invalidateQueries({
|
|
queryKey: ['brands', selectedBrandId.value, 'competitors'],
|
|
})
|
|
},
|
|
onError: (error) => message.error(formatError(error)),
|
|
}),
|
|
}
|
|
|
|
const selectedKeyword = computed(
|
|
() => keywordsQuery.data.value?.find((item) => item.id === selectedKeywordId.value) ?? null,
|
|
)
|
|
|
|
const questionColumns = computed<TableColumnsType<Question>>(() => [
|
|
{
|
|
title: t('brands.sections.questions'),
|
|
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',
|
|
},
|
|
])
|
|
|
|
watch(
|
|
() => brandListQuery.data.value,
|
|
(brands) => {
|
|
if (!selectedBrandId.value && brands?.length) {
|
|
selectedBrandId.value = brands[0].id
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
() => keywordsQuery.data.value,
|
|
(keywords) => {
|
|
if (!keywords?.length) {
|
|
selectedKeywordId.value = null
|
|
return
|
|
}
|
|
|
|
const stillExists = keywords.some((item) => item.id === selectedKeywordId.value)
|
|
if (!stillExists) {
|
|
selectedKeywordId.value = keywords[0].id
|
|
}
|
|
},
|
|
{ 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 openKeywordModal(keyword?: Keyword): void {
|
|
if (!selectedBrandId.value) {
|
|
message.warning(t('brands.messages.chooseBrand'))
|
|
return
|
|
}
|
|
if (!keyword && keywordLimitReached.value) {
|
|
message.warning(
|
|
t('brands.messages.keywordLimitReached', {
|
|
limit: brandLibrarySummary.value?.max_keywords ?? 0,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
editingKeywordId.value = keyword?.id ?? null
|
|
keywordForm.name = keyword?.name ?? ''
|
|
keywordModalOpen.value = true
|
|
}
|
|
|
|
function openQuestionModal(question?: Question): void {
|
|
if (!selectedBrandId.value) {
|
|
message.warning(t('brands.messages.chooseBrand'))
|
|
return
|
|
}
|
|
if (!question && questionLimitReached.value) {
|
|
message.warning(
|
|
t('brands.messages.questionLimitReached', {
|
|
limit: brandLibrarySummary.value?.max_questions_per_keyword ?? 0,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
editingQuestionId.value = question?.id ?? null
|
|
questionForm.keyword_id = question?.keyword_id ?? selectedKeywordId.value ?? undefined
|
|
questionForm.question_text = question?.question_text ?? ''
|
|
questionModalOpen.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 submitKeyword(): Promise<void> {
|
|
if (!keywordForm.name.trim()) {
|
|
return
|
|
}
|
|
if (editingKeywordId.value) {
|
|
await keywordMutations.update.mutateAsync()
|
|
return
|
|
}
|
|
await keywordMutations.create.mutateAsync()
|
|
}
|
|
|
|
async function submitQuestion(): Promise<void> {
|
|
if (!questionForm.keyword_id) {
|
|
message.warning(t('brands.messages.chooseKeyword'))
|
|
return
|
|
}
|
|
if (!questionForm.question_text.trim()) {
|
|
return
|
|
}
|
|
if (editingQuestionId.value) {
|
|
await questionMutations.update.mutateAsync()
|
|
return
|
|
}
|
|
await questionMutations.create.mutateAsync()
|
|
}
|
|
|
|
async function submitCompetitor(): Promise<void> {
|
|
if (!competitorForm.name.trim()) {
|
|
return
|
|
}
|
|
if (editingCompetitorId.value) {
|
|
await competitorMutations.update.mutateAsync()
|
|
return
|
|
}
|
|
await competitorMutations.create.mutateAsync()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="brands-view">
|
|
<section class="brands-view__top-card">
|
|
<div class="brands-view__header">
|
|
<div class="brands-view__header-title">
|
|
<h2>{{ t('brands.title') }}</h2>
|
|
<p>{{ t('brands.description') }}</p>
|
|
</div>
|
|
<div class="brands-view__header-actions">
|
|
<a-button type="primary" :disabled="brandLimitReached" @click="openBrandModal()">
|
|
<template #icon><PlusOutlined /></template>
|
|
{{ t('brands.newBrand') }}
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="brands-view__quota-strip">
|
|
<div class="brands-view__quota-card brands-view__quota-card--plan">
|
|
<span class="brands-view__quota-label">{{ t('brands.quota.plan') }}</span>
|
|
<strong>{{ brandLibrarySummary?.plan_name || t('shell.planFallback') }}</strong>
|
|
<p>{{ t('brands.quota.planHint') }}</p>
|
|
</div>
|
|
<div class="brands-view__quota-card">
|
|
<span class="brands-view__quota-label">{{ t('brands.quota.brands') }}</span>
|
|
<strong>
|
|
{{ brandLibrarySummary?.used_brands ?? 0 }} /
|
|
{{ brandLibrarySummary?.max_brands ?? '--' }}
|
|
</strong>
|
|
<p>{{ t('brands.quota.brandsHint') }}</p>
|
|
</div>
|
|
<div class="brands-view__quota-card">
|
|
<span class="brands-view__quota-label">{{ t('brands.quota.keywords') }}</span>
|
|
<strong>
|
|
{{ brandLibrarySummary?.used_keywords ?? 0 }} /
|
|
{{ brandLibrarySummary?.max_keywords ?? '--' }}
|
|
</strong>
|
|
<p>{{ t('brands.quota.keywordsHint') }}</p>
|
|
</div>
|
|
<div class="brands-view__quota-card">
|
|
<span class="brands-view__quota-label">{{ t('brands.quota.questions') }}</span>
|
|
<strong>{{ brandLibrarySummary?.max_questions_per_keyword ?? '--' }}</strong>
|
|
<p>{{ t('brands.quota.questionsHint') }}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="brands-view__brand-rail">
|
|
<div class="brands-view__section-head">
|
|
<div>
|
|
<p class="eyebrow">{{ t('brands.railTitle') }}</p>
|
|
<h3>{{ t('brands.railTitle') }}</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="brandListQuery.data.value?.length" class="brands-view__brand-grid">
|
|
<article
|
|
v-for="brand in brandListQuery.data.value"
|
|
:key="brand.id"
|
|
class="brands-view__brand-card"
|
|
:class="{ 'brands-view__brand-card--active': brand.id === selectedBrandId }"
|
|
@click="selectedBrandId = brand.id"
|
|
>
|
|
<div class="brands-view__brand-card-top">
|
|
<div>
|
|
<h4>{{ brand.name }}</h4>
|
|
<p>{{ brand.description || '--' }}</p>
|
|
</div>
|
|
|
|
<div class="brands-view__brand-card-actions">
|
|
<a-tooltip :title="t('common.edit')">
|
|
<a-button
|
|
type="text"
|
|
shape="circle"
|
|
size="small"
|
|
class="action-btn action-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"
|
|
class="action-btn action-delete"
|
|
:title="t('common.delete')"
|
|
@click.stop
|
|
>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="brands-view__brand-meta">
|
|
<span>{{ t('brands.meta.keywordCount', { count: brand.keyword_count }) }}</span>
|
|
<span>{{ t('brands.meta.questionCount', { count: brand.question_count }) }}</span>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<a-empty v-else :description="t('brands.empty.brands')" />
|
|
</section>
|
|
|
|
<template v-if="selectedBrandId">
|
|
<a-tabs v-model:activeKey="activeTab" class="brands-view__tabs">
|
|
<a-tab-pane key="keywords" :tab="t('brands.tabs.keywords')">
|
|
<section class="brands-view__keyword-layout">
|
|
<aside class="brands-view__keywords-panel">
|
|
<div class="brands-view__section-head brands-view__section-head--compact">
|
|
<div>
|
|
<h3>{{ t('brands.sections.keywords') }}</h3>
|
|
<p class="brands-view__section-hint">
|
|
{{ t('brands.meta.brandKeywordCount', { count: selectedBrandKeywordCount }) }}
|
|
<span class="brands-view__dot">·</span>
|
|
{{
|
|
t('brands.meta.globalKeywordUsage', {
|
|
used: brandLibrarySummary?.used_keywords ?? 0,
|
|
total: brandLibrarySummary?.max_keywords ?? 0,
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
<a-button
|
|
type="primary"
|
|
size="small"
|
|
:disabled="keywordLimitReached"
|
|
@click="openKeywordModal()"
|
|
>
|
|
{{ t('brands.actions.newKeyword') }}
|
|
</a-button>
|
|
</div>
|
|
|
|
<div v-if="keywordsQuery.data.value?.length" class="brands-view__keyword-list">
|
|
<button
|
|
v-for="keyword in keywordsQuery.data.value"
|
|
:key="keyword.id"
|
|
type="button"
|
|
class="brands-view__keyword-item"
|
|
:class="{ 'brands-view__keyword-item--active': keyword.id === selectedKeywordId }"
|
|
@click="selectedKeywordId = keyword.id"
|
|
>
|
|
<div class="brands-view__keyword-copy">
|
|
<span>{{ keyword.name }}</span>
|
|
<small>
|
|
{{
|
|
t('brands.meta.keywordQuestionUsage', {
|
|
used: questionCountByKeyword.get(keyword.id) ?? 0,
|
|
total: brandLibrarySummary?.max_questions_per_keyword ?? 0,
|
|
})
|
|
}}
|
|
</small>
|
|
</div>
|
|
<div class="brands-view__keyword-actions">
|
|
<a-tooltip :title="t('common.edit')">
|
|
<a-button
|
|
type="text"
|
|
shape="circle"
|
|
size="small"
|
|
class="action-btn action-edit"
|
|
@click.stop="openKeywordModal(keyword)"
|
|
>
|
|
<EditOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-popconfirm @confirm="keywordMutations.remove.mutate(keyword.id)">
|
|
<template #title>{{ t('common.delete') }}</template>
|
|
<a-button
|
|
type="text"
|
|
shape="circle"
|
|
size="small"
|
|
class="action-btn action-delete"
|
|
:title="t('common.delete')"
|
|
@click.stop
|
|
>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<a-empty v-else :description="t('common.noData')" />
|
|
</aside>
|
|
|
|
<section class="brands-view__questions-panel">
|
|
<div class="brands-view__section-head brands-view__section-head--compact">
|
|
<div>
|
|
<p class="eyebrow">{{ t('brands.sections.questions') }}</p>
|
|
<h3>{{ selectedKeyword?.name || t('brands.sections.questions') }}</h3>
|
|
<p class="brands-view__section-hint">
|
|
{{
|
|
t('brands.meta.keywordQuestionUsage', {
|
|
used: selectedKeywordQuestionCount,
|
|
total: brandLibrarySummary?.max_questions_per_keyword ?? 0,
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
<a-button
|
|
type="primary"
|
|
size="small"
|
|
:disabled="!selectedKeywordId || questionLimitReached"
|
|
@click="openQuestionModal()"
|
|
>
|
|
{{ t('brands.actions.newQuestion') }}
|
|
</a-button>
|
|
</div>
|
|
|
|
<a-table
|
|
:columns="questionColumns"
|
|
:data-source="filteredQuestionsQuery.data.value || []"
|
|
:loading="filteredQuestionsQuery.isPending.value"
|
|
:pagination="false"
|
|
row-key="id"
|
|
>
|
|
<template #emptyText>{{ t('brands.empty.questions') }}</template>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'question_text'">
|
|
<div class="brands-view__question-cell">
|
|
<strong>{{ record.question_text }}</strong>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="column.key === 'created_at'">
|
|
<span class="brands-view__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 action-edit"
|
|
@click="openQuestionModal(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"
|
|
class="action-btn action-delete"
|
|
:title="t('common.delete')"
|
|
>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</section>
|
|
</section>
|
|
</a-tab-pane>
|
|
|
|
<a-tab-pane key="competitors" :tab="t('brands.tabs.competitors')">
|
|
<section class="brands-view__competitors-panel">
|
|
<div class="brands-view__section-head brands-view__section-head--compact">
|
|
<div>
|
|
<p class="eyebrow">{{ t('brands.sections.competitors') }}</p>
|
|
<h3>{{ t('brands.sections.competitors') }}</h3>
|
|
</div>
|
|
<a-button type="primary" size="small" @click="openCompetitorModal()">
|
|
{{ t('brands.actions.newCompetitor') }}
|
|
</a-button>
|
|
</div>
|
|
|
|
<div v-if="competitorsQuery.data.value?.length" class="brands-view__competitor-grid">
|
|
<article
|
|
v-for="competitor in competitorsQuery.data.value"
|
|
:key="competitor.id"
|
|
class="brands-view__competitor-card"
|
|
>
|
|
<div class="brands-view__competitor-head">
|
|
<div>
|
|
<h4>{{ competitor.name }}</h4>
|
|
<a :href="competitor.website || undefined" target="_blank" rel="noreferrer">
|
|
{{ competitor.website || '--' }}
|
|
</a>
|
|
</div>
|
|
<div class="brands-view__inline-actions">
|
|
<a-tooltip :title="t('common.edit')">
|
|
<a-button
|
|
type="text"
|
|
shape="circle"
|
|
size="small"
|
|
class="action-btn action-edit"
|
|
@click="openCompetitorModal(competitor)"
|
|
>
|
|
<EditOutlined />
|
|
</a-button>
|
|
</a-tooltip>
|
|
<a-popconfirm @confirm="competitorMutations.remove.mutate(competitor.id)">
|
|
<template #title>{{ t('common.delete') }}</template>
|
|
<a-button
|
|
type="text"
|
|
shape="circle"
|
|
size="small"
|
|
class="action-btn action-delete"
|
|
:title="t('common.delete')"
|
|
>
|
|
<DeleteOutlined />
|
|
</a-button>
|
|
</a-popconfirm>
|
|
</div>
|
|
</div>
|
|
<p>{{ competitor.description || '--' }}</p>
|
|
<div class="brands-view__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')" />
|
|
</section>
|
|
</a-tab-pane>
|
|
</a-tabs>
|
|
</template>
|
|
|
|
<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="keywordModalOpen"
|
|
:title="editingKeywordId ? t('common.edit') : t('brands.actions.newKeyword')"
|
|
centered
|
|
@ok="submitKeyword"
|
|
>
|
|
<a-form layout="vertical">
|
|
<a-form-item :label="t('brands.form.keywordName')">
|
|
<a-input v-model:value="keywordForm.name" />
|
|
</a-form-item>
|
|
</a-form>
|
|
</a-modal>
|
|
|
|
<a-modal
|
|
v-model:open="questionModalOpen"
|
|
:title="editingQuestionId ? t('common.edit') : t('brands.actions.newQuestion')"
|
|
centered
|
|
@ok="submitQuestion"
|
|
>
|
|
<a-form layout="vertical">
|
|
<a-form-item :label="t('brands.sections.keywords')">
|
|
<a-select
|
|
v-model:value="questionForm.keyword_id"
|
|
:options="
|
|
(keywordsQuery.data.value || []).map((item) => ({ label: item.name, value: item.id }))
|
|
"
|
|
/>
|
|
</a-form-item>
|
|
<a-form-item :label="t('brands.form.questionText')">
|
|
<a-textarea v-model:value="questionForm.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: 22px;
|
|
}
|
|
|
|
.brands-view__top-card,
|
|
.brands-view__brand-rail,
|
|
.brands-view__keywords-panel,
|
|
.brands-view__questions-panel,
|
|
.brands-view__competitors-panel {
|
|
padding: 24px;
|
|
background: #fff;
|
|
border: 1px solid #e6edf5;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.brands-view__top-card {
|
|
padding: 0;
|
|
}
|
|
|
|
.brands-view__header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 24px;
|
|
}
|
|
|
|
.brands-view__quota-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 12px;
|
|
padding: 0 24px 24px;
|
|
border-top: 1px solid #eef3f8;
|
|
}
|
|
|
|
.brands-view__quota-card {
|
|
padding: 16px 18px;
|
|
border: 1px solid #e6edf5;
|
|
border-radius: 12px;
|
|
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
|
|
}
|
|
|
|
.brands-view__quota-card--plan {
|
|
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
|
|
border-color: #cfe0ff;
|
|
}
|
|
|
|
.brands-view__quota-label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
font-size: 12px;
|
|
letter-spacing: 0.04em;
|
|
color: #6b7280;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.brands-view__quota-card strong {
|
|
display: block;
|
|
font-size: 22px;
|
|
line-height: 1.2;
|
|
color: #111827;
|
|
}
|
|
|
|
.brands-view__quota-card p {
|
|
margin: 8px 0 0;
|
|
color: #6b7280;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.brands-view__header-title h2 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #1a1a1a;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.brands-view__header-title p {
|
|
margin: 6px 0 0 0;
|
|
font-size: 13px;
|
|
color: #8c8c8c;
|
|
}
|
|
|
|
.brands-view__header-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-left: auto; /* explicit push to right just in case */
|
|
}
|
|
|
|
.brands-view__section-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.brands-view__section-head h3 {
|
|
margin: 6px 0 0;
|
|
font-size: 20px;
|
|
font-weight: 800;
|
|
color: #111827;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.brands-view__section-head h3::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 4px;
|
|
height: 18px;
|
|
border-radius: 2px;
|
|
background: #1f5cff;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
.brands-view__section-head--compact h3 {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.brands-view__section-head--compact h3::before {
|
|
height: 16px;
|
|
}
|
|
|
|
.brands-view__section-hint {
|
|
margin: 8px 0 0;
|
|
color: #6b7280;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.brands-view__dot {
|
|
margin: 0 8px;
|
|
color: #c0cad8;
|
|
}
|
|
|
|
.brands-view__brand-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
.brands-view__brand-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
padding: 18px;
|
|
background: #fff;
|
|
border: 1px solid #e6edf5;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition:
|
|
transform 0.2s,
|
|
box-shadow 0.2s;
|
|
}
|
|
|
|
.brands-view__brand-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.brands-view__brand-card-actions,
|
|
.brands-view__keyword-actions,
|
|
.brands-view__competitor-head .brands-view__inline-actions {
|
|
opacity: 0;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.brands-view__brand-card:hover .brands-view__brand-card-actions,
|
|
.brands-view__brand-card:focus-within .brands-view__brand-card-actions,
|
|
.brands-view__keyword-item:hover .brands-view__keyword-actions,
|
|
.brands-view__keyword-item:focus-within .brands-view__keyword-actions,
|
|
.brands-view__competitor-card:hover .brands-view__competitor-head .brands-view__inline-actions,
|
|
.brands-view__competitor-card:focus-within
|
|
.brands-view__competitor-head
|
|
.brands-view__inline-actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
.brands-view__brand-card--active {
|
|
border-color: #bfd7ff;
|
|
background: #f0f7ff;
|
|
box-shadow: 0 18px 40px rgba(31, 92, 255, 0.08);
|
|
}
|
|
|
|
.brands-view__brand-card-top {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.brands-view__brand-card h4,
|
|
.brands-view__competitor-card h4 {
|
|
margin: 0;
|
|
}
|
|
|
|
.brands-view__brand-card p,
|
|
.brands-view__competitor-card p {
|
|
margin: 8px 0 0;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.brands-view__brand-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.brands-view__brand-meta span {
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
background: #edf4ff;
|
|
color: #2458d3;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.brands-view__brand-card-actions,
|
|
.brands-view__keyword-actions,
|
|
.brands-view__inline-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.brands-view__keyword-layout {
|
|
display: grid;
|
|
grid-template-columns: 320px minmax(0, 1fr);
|
|
gap: 20px;
|
|
}
|
|
|
|
.brands-view__keyword-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.brands-view__keyword-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
background: #fff;
|
|
border: 1px solid #e6edf5;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.brands-view__keyword-copy {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.brands-view__keyword-copy span {
|
|
font-weight: 600;
|
|
color: #111827;
|
|
}
|
|
|
|
.brands-view__keyword-copy small {
|
|
color: #6b7280;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.brands-view__keyword-item:hover {
|
|
border-color: #bfd7ff;
|
|
background: #f0f7ff;
|
|
}
|
|
|
|
.brands-view__keyword-item--active {
|
|
border-color: #bfd7ff;
|
|
background: #eef5ff;
|
|
}
|
|
|
|
.brands-view__question-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.brands-view__datetime {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.brands-view__competitor-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
.brands-view__competitor-card {
|
|
padding: 18px;
|
|
background: #fff;
|
|
border: 1px solid #e6edf5;
|
|
border-radius: 12px;
|
|
transition:
|
|
transform 0.2s,
|
|
box-shadow 0.2s;
|
|
}
|
|
|
|
.brands-view__competitor-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.brands-view__competitor-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.brands-view__competitor-head a {
|
|
color: #1f5cff;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.brands-view__competitor-lines {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.brands-view__competitor-lines span {
|
|
padding: 6px 10px;
|
|
background: #e8f0ff;
|
|
border-radius: 999px;
|
|
color: #1f5cff;
|
|
font-size: 12px;
|
|
}
|
|
|
|
@media (max-width: 1080px) {
|
|
.brands-view__quota-strip,
|
|
.brands-view__brand-grid,
|
|
.brands-view__keyword-layout,
|
|
.brands-view__competitor-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.brands-view__header,
|
|
.brands-view__section-head {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
}
|
|
</style>
|