Files
geo/apps/admin-web/src/views/BrandsView.vue
T
root fb05f07e84 refactor(admin-web): drop eyebrow labels from page heroes
Remove the redundant eyebrow text above page titles in PageHero,
BrandsView and TrackingView, and clean up the now-unused i18n keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:19:14 +08:00

1245 lines
38 KiB
Vue

<script setup lang="ts">
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import type { Brand, BrandLibrarySummary, Competitor, Keyword, Question } from "@geo/shared-types";
import type { TableColumnsType } 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>