chore(frontend): introduce prettier + eslint and prune unused code
- 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>
This commit is contained in:
@@ -1,134 +1,132 @@
|
||||
<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 { 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";
|
||||
import { brandsApi } from '@/lib/api'
|
||||
import { formatDateTime } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref("keywords");
|
||||
const selectedBrandId = ref<number | null>(null);
|
||||
const selectedKeywordId = ref<number | null>(null);
|
||||
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 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: "",
|
||||
});
|
||||
name: '',
|
||||
website: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const keywordForm = reactive({
|
||||
name: "",
|
||||
});
|
||||
name: '',
|
||||
})
|
||||
|
||||
const questionForm = reactive({
|
||||
keyword_id: undefined as number | undefined,
|
||||
question_text: "",
|
||||
});
|
||||
question_text: '',
|
||||
})
|
||||
|
||||
const competitorForm = reactive({
|
||||
name: "",
|
||||
website: "",
|
||||
description: "",
|
||||
product_lines: "",
|
||||
});
|
||||
name: '',
|
||||
website: '',
|
||||
description: '',
|
||||
product_lines: '',
|
||||
})
|
||||
|
||||
const brandListQuery = useQuery({
|
||||
queryKey: ["brands", "list"],
|
||||
queryKey: ['brands', 'list'],
|
||||
queryFn: () => brandsApi.list(),
|
||||
});
|
||||
})
|
||||
|
||||
const brandLibrarySummaryQuery = useQuery({
|
||||
queryKey: ["brands", "library-summary"],
|
||||
queryKey: ['brands', 'library-summary'],
|
||||
queryFn: () => brandsApi.getLibrarySummary(),
|
||||
});
|
||||
})
|
||||
|
||||
const keywordsQuery = useQuery({
|
||||
queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]),
|
||||
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"]),
|
||||
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]),
|
||||
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"]),
|
||||
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 brandLibrarySummary = computed<BrandLibrarySummary | null>(
|
||||
() => brandLibrarySummaryQuery.data.value ?? null,
|
||||
)
|
||||
|
||||
const selectedBrand = computed(() =>
|
||||
brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
||||
);
|
||||
const selectedBrand = computed(
|
||||
() => brandListQuery.data.value?.find((item) => item.id === selectedBrandId.value) ?? null,
|
||||
)
|
||||
|
||||
const questionCountByKeyword = computed(() => {
|
||||
const counts = new Map<number, number>();
|
||||
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);
|
||||
counts.set(item.keyword_id, (counts.get(item.keyword_id) ?? 0) + 1)
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
return counts
|
||||
})
|
||||
|
||||
const selectedBrandKeywordCount = computed(() => selectedBrand.value?.keyword_count ?? 0);
|
||||
const selectedBrandKeywordCount = computed(() => selectedBrand.value?.keyword_count ?? 0)
|
||||
const selectedKeywordQuestionCount = computed(() => {
|
||||
if (!selectedKeywordId.value) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
return questionCountByKeyword.value.get(selectedKeywordId.value) ?? 0;
|
||||
});
|
||||
return questionCountByKeyword.value.get(selectedKeywordId.value) ?? 0
|
||||
})
|
||||
|
||||
const brandLimitReached = computed(() => {
|
||||
if (!brandLibrarySummary.value) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return brandLibrarySummary.value.remaining_brands <= 0;
|
||||
});
|
||||
return brandLibrarySummary.value.remaining_brands <= 0
|
||||
})
|
||||
|
||||
const keywordLimitReached = computed(() => {
|
||||
if (!brandLibrarySummary.value) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return brandLibrarySummary.value.remaining_keywords <= 0;
|
||||
});
|
||||
return brandLibrarySummary.value.remaining_keywords <= 0
|
||||
})
|
||||
|
||||
const questionLimitReached = computed(() => {
|
||||
if (!brandLibrarySummary.value || !selectedKeywordId.value) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return selectedKeywordQuestionCount.value >= brandLibrarySummary.value.max_questions_per_keyword;
|
||||
});
|
||||
return selectedKeywordQuestionCount.value >= brandLibrarySummary.value.max_questions_per_keyword
|
||||
})
|
||||
|
||||
const brandMutations = {
|
||||
create: useMutation({
|
||||
@@ -139,13 +137,13 @@ const brandMutations = {
|
||||
description: brandForm.description.trim() || null,
|
||||
}),
|
||||
onSuccess: async (brand) => {
|
||||
message.success(t("brands.messages.createBrand"));
|
||||
brandModalOpen.value = false;
|
||||
selectedBrandId.value = brand.id;
|
||||
message.success(t('brands.messages.createBrand'))
|
||||
brandModalOpen.value = false
|
||||
selectedBrandId.value = brand.id
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
||||
])
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -157,28 +155,28 @@ const brandMutations = {
|
||||
description: brandForm.description.trim() || null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateBrand"));
|
||||
brandModalOpen.value = false;
|
||||
message.success(t('brands.messages.updateBrand'))
|
||||
brandModalOpen.value = false
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
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;
|
||||
message.success(t('brands.messages.deleteBrand'))
|
||||
selectedBrandId.value = null
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
||||
])
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const keywordMutations = {
|
||||
create: useMutation({
|
||||
@@ -187,12 +185,12 @@ const keywordMutations = {
|
||||
name: keywordForm.name.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createKeyword"));
|
||||
keywordModalOpen.value = false;
|
||||
message.success(t('brands.messages.createKeyword'))
|
||||
keywordModalOpen.value = false
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'keywords'] }),
|
||||
])
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -202,12 +200,12 @@ const keywordMutations = {
|
||||
name: keywordForm.name.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.updateKeyword"));
|
||||
keywordModalOpen.value = false;
|
||||
message.success(t('brands.messages.updateKeyword'))
|
||||
keywordModalOpen.value = false
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'keywords'] }),
|
||||
])
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -215,19 +213,19 @@ const keywordMutations = {
|
||||
mutationFn: (keywordId: number) =>
|
||||
brandsApi.removeKeyword(selectedBrandId.value as number, keywordId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteKeyword"));
|
||||
message.success(t('brands.messages.deleteKeyword'))
|
||||
if (selectedKeywordId.value === editingKeywordId.value) {
|
||||
selectedKeywordId.value = null;
|
||||
selectedKeywordId.value = null
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "keywords"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
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({
|
||||
@@ -237,12 +235,12 @@ const questionMutations = {
|
||||
question_text: questionForm.question_text.trim(),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.createQuestion"));
|
||||
questionModalOpen.value = false;
|
||||
message.success(t('brands.messages.createQuestion'))
|
||||
questionModalOpen.value = false
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'questions'] }),
|
||||
])
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -252,9 +250,11 @@ const questionMutations = {
|
||||
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"] });
|
||||
message.success(t('brands.messages.updateQuestion'))
|
||||
questionModalOpen.value = false
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['brands', selectedBrandId.value, 'questions'],
|
||||
})
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -262,15 +262,15 @@ const questionMutations = {
|
||||
mutationFn: (questionId: number) =>
|
||||
brandsApi.removeQuestion(selectedBrandId.value as number, questionId),
|
||||
onSuccess: async () => {
|
||||
message.success(t("brands.messages.deleteQuestion"));
|
||||
message.success(t('brands.messages.deleteQuestion'))
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["brands"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["brands", selectedBrandId.value, "questions"] }),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: ['brands'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['brands', selectedBrandId.value, 'questions'] }),
|
||||
])
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const competitorMutations = {
|
||||
create: useMutation({
|
||||
@@ -282,24 +282,32 @@ const competitorMutations = {
|
||||
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"] });
|
||||
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),
|
||||
}),
|
||||
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"] });
|
||||
message.success(t('brands.messages.updateCompetitor'))
|
||||
competitorModalOpen.value = false
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['brands', selectedBrandId.value, 'competitors'],
|
||||
})
|
||||
},
|
||||
onError: (error) => message.error(formatError(error)),
|
||||
}),
|
||||
@@ -307,190 +315,192 @@ const competitorMutations = {
|
||||
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"] });
|
||||
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 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('brands.sections.questions'),
|
||||
dataIndex: 'question_text',
|
||||
key: 'question_text',
|
||||
},
|
||||
{
|
||||
title: t("common.createdAt"),
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
title: t('common.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: t("common.actions"),
|
||||
key: "actions",
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
align: "right",
|
||||
align: 'right',
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
watch(
|
||||
() => brandListQuery.data.value,
|
||||
(brands) => {
|
||||
if (!selectedBrandId.value && brands?.length) {
|
||||
selectedBrandId.value = brands[0].id;
|
||||
selectedBrandId.value = brands[0].id
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
)
|
||||
|
||||
watch(
|
||||
() => keywordsQuery.data.value,
|
||||
(keywords) => {
|
||||
if (!keywords?.length) {
|
||||
selectedKeywordId.value = null;
|
||||
return;
|
||||
selectedKeywordId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const stillExists = keywords.some((item) => item.id === selectedKeywordId.value);
|
||||
const stillExists = keywords.some((item) => item.id === selectedKeywordId.value)
|
||||
if (!stillExists) {
|
||||
selectedKeywordId.value = keywords[0].id;
|
||||
selectedKeywordId.value = keywords[0].id
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
)
|
||||
|
||||
function parseProductLines(value: string): string[] | undefined {
|
||||
const lines = value
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return lines.length ? lines : undefined;
|
||||
.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 value.filter((item) => typeof item === 'string').join(', ')
|
||||
}
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
function openBrandModal(brand?: Brand): void {
|
||||
if (!brand && brandLimitReached.value) {
|
||||
message.warning(
|
||||
t("brands.messages.brandLimitReached", {
|
||||
t('brands.messages.brandLimitReached', {
|
||||
limit: brandLibrarySummary.value?.max_brands ?? 0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
editingBrandId.value = brand?.id ?? null;
|
||||
brandForm.name = brand?.name ?? "";
|
||||
brandForm.website = brand?.website ?? "";
|
||||
brandForm.description = brand?.description ?? "";
|
||||
brandModalOpen.value = true;
|
||||
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;
|
||||
message.warning(t('brands.messages.chooseBrand'))
|
||||
return
|
||||
}
|
||||
if (!keyword && keywordLimitReached.value) {
|
||||
message.warning(
|
||||
t("brands.messages.keywordLimitReached", {
|
||||
t('brands.messages.keywordLimitReached', {
|
||||
limit: brandLibrarySummary.value?.max_keywords ?? 0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
editingKeywordId.value = keyword?.id ?? null;
|
||||
keywordForm.name = keyword?.name ?? "";
|
||||
keywordModalOpen.value = true;
|
||||
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;
|
||||
message.warning(t('brands.messages.chooseBrand'))
|
||||
return
|
||||
}
|
||||
if (!question && questionLimitReached.value) {
|
||||
message.warning(
|
||||
t("brands.messages.questionLimitReached", {
|
||||
t('brands.messages.questionLimitReached', {
|
||||
limit: brandLibrarySummary.value?.max_questions_per_keyword ?? 0,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
editingQuestionId.value = question?.id ?? null;
|
||||
questionForm.keyword_id = question?.keyword_id ?? selectedKeywordId.value ?? undefined;
|
||||
questionForm.question_text = question?.question_text ?? "";
|
||||
questionModalOpen.value = true;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
return
|
||||
}
|
||||
if (editingBrandId.value) {
|
||||
await brandMutations.update.mutateAsync();
|
||||
return;
|
||||
await brandMutations.update.mutateAsync()
|
||||
return
|
||||
}
|
||||
await brandMutations.create.mutateAsync();
|
||||
await brandMutations.create.mutateAsync()
|
||||
}
|
||||
|
||||
async function submitKeyword(): Promise<void> {
|
||||
if (!keywordForm.name.trim()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (editingKeywordId.value) {
|
||||
await keywordMutations.update.mutateAsync();
|
||||
return;
|
||||
await keywordMutations.update.mutateAsync()
|
||||
return
|
||||
}
|
||||
await keywordMutations.create.mutateAsync();
|
||||
await keywordMutations.create.mutateAsync()
|
||||
}
|
||||
|
||||
async function submitQuestion(): Promise<void> {
|
||||
if (!questionForm.keyword_id) {
|
||||
message.warning(t("brands.messages.chooseKeyword"));
|
||||
return;
|
||||
message.warning(t('brands.messages.chooseKeyword'))
|
||||
return
|
||||
}
|
||||
if (!questionForm.question_text.trim()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (editingQuestionId.value) {
|
||||
await questionMutations.update.mutateAsync();
|
||||
return;
|
||||
await questionMutations.update.mutateAsync()
|
||||
return
|
||||
}
|
||||
await questionMutations.create.mutateAsync();
|
||||
await questionMutations.create.mutateAsync()
|
||||
}
|
||||
|
||||
async function submitCompetitor(): Promise<void> {
|
||||
if (!competitorForm.name.trim()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
if (editingCompetitorId.value) {
|
||||
await competitorMutations.update.mutateAsync();
|
||||
return;
|
||||
await competitorMutations.update.mutateAsync()
|
||||
return
|
||||
}
|
||||
await competitorMutations.create.mutateAsync();
|
||||
await competitorMutations.create.mutateAsync()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -505,35 +515,37 @@ async function submitCompetitor(): Promise<void> {
|
||||
<div class="brands-view__header-actions">
|
||||
<a-button type="primary" :disabled="brandLimitReached" @click="openBrandModal()">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ t("brands.newBrand") }}
|
||||
{{ 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>
|
||||
<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>
|
||||
<span class="brands-view__quota-label">{{ t('brands.quota.brands') }}</span>
|
||||
<strong>
|
||||
{{ brandLibrarySummary?.used_brands ?? 0 }} / {{ brandLibrarySummary?.max_brands ?? "--" }}
|
||||
{{ brandLibrarySummary?.used_brands ?? 0 }} /
|
||||
{{ brandLibrarySummary?.max_brands ?? '--' }}
|
||||
</strong>
|
||||
<p>{{ t("brands.quota.brandsHint") }}</p>
|
||||
<p>{{ t('brands.quota.brandsHint') }}</p>
|
||||
</div>
|
||||
<div class="brands-view__quota-card">
|
||||
<span class="brands-view__quota-label">{{ t("brands.quota.keywords") }}</span>
|
||||
<span class="brands-view__quota-label">{{ t('brands.quota.keywords') }}</span>
|
||||
<strong>
|
||||
{{ brandLibrarySummary?.used_keywords ?? 0 }} / {{ brandLibrarySummary?.max_keywords ?? "--" }}
|
||||
{{ brandLibrarySummary?.used_keywords ?? 0 }} /
|
||||
{{ brandLibrarySummary?.max_keywords ?? '--' }}
|
||||
</strong>
|
||||
<p>{{ t("brands.quota.keywordsHint") }}</p>
|
||||
<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>
|
||||
<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>
|
||||
@@ -541,8 +553,8 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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>
|
||||
<p class="eyebrow">{{ t('brands.railTitle') }}</p>
|
||||
<h3>{{ t('brands.railTitle') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -557,7 +569,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
<div class="brands-view__brand-card-top">
|
||||
<div>
|
||||
<h4>{{ brand.name }}</h4>
|
||||
<p>{{ brand.description || "--" }}</p>
|
||||
<p>{{ brand.description || '--' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="brands-view__brand-card-actions">
|
||||
@@ -573,7 +585,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm @confirm="brandMutations.remove.mutate(brand.id)">
|
||||
<template #title>{{ t("brands.deleteBrand") }}</template>
|
||||
<template #title>{{ t('brands.deleteBrand') }}</template>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -589,8 +601,8 @@ async function submitCompetitor(): Promise<void> {
|
||||
</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>
|
||||
<span>{{ t('brands.meta.keywordCount', { count: brand.keyword_count }) }}</span>
|
||||
<span>{{ t('brands.meta.questionCount', { count: brand.question_count }) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -604,14 +616,16 @@ async function submitCompetitor(): Promise<void> {
|
||||
<aside class="brands-view__keywords-panel">
|
||||
<div class="brands-view__section-head brands-view__section-head--compact">
|
||||
<div>
|
||||
<h3>{{ t("brands.sections.keywords") }}</h3>
|
||||
<h3>{{ t('brands.sections.keywords') }}</h3>
|
||||
<p class="brands-view__section-hint">
|
||||
{{ t("brands.meta.brandKeywordCount", { count: selectedBrandKeywordCount }) }}
|
||||
{{ 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,
|
||||
}) }}
|
||||
{{
|
||||
t('brands.meta.globalKeywordUsage', {
|
||||
used: brandLibrarySummary?.used_keywords ?? 0,
|
||||
total: brandLibrarySummary?.max_keywords ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<a-button
|
||||
@@ -620,7 +634,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
:disabled="keywordLimitReached"
|
||||
@click="openKeywordModal()"
|
||||
>
|
||||
{{ t("brands.actions.newKeyword") }}
|
||||
{{ t('brands.actions.newKeyword') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -636,10 +650,12 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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,
|
||||
}) }}
|
||||
{{
|
||||
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">
|
||||
@@ -655,7 +671,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm @confirm="keywordMutations.remove.mutate(keyword.id)">
|
||||
<template #title>{{ t("common.delete") }}</template>
|
||||
<template #title>{{ t('common.delete') }}</template>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -676,13 +692,15 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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="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,
|
||||
}) }}
|
||||
{{
|
||||
t('brands.meta.keywordQuestionUsage', {
|
||||
used: selectedKeywordQuestionCount,
|
||||
total: brandLibrarySummary?.max_questions_per_keyword ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<a-button
|
||||
@@ -691,7 +709,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
:disabled="!selectedKeywordId || questionLimitReached"
|
||||
@click="openQuestionModal()"
|
||||
>
|
||||
{{ t("brands.actions.newQuestion") }}
|
||||
{{ t('brands.actions.newQuestion') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -702,7 +720,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #emptyText>{{ t("brands.empty.questions") }}</template>
|
||||
<template #emptyText>{{ t('brands.empty.questions') }}</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'question_text'">
|
||||
<div class="brands-view__question-cell">
|
||||
@@ -710,7 +728,9 @@ async function submitCompetitor(): Promise<void> {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
<span class="brands-view__datetime">{{ formatDateTime(record.created_at) }}</span>
|
||||
<span class="brands-view__datetime">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
@@ -726,7 +746,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm @confirm="questionMutations.remove.mutate(record.id)">
|
||||
<template #title>{{ t("common.delete") }}</template>
|
||||
<template #title>{{ t('common.delete') }}</template>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -749,11 +769,11 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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>
|
||||
<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") }}
|
||||
{{ t('brands.actions.newCompetitor') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -767,7 +787,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
<div>
|
||||
<h4>{{ competitor.name }}</h4>
|
||||
<a :href="competitor.website || undefined" target="_blank" rel="noreferrer">
|
||||
{{ competitor.website || "--" }}
|
||||
{{ competitor.website || '--' }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="brands-view__inline-actions">
|
||||
@@ -783,7 +803,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-popconfirm @confirm="competitorMutations.remove.mutate(competitor.id)">
|
||||
<template #title>{{ t("common.delete") }}</template>
|
||||
<template #title>{{ t('common.delete') }}</template>
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
@@ -796,7 +816,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ competitor.description || "--" }}</p>
|
||||
<p>{{ competitor.description || '--' }}</p>
|
||||
<div class="brands-view__competitor-lines">
|
||||
<span
|
||||
v-for="line in Array.isArray(competitor.product_lines_json)
|
||||
@@ -857,7 +877,9 @@ async function submitCompetitor(): Promise<void> {
|
||||
<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 }))"
|
||||
:options="
|
||||
(keywordsQuery.data.value || []).map((item) => ({ label: item.name, value: item.id }))
|
||||
"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('brands.form.questionText')">
|
||||
@@ -1004,7 +1026,7 @@ async function submitCompetitor(): Promise<void> {
|
||||
}
|
||||
|
||||
.brands-view__section-head h3::before {
|
||||
content: "";
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
@@ -1047,12 +1069,14 @@ async function submitCompetitor(): Promise<void> {
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
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);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.brands-view__brand-card-actions,
|
||||
@@ -1067,7 +1091,9 @@ async function submitCompetitor(): Promise<void> {
|
||||
.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 {
|
||||
.brands-view__competitor-card:focus-within
|
||||
.brands-view__competitor-head
|
||||
.brands-view__inline-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1191,12 +1217,14 @@ async function submitCompetitor(): Promise<void> {
|
||||
background: #fff;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
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);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.brands-view__competitor-head {
|
||||
|
||||
Reference in New Issue
Block a user