Competitors.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. <template>
  2. <div class="p-6 max-w-3xl mx-auto">
  3. <div class="mb-6">
  4. <h1 class="text-2xl font-bold text-white">{{ t('competitors.sectionTitle') }}</h1>
  5. <p class="text-gray-400 mt-1">{{ t('competitors.sectionSubtitle') }}</p>
  6. </div>
  7. <div v-if="competitorStore.error" class="mb-4 p-3 bg-red-900/40 border border-red-700 rounded text-red-300 text-sm">
  8. {{ competitorStore.error }}
  9. </div>
  10. <!-- Competitor cards -->
  11. <div v-if="competitorStore.competitors.length" class="space-y-4 mb-6">
  12. <div
  13. v-for="competitor in competitorStore.competitors"
  14. :key="competitor._id"
  15. class="bg-gray-800 border border-gray-700 rounded-lg p-5"
  16. >
  17. <!-- Header row -->
  18. <div class="flex items-start justify-between gap-3 mb-3">
  19. <div class="flex-1 min-w-0">
  20. <template v-if="editingId === competitor._id">
  21. <input
  22. v-model="editForm.name"
  23. class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-white text-sm mb-2 focus:outline-none focus:border-violet-500"
  24. :placeholder="t('competitors.namePlaceholder')"
  25. />
  26. <input
  27. v-model="editForm.websiteUrl"
  28. class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-violet-500"
  29. :placeholder="t('competitors.websitePlaceholder')"
  30. />
  31. </template>
  32. <template v-else>
  33. <div class="font-semibold text-white">{{ competitor.name }}</div>
  34. <a :href="competitor.websiteUrl" target="_blank" rel="noopener" class="text-violet-400 text-sm hover:underline truncate block">{{ competitor.websiteUrl }}</a>
  35. </template>
  36. </div>
  37. <div class="flex gap-2 shrink-0">
  38. <template v-if="editingId === competitor._id">
  39. <button @click="saveEdit(competitor._id)" class="text-xs px-3 py-1 bg-violet-600 hover:bg-violet-500 text-white rounded">{{ t('competitors.save') }}</button>
  40. <button @click="cancelEdit" class="text-xs px-3 py-1 bg-gray-600 hover:bg-gray-500 text-white rounded">{{ t('competitors.cancel') }}</button>
  41. </template>
  42. <template v-else>
  43. <button @click="startEdit(competitor)" class="text-xs px-3 py-1 bg-gray-600 hover:bg-gray-500 text-white rounded">{{ t('competitors.edit') }}</button>
  44. <button @click="confirmDelete(competitor._id)" class="text-xs px-3 py-1 bg-red-800 hover:bg-red-700 text-white rounded">{{ t('competitors.delete') }}</button>
  45. </template>
  46. </div>
  47. </div>
  48. <!-- Social URLs collapsible -->
  49. <details class="mb-3">
  50. <summary class="text-sm text-gray-400 cursor-pointer hover:text-gray-200 select-none">{{ t('competitors.socialUrls') }}</summary>
  51. <div class="mt-2 space-y-1.5">
  52. <div v-for="platform in socialPlatforms" :key="platform.key" class="flex items-center gap-2">
  53. <i :class="platform.icon" class="w-4 text-center text-gray-400 text-sm"></i>
  54. <input
  55. :value="getEditSocialUrl(competitor, platform.key)"
  56. @change="setSocialUrl(competitor, platform.key, ($event.target as HTMLInputElement).value)"
  57. @blur="saveSocialUrl(competitor)"
  58. class="flex-1 bg-gray-700 border border-gray-600 rounded px-2.5 py-1 text-white text-xs focus:outline-none focus:border-violet-500"
  59. :placeholder="platform.placeholder"
  60. />
  61. </div>
  62. </div>
  63. </details>
  64. <!-- Action buttons -->
  65. <div class="flex flex-wrap gap-2 mb-3">
  66. <button
  67. @click="competitorStore.scrapeCompetitor(competitor._id)"
  68. :disabled="competitorStore.scraping[competitor._id]"
  69. class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-gray-600 hover:bg-gray-500 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
  70. >
  71. <i class="fa-solid fa-rotate" :class="{ 'animate-spin': competitorStore.scraping[competitor._id] }"></i>
  72. {{ competitorStore.scraping[competitor._id] ? t('competitors.scraping') : t('competitors.scrapeNow') }}
  73. </button>
  74. <button
  75. @click="competitorStore.summarizeCompetitor(competitor._id)"
  76. :disabled="competitorStore.summarizing[competitor._id] || !competitor.scrapedContent.length"
  77. class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
  78. >
  79. <i class="fa-solid fa-wand-magic-sparkles" :class="{ 'animate-pulse': competitorStore.summarizing[competitor._id] }"></i>
  80. {{ competitorStore.summarizing[competitor._id] ? t('competitors.summarizing') : t('competitors.summarizeAi') }}
  81. </button>
  82. <button
  83. @click="competitorStore.extractKeywords(competitor._id)"
  84. :disabled="competitorStore.extractingKeywords[competitor._id] || !competitor.scrapedContent.length"
  85. class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-blue-700 hover:bg-blue-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
  86. >
  87. <i class="fa-solid fa-tags" :class="{ 'animate-pulse': competitorStore.extractingKeywords[competitor._id] }"></i>
  88. {{ competitorStore.extractingKeywords[competitor._id] ? t('competitors.extractingKeywords') : t('competitors.extractKeywords') }}
  89. </button>
  90. </div>
  91. <!-- Scrape result message -->
  92. <div v-if="competitorStore.scrapeResults[competitor._id]" class="mb-3 text-xs px-3 py-1.5 rounded" :class="competitorStore.scrapeResults[competitor._id].ok ? 'bg-green-900/40 text-green-300' : 'bg-amber-900/40 text-amber-300'">
  93. {{ competitorStore.scrapeResults[competitor._id].ok
  94. ? (competitorStore.scrapeResults[competitor._id].sources > 0
  95. ? t('competitors.scrapeSuccess', { count: competitorStore.scrapeResults[competitor._id].sources })
  96. : t('competitors.scrapeNoContent'))
  97. : competitorStore.scrapeResults[competitor._id].message }}
  98. </div>
  99. <!-- Last scraped -->
  100. <div v-if="competitor.lastScraped" class="text-xs text-gray-500 mb-3">
  101. {{ t('competitors.lastScraped') }}: {{ new Date(competitor.lastScraped).toLocaleString() }}
  102. </div>
  103. <!-- AI Summary -->
  104. <div v-if="competitor.aiSummary" class="mb-3 p-3 bg-gray-700/50 rounded border border-gray-600 text-sm text-gray-200">
  105. <div class="text-xs text-violet-400 font-medium mb-1">{{ t('competitors.aiSummaryLabel') }}</div>
  106. {{ competitor.aiSummary }}
  107. </div>
  108. <!-- Keywords -->
  109. <div v-if="competitor.keywords && competitor.keywords.length" class="mt-3">
  110. <div class="text-xs text-blue-400 font-medium mb-2">{{ t('competitors.keywordsLabel') }}</div>
  111. <div class="flex flex-wrap gap-1.5">
  112. <span
  113. v-for="kw in competitor.keywords"
  114. :key="kw"
  115. class="inline-block text-xs px-2 py-0.5 bg-blue-900/40 border border-blue-700/50 text-blue-300 rounded-full"
  116. >{{ kw }}</span>
  117. </div>
  118. </div>
  119. </div>
  120. </div>
  121. <!-- Empty state -->
  122. <div v-else-if="!competitorStore.loading" class="mb-6 p-8 text-center bg-gray-800 border border-gray-700 rounded-lg text-gray-400">
  123. {{ t('competitors.emptyState') }}
  124. </div>
  125. <!-- Add competitor form -->
  126. <div v-if="competitorStore.competitors.length < 2" class="bg-gray-800 border border-gray-700 rounded-lg p-5">
  127. <h2 class="text-sm font-semibold text-white mb-3">{{ t('competitors.addCompetitor') }}</h2>
  128. <div class="space-y-2">
  129. <input
  130. v-model="newForm.name"
  131. class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-violet-500"
  132. :placeholder="t('competitors.namePlaceholder')"
  133. />
  134. <input
  135. v-model="newForm.websiteUrl"
  136. class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-violet-500"
  137. :placeholder="t('competitors.websitePlaceholder')"
  138. />
  139. </div>
  140. <button
  141. @click="createCompetitor"
  142. :disabled="!newForm.name.trim() || !newForm.websiteUrl.trim()"
  143. class="mt-3 px-4 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm rounded disabled:opacity-50 disabled:cursor-not-allowed"
  144. >
  145. {{ t('competitors.addButton') }}
  146. </button>
  147. </div>
  148. <p v-else class="text-xs text-gray-500 text-center">{{ t('competitors.maxReached') }}</p>
  149. </div>
  150. </template>
  151. <script setup lang="ts">
  152. import { ref, reactive, onMounted } from 'vue'
  153. import { useI18n } from 'vue-i18n'
  154. import { useCompetitorStore, type Competitor } from '../stores/competitors'
  155. const { t } = useI18n()
  156. const competitorStore = useCompetitorStore()
  157. const socialPlatforms = [
  158. { key: 'twitter', icon: 'fa-brands fa-x-twitter', placeholder: 'https://twitter.com/username' },
  159. { key: 'facebook', icon: 'fa-brands fa-facebook', placeholder: 'https://facebook.com/page' },
  160. { key: 'instagram', icon: 'fa-brands fa-instagram', placeholder: 'https://instagram.com/username' },
  161. { key: 'linkedin', icon: 'fa-brands fa-linkedin', placeholder: 'https://linkedin.com/company/name' },
  162. { key: 'bluesky', icon: 'fa-brands fa-bluesky', placeholder: 'https://bsky.app/profile/handle.bsky.social' },
  163. { key: 'mastodon', icon: 'fa-brands fa-mastodon', placeholder: 'https://mastodon.social/@username' },
  164. { key: 'tiktok', icon: 'fa-brands fa-tiktok', placeholder: 'https://tiktok.com/@username' },
  165. { key: 'youtube', icon: 'fa-brands fa-youtube', placeholder: 'https://youtube.com/@channel' },
  166. { key: 'pinterest', icon: 'fa-brands fa-pinterest', placeholder: 'https://pinterest.com/username' },
  167. ]
  168. const newForm = reactive({ name: '', websiteUrl: '' })
  169. const editingId = ref<string | null>(null)
  170. const editForm = reactive({ name: '', websiteUrl: '' })
  171. const pendingSocialUrls = reactive<Record<string, Record<string, string>>>({})
  172. function getEditSocialUrl(competitor: Competitor, platform: string): string {
  173. return pendingSocialUrls[competitor._id]?.[platform] ?? competitor.socialUrls?.[platform] ?? ''
  174. }
  175. function setSocialUrl(competitor: Competitor, platform: string, value: string) {
  176. if (!pendingSocialUrls[competitor._id]) pendingSocialUrls[competitor._id] = {}
  177. pendingSocialUrls[competitor._id][platform] = value
  178. }
  179. async function saveSocialUrl(competitor: Competitor) {
  180. if (!pendingSocialUrls[competitor._id]) return
  181. const merged = { ...competitor.socialUrls }
  182. for (const [k, v] of Object.entries(pendingSocialUrls[competitor._id])) {
  183. if (v) merged[k] = v
  184. else delete merged[k]
  185. }
  186. await competitorStore.updateCompetitor(competitor._id, { socialUrls: merged })
  187. }
  188. async function createCompetitor() {
  189. if (!newForm.name.trim() || !newForm.websiteUrl.trim()) return
  190. const ok = await competitorStore.addCompetitor({ name: newForm.name.trim(), websiteUrl: newForm.websiteUrl.trim() })
  191. if (ok) {
  192. newForm.name = ''
  193. newForm.websiteUrl = ''
  194. }
  195. }
  196. function startEdit(competitor: Competitor) {
  197. editingId.value = competitor._id
  198. editForm.name = competitor.name
  199. editForm.websiteUrl = competitor.websiteUrl
  200. }
  201. function cancelEdit() {
  202. editingId.value = null
  203. }
  204. async function saveEdit(id: string) {
  205. await competitorStore.updateCompetitor(id, { name: editForm.name.trim(), websiteUrl: editForm.websiteUrl.trim() })
  206. editingId.value = null
  207. }
  208. async function confirmDelete(id: string) {
  209. if (confirm(t('competitors.confirmDelete'))) {
  210. await competitorStore.deleteCompetitor(id)
  211. }
  212. }
  213. onMounted(() => {
  214. competitorStore.fetchCompetitors()
  215. })
  216. </script>