feat: add brand library management features
- Introduced BrandLibrarySummary type to encapsulate brand library limits and usage. - Updated Brand interface to include keyword_count and question_count fields. - Implemented brand library limits in the backend, including max brands, keywords, and questions per keyword. - Added API endpoint to retrieve brand library summary. - Enhanced BrandsView.vue to display brand library usage and limits. - Implemented computed properties to manage brand, keyword, and question limits in the UI. - Updated mutation handlers to invalidate relevant queries upon creating/updating brands, keywords, and questions. - Added visual indicators for brand, keyword, and question limits in the UI. - Enhanced error handling for exceeding brand and keyword limits during creation.
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
} from "@ant-design/icons-vue";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { message } from "ant-design-vue";
|
||||
import type { Brand, Competitor, Keyword, Question } from "@geo/shared-types";
|
||||
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";
|
||||
@@ -58,6 +58,11 @@ const brandListQuery = useQuery({
|
||||
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)),
|
||||
@@ -82,6 +87,49 @@ const competitorsQuery = useQuery({
|
||||
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: () =>
|
||||
@@ -141,7 +189,10 @@ const keywordMutations = {
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createKeyword"));
|
||||
keywordModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -153,7 +204,10 @@ const keywordMutations = {
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateKeyword"));
|
||||
keywordModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -166,6 +220,7 @@ const keywordMutations = {
|
||||
selectedKeywordId.value = null;
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
@@ -184,7 +239,10 @@ const questionMutations = {
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createQuestion"));
|
||||
questionModalOpen.value = false;
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -205,7 +263,10 @@ const questionMutations = {
|
||||
brandsApi.removeQuestion(selectedBrandId.value as number, questionId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteQuestion"));
|
||||
await queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] });
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -319,6 +380,14 @@ function stringifyProductLines(value: unknown): string {
|
||||
}
|
||||
|
||||
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 ?? "";
|
||||
@@ -331,6 +400,14 @@ function openKeywordModal(keyword?: Keyword): void {
|
||||
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;
|
||||
@@ -341,6 +418,14 @@ function openQuestionModal(question?: Question): void {
|
||||
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 ?? "";
|
||||
@@ -419,12 +504,39 @@ async function submitCompetitor(): Promise<void> {
|
||||
<p>{{ t('brands.description') }}</p>
|
||||
</div>
|
||||
<div class="brands-view__header-actions">
|
||||
<a-button type="primary" @click="openBrandModal()">
|
||||
<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">
|
||||
@@ -476,6 +588,11 @@ async function submitCompetitor(): Promise<void> {
|
||||
</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')" />
|
||||
@@ -489,8 +606,21 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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" @click="openKeywordModal()">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="keywordLimitReached"
|
||||
@click="openKeywordModal()"
|
||||
>
|
||||
{{ t("brands.actions.newKeyword") }}
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -504,7 +634,15 @@ async function submitCompetitor(): Promise<void> {
|
||||
:class="{ 'brands-view__keyword-item--active': keyword.id === selectedKeywordId }"
|
||||
@click="selectedKeywordId = keyword.id"
|
||||
>
|
||||
<span>{{ keyword.name }}</span>
|
||||
<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
|
||||
@@ -541,8 +679,19 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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" @click="openQuestionModal()">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!selectedKeywordId || questionLimitReached"
|
||||
@click="openQuestionModal()"
|
||||
>
|
||||
{{ t("brands.actions.newQuestion") }}
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -775,6 +924,49 @@ async function submitCompetitor(): Promise<void> {
|
||||
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;
|
||||
@@ -830,6 +1022,17 @@ async function submitCompetitor(): Promise<void> {
|
||||
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));
|
||||
@@ -893,6 +1096,21 @@ async function submitCompetitor(): Promise<void> {
|
||||
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 {
|
||||
@@ -927,6 +1145,23 @@ async function submitCompetitor(): Promise<void> {
|
||||
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;
|
||||
@@ -992,6 +1227,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.brands-view__quota-strip,
|
||||
.brands-view__brand-grid,
|
||||
.brands-view__keyword-layout,
|
||||
.brands-view__competitor-grid {
|
||||
@@ -1000,6 +1236,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.brands-view__header,
|
||||
.brands-view__section-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
Reference in New Issue
Block a user