diff --git a/.github/workflows/landing.yml b/.github/workflows/landing.yml new file mode 100644 index 00000000..05949955 --- /dev/null +++ b/.github/workflows/landing.yml @@ -0,0 +1,52 @@ +name: Deploy Landing to GitHub Pages + +on: + push: + branches: [main] + paths: [landing/**] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + working-directory: landing + run: npm ci + + - name: Generate static site + working-directory: landing + env: + NUXT_APP_BASE_URL: /claude_agent_teams_ui/ + run: npx nuxt generate + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: landing/.output/public + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/landing/.eslintrc.cjs b/landing/.eslintrc.cjs new file mode 100644 index 00000000..774ebdad --- /dev/null +++ b/landing/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@nuxt/eslint-config"], +}; diff --git a/landing/.gitignore b/landing/.gitignore new file mode 100644 index 00000000..c7d2a8bc --- /dev/null +++ b/landing/.gitignore @@ -0,0 +1,9 @@ +node_modules +.nuxt +.output +.dist +.env + +# Large video files +public/video/*.mp4 +assets/video/*.mp4 diff --git a/landing/.prettierignore b/landing/.prettierignore new file mode 100644 index 00000000..a80a0fe2 --- /dev/null +++ b/landing/.prettierignore @@ -0,0 +1,5 @@ +node_modules +.nuxt +.output +.dist +.env diff --git a/landing/.prettierrc b/landing/.prettierrc new file mode 100644 index 00000000..521e2711 --- /dev/null +++ b/landing/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "trailingComma": "all" +} diff --git a/landing/README.md b/landing/README.md new file mode 100644 index 00000000..e818eb8a --- /dev/null +++ b/landing/README.md @@ -0,0 +1,20 @@ +# Claude Agent Teams Landing + +## Quick start + +```bash +pnpm install +pnpm dev +``` + +## Build (SSG) + +```bash +pnpm generate +pnpm preview +``` + +## Notes +- Static-first (SSG) by design. +- Locale auto-detection: cookie -> browser settings -> fallback `en`. +- Theme auto-detection: localStorage -> system preference -> fallback `light`. diff --git a/landing/app.vue b/landing/app.vue new file mode 100644 index 00000000..f8eacfa7 --- /dev/null +++ b/landing/app.vue @@ -0,0 +1,5 @@ + diff --git a/landing/assets/images/screenshots/dark.svg b/landing/assets/images/screenshots/dark.svg new file mode 100644 index 00000000..2a81b9dd --- /dev/null +++ b/landing/assets/images/screenshots/dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + Dark theme + diff --git a/landing/assets/images/screenshots/light.svg b/landing/assets/images/screenshots/light.svg new file mode 100644 index 00000000..d8294fdd --- /dev/null +++ b/landing/assets/images/screenshots/light.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + Light theme + diff --git a/landing/assets/images/screenshots/main_dark.png b/landing/assets/images/screenshots/main_dark.png new file mode 100644 index 00000000..6357c950 Binary files /dev/null and b/landing/assets/images/screenshots/main_dark.png differ diff --git a/landing/assets/images/screenshots/main_light.png b/landing/assets/images/screenshots/main_light.png new file mode 100644 index 00000000..3b14ee22 Binary files /dev/null and b/landing/assets/images/screenshots/main_light.png differ diff --git a/landing/assets/images/screenshots/record_dark.png b/landing/assets/images/screenshots/record_dark.png new file mode 100644 index 00000000..9b9fd284 Binary files /dev/null and b/landing/assets/images/screenshots/record_dark.png differ diff --git a/landing/assets/images/screenshots/record_light.png b/landing/assets/images/screenshots/record_light.png new file mode 100644 index 00000000..86c95892 Binary files /dev/null and b/landing/assets/images/screenshots/record_light.png differ diff --git a/landing/assets/images/screenshots/recording.svg b/landing/assets/images/screenshots/recording.svg new file mode 100644 index 00000000..e824a841 --- /dev/null +++ b/landing/assets/images/screenshots/recording.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Recording + diff --git a/landing/assets/images/screenshots/settings.svg b/landing/assets/images/screenshots/settings.svg new file mode 100644 index 00000000..d40150a5 --- /dev/null +++ b/landing/assets/images/screenshots/settings.svg @@ -0,0 +1,10 @@ + + + + + + + + + Settings + diff --git a/landing/assets/images/screenshots/settings_dark.png b/landing/assets/images/screenshots/settings_dark.png new file mode 100644 index 00000000..4748e234 Binary files /dev/null and b/landing/assets/images/screenshots/settings_dark.png differ diff --git a/landing/assets/images/screenshots/settings_light.png b/landing/assets/images/screenshots/settings_light.png new file mode 100644 index 00000000..dfa6461c Binary files /dev/null and b/landing/assets/images/screenshots/settings_light.png differ diff --git a/landing/assets/logo-128.webp b/landing/assets/logo-128.webp new file mode 100644 index 00000000..51e02888 Binary files /dev/null and b/landing/assets/logo-128.webp differ diff --git a/landing/assets/logo-192.png b/landing/assets/logo-192.png new file mode 100644 index 00000000..cfa81f1a Binary files /dev/null and b/landing/assets/logo-192.png differ diff --git a/landing/assets/styles/main.scss b/landing/assets/styles/main.scss new file mode 100644 index 00000000..4f1ead04 --- /dev/null +++ b/landing/assets/styles/main.scss @@ -0,0 +1,76 @@ +:root { + color-scheme: light dark; +} + +body { + margin: 0; + font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + background: rgb(var(--v-theme-background)); + color: rgb(var(--v-theme-on-background)); +} + +.page { + position: relative; + overflow: clip; +} + +.section { + padding: 64px 0; +} + +.section-title { + margin-bottom: 24px; +} + +.hero { + padding-top: 96px; +} + +.anchor-offset { + scroll-margin-top: 96px; +} + +/* Monospace accent font for technical elements */ +.mono { + font-family: "JetBrains Mono", "Fira Code", monospace; +} + +@media (max-width: 960px) { + .page { + padding: 32px 0 64px; + } + + .section { + padding: 48px 0; + } + + .hero { + padding-top: 72px; + } + + .anchor-offset { + scroll-margin-top: 72px; + } +} + +@media (max-width: 600px) { + .page { + padding: 24px 0 48px; + } + + .section { + padding: 40px 0; + } + + .hero { + padding-top: 64px; + } + + .anchor-offset { + scroll-margin-top: 64px; + } +} + +.app-header { + backdrop-filter: blur(10px); +} diff --git a/landing/components/PageBackground.vue b/landing/components/PageBackground.vue new file mode 100644 index 00000000..6fa18bdc --- /dev/null +++ b/landing/components/PageBackground.vue @@ -0,0 +1,139 @@ + + + diff --git a/landing/components/SectionDivider.vue b/landing/components/SectionDivider.vue new file mode 100644 index 00000000..da3bf95a --- /dev/null +++ b/landing/components/SectionDivider.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/landing/components/common/AppLogo.vue b/landing/components/common/AppLogo.vue new file mode 100644 index 00000000..5922b620 --- /dev/null +++ b/landing/components/common/AppLogo.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/landing/components/common/ThemeToggle.vue b/landing/components/common/ThemeToggle.vue new file mode 100644 index 00000000..742def30 --- /dev/null +++ b/landing/components/common/ThemeToggle.vue @@ -0,0 +1,39 @@ + + + diff --git a/landing/components/layout/AppFooter.vue b/landing/components/layout/AppFooter.vue new file mode 100644 index 00000000..4021f4d5 --- /dev/null +++ b/landing/components/layout/AppFooter.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/landing/components/layout/AppHeader.vue b/landing/components/layout/AppHeader.vue new file mode 100644 index 00000000..9e6e90ee --- /dev/null +++ b/landing/components/layout/AppHeader.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/landing/components/layout/LanguageSwitcher.vue b/landing/components/layout/LanguageSwitcher.vue new file mode 100644 index 00000000..ccec5d7e --- /dev/null +++ b/landing/components/layout/LanguageSwitcher.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/landing/components/sections/ComparisonSection.vue b/landing/components/sections/ComparisonSection.vue new file mode 100644 index 00000000..9d8a2629 --- /dev/null +++ b/landing/components/sections/ComparisonSection.vue @@ -0,0 +1,615 @@ + + + + + diff --git a/landing/components/sections/DownloadSection.vue b/landing/components/sections/DownloadSection.vue new file mode 100644 index 00000000..8237ab02 --- /dev/null +++ b/landing/components/sections/DownloadSection.vue @@ -0,0 +1,496 @@ + + + + + diff --git a/landing/components/sections/FAQSection.vue b/landing/components/sections/FAQSection.vue new file mode 100644 index 00000000..85fc9f14 --- /dev/null +++ b/landing/components/sections/FAQSection.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/landing/components/sections/FeaturesSection.vue b/landing/components/sections/FeaturesSection.vue new file mode 100644 index 00000000..36d31f9f --- /dev/null +++ b/landing/components/sections/FeaturesSection.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/landing/components/sections/HeroSection.vue b/landing/components/sections/HeroSection.vue new file mode 100644 index 00000000..84cad3a8 --- /dev/null +++ b/landing/components/sections/HeroSection.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/landing/components/sections/PricingSection.vue b/landing/components/sections/PricingSection.vue new file mode 100644 index 00000000..1cabc101 --- /dev/null +++ b/landing/components/sections/PricingSection.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/landing/components/sections/ScreenshotsSection.vue b/landing/components/sections/ScreenshotsSection.vue new file mode 100644 index 00000000..44eb07aa --- /dev/null +++ b/landing/components/sections/ScreenshotsSection.vue @@ -0,0 +1,499 @@ + + + + + diff --git a/landing/components/sections/TestimonialsSection.vue b/landing/components/sections/TestimonialsSection.vue new file mode 100644 index 00000000..eea83402 --- /dev/null +++ b/landing/components/sections/TestimonialsSection.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/landing/components/ui/FeatureCard.vue b/landing/components/ui/FeatureCard.vue new file mode 100644 index 00000000..72132724 --- /dev/null +++ b/landing/components/ui/FeatureCard.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/landing/components/ui/HeroDemo.vue b/landing/components/ui/HeroDemo.vue new file mode 100644 index 00000000..ee4033d4 --- /dev/null +++ b/landing/components/ui/HeroDemo.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/landing/components/ui/HeroDemoVideo.vue b/landing/components/ui/HeroDemoVideo.vue new file mode 100644 index 00000000..c13efdaa --- /dev/null +++ b/landing/components/ui/HeroDemoVideo.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/landing/composables/useAnalytics.ts b/landing/composables/useAnalytics.ts new file mode 100644 index 00000000..7778618c --- /dev/null +++ b/landing/composables/useAnalytics.ts @@ -0,0 +1,24 @@ +type DownloadEventParams = { + os: string; + arch: string; + version?: string | null; + source: string; +}; + +export const useAnalytics = () => { + const trackNavClick = (_target: string) => {}; + const trackLanguageSwitch = (_from: string, _to: string) => {}; + const trackThemeToggle = (_theme: "light" | "dark") => {}; + const trackDownloadClick = (_params: DownloadEventParams) => {}; + const trackSectionView = (_sectionId: string) => {}; + const trackFaqExpand = (_faqId: string, _question: string) => {}; + + return { + trackNavClick, + trackLanguageSwitch, + trackThemeToggle, + trackDownloadClick, + trackSectionView, + trackFaqExpand, + }; +}; diff --git a/landing/composables/useBrowserTheme.ts b/landing/composables/useBrowserTheme.ts new file mode 100644 index 00000000..c4de2a75 --- /dev/null +++ b/landing/composables/useBrowserTheme.ts @@ -0,0 +1,70 @@ +import { computed, watch, onUnmounted } from "vue"; +import { useThemeStore } from "~/stores/theme"; + +export const useBrowserTheme = () => { + const themeStore = useThemeStore(); + const { $vuetifyTheme } = useNuxtApp(); + const vuetifyTheme = $vuetifyTheme as { + global: { name: import("vue").Ref; current: import("vue").Ref }; + change: (name: string) => void; + } | null; + let mediaQueryHandler: ((event: MediaQueryListEvent) => void) | null = null; + let mediaQuery: MediaQueryList | null = null; + + const applyVuetifyTheme = (name: "light" | "dark") => { + if (!vuetifyTheme) return; + if (typeof vuetifyTheme.change === "function") { + vuetifyTheme.change(name); + } else { + vuetifyTheme.global.name.value = name; + } + }; + + const applyTheme = (name: "light" | "dark") => { + themeStore.setTheme(name, true); + applyVuetifyTheme(name); + }; + + const initTheme = () => { + if (!process.client) return; + const initialTheme = themeStore.getInitialTheme(); + themeStore.setTheme(initialTheme, false); + applyVuetifyTheme(initialTheme); + + if (process.client && !themeStore.userSelected) { + mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQueryHandler = (event: MediaQueryListEvent) => { + if (!themeStore.userSelected) { + const newTheme = event.matches ? "dark" : "light"; + themeStore.setTheme(newTheme, false); + applyVuetifyTheme(newTheme); + } + }; + mediaQuery.addEventListener("change", mediaQueryHandler); + } + }; + + const toggleTheme = () => { + applyTheme(themeStore.current === "dark" ? "light" : "dark"); + }; + + onUnmounted(() => { + if (mediaQuery && mediaQueryHandler) { + mediaQuery.removeEventListener("change", mediaQueryHandler); + } + }); + + watch( + () => themeStore.current, + (value) => { + applyVuetifyTheme(value as "light" | "dark"); + } + ); + + return { + currentTheme: computed(() => themeStore.current), + isDark: computed(() => themeStore.current === "dark"), + initTheme, + toggleTheme + }; +}; diff --git a/landing/composables/useLandingContent.ts b/landing/composables/useLandingContent.ts new file mode 100644 index 00000000..ac0c68da --- /dev/null +++ b/landing/composables/useLandingContent.ts @@ -0,0 +1,10 @@ +import { computed } from "vue"; +import { getContent } from "~/data/content"; +import type { LocaleCode } from "~/data/i18n"; + +export const useLandingContent = () => { + const { locale } = useI18n(); + const content = computed(() => getContent(locale.value as LocaleCode)); + + return { content }; +}; diff --git a/landing/composables/useLocation.ts b/landing/composables/useLocation.ts new file mode 100644 index 00000000..7bc1aab8 --- /dev/null +++ b/landing/composables/useLocation.ts @@ -0,0 +1,39 @@ +import { supportedLocales } from "~/data/i18n"; +import { useLocaleStore } from "~/stores/locale"; + +export const useLocation = () => { + const nuxtApp = useNuxtApp(); + const i18n = nuxtApp.$i18n; + const localeStore = useLocaleStore(); + const cookie = useCookie("i18n_redirected", { default: () => "" }); + + const getBrowserLocale = () => { + if (!process.client) return "en"; + const browserLocale = navigator.language || "en"; + const normalized = browserLocale.split("-")[0].toLowerCase(); + const supported = supportedLocales.map((item) => item.code); + return supported.includes(normalized) ? normalized : "en"; + }; + + const initLocale = () => { + if (cookie.value) { + localeStore.setLocale(cookie.value, false); + if (i18n?.setLocale) { + i18n.setLocale(cookie.value); + } else if (i18n?.locale?.value) { + i18n.locale.value = cookie.value; + } + return; + } + const detected = getBrowserLocale(); + localeStore.setLocale(detected, false); + if (i18n?.setLocale) { + i18n.setLocale(detected); + } else if (i18n?.locale?.value) { + i18n.locale.value = detected; + } + cookie.value = detected; + }; + + return { initLocale, getBrowserLocale }; +}; diff --git a/landing/composables/usePageSeo.ts b/landing/composables/usePageSeo.ts new file mode 100644 index 00000000..cf4b4736 --- /dev/null +++ b/landing/composables/usePageSeo.ts @@ -0,0 +1,165 @@ +import { computed } from "vue"; +import { supportedLocales, defaultLocale } from "~/data/i18n"; +import { getContent } from "~/data/content"; +import type { LocaleCode } from "~/data/i18n"; + +type PageSeoImage = { + url: string; + width?: number; + height?: number; + type?: string; + alt?: string; +}; + +type PageSeoOptions = { + type?: "website" | "article"; + robots?: string; + image?: PageSeoImage; +}; + +export const usePageSeo = (titleKey: string, descriptionKey: string, options: PageSeoOptions = {}) => { + const { t, locale } = useI18n(); + const route = useRoute(); + const config = useRuntimeConfig(); + const siteUrl = config.public.siteUrl || "https://example.com"; + const siteName = (config as any)?.site?.name || "Claude Agent Teams"; + const switchLocale = useSwitchLocalePath(); + + const title = computed(() => t(titleKey)); + const description = computed(() => t(descriptionKey)); + + const canonicalPath = computed(() => route.path); + const canonicalUrl = computed(() => `${siteUrl}${canonicalPath.value}`); + + const resolvedImage = computed(() => { + if (options.image) return options.image; + return { + url: "/og-image.png", + width: 1200, + height: 630, + type: "image/png", + alt: `${siteName} — AI agent orchestration` + }; + }); + + const resolvedImageUrl = computed(() => { + // Если сборщик вернул относительный путь — сделаем абсолютный. + const url = resolvedImage.value.url; + return url.startsWith("http") ? url : new URL(url, siteUrl).toString(); + }); + + useSeoMeta({ + title, + description, + ogTitle: title, + ogDescription: description, + ogType: options.type || "website", + ogSiteName: siteName, + ogUrl: canonicalUrl, + ogImage: resolvedImageUrl, + ogImageType: computed(() => resolvedImage.value.type), + ogImageWidth: computed(() => (resolvedImage.value.width ? String(resolvedImage.value.width) : undefined)), + ogImageHeight: computed(() => (resolvedImage.value.height ? String(resolvedImage.value.height) : undefined)), + ogImageAlt: computed(() => resolvedImage.value.alt), + twitterCard: "summary_large_image", + twitterTitle: title, + twitterDescription: description, + twitterImage: resolvedImageUrl, + twitterImageAlt: computed(() => resolvedImage.value.alt), + robots: + options.robots || + "index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" + }); + + useHead(() => { + const links = supportedLocales.map((locale) => { + const path = switchLocale(locale.code) || canonicalPath.value; + return { + rel: "alternate", + hreflang: locale.code, + href: `${siteUrl}${path}` + }; + }); + + const defaultPath = switchLocale(defaultLocale) || canonicalPath.value; + links.push({ rel: "alternate", hreflang: "x-default", href: `${siteUrl}${defaultPath}` }); + links.push({ rel: "canonical", href: canonicalUrl.value }); + + const jsonLd: any[] = [ + { + "@context": "https://schema.org", + "@type": "WebSite", + name: siteName, + url: siteUrl + }, + { + "@context": "https://schema.org", + "@type": "Organization", + name: siteName, + url: siteUrl, + logo: `${siteUrl}/favicon.ico`, + sameAs: [ + `https://github.com/${config.public.githubRepo}` + ] + } + ]; + + // Для главной и страницы скачивания добавим более "вкусную" разметку. + const isDownload = canonicalPath.value.endsWith("/download"); + const isHome = canonicalPath.value === "/"; + if (isHome || isDownload) { + jsonLd.push({ + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: siteName, + applicationCategory: "BusinessApplication", + operatingSystem: "Windows, macOS, Linux", + description: description.value, + url: canonicalUrl.value, + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD" + }, + downloadUrl: config.public.githubReleasesUrl || `https://github.com/${config.public.githubRepo}/releases` + }); + } + + // FAQ rich snippets — Google показывает их прямо в выдаче + if (isHome) { + const content = getContent(locale.value as LocaleCode); + if (content.faq?.length) { + jsonLd.push({ + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: content.faq.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { + "@type": "Answer", + // HTML-теги из ответа убираем для JSON-LD + text: item.answer.replace(/<[^>]*>/g, "") + } + })) + }); + } + } + + return { + htmlAttrs: { lang: locale.value || "en" }, + link: links, + meta: [ + { name: "author", content: "Claude Agent Teams" }, + { name: "application-name", content: siteName }, + { name: "apple-mobile-web-app-title", content: siteName }, + { name: "format-detection", content: "telephone=no" }, + { name: "theme-color", content: "#00f0ff" }, + { name: "keywords", content: "claude code, agent teams, AI agents, kanban board, code review, multi-agent orchestration, desktop app, free, open source" } + ], + script: jsonLd.map((item) => ({ + type: "application/ld+json", + children: JSON.stringify(item) + })) + }; + }); +}; diff --git a/landing/composables/useParallaxSections.ts b/landing/composables/useParallaxSections.ts new file mode 100644 index 00000000..ccaa2322 --- /dev/null +++ b/landing/composables/useParallaxSections.ts @@ -0,0 +1,60 @@ +import { ref, onMounted, onUnmounted, nextTick } from "vue"; + +/** + * Параллакс-эффект для фоновых орбов через одну секцию. + * На мобилке отключён — мешает touch-скроллу и жрёт батарею. + */ +export const useParallaxSections = (speed = 0.1) => { + const containerRef = ref(null); + let ticking = false; + let targets: { bg: HTMLElement; section: HTMLElement }[] = []; + + function collect() { + if (!containerRef.value) return; + targets = []; + const sections = containerRef.value.querySelectorAll(".section"); + sections.forEach((section, i) => { + if (i % 2 === 0) return; + const bg = section.querySelector('[class*="__bg"]'); + if (!bg) return; + bg.style.willChange = "transform"; + targets.push({ bg, section: section as HTMLElement }); + }); + } + + function update() { + for (const { bg, section } of targets) { + const rect = section.getBoundingClientRect(); + const offset = rect.top * speed; + bg.style.transform = `translateY(${offset}px)`; + } + } + + function onScroll() { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + update(); + ticking = false; + }); + } + + onMounted(async () => { + if (window.innerWidth < 768) return; + + await nextTick(); + // Ждём пока lazy-компоненты прогрузятся + setTimeout(() => { + collect(); + update(); + }, 600); + window.addEventListener("scroll", onScroll, { passive: true }); + }); + + onUnmounted(() => { + window.removeEventListener("scroll", onScroll); + targets = []; + }); + + return { containerRef }; +}; diff --git a/landing/composables/usePlatform.ts b/landing/composables/usePlatform.ts new file mode 100644 index 00000000..5cec0e36 --- /dev/null +++ b/landing/composables/usePlatform.ts @@ -0,0 +1,24 @@ +import { computed, onMounted, ref } from "vue"; +import { detectMacArch, detectPlatform } from "~/utils/platform"; + +export const usePlatform = () => { + const platform = ref("unknown"); + const arch = ref("unknown"); + + onMounted(() => { + const ua = navigator.userAgent; + platform.value = detectPlatform(ua); + if (platform.value === "macos") { + arch.value = detectMacArch(ua); + } + }); + + const label = computed(() => { + if (platform.value === "macos") return "macOS"; + if (platform.value === "windows") return "Windows"; + if (platform.value === "linux") return "Linux"; + return "your OS"; + }); + + return { platform, arch, label }; +}; diff --git a/landing/composables/useReleaseDownloads.ts b/landing/composables/useReleaseDownloads.ts new file mode 100644 index 00000000..cc876b4d --- /dev/null +++ b/landing/composables/useReleaseDownloads.ts @@ -0,0 +1,174 @@ +import type { DownloadArch, DownloadOs } from "~/data/downloads"; + +// --- Типы GitHub API --- + +type ReleaseAsset = { + name: string; + browser_download_url: string; + size: number; +}; + +type GitHubRelease = { + tag_name: string; + name: string; + body: string; + published_at: string; + assets: ReleaseAsset[]; +}; + +// --- Типы нашего API --- + +type Variant = { url: string | null; platformKey: string | null; version: string | null }; + +type DownloadsApiResponse = { + ok: boolean; + source: "github-releases"; + fetchedAt: string; + version: string | null; + notes: string | null; + pubDate: string | null; + variants: { + macos: { arm64: Variant; x64: Variant; universal: Variant }; + windows: { x64: Variant }; + linux: { appimage: Variant; deb: Variant }; + }; +}; + +type ResolveResult = { url: string; version: string | null } | null; + +// --- Парсинг GitHub Release → наш формат --- + +const CACHE_KEY = "cat_releases"; +const CACHE_TTL = 10 * 60 * 1000; // 10 минут + +const emptyVariant: Variant = { url: null, platformKey: null, version: null }; + +function findAsset(assets: ReleaseAsset[], pattern: RegExp): ReleaseAsset | null { + return assets.find((a) => pattern.test(a.name)) || null; +} + +function toVariant(asset: ReleaseAsset | null, version: string | null): Variant { + if (!asset) return { ...emptyVariant }; + return { url: asset.browser_download_url, platformKey: asset.name, version }; +} + +function parseGitHubRelease(release: GitHubRelease): DownloadsApiResponse { + const version = release.tag_name?.replace(/^v/, "") || null; + const assets = (release.assets || []).filter( + (a) => !a.name.endsWith(".sig") && !a.name.endsWith(".json") && !a.name.endsWith(".tar.gz") + ); + + return { + ok: assets.length > 0, + source: "github-releases", + fetchedAt: new Date().toISOString(), + version, + notes: release.body || null, + pubDate: release.published_at || null, + variants: { + macos: { + arm64: toVariant(findAsset(assets, /[-_]arm64\.dmg$/i), version), + x64: toVariant(findAsset(assets, /[-_]x64\.dmg$/i), version), + universal: { ...emptyVariant }, + }, + windows: { + x64: toVariant( + findAsset(assets, /[-_]Setup\.exe$/i) || findAsset(assets, /\.exe$/i) || findAsset(assets, /\.msi$/i), + version + ), + }, + linux: { + appimage: toVariant(findAsset(assets, /\.AppImage$/i), version), + deb: toVariant(findAsset(assets, /\.deb$/i), version), + }, + }, + }; +} + +// --- sessionStorage кеш --- + +function readCache(): DownloadsApiResponse | null { + try { + const raw = sessionStorage.getItem(CACHE_KEY); + if (!raw) return null; + const { ts, data } = JSON.parse(raw); + if (Date.now() - ts > CACHE_TTL) return null; + return data; + } catch { + return null; + } +} + +function writeCache(data: DownloadsApiResponse): void { + try { + sessionStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), data })); + } catch { + // sessionStorage может быть недоступен (private mode и т.д.) + } +} + +// --- Composable --- + +export const useReleaseDownloads = () => { + const config = useRuntimeConfig(); + const githubRepo = (config.public.githubRepo as string) || "777genius/claude_agent_teams_ui"; + + const fallbackUrl = + (config.public.githubReleasesUrl as string) || + `https://github.com/${githubRepo}/releases`; + + // useAsyncData дедуплицирует запросы по ключу — все компоненты шарят один результат + const { data, pending, error } = useAsyncData("releases", async () => { + const cached = readCache(); + if (cached) return cached; + + const release = await $fetch( + `https://api.github.com/repos/${githubRepo}/releases/latest`, + { + headers: { Accept: "application/vnd.github+json" }, + } + ); + + const parsed = parseGitHubRelease(release); + writeCache(parsed); + return parsed; + }, { + server: false, + lazy: true, + }); + + const resolve = (os: DownloadOs, arch: DownloadArch | "unknown"): ResolveResult => { + const api = data.value; + if (!api?.ok) return null; + + if (os === "windows") { + const v = api.variants.windows.x64; + return v.url ? { url: v.url, version: v.version || api.version } : null; + } + + if (os === "linux") { + const v = api.variants.linux.appimage.url ? api.variants.linux.appimage : api.variants.linux.deb; + return v.url ? { url: v.url, version: v.version || api.version } : null; + } + + // macOS: сначала universal, потом по архитектуре + if (os === "macos") { + const universal = api.variants.macos.universal; + if (universal.url) return { url: universal.url, version: universal.version || api.version }; + + const byArch = arch === "arm64" ? api.variants.macos.arm64 : api.variants.macos.x64; + if (byArch.url) return { url: byArch.url, version: byArch.version || api.version }; + + const any = api.variants.macos.arm64.url ? api.variants.macos.arm64 : api.variants.macos.x64; + return any.url ? { url: any.url, version: any.version || api.version } : null; + } + + return null; + }; + + const resolveUrlOrFallback = (os: DownloadOs, arch: DownloadArch | "unknown"): string => { + return resolve(os, arch)?.url || fallbackUrl; + }; + + return { data, pending, error, fallbackUrl, resolve, resolveUrlOrFallback }; +}; diff --git a/landing/composables/useTrackSections.ts b/landing/composables/useTrackSections.ts new file mode 100644 index 00000000..46a592f7 --- /dev/null +++ b/landing/composables/useTrackSections.ts @@ -0,0 +1,35 @@ +import { sectionOrder } from "~/data/sections"; + +export const useTrackSections = () => { + if (!import.meta.client) return; + + const { trackSectionView } = useAnalytics(); + const seen = new Set(); + + let observer: IntersectionObserver | null = null; + + onMounted(() => { + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const id = entry.target.id; + if (!id || seen.has(id)) continue; + seen.add(id); + trackSectionView(id); + observer?.unobserve(entry.target); + } + }, + { threshold: 0.3, rootMargin: "0px 0px -10% 0px" }, + ); + + for (const sectionId of sectionOrder) { + const el = document.getElementById(sectionId); + if (el) observer.observe(el); + } + }); + + onUnmounted(() => { + observer?.disconnect(); + }); +}; diff --git a/landing/content/en.json b/landing/content/en.json new file mode 100644 index 00000000..3b5c58ed --- /dev/null +++ b/landing/content/en.json @@ -0,0 +1,102 @@ +{ + "hero": { + "title": "Claude Agent Teams", + "subtitle": "You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee." + }, + "features": [ + { + "id": "agentTeams", + "title": "Agent Teams", + "description": "Create teams with different roles. Agents work autonomously in parallel, communicate with each other, and collaborate across teams." + }, + { + "id": "kanban", + "title": "Kanban Board", + "description": "Tasks change status in real-time as agents work. Drag, assign, review — all on a visual board." + }, + { + "id": "codeReview", + "title": "Code Review", + "description": "Diff view per task with accept, reject, and comment. Built-in code editor with Git support." + }, + { + "id": "crossTeam", + "title": "Cross-Team Communication", + "description": "Agents message each other within and across teams. Direct messaging, task comments, and quick actions." + }, + { + "id": "soloMode", + "title": "Solo Mode", + "description": "Start with a single agent that self-manages tasks. Expand to a full team whenever you need more power." + }, + { + "id": "liveProcesses", + "title": "Live Processes", + "description": "See running agents, open URLs in browser, monitor token usage and session context in real-time." + } + ], + "faq": [ + { + "id": "whatIsIt", + "question": "What is Claude Agent Teams?", + "answer": "A desktop app that lets you assemble AI agent teams powered by Claude Code. Each agent has a role, works autonomously, and collaborates with teammates — all managed through a kanban board." + }, + { + "id": "isFree", + "question": "Is it really free?", + "answer": "Yes. 100% free, open source, no API keys required. The app runs entirely locally using your existing Claude Code setup." + }, + { + "id": "platforms", + "question": "Which platforms are supported?", + "answer": "macOS (Apple Silicon and Intel), Windows, and Linux." + }, + { + "id": "howItWorks", + "question": "How does it work?", + "answer": "Install the app, create a team, assign roles — agents start working in parallel. You monitor progress on the kanban board, review code diffs, and communicate with agents directly." + }, + { + "id": "privacy", + "question": "Is my code private?", + "answer": "Everything runs locally on your machine. No data is sent to external servers. Your code, conversations, and agent activity stay entirely private." + }, + { + "id": "requirements", + "question": "What do I need to get started?", + "answer": "Just install the app — it includes built-in Claude Code installation and authentication. Zero-setup onboarding gets you running in minutes." + } + ], + "download": { + "title": "Download", + "note": "Choose your platform and start building with AI agent teams." + }, + "testimonials": [ + { "id": "user1", "name": "Alex K.", "role": "Tech Lead", "text": "Finally, a tool that lets me manage AI agents like I manage my engineering team. The kanban board is a game-changer for keeping track of parallel agent work." }, + { "id": "user2", "name": "Sarah M.", "role": "Full-stack Developer", "text": "Solo mode is perfect for quick tasks. When I need more firepower, I spin up a full team in seconds. The cross-team communication just works." }, + { "id": "user3", "name": "David R.", "role": "Senior Engineer", "text": "The code review workflow is brilliant — diff view per task, accept/reject, comments. It's like having a team of junior devs that actually follow instructions." }, + { "id": "user4", "name": "Yuki T.", "role": "DevOps Engineer", "text": "Live process monitoring and context tracking are incredibly useful. I can see exactly what each agent is doing and how much context they're using." }, + { "id": "user5", "name": "Maria S.", "role": "Indie Developer", "text": "Zero-setup onboarding is real — installed the app, authenticated once, and had agents working on my codebase within 5 minutes. No API keys, no config files." }, + { "id": "user6", "name": "Chris L.", "role": "Startup CTO", "text": "This completely changed how I prototype. I set up agent teams for different parts of the stack and let them work in parallel. 10x productivity boost, no joke." } + ], + "pricing": [ + { + "id": "free", + "name": "Free Forever", + "price": "$0", + "period": "forever", + "description": "Everything included. No limits, no API keys, no credit card.", + "features": [ + "Unlimited agent teams", + "Kanban board with real-time updates", + "Code review with diff view", + "Cross-team communication", + "Solo & team modes", + "Live process monitoring", + "Built-in code editor", + "MCP integration" + ], + "highlighted": true + } + ] +} diff --git a/landing/content/ru.json b/landing/content/ru.json new file mode 100644 index 00000000..e7d772f8 --- /dev/null +++ b/landing/content/ru.json @@ -0,0 +1,102 @@ +{ + "hero": { + "title": "Claude Agent Teams", + "subtitle": "Вы — CTO, агенты — ваша команда. Они сами берут задачи, переписываются друг с другом, ревьюят код друг друга. А вы просто смотрите на канбан-доску и пьёте кофе." + }, + "features": [ + { + "id": "agentTeams", + "title": "Команды агентов", + "description": "Создавайте команды с разными ролями. Агенты работают автономно и параллельно, общаются друг с другом и сотрудничают между командами." + }, + { + "id": "kanban", + "title": "Канбан-доска", + "description": "Задачи меняют статус в реальном времени. Перетаскивайте, назначайте, ревьюте — всё на визуальной доске." + }, + { + "id": "codeReview", + "title": "Код-ревью", + "description": "Diff-просмотр по каждой задаче с одобрением, отклонением и комментариями. Встроенный редактор кода с поддержкой Git." + }, + { + "id": "crossTeam", + "title": "Межкомандная коммуникация", + "description": "Агенты общаются внутри и между командами. Прямые сообщения, комментарии к задачам и быстрые действия." + }, + { + "id": "soloMode", + "title": "Соло-режим", + "description": "Начните с одного агента, который сам управляет задачами. Расширяйте до полной команды когда нужно больше мощности." + }, + { + "id": "liveProcesses", + "title": "Живые процессы", + "description": "Следите за запущенными агентами, открывайте URL в браузере, мониторьте использование токенов и контекста в реальном времени." + } + ], + "faq": [ + { + "id": "whatIsIt", + "question": "Что такое Claude Agent Teams?", + "answer": "Десктопное приложение, которое позволяет собирать команды ИИ-агентов на базе Claude Code. Каждый агент имеет роль, работает автономно и взаимодействует с тиммейтами — всё управляется через канбан-доску." + }, + { + "id": "isFree", + "question": "Это действительно бесплатно?", + "answer": "Да. 100% бесплатно, открытый исходный код, API-ключи не требуются. Приложение работает полностью локально, используя вашу существующую настройку Claude Code." + }, + { + "id": "platforms", + "question": "Какие платформы поддерживаются?", + "answer": "macOS (Apple Silicon и Intel), Windows и Linux." + }, + { + "id": "howItWorks", + "question": "Как это работает?", + "answer": "Установите приложение, создайте команду, назначьте роли — агенты начинают работать параллельно. Следите за прогрессом на канбан-доске, ревьюте diff кода и общайтесь с агентами напрямую." + }, + { + "id": "privacy", + "question": "Мой код в безопасности?", + "answer": "Всё работает локально на вашей машине. Никакие данные не отправляются на внешние серверы. Ваш код, разговоры и активность агентов остаются полностью приватными." + }, + { + "id": "requirements", + "question": "Что нужно для начала?", + "answer": "Просто установите приложение — оно включает встроенную установку и аутентификацию Claude Code. Онбординг без настройки запустит вас за минуты." + } + ], + "download": { + "title": "Скачать", + "note": "Выберите платформу и начните работать с командами ИИ-агентов." + }, + "testimonials": [ + { "id": "user1", "name": "Алексей К.", "role": "Tech Lead", "text": "Наконец инструмент, который позволяет мне управлять ИИ-агентами так же, как я управляю инженерной командой. Канбан-доска — это прорыв для отслеживания параллельной работы агентов." }, + { "id": "user2", "name": "Сара М.", "role": "Full-stack разработчик", "text": "Соло-режим идеален для быстрых задач. Когда нужно больше мощности, развёртываю полную команду за секунды. Межкомандная коммуникация просто работает." }, + { "id": "user3", "name": "Дмитрий Р.", "role": "Senior Engineer", "text": "Ревью кода — блестяще реализовано. Diff-просмотр по задаче, одобрение/отклонение, комментарии. Как команда джунов, которые реально следуют инструкциям." }, + { "id": "user4", "name": "Юки Т.", "role": "DevOps Engineer", "text": "Мониторинг процессов и отслеживание контекста невероятно полезны. Вижу, что делает каждый агент и сколько контекста использует." }, + { "id": "user5", "name": "Мария С.", "role": "Инди-разработчик", "text": "Онбординг без настройки — реальность. Установила приложение, авторизовалась один раз, и агенты работали с моей кодовой базой за 5 минут. Без API-ключей, без конфигов." }, + { "id": "user6", "name": "Крис Л.", "role": "Startup CTO", "text": "Это полностью изменило мой подход к прототипированию. Настраиваю команды агентов для разных частей стека и пускаю их работать параллельно. 10x прирост продуктивности, без шуток." } + ], + "pricing": [ + { + "id": "free", + "name": "Бесплатно навсегда", + "price": "$0", + "period": "навсегда", + "description": "Всё включено. Без лимитов, без API-ключей, без кредитной карты.", + "features": [ + "Безлимитные команды агентов", + "Канбан-доска с обновлениями в реальном времени", + "Код-ревью с diff-просмотром", + "Межкомандная коммуникация", + "Соло и командный режимы", + "Мониторинг живых процессов", + "Встроенный редактор кода", + "Интеграция MCP" + ], + "highlighted": true + } + ] +} diff --git a/landing/data/content.ts b/landing/data/content.ts new file mode 100644 index 00000000..d9ff3b0e --- /dev/null +++ b/landing/data/content.ts @@ -0,0 +1,13 @@ +import en from "~/content/en.json"; +import ru from "~/content/ru.json"; +import type { LandingContent, LocalizedContent } from "~/types/content"; +import type { LocaleCode } from "~/data/i18n"; + +export const contentByLocale: LocalizedContent = { + en, + ru +}; + +export const getContent = (locale: LocaleCode): LandingContent => { + return contentByLocale[locale] ?? contentByLocale.en; +}; diff --git a/landing/data/downloads.ts b/landing/data/downloads.ts new file mode 100644 index 00000000..d5a65d9c --- /dev/null +++ b/landing/data/downloads.ts @@ -0,0 +1,29 @@ +export type DownloadOs = "macos" | "windows" | "linux"; +export type DownloadArch = "arm64" | "x64" | "universal"; + +export const downloadAssets = [ + { + id: "macos", + os: "macos", + arch: "universal", + label: "macOS", + archLabel: "Apple Silicon / Intel", + url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg" + }, + { + id: "windows-x64", + os: "windows", + arch: "x64", + label: "Windows", + archLabel: "64-bit", + url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe" + }, + { + id: "linux-appimage", + os: "linux", + arch: "x64", + label: "Linux", + archLabel: "64-bit", + url: "https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage" + } +] as const; diff --git a/landing/data/faq.ts b/landing/data/faq.ts new file mode 100644 index 00000000..a93fdb3b --- /dev/null +++ b/landing/data/faq.ts @@ -0,0 +1,8 @@ +export const faqItems = [ + { id: "whatIsIt" }, + { id: "isFree" }, + { id: "platforms" }, + { id: "howItWorks" }, + { id: "privacy" }, + { id: "requirements" } +] as const; diff --git a/landing/data/features.ts b/landing/data/features.ts new file mode 100644 index 00000000..51c12617 --- /dev/null +++ b/landing/data/features.ts @@ -0,0 +1,10 @@ +import { mdiAccountGroupOutline, mdiViewDashboardOutline, mdiCodeBracesBox, mdiMessageTextOutline, mdiAccountOutline, mdiChartTimelineVariant } from '@mdi/js' + +export const features = [ + { id: "agentTeams", icon: mdiAccountGroupOutline, key: "agentTeams", accent: "#00f0ff" }, + { id: "kanban", icon: mdiViewDashboardOutline, key: "kanban", accent: "#ff00ff" }, + { id: "codeReview", icon: mdiCodeBracesBox, key: "codeReview", accent: "#39ff14" }, + { id: "crossTeam", icon: mdiMessageTextOutline, key: "crossTeam", accent: "#ffd700" }, + { id: "soloMode", icon: mdiAccountOutline, key: "soloMode", accent: "#00f0ff" }, + { id: "liveProcesses", icon: mdiChartTimelineVariant, key: "liveProcesses", accent: "#ff00ff" } +] as const; diff --git a/landing/data/i18n.ts b/landing/data/i18n.ts new file mode 100644 index 00000000..ecf02e21 --- /dev/null +++ b/landing/data/i18n.ts @@ -0,0 +1,38 @@ +export type LocaleCode = "en" | "ru"; + +export const supportedLocales = [ + { code: "en", iso: "en-US", name: "English", flag: "\u{1F1FA}\u{1F1F8}", file: "en.json" }, + { code: "ru", iso: "ru-RU", name: "Русский", flag: "\u{1F1F7}\u{1F1FA}", file: "ru.json" } +] as const; + +export const defaultLocale: LocaleCode = "en"; + +export const pages = [ + "/", + "/download" +] as const; + +/** Pages for sitemap */ +export const sitemapPages = [ + "/", + "/download" +] as const; + +/** Generates i18n routes for a given list of pages */ +const buildI18nRoutes = (source: readonly string[]): string[] => { + const routes: string[] = []; + for (const page of source) { + routes.push(page); + for (const locale of supportedLocales) { + if (locale.code === defaultLocale) continue; + routes.push(page === "/" ? `/${locale.code}` : `/${locale.code}${page}`); + } + } + return routes; +}; + +/** All i18n routes (for prerender) */ +export const generateI18nRoutes = (): string[] => buildI18nRoutes(pages); + +/** i18n routes for sitemap only */ +export const generateSitemapRoutes = (): string[] => buildI18nRoutes(sitemapPages); diff --git a/landing/data/sections.ts b/landing/data/sections.ts new file mode 100644 index 00000000..91478672 --- /dev/null +++ b/landing/data/sections.ts @@ -0,0 +1,9 @@ +export const sectionOrder = [ + "hero", + "features", + "screenshots", + "pricing", + "testimonials", + "download", + "faq" +] as const; diff --git a/landing/data/testimonials.ts b/landing/data/testimonials.ts new file mode 100644 index 00000000..c6ce83d5 --- /dev/null +++ b/landing/data/testimonials.ts @@ -0,0 +1,8 @@ +export const testimonials = [ + { id: "user1", avatar: "#00f0ff" }, + { id: "user2", avatar: "#ff00ff" }, + { id: "user3", avatar: "#39ff14" }, + { id: "user4", avatar: "#ffd700" }, + { id: "user5", avatar: "#00f0ff" }, + { id: "user6", avatar: "#ff00ff" }, +] as const; diff --git a/landing/docs/ARCHITECTURE_GUARDRAILS.md b/landing/docs/ARCHITECTURE_GUARDRAILS.md new file mode 100644 index 00000000..0a09bfac --- /dev/null +++ b/landing/docs/ARCHITECTURE_GUARDRAILS.md @@ -0,0 +1,49 @@ +# Архитектурные инварианты (Landing) + +Этот файл — про правила, которые защищают лендинг от “тихой деградации” (SEO/SSG/перф/поддерживаемость). +Если правило нарушается — это не вкусовщина, это потенциальный регресс. + +## 1) SSG — не обсуждается +- Лендинг деплоится как **статик**. Мы не рассчитываем на backend на каждый запрос. +- Любые идеи “а давайте по IP определим язык/страницу” — это **отдельная задача** (edge/runtime), не часть текущего лендинга. + +## 2) Контент и i18n — два слоя +- **Микрокопирайт** (кнопки/лейблы/мелкие подписи) — `landing/locales/*`. +- **Контент секций** (FAQ, список фич, провайдеры, тексты блоков) — `landing/content/{locale}.*` с одинаковой структурой. +- **Стабильные id**: + - `id` элементов (FAQ/фичи/провайдеры) не меняем. + - Меняем текст — да. Меняем `id` — только если сознательно ломаем совместимость и понимаем последствия. + +## 3) Store-лимиты (чтобы не раздуть проект) +Pinia используем только там, где реально есть общий state: +- `theme` (dark/light) +- `locale` (выбранный язык) +- `download` (os/arch/preferredMacArch/selectedAsset) + +Всё остальное — props + данные/контент. Никаких “store для секции Features”. + +## 4) Источник правды по URL (i18n + SSG + sitemap) +Один источник правды: +- список поддерживаемых локалей +- список страниц, которые должны существовать +- правила генерации i18n-роутов + +Из этого должны получаться: +- пререндер-роуты для SSG +- sitemap +- корректные `alternate`/`canonical` + +## 5) Downloads — ответственность и процесс обновления +- Есть один способ обновлять релизы/ссылки, понятный команде. +- Если используем статичный `landing/data/downloads.ts`: + - обновление версии/URL/sha/размера — часть релиз-процесса + - перед деплоем прогоняем быструю проверку ссылок (хотя бы HEAD/GET на 200) + +## 6) Аналитика и согласие +- По умолчанию **не отправляем аналитику до пользовательского действия**, если проекту нужен consent (зависит от политики/юрисдикции). +- Никаких персональных данных, никаких “текстов транскрипций”, никаких ключей. + +## 7) Минимальные бюджеты качества +- Главная страница: Lighthouse без красных метрик (LCP/CLS). +- A11y: клавиатура/фокус/label/alt — не “по настроению”, а обязательный критерий готовности. + diff --git a/landing/docs/LANDING_PLAN.md b/landing/docs/LANDING_PLAN.md new file mode 100644 index 00000000..a04c5525 --- /dev/null +++ b/landing/docs/LANDING_PLAN.md @@ -0,0 +1,1027 @@ +# План разработки лендинга для Voice-to-Text + +## Обзор проекта + +**Voice-to-Text** — десктопное приложение для преобразования голоса в текст с фокусом на приватность и офлайн-поддержку. Построено на Tauri, Rust и Vue 3. + +### Ключевые особенности приложения: +- 🎤 **Real-time транскрипция** с поддержкой нескольких провайдеров (Deepgram, AssemblyAI, Whisper) +- 🔒 **Privacy-focused** — API ключи хранятся локально, нет облачного хранилища +- 🌍 **Кроссплатформенность** — macOS, Windows, Linux +- ⚡ **Глобальные хоткеи** — быстрый доступ через горячие клавиши +- 📋 **Автоматическое копирование** в буфер обмена +- 🎨 **Современный UI** с glass morphism эффектами +- 🌐 **Мультиязычность** — поддержка 6 языков (en, ru, es, fr, de, uk) + +--- + +## Технологический стек + +### Основные технологии: +- **Nuxt 3** — фреймворк для Vue.js с SSR/SSG +- **Vuetify 3** — Material Design компоненты +- **Vue I18n** — локализация (совместимо с текущим `vue-i18n`) +- **TypeScript** — типизация +- **Vite** — сборщик (встроен в Nuxt) +- **Pinia** — управление общим состоянием (через `@pinia/nuxt`) + +### Дополнительные библиотеки: +- **@nuxtjs/i18n** — интеграция i18n с Nuxt +- **@nuxtjs/seo** — SEO оптимизация +- **@vueuse/nuxt** — композаблы для Vue (например `usePreferredDark`) +- **nuxt-icon** — иконки +- **@nuxtjs/ipx** — обработка изображений (опционально) +- **swiper** — карусель скриншотов (Vue интеграция) +- **@nuxt/eslint** + **Prettier** — линтинг и форматирование кода + +Примечания по зависимостям: +- Google Fonts лучше не подключать как внешнюю зависимость. Либо системный шрифт, либо self-host (проще с приватностью и стабильностью). + +--- + +## Архитектура проекта + +``` +landing/ +├── data/ # статические данные/конфиги секций (без Nuxt контекста) +├── types/ # TS типы (без Nuxt контекста) +├── utils/ # чистые функции (без Nuxt контекста, легко тестировать) +├── nuxt.config.ts # Конфигурация Nuxt +├── package.json +├── tsconfig.json +├── .env # Переменные окружения +│ +├── locales/ # Файлы локализации +│ ├── en.json +│ ├── ru.json +│ ├── es.json +│ ├── fr.json +│ ├── de.json +│ └── uk.json +│ +├── assets/ # Статические ресурсы +│ ├── images/ +│ │ ├── hero-bg.jpg +│ │ ├── screenshot-dark.png +│ │ ├── screenshot-light.png +│ │ ├── features/ +│ │ └── platforms/ +│ ├── videos/ # Демо-видео (опционально) +│ └── styles/ +│ └── main.scss # Глобальные стили +│ +├── components/ # Vue компоненты +│ ├── layout/ +│ │ ├── AppHeader.vue # Шапка с навигацией +│ │ ├── AppFooter.vue # Подвал +│ │ └── LanguageSwitcher.vue # Переключатель языков +│ │ +│ ├── sections/ +│ │ ├── HeroSection.vue # Главный экран +│ │ ├── FeaturesSection.vue # Особенности +│ │ ├── ProvidersSection.vue # STT провайдеры +│ │ ├── ScreenshotsSection.vue # Скриншоты +│ │ ├── DownloadSection.vue # Секция загрузки +│ │ ├── PrivacySection.vue # Приватность +│ │ └── FAQSection.vue # Частые вопросы +│ │ +│ ├── ui/ +│ │ ├── DownloadButton.vue # Кнопка загрузки +│ │ ├── FeatureCard.vue # Карточка фичи +│ │ ├── PlatformBadge.vue # Бейдж платформы +│ │ └── ScreenshotCarousel.vue # Карусель скриншотов +│ │ +│ └── common/ +│ ├── AppLogo.vue +│ └── ThemeToggle.vue # Переключатель темы (если нужен) +│ +├── composables/ # Композаблы +│ ├── useDownload.ts # Логика загрузки +│ ├── useAnalytics.ts # Аналитика (опционально) +│ ├── usePlatform.ts # Определение платформы +│ ├── useBrowserTheme.ts # Определение темы браузера +│ └── useLocation.ts # Определение локации пользователя +│ +├── layouts/ +│ └── default.vue # Основной layout +│ +├── plugins/ # Плагины Nuxt +│ ├── vuetify.ts # Инициализация Vuetify +│ └── init-theme-locale.client.ts # Автоинициализация темы и локали +│ +├── pages/ +│ ├── index.vue # Главная страница +│ ├── download.vue # Страница загрузки +│ └── privacy.vue # Политика приватности (опционально) +│ +├── public/ # Публичные файлы +│ ├── favicon.ico +│ ├── robots.txt +│ └── sitemap.xml +│ +└── server/ # Server API (если нужен) +``` + +--- + +## Правила разделения логики + +- **composables/**: завязаны на Nuxt/Vue (реактивность, `useCookie`, `navigateTo`, `useRoute`, `useFetch`, `useHead` и т.д.) +- **utils/**: чистые функции без Nuxt контекста (легко тестировать, переиспользовать) + +## Структура страниц + +### 1. Главная страница (`/`) + +#### Hero Section +- **Заголовок**: "Voice to Text — Privacy-Focused Transcription" +- **Подзаголовок**: Краткое описание приложения +- **CTA кнопки**: + - "Download for [Platform]" (определяется автоматически) + - "View Features" (скролл к секции) +- **Фоновое изображение/видео**: Демонстрация приложения + +#### Features Section +**6 основных фич в виде карточек:** + +1. **Real-time Transcription** + - Иконка: 🎤 + - Описание: Мгновенная транскрипция с частичными результатами + +2. **Privacy-Focused** + - Иконка: 🔒 + - Описание: API ключи хранятся локально, нет облачного хранилища + +3. **Multiple Providers** + - Иконка: 🌐 + - Описание: Deepgram, AssemblyAI, Whisper (офлайн) + +4. **Cross-Platform** + - Иконка: 💻 + - Описание: macOS, Windows, Linux + +5. **Global Hotkeys** + - Иконка: ⌨️ + - Описание: Быстрый доступ через горячие клавиши + +6. **Auto-Copy** + - Иконка: 📋 + - Описание: Автоматическое копирование в буфер обмена + +#### Providers Section +**Детальное описание STT провайдеров:** + +- **Deepgram** (Nova-2/3) + - Низкая задержка + - Высокое качество + - Автоматический выбор модели (Nova-3 для английского, Nova-2 для русского) + +- **AssemblyAI** (Universal-Streaming v3) + - Высокое качество + - Облачный сервис + +- **Whisper Local** (офлайн) + - Полностью офлайн + - Требует cmake и загрузки модели + +#### Screenshots Section +**Карусель скриншотов (Swiper):** +- Используем Swiper для Vue: `https://swiperjs.com/vue` +- На **десктопе** должно быть видно **сразу несколько** скриншотов на экране (не один с обязательным “далее”) + - Пример: `slidesPerView: 2-4` на широких экранах с `breakpoints` + - Допускается режим с “частичным” превью следующего слайда +- На мобильных: 1 скрин, свайп, пагинация +- Набор скринов: + - Темная тема + - Светлая тема + - Настройки + - Процесс записи + +#### Download Section +**Платформо-специфичные кнопки загрузки с автоопределением ОС:** +- Определяем текущую ОС пользователя и показываем **приоритетно** релевантную загрузку +- Если ОС определить не удалось — показываем **все** ОС +- Для macOS учитывать архитектуру (Apple Silicon vs Intel): + - Если получилось определить — выбираем нужную сборку по умолчанию + - Если нет — показываем оба варианта и даём выбрать вручную + +**Дополнительно:** +- Версия приложения +- Размер файла +- Системные требования +- Changelog ссылка + +#### Privacy Section +**Ключевые моменты приватности:** +- Локальное хранение API ключей +- Нет облачного хранилища транскрипций +- Опциональное использование собственных API ключей + +**Open Source:** +- Да: часть компонентов/модулей планируем сделать open-source (для маркетинга и доверия) + +#### FAQ Section +**Частые вопросы:** +1. Какие платформы поддерживаются? +2. Нужен ли интернет для работы? +3. Как настроить API ключи? +4. Можно ли использовать офлайн? +5. Как изменить горячие клавиши? +6. Безопасны ли мои данные? + +### 2. Страница загрузки (`/download`) + +- **Определение платформы** автоматически +- **Кнопки загрузки** для всех платформ +- **Инструкции по установке** для каждой ОС +- **Системные требования** +- **Changelog** (последние версии) + +--- + +## Локализация (i18n) + +### Поддерживаемые языки: +- 🇺🇸 English (`en`) +- 🇷🇺 Русский (`ru`) +- 🇪🇸 Español (`es`) +- 🇫🇷 Français (`fr`) +- 🇩🇪 Deutsch (`de`) +- 🇺🇦 Українська (`uk`) + +### Структура файлов локализации: + +```json +// locales/en.json +{ + "meta": { + "title": "Voice to Text - Privacy-Focused Transcription", + "description": "..." + }, + "nav": { + "features": "Features", + "download": "Download", + "privacy": "Privacy" + }, + "hero": { + "title": "Voice to Text", + "subtitle": "Privacy-focused voice-to-text application with offline support", + "download": "Download for {platform}", + "viewFeatures": "View Features" + }, + "features": { + "realtime": { + "title": "Real-time Transcription", + "description": "..." + }, + "privacy": { + "title": "Privacy-Focused", + "description": "..." + } + // ... остальные фичи + }, + "providers": { + "title": "Multiple STT Providers", + "deepgram": { + "name": "Deepgram", + "description": "..." + } + // ... остальные провайдеры + }, + "download": { + "title": "Download", + "forPlatform": "Download for {platform}", + "systemRequirements": "System Requirements", + "version": "Version {version}" + }, + "faq": { + "title": "Frequently Asked Questions", + "items": [ + { + "question": "...", + "answer": "..." + } + ] + } +} +``` + +### Модель контента (чтобы i18n не превращалась в ад) + +Проблема классическая: если хранить “структуру секций” в `data/*`, а весь контент раскидать по ключам i18n, то любая правка превращается в квест “найди 20 ключей в 6 языках”. + +Решение — разделить два слоя: +- **Микрокопирайт** (кнопки, лейблы, мелкие подписи) — остаётся в `landing/locales/*`. +- **Контент секций** (FAQ, список фич, провайдеры, тексты блоков) — лежит в **локализованных контент-файлах** с одинаковой структурой по всем языкам. + +Рекомендуемая схема: +- `landing/content/en.ts`, `landing/content/ru.ts`, ... (или `.json`, если удобнее). +- Внутри — один типизированный объект `LandingContent`, где: + - элементы имеют **стабильные `id`** (например `faq.items[].id`, `features.items[].id`) + - порядок можно менять без рефакторинга компонентов + - длинные тексты редактируются “в одном месте” для каждой локали + +Минимальные правила дисциплины: +- `id` неизменяемы (меняем текст, но не идентификатор). +- Если добавили/удалили элемент — правим **все локали** (это легко проверяется автоматикой). +- Для контента не используем “вложенные ключи на 10 уровней”, держим структуру простой и читаемой. + +Проверка качества: +- Добавляем маленькую проверку (скрипт/тест), которая сравнивает структуру контента между локалями и падает, если где-то не хватает ключей/элементов. + +Минимальный “контракт” этой проверки: +- сравниваем структуру (ключи/массивы по `id`), а не тексты +- ошибка должна показывать, **какой `id`/ключ отсутствует** и **в какой локали** +- проверка запускается локально и в CI (чтобы не ловить это уже на проде) + +### Настройка i18n в Nuxt: + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + modules: [ + '@nuxtjs/i18n', + 'vuetify/nuxt' + ], + i18n: { + locales: [ + { code: 'en', iso: 'en-US', file: 'en.json', name: 'English' }, + { code: 'ru', iso: 'ru-RU', file: 'ru.json', name: 'Русский' }, + { code: 'es', iso: 'es-ES', file: 'es.json', name: 'Español' }, + { code: 'fr', iso: 'fr-FR', file: 'fr.json', name: 'Français' }, + { code: 'de', iso: 'de-DE', file: 'de.json', name: 'Deutsch' }, + { code: 'uk', iso: 'uk-UA', file: 'uk.json', name: 'Українська' } + ], + defaultLocale: 'en', + strategy: 'prefix_except_default', + detectBrowserLanguage: { + useCookie: true, + cookieKey: 'i18n_redirected', + redirectOn: 'root', + // Определение языка по локации (через composable) + alwaysRedirect: false, + fallbackLocale: 'en' + } + } +}) +``` + +### Автоматическое определение языка по локации: + +Логика определения языка: +1. **Проверка cookie** — если пользователь уже выбирал язык, использовать его +2. **Определение по браузеру** — `navigator.language` или `navigator.languages` +3. **Fallback** — английский язык по умолчанию + +Важно: +- Лендинг планируется как **SSG (статический)**. Значит IP-геолокация на сервере здесь неуместна: негде “серверу” исполняться на каждый запрос. +- Если когда-то понадобится geo-редирект — это отдельная задача (edge/runtime), не часть текущего лендинга. + +**Маппинг стран к языкам:** +- 🇷🇺 Россия, Беларусь, Казахстан → `ru` +- 🇺🇦 Украина → `uk` +- 🇪🇸 Испания, Латинская Америка → `es` +- 🇫🇷 Франция, Бельгия, Швейцария (французский) → `fr` +- 🇩🇪 Германия, Австрия, Швейцария (немецкий) → `de` +- 🇺🇸 Остальные → `en` + +--- + +## Компоненты Vuetify + +### Используемые компоненты: + +1. **v-app-bar** — шапка сайта +2. **v-container**, **v-row**, **v-col** — сетка +3. **v-card** — карточки фич +4. **v-btn** — кнопки +5. **v-carousel** — карусель скриншотов +6. **v-expansion-panels** — FAQ аккордеон +7. **v-chip** — бейджи платформ +8. **v-select** — выбор языка +9. **v-icon** — иконки +10. **v-divider** — разделители + +### Кастомизация темы: + +```typescript +// plugins/vuetify.ts +import { createVuetify } from 'vuetify' + +export default defineNuxtPlugin((nuxtApp) => { + // Определение темы браузера при инициализации + const getInitialTheme = (): 'dark' | 'light' => { + if (process.client) { + // Проверка сохраненной темы в localStorage + const savedTheme = localStorage.getItem('vuetify-theme') + if (savedTheme === 'dark' || savedTheme === 'light') { + return savedTheme + } + + // Определение по системным настройкам браузера + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + } + return 'light' // Fallback для SSR + } + + const vuetify = createVuetify({ + theme: { + defaultTheme: getInitialTheme(), + themes: { + dark: { + colors: { + primary: '#6366f1', // indigo + secondary: '#8b5cf6', // purple + accent: '#ec4899', // pink + background: '#0f172a', // slate-900 + surface: '#1e293b', // slate-800 + } + }, + light: { + colors: { + primary: '#6366f1', + secondary: '#8b5cf6', + accent: '#ec4899', + background: '#ffffff', + surface: '#f8fafc', // slate-50 + } + } + } + } + }) + + // Слушатель изменений системной темы + if (process.client) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleThemeChange = (e: MediaQueryListEvent) => { + const savedTheme = localStorage.getItem('vuetify-theme') + // Автоматически менять только если пользователь не выбирал тему вручную + if (!savedTheme) { + vuetify.theme.global.name.value = e.matches ? 'dark' : 'light' + } + } + mediaQuery.addEventListener('change', handleThemeChange) + } + + nuxtApp.vueApp.use(vuetify) +}) +``` + +--- + +## SEO оптимизация + +### Meta теги: + +```vue + + +``` + +### Структурированные данные (JSON-LD): + +```typescript +// composables/useStructuredData.ts +export const useStructuredData = () => { + const { $i18n } = useNuxtApp() + + const softwareApplication = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + "name": "Voice to Text", + "applicationCategory": "UtilityApplication", + "operatingSystem": ["macOS", "Windows", "Linux"], + "offers": { + "@type": "Offer", + "price": "0", + "priceCurrency": "USD" + } + } + + return { softwareApplication } +} +``` + +--- + +## Функциональность + +### 1. Определение платформы + +```typescript +// composables/usePlatform.ts +export const usePlatform = () => { + const platform = ref<'macos' | 'windows' | 'linux' | 'unknown'>('unknown') + + if (process.client) { + const userAgent = navigator.userAgent.toLowerCase() + if (userAgent.includes('mac')) platform.value = 'macos' + else if (userAgent.includes('win')) platform.value = 'windows' + else if (userAgent.includes('linux')) platform.value = 'linux' + } + + const downloadUrl = computed(() => { + // Логика формирования URL для загрузки + const baseUrl = 'https://github.com/777genius/voice-to-text/releases' + // ... + }) + + return { platform, downloadUrl } +} +``` + +### 2. Определение темы браузера + +```typescript +// composables/useBrowserTheme.ts +import { usePreferredDark } from '@vueuse/core' +import { useTheme } from 'vuetify' + +export const useBrowserTheme = () => { + const { $vuetify } = useNuxtApp() + const preferredDark = usePreferredDark() + const theme = useTheme() + + // Инициализация темы при первом посещении + const initTheme = () => { + if (process.client) { + const savedTheme = localStorage.getItem('vuetify-theme') + + if (savedTheme) { + // Использовать сохраненную тему + theme.global.name.value = savedTheme as 'dark' | 'light' + } else { + // Использовать системную тему браузера + theme.global.name.value = preferredDark.value ? 'dark' : 'light' + localStorage.setItem('vuetify-theme', theme.global.name.value) + } + } + } + + // Переключение темы + const toggleTheme = () => { + const newTheme = theme.global.name.value === 'dark' ? 'light' : 'dark' + theme.global.name.value = newTheme + localStorage.setItem('vuetify-theme', newTheme) + } + + // Установка конкретной темы + const setTheme = (themeName: 'dark' | 'light') => { + theme.global.name.value = themeName + localStorage.setItem('vuetify-theme', themeName) + } + + // Слушатель изменений системной темы (только если пользователь не выбирал вручную) + if (process.client) { + watch(preferredDark, (isDark) => { + const savedTheme = localStorage.getItem('vuetify-theme') + if (!savedTheme) { + theme.global.name.value = isDark ? 'dark' : 'light' + } + }) + } + + return { + currentTheme: computed(() => theme.global.name.value), + isDark: computed(() => theme.global.name.value === 'dark'), + initTheme, + toggleTheme, + setTheme + } +} +``` + +### 3. Определение локации пользователя + +```typescript +// composables/useLocation.ts +export const useLocation = () => { + const { $i18n } = useNuxtApp() + const locale = useCookie('i18n_redirected', { default: () => 'en' }) + + // Маппинг стран к языкам + const countryToLocale: Record = { + 'RU': 'ru', // Россия + 'BY': 'ru', // Беларусь + 'KZ': 'ru', // Казахстан + 'UA': 'uk', // Украина + 'ES': 'es', // Испания + 'MX': 'es', // Мексика + 'AR': 'es', // Аргентина + 'CO': 'es', // Колумбия + 'CL': 'es', // Чили + 'PE': 'es', // Перу + 'FR': 'fr', // Франция + 'BE': 'fr', // Бельгия (французский) + 'CH': 'de', // Швейцария (по умолчанию немецкий, можно улучшить) + 'DE': 'de', // Германия + 'AT': 'de', // Австрия + } + + // Определение языка по браузеру + const getBrowserLocale = (): string => { + if (process.client) { + const browserLang = navigator.language || (navigator as any).userLanguage + const langCode = browserLang.split('-')[0].toLowerCase() + + // Проверка поддерживаемых языков + const supportedLocales = ['en', 'ru', 'es', 'fr', 'de', 'uk'] + if (supportedLocales.includes(langCode)) { + return langCode + } + + // Проверка полного кода (например, ru-RU) + const fullCode = browserLang.toLowerCase() + if (fullCode.startsWith('ru')) return 'ru' + if (fullCode.startsWith('uk')) return 'uk' + if (fullCode.startsWith('es')) return 'es' + if (fullCode.startsWith('fr')) return 'fr' + if (fullCode.startsWith('de')) return 'de' + } + return 'en' + } + + // Важно: лендинг статический (SSG), поэтому IP-геолокацию не используем. + + // Инициализация языка при первом посещении + const initLocale = async () => { + // Если язык уже выбран пользователем, не менять + if (locale.value && locale.value !== 'en') { + return + } + + // Приоритет: cookie > браузер > fallback + let detectedLocale = locale.value || 'en' + + // На клиенте определяем по браузеру + detectedLocale = getBrowserLocale() + + // Устанавливаем язык только если он отличается от текущего + if (detectedLocale !== $i18n.locale.value) { + const { switchLocalePath } = useI18n() + await navigateTo(switchLocalePath(detectedLocale)) + } + } + + return { + currentLocale: computed(() => $i18n.locale.value), + initLocale, + getBrowserLocale + } +} +``` + +### 4. Плагин для автоматической инициализации + +```typescript +// plugins/init-theme-locale.client.ts +export default defineNuxtPlugin(async () => { + const { initTheme } = useBrowserTheme() + const { initLocale } = useLocation() + + // Инициализация темы + initTheme() + + // Инициализация локали (только при первом посещении) + const hasVisited = useCookie('has_visited', { default: () => false }) + if (!hasVisited.value) { + await initLocale() + hasVisited.value = true + } +}) +``` + +### 5. Статистика загрузок (опционально) + +Не делаем. + +### 6. Аналитика (опционально) + +```typescript +// composables/useAnalytics.ts +export const useAnalytics = () => { + const trackDownload = (platform: string) => { + if (process.client) { + // Google Analytics, Plausible, или другая аналитика + gtag('event', 'download', { platform }) + } + } + + return { trackDownload } +} +``` + +**Решение по аналитике:** +- Делаем **GA4** (Google Analytics) + события: + - `download_click` (platform, arch, version, locale) + - `download_page_view` + - `language_change` + - `theme_change` + - `faq_open` + - `cta_view_features_click` +- Для подключения нужен `GA4 Measurement ID` (например, `G-XXXXXXXXXX`) и/или доступы к аккаунту. В коде предусматриваем конфиг через env, а фактическое подключение выполняется владельцем аккаунта. + +--- + +## Дизайн и стилизация + +### Цветовая схема: + +**Темная тема:** +- Фон: `#0f172a` (slate-900) +- Поверхности: `#1e293b` (slate-800) +- Акцент: `#6366f1` (indigo-500) +- Текст: `#f1f5f9` (slate-100) + +**Светлая тема:** +- Фон: `#ffffff` +- Поверхности: `#f8fafc` (slate-50) +- Акцент: `#6366f1` (indigo-500) +- Текст: `#0f172a` (slate-900) + +### Типографика: + +- **Заголовки**: Inter или System Font Stack +- **Текст**: Inter или System Font Stack +- **Моноширинный**: JetBrains Mono (для кода) + +### Анимации: + +- Плавные переходы при скролле +- Hover эффекты на карточках +- Параллакс для hero секции (опционально) + +--- + +## Деплой + +### Рекомендуемые платформы: + +1. **Vercel** (рекомендуется) + - Автоматический деплой из Git + - SSR/SSG поддержка + - CDN по умолчанию + +2. **Netlify** + - Аналогично Vercel + - Хорошая поддержка Nuxt + +3. **GitHub Pages** (только SSG) + - Бесплатный хостинг + - Требует `nuxt generate` + +### Target деплой: +- **Render** +- Режим: **Static Site Generation (SSG)** (статическая генерация) + +### Конфигурация для деплоя: + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + // Для SSG (пререндер страниц, а не SPA): + ssr: true, + nitro: { preset: 'static' }, + routeRules: { + '/': { prerender: true }, + '/download': { prerender: true }, + }, +}) +``` + +### Важно про i18n + SSG (критично для SEO) + +`routeRules` выше — это только базовый пример. Для i18n нужно гарантировать, что **пререндерятся все локали**. + +Правило: +- Источник правды — список локалей + список страниц. +- На их основе формируем список путей для пререндера и для sitemap. + +Пример логики (идея, не финальный код): +- Локали: `['en', 'ru', 'es', 'fr', 'de', 'uk']`, default = `en` +- Страницы: `['/', '/download']` +- Генерация путей: + - для `en`: `['/', '/download']` + - для остальных: `['/ru', '/ru/download']`, `['/es', '/es/download']`, ... + +Это решает сразу две проблемы: +- не теряем локали при деплое (все страницы реально существуют как статик) +- sitemap/alternate можно генерировать из той же таблицы + +См. также: `landing/docs/ARCHITECTURE_GUARDRAILS.md` (раздел про “источник правды по URL”). + +--- + +## Чеклист разработки + +### Фаза 1: Настройка проекта +- [ ] Инициализация Nuxt 3 проекта +- [ ] Установка Vuetify 3 +- [ ] Настройка TypeScript +- [ ] Настройка Pinia (`@pinia/nuxt`) и базовых stores (общие состояния не держим “размазанными” по компонентам) +- [ ] Настройка i18n с определением локации +- [ ] Настройка Vuetify с определением темы браузера +- [ ] Установка @vueuse/nuxt для usePreferredDark +- [ ] ESLint (`@nuxt/eslint`) + Prettier (единый стиль форматирования) +- [ ] Базовая структура папок + +### Фаза 2: Layout и навигация +- [ ] Создание `default.vue` layout +- [ ] Компонент `AppHeader.vue` с навигацией +- [ ] Компонент `AppFooter.vue` +- [ ] Компонент `LanguageSwitcher.vue` +- [ ] Адаптивная навигация (мобильное меню) + +### Фаза 3: Главная страница +- [ ] Hero Section +- [ ] Features Section (6 карточек) +- [ ] Providers Section +- [ ] Screenshots Section (карусель) +- [ ] Download Section +- [ ] Privacy Section +- [ ] FAQ Section + +### Фаза 4: Локализация и тема +- [ ] Переводы для всех 6 языков +- [ ] Композабл `useBrowserTheme.ts` для определения темы +- [ ] Композабл `useLocation.ts` для определения локации +- [ ] Плагин `init-theme-locale.client.ts` для автоинициализации +- [ ] Маппинг стран к языкам +- [ ] Определение языка по браузеру (client-side) +- [ ] Определение темы по системным настройкам +- [ ] Сохранение выбора пользователя в cookies/localStorage +- [ ] Тестирование переключения языков и темы +- [ ] SEO мета-теги для каждого языка +- [ ] Правильные URL для каждого языка + +### Фаза 5: Функциональность +- [ ] Определение платформы пользователя +- [ ] Кнопки загрузки с правильными ссылками +- [ ] Страница `/download` с инструкциями +- [ ] Аналитика (GA4) + события (скачивание, смена языка/темы, FAQ, CTA) + +### Фаза 6: Оптимизация +- [ ] Оптимизация изображений +- [ ] Lazy loading для секций +- [ ] SEO оптимизация +- [ ] Структурированные данные +- [ ] Sitemap и robots.txt + +### Фаза 7: Тестирование +- [ ] Тестирование на разных устройствах +- [ ] Тестирование всех языков +- [ ] Тестирование автоматического определения языка по браузеру +- [ ] Тестирование автоматического определения темы +- [ ] Тестирование переключения темы вручную +- [ ] Тестирование сохранения выбора пользователя +- [ ] Проверка производительности +- [ ] Проверка доступности (a11y) + +### Фаза 8: Деплой +- [ ] Настройка CI/CD +- [ ] Деплой на выбранную платформу +- [ ] Настройка домена +- [ ] SSL сертификат + +--- + +## Дополнительные рекомендации + +### Определение темы и локации: +- **Приоритет определения языка:** + 1. Cookie (если пользователь уже выбирал) + 2. Браузерные настройки (`navigator.language`) + 3. Fallback на английский + +- **Приоритет определения темы:** + 1. localStorage (если пользователь выбирал вручную) + 2. Системные настройки браузера (`prefers-color-scheme`) + 3. Fallback на светлую тему + +- **Альтернативные API для геолокации:** + - Не используем в рамках текущего статического лендинга (SSG). + +- **Обработка ошибок:** + - Таймаут для API запросов (3 секунды) + - Graceful fallback на браузерные настройки + - Логирование ошибок для отладки + +### Производительность: +- Использовать `nuxt/image` для оптимизации изображений +- Lazy loading для компонентов ниже fold +- Code splitting для больших компонентов + - Никаких внешних geo-запросов: меньше точек отказа и проще с приватностью. + +### Доступность: +- Семантический HTML +- ARIA атрибуты +- Keyboard navigation +- Контрастность цветов (WCAG AA) + +### Аналитика: +- Google Analytics 4 +- Plausible (privacy-focused) +- Yandex Metrika (для русскоязычной аудитории) + +### Мониторинг: +- Sentry для отслеживания ошибок +- Uptime monitoring + +--- + +## Примеры реализации компонентов + +### Hero Section + +```vue + +``` + +### Feature Card + +```vue + +``` + +--- + +## Контакты и ресурсы + +- **GitHub**: https://github.com/777genius/voice-to-text +- **Документация**: (если есть) +- **Поддержка**: (если есть) + +--- + +**Версия плана**: 1.0 +**Дата создания**: 2025-01-17 +**Статус**: Готов к реализации + +--- + +## План итераций (сначала планируем, затем перепроверяем, потом реализуем) + +Процесс: +1) Сначала готовим максимально подробные планы итераций. +2) Затем **несколько раз** перепроверяем планы (полнота, несостыковки, риски, критерии готовности). +3) Только после этого начинаем реализацию пошагово. +4) После реализации — сверяемся с планами и ещё раз перепроверяем соответствие. + +Файлы итераций: +- `landing/docs/iterations/ITERATION_00_REQUIREMENTS.md` +- `landing/docs/iterations/ITERATION_01_SCAFFOLDING.md` +- `landing/docs/iterations/ITERATION_02_UI_SECTIONS.md` +- `landing/docs/iterations/ITERATION_03_SWIPER_AND_DOWNLOAD.md` +- `landing/docs/iterations/ITERATION_04_ANALYTICS_SEO_SSG_RENDER.md` diff --git a/landing/docs/iterations/ITERATION_00_REQUIREMENTS.md b/landing/docs/iterations/ITERATION_00_REQUIREMENTS.md new file mode 100644 index 00000000..531e4e3c --- /dev/null +++ b/landing/docs/iterations/ITERATION_00_REQUIREMENTS.md @@ -0,0 +1,52 @@ +## Итерация 00 — фиксация требований и источников данных + +### Цель +Согласовать “что именно делаем”, чтобы не переписывать UX/архитектуру на ходу. + +### Входные решения (обязательные) +- **Карусель скриншотов**: Swiper Vue (`https://swiperjs.com/vue`), на десктопе видно **несколько** скринов одновременно. +- **Download Section**: + - Автоопределение ОС. + - Если не определили — показываем все ОС. + - Для macOS — учёт **Apple Silicon vs Intel** (если детект не точный — дать выбор пользователю). +- **Privacy**: + - Локальное хранение API ключей. + - Опциональное использование собственных API ключей. + - Нет облачного хранилища транскрипций. +- **Open Source**: да, часть компонентов/модулей будет open-source (маркетинг/доверие). +- **Статистика загрузок**: не делаем. +- **Аналитика**: GA4 + события скачивания и ключевых действий. +- **Деплой**: Render, **SSG**. +- **State management**: Pinia (общее состояние не размазываем по компонентам). +- **Code quality**: ESLint (`@nuxt/eslint`) + Prettier. +- **Структура**: добавляем `landing/data`, `landing/types`, `landing/utils` + правило `composables/` vs `utils/`. + +### Важно про SSG (чтобы не было сюрпризов в конце) +- Лендинг остаётся **статическим**: без собственного backend на каждый запрос. +- Поэтому **IP-геолокацию на сервере не делаем** (в чистом SSG её просто негде исполнять). +- Автовыбор языка: **cookie/localStorage (если пользователь выбирал)** → **настройки браузера** → fallback на `en`. Всё остальное (гео, Accept-Language на сервере) — только если появится отдельный runtime/edge, но это уже другая задача. + +### Решения/данные, которые нужно получить от владельца проекта +- **Релизы/артефакты**: где лежат ссылки на `.dmg/.exe/.msi/.deb/.AppImage`, как формируется URL (GitHub Releases / отдельный CDN). +- **macOS артефакты**: есть ли separate сборки `arm64` и `x64` или один universal. +- **GA4**: + - Measurement ID вида `G-XXXXXXXXXX` (и подтверждение, что GA4 property уже создан). + - Список обязательных событий/параметров (если есть требования маркетинга). + +### Контракт по загрузкам (нужно согласовать один раз) +- **Источник правды**: GitHub Releases (рекомендуется) или статичный список в `landing/data/downloads.ts` (если релизы ведутся вручную). +- **Нейминг ассетов**: как минимум, чтобы можно было однозначно сопоставить `os + arch + extension` (например `VoiceToText-1.2.3-mac-arm64.dmg`). +- **Страница /download**: откуда берём “версия/размер/дата” (из релиза или руками в data). +- **Проверки**: если есть sha256/подпись — решаем, показываем ли это пользователю (желательно да). + +### Критерии готовности +- Все пункты выше подтверждены. +- Есть источник правды по ссылкам загрузок и форматам релизов. +- Определены обязательные GA4 события и параметры. +- Подтверждено, что лендинг действительно деплоится как **статик** (SSG), без server runtime. + +### Чеклист перепроверки (прогнать 2–3 раза) +- Нет “опциональных” фич, которые фактически обязательны. +- Нет скрытых зависимостей (например, server API для статистики). +- SSG и Render не конфликтуют с выбранными модулями. + diff --git a/landing/docs/iterations/ITERATION_01_SCAFFOLDING.md b/landing/docs/iterations/ITERATION_01_SCAFFOLDING.md new file mode 100644 index 00000000..b2a10205 --- /dev/null +++ b/landing/docs/iterations/ITERATION_01_SCAFFOLDING.md @@ -0,0 +1,47 @@ +## Итерация 01 — каркас проекта + качество кода + базовая архитектура + +### Цель +Поднять Nuxt 3 проект под SSG, включить Vuetify, i18n, Pinia, ESLint+Prettier и заложить архитектурные правила. + +### Ключевые принципы (чтобы потом не переделывать) +- Лендинг деплоится как **статик (SSG)**, поэтому не закладываем фичи, которые требуют сервер на каждый запрос. +- Источник правды по контенту: `landing/data/*` + `landing/locales/*`. Компоненты секций рендерят данные, а не “хранят смысл внутри себя”. +- Логика, которую хочется тестировать, живёт в `landing/utils/*`. Всё что про Nuxt/Vue — в `composables/*`. + +### Шаги +1) **Создать проект `landing/` (Nuxt 3)**: + - SSG-ориентация: конфиг и скрипты сборки/генерации. +2) **Подключить Vuetify 3**: + - Material 3, темы (dark/light), сохранение выбора. +3) **Подключить i18n**: + - 6 языков, стратегия URL (prefix_except_default). + - Автовыбор языка при первом визите: cookie (если выбирали) → настройки браузера → fallback. + - Важно: IP-гео в рамках статического лендинга не используем. +4) **Подключить Pinia**: + - Stores для: темы, локали, платформы/архитектуры, ссылок загрузки. +5) **ESLint + Prettier**: + - `@nuxt/eslint`, Prettier, единые правила форматирования. + - Команды `lint`, `lint:fix`, `format`. +6) **Структура папок**: + - Добавить `landing/data`, `landing/types`, `landing/utils`. + - Зафиксировать правило: composables = Nuxt/Vue, utils = чистые функции. + +### Выход (deliverables) +- Проект собирается и генерится в SSG режиме. +- Локализация/темы/Pinia подключены и работают в минимальном виде. +- Линтер/форматтер настроены, есть команды в `package.json`. +- Созданы заготовки данных и типов (без бизнес-логики секций). + - Есть короткий README как запускать/собирать локально (одинаково для всей команды). + +### Критерии готовности +- `pnpm/yarn/npm` workflow согласован и описан. +- `generate`/`build`/`preview` проходят локально. +- ESLint и Prettier не конфликтуют (нет “пинпонга” форматирования). + - Подтвержден publish directory для статического хостинга (чтобы потом не гадать на деплое). + +### Чеклист перепроверки (2–3 прохода) +- SSG: нет использования SSR-only API в runtime. +- i18n: URL стратегия соответствует требованиям SEO. +- Pinia: состояния не дублируются в компонентах. + - Минимальная страница открывается без ошибок с отключённым JS (хотя бы смысловой контент). + diff --git a/landing/docs/iterations/ITERATION_02_UI_SECTIONS.md b/landing/docs/iterations/ITERATION_02_UI_SECTIONS.md new file mode 100644 index 00000000..3680ca15 --- /dev/null +++ b/landing/docs/iterations/ITERATION_02_UI_SECTIONS.md @@ -0,0 +1,45 @@ +## Итерация 02 — страницы и секции (UI) на данных из `data/` + +### Цель +Собрать UI лендинга с секциями из плана, но без “случайного” стейта в компонентах: данные/конфиги лежат в `landing/data`, общее состояние — в Pinia. + +### Правила, чтобы лендинг было легко менять +- **Порядок секций** задаётся конфигом (например `landing/data/sections.ts`), чтобы можно было переставлять/скрывать блоки без переписывания `pages/index.vue`. +- **Тексты и контент**: разделяем “микрокопирайт” и “контент секций”. + - Микрокопирайт (кнопки, лейблы, короткие подписи) — через i18n (`landing/locales/*`). + - Контент секций (FAQ, список фич, описания провайдеров, тексты блоков) — через локализованные контент-файлы по структуре (например `landing/content/{locale}.ts` или `landing/content/{locale}.json`). + - В `landing/data/*` храним **структуру и стабильные id**, а не длинные строки и не “россыпь ключей”. + - Правило простое: чтобы изменить один пункт FAQ, не нужно искать 10 ключей — открыл контент-файл нужной локали и поправил. +- **UI-атомы** (`components/ui/*`) не знают про бизнес и не ходят в store. Они получают props и рендерят. +- **Секции** (`components/sections/*`) отвечают за композицию: берут данные из `data/*`, нужные значения из store и связывают это с UI. + +### Шаги +1) **Layout**: + - `layouts/default.vue`: header/footer, контейнер, базовые отступы. +2) **Страница `/`**: + - Hero, Features, Providers, Screenshots, Download, Privacy, FAQ. +3) **Страница `/download`**: + - Платформо-специфичный блок + инструкции установки. +4) **Секция Privacy**: + - Чётко подсветить: локальное хранение ключей, опциональные ключи, отсутствие облачного хранилища. + - Отдельный блок: “часть компонентов open-source” (без излишних обещаний). +5) **i18n покрытие**: + - Все тексты вытащены в locales, не оставляем хардкод строк. +6) **Data-driven подход**: + - Карточки фич, FAQ, провайдеры, список платформ — из `landing/data/*`. +7) **Навигация по секциям**: + - Якоря/scroll в рамках страницы так, чтобы это было доступно (фокус/URL, без поломки истории). + +### Критерии готовности +- Компоненты секций не содержат бизнес-данных “внутри себя”. +- Минимум логики внутри шаблонов, максимум — через computed/props и util-функции. +- i18n покрывает весь UI без пропусков. + - Любую секцию можно скрыть конфигом, и страница остаётся валидной. + - Для изображений есть корректные `alt`/подписи, а для кнопок — понятные названия/label. + +### Чеклист перепроверки +- Нет дублирования конфигов секций. +- Секции легко переставить/скрыть, не ломая остальное. +- Нет лишних сторонних зависимостей “на всякий случай”. + - На мобильных нет “сломанных” отступов/переполнений (особенно в таблицах/списках). + diff --git a/landing/docs/iterations/ITERATION_03_SWIPER_AND_DOWNLOAD.md b/landing/docs/iterations/ITERATION_03_SWIPER_AND_DOWNLOAD.md new file mode 100644 index 00000000..6def643b --- /dev/null +++ b/landing/docs/iterations/ITERATION_03_SWIPER_AND_DOWNLOAD.md @@ -0,0 +1,67 @@ +## Итерация 03 — Swiper карусель + Download UX (OS + macOS arch) + +### Цель +Сделать две “ключевые” UX части максимально качественно: карусель скриншотов и умный блок скачивания. + +### Часть A: Screenshots (Swiper) +#### Требования +- Swiper Vue (`https://swiperjs.com/vue`) +- Desktop: на экране одновременно видно **несколько** скринов. +- Mobile: 1 скрин, свайп, pagination. + +#### План реализации +- Компонент `ScreenshotCarousel.vue`: + - `breakpoints` для `slidesPerView` (примерно: 1 / 2 / 3 / 4 в зависимости от ширины) + - `spaceBetween`, `grabCursor`, `keyboard`, `a11y` + - `lazy` для изображений (если уместно) +- `landing/data/screenshots.ts`: массив скринов, подписи, alt-тексты, темы (dark/light) + +#### Критерии готовности +- Desktop показывает 2–4 скрина одновременно. +- Навигация доступна клавиатурой. +- Alt тексты корректны. + - Изображения не “раздувают” страницу: есть ограничение по размерам, понятные форматы, нет тяжёлых файлов “на авось”. + +### Часть B: Download Section (OS + macOS arch) +#### Требования +- Определяем ОС. +- Если неизвестно — показываем все. +- macOS: учитываем Apple Silicon vs Intel: + - если детект уверенный — автоселект + - если нет — выбор пользователю + +#### План реализации +- `landing/utils/platform.ts`: чистые функции: + - parse platform (mac/windows/linux/unknown) + - parse arch (arm64/x64/unknown) через `userAgentData` где доступно + - важно: не “угадывать” там, где точности нет — лучше показать выбор и запомнить решение пользователя +- `landing/stores/downloadStore.ts`: + - хранит `platform`, `arch`, `preferredMacArch` (если пользователь выбрал), `selectedAsset` + - умеет fallback-логики и приоритеты отображения +- `landing/data/downloads.ts`: + - описывает доступные артефакты (os, arch, extension, url, label) + +#### Контракт UI/данных (чтобы потом не разъезжалось) +- В `downloads.ts` у каждого ассета есть: + - `os`: `macos | windows | linux` + - `arch`: `arm64 | x64 | universal | unknown` + - `variant`: например `dmg | zip | exe | msi | appimage | deb | rpm` (что реально есть) + - `url`, `labelKey` (i18n), опционально `sha256`, `sizeBytes` +- Блок download умеет: + - показывать “рекомендованный” вариант сверху (если уверенно определили) + - всегда давать способ выбрать альтернативу (особенно для macOS) + - сохранять выбор пользователя (локально) + +#### Критерии готовности +- Для “unknown” показывается полный список ОС. +- Для macOS всегда понятен выбор: Silicon/Intel. +- Выбор пользователя запоминается. + - Логика выбора ассета покрыта тестами на основные кейсы (хотя бы таблица кейсов). + - Все ссылки валидны (нет 404 в конфиге), плюс есть быстрый способ проверить это локально. + +### Чеклист перепроверки (несколько проходов) +- Проверка на macOS Safari/Chrome, Windows, Linux (эмуляция user agent). +- A11y: keyboard, screen reader labels. +- Логика не завязана на Nuxt в `utils/`. + - Если `userAgentData` недоступен, UX остаётся понятным (никакой “магии”, только явный выбор). + diff --git a/landing/docs/iterations/ITERATION_04_ANALYTICS_SEO_SSG_RENDER.md b/landing/docs/iterations/ITERATION_04_ANALYTICS_SEO_SSG_RENDER.md new file mode 100644 index 00000000..802079d2 --- /dev/null +++ b/landing/docs/iterations/ITERATION_04_ANALYTICS_SEO_SSG_RENDER.md @@ -0,0 +1,64 @@ +## Итерация 04 — аналитика (GA4), SEO, SSG и деплой на Render + +### Цель +Подключить аналитику без “магии” и сделать финальную полировку под SSG + Render. + +### Аналитика (GA4) +#### Требования +- GA4, события: + - `download_click` + - `download_page_view` + - `language_change` + - `theme_change` + - `faq_open` + - `cta_view_features_click` + +#### План +- Конфиг через env (например `NUXT_PUBLIC_GA_ID`). +- Единая утилита/композабл `useAnalytics()`: + - гарантирует отсутствие вызовов на сервере + - нормализует payload (platform, arch, locale, version) +- События из UI триггерятся централизованно (минимум дублей). + +#### Важно +Я не могу “привязать к своему гугл аккаунту” или создать property сам — нужен Measurement ID/доступы от владельца. В плане и коде делаем всё так, чтобы подключение было 1 строкой после получения ID. + +#### Минимальный контракт событий (чтобы аналитика была полезной) +- `download_click`: `os`, `arch`, `variant`, `version` (если есть), `locale`, `source` (hero/download-page/footer) +- `language_change`: `from`, `to` +- `theme_change`: `from`, `to` +- `faq_open`: `item_id` +- `cta_view_features_click`: `source` +- Все события: без персональных данных, без текста транскрипций, без key-материалов (приватность соблюдаем). + +### SEO +- Meta теги по языкам, og/twitter, robots/sitemap. +- Проверка корректности canonical/alternate (i18n). + - Семантика: один `h1` на страницу, корректные заголовки секций (`h2/h3`), читаемая структура. + - Изображения: `alt`, `width/height` чтобы не было CLS. + +### SSG +- Проверка, что все страницы генерятся. +- Нет runtime зависимостей от SSR. + - Это именно **SSG (пререндер страниц)**, а не SPA: важно для SEO. + - Отдельно проверяем генерацию i18n-роутов (все локали, которые включены). + +### Render +- Документируем шаги деплоя под static hosting Render: + - build command + - publish directory + - переменные окружения (GA ID) + - Документируем кэширование статических ассетов (правильные headers на стороне Render). + +### Критерии готовности +- События отправляются один раз и с корректными параметрами. +- SSG сборка стабильна. +- Документирован деплой на Render. + - Lighthouse (desktop/mobile) без красных метрик на главной (особенно LCP/CLS). + +### Чеклист перепроверки (2–3 прохода) +- События: нет лишних/дублирующихся триггеров. +- SEO: title/description на всех локалях. +- Статические ассеты оптимизированы (разумные размеры). + - Нет трекинга до пользовательского действия, если потребуется баннер согласия (это заранее решаем политикой проекта). + diff --git a/landing/docs/iterations/README.md b/landing/docs/iterations/README.md new file mode 100644 index 00000000..4838386c --- /dev/null +++ b/landing/docs/iterations/README.md @@ -0,0 +1,18 @@ +# Landing (Voice-to-Text) — планы итераций + +Здесь лежат **планы итераций** для разработки лендинга. + +Правило процесса: +1) Сначала уточняем требования и фиксируем их в планах итераций (максимально подробно). +2) Затем **несколько раз перепроверяем** планы (логика, полнота, риски, несостыковки, критерии “готово”). +3) Только после этого начинаем реализацию строго по шагам. + +Общее правило качества (Definition of Done для любой итерации): +- **SSG-совместимость**: нет логики, которая требует сервер на каждом запросе (лендинг — статический). +- **Контент редактируемый**: тексты/списки/ссылки лежат в `landing/data/*` и `landing/content/*`/`landing/locales/*`, а не “зашиты” в секциях. +- **SEO**: корректные `title/description`, `og/twitter`, `canonical`, `alternate` (i18n), sitemap/robots. +- **A11y**: навигация с клавиатуры, корректные подписи/alt, адекватный фокус, контраст. +- **Производительность**: изображения оптимизированы, нет тяжёлых блокирующих ресурсов, разумные размеры бандла. +- **Проверяемость**: ключевая логика (platform/arch, выбор ассета) вынесена в `utils/` и покрыта тестами (минимум smoke). + +Рекомендация: держать под рукой `landing/docs/ARCHITECTURE_GUARDRAILS.md` — там перечислены инварианты, которые защищают от регрессов (SSG, i18n, sitemap, downloads, analytics). diff --git a/landing/error.vue b/landing/error.vue new file mode 100644 index 00000000..b16b4f97 --- /dev/null +++ b/landing/error.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/landing/eslint.config.mjs b/landing/eslint.config.mjs new file mode 100644 index 00000000..934c3a1d --- /dev/null +++ b/landing/eslint.config.mjs @@ -0,0 +1,6 @@ +// @ts-check +import withNuxt from './.nuxt/eslint.config.mjs' + +export default withNuxt( + // Your custom configs here +) diff --git a/landing/layouts/default.vue b/landing/layouts/default.vue new file mode 100644 index 00000000..0a4397d7 --- /dev/null +++ b/landing/layouts/default.vue @@ -0,0 +1,16 @@ + + + diff --git a/landing/locales/en.json b/landing/locales/en.json new file mode 100644 index 00000000..f24a5917 --- /dev/null +++ b/landing/locales/en.json @@ -0,0 +1,110 @@ +{ + "nav": { + "features": "Features", + "comparison": "Compare", + "download": "Download", + "faq": "FAQ", + "viewOnGithub": "View on GitHub" + }, + "hero": { + "badge": "Claude Agent Teams", + "downloadNow": "Download Now", + "ctaPrimary": "Download for {platform}", + "ctaSecondary": "View Features", + "preview": "Product preview", + "trust": { + "agentTeams": "Agent Teams", + "kanban": "Kanban Board", + "openSource": "Open Source" + }, + "watchDemo": "Watch Demo", + "videoUnavailable": "Video unavailable" + }, + "download": { + "title": "Download", + "detected": "Detected", + "systemRequirements": "System requirements", + "version": "Version {version}" + }, + "theme": { + "dark": "Dark", + "light": "Light" + }, + "language": { + "label": "Language" + }, + "features": { + "sectionTitle": "Everything you need for AI agent orchestration", + "sectionSubtitle": "Powerful tools that make multi-agent collaboration actually work." + }, + "pricing": { + "sectionTitle": "100% Free. No strings attached.", + "sectionSubtitle": "Open source, no API keys, no configuration. Just install and go.", + "getStarted": "Download Now", + "popular": "Free Forever", + "note": "100% open source. No API keys. No configuration. Runs entirely locally." + }, + "testimonials": { + "sectionTitle": "What developers say", + "sectionSubtitle": "Real feedback from real builders", + "showMore": "Show more", + "showLess": "Show less", + "feedbackCta": "Want to share your experience? Open an issue on" + }, + "faq": { + "sectionTitle": "Got questions? We've got answers", + "subtitle": "Everything you need to know about Claude Agent Teams" + }, + "comparison": { + "sectionTitle": "How we compare", + "sectionSubtitle": "Feature-by-feature comparison with other AI coding tools.", + "feature": "Feature", + "features": { + "crossTeam": "Cross-team communication", + "agentMessaging": "Agent-to-agent messaging", + "linkedTasks": "Linked tasks", + "sessionAnalysis": "Session analysis", + "taskAttachments": "Task attachments", + "hunkReview": "Hunk-level review", + "codeEditor": "Built-in code editor", + "fullAutonomy": "Full autonomy", + "taskDeps": "Task dependencies", + "reviewWorkflow": "Review workflow", + "zeroSetup": "Zero setup", + "kanban": "Kanban board", + "execLog": "Execution log viewer", + "liveProcesses": "Live processes", + "perTaskReview": "Per-task code review", + "flexAutonomy": "Flexible autonomy", + "worktree": "Git worktree isolation", + "multiAgent": "Multi-agent backend", + "price": "Price" + } + }, + "screenshots": { + "sectionTitle": "See it in action", + "sectionSubtitle": "Real screenshots from the app — kanban board, code review, agent teams, and more." + }, + "common": { + "learnMore": "Learn more" + }, + "footer": { + "copyright": "© {year} Claude Agent Teams", + "tagline": "AI agent orchestration for developers", + "links": { + "github": "GitHub", + "docs": "Documentation" + } + }, + "meta": { + "homeTitle": "Claude Agent Teams — AI Agent Orchestration for Developers", + "homeDescription": "Free, open-source desktop app for assembling AI agent teams. Kanban board, code review, cross-team communication. Runs entirely locally." + }, + "error": { + "notFoundTitle": "Page not found", + "notFoundDescription": "The page you are looking for does not exist or has been moved.", + "genericTitle": "Something went wrong", + "genericDescription": "An unexpected error occurred. Please try again later.", + "goHome": "Go to homepage" + } +} diff --git a/landing/locales/ru.json b/landing/locales/ru.json new file mode 100644 index 00000000..d689f2e9 --- /dev/null +++ b/landing/locales/ru.json @@ -0,0 +1,110 @@ +{ + "nav": { + "features": "Возможности", + "comparison": "Сравнение", + "download": "Скачать", + "faq": "FAQ", + "viewOnGithub": "View on GitHub" + }, + "hero": { + "badge": "Claude Agent Teams", + "downloadNow": "Скачать", + "ctaPrimary": "Скачать для {platform}", + "ctaSecondary": "Посмотреть возможности", + "preview": "Превью продукта", + "trust": { + "agentTeams": "Команды агентов", + "kanban": "Канбан-доска", + "openSource": "Open Source" + }, + "watchDemo": "Смотреть демо", + "videoUnavailable": "Видео недоступно" + }, + "download": { + "title": "Скачать", + "detected": "Определено", + "systemRequirements": "Системные требования", + "version": "Версия {version}" + }, + "theme": { + "dark": "Тёмная", + "light": "Светлая" + }, + "language": { + "label": "Язык" + }, + "features": { + "sectionTitle": "Всё для оркестрации ИИ-агентов", + "sectionSubtitle": "Мощные инструменты, которые делают мультиагентную совместную работу реальностью." + }, + "pricing": { + "sectionTitle": "100% Бесплатно. Без подвоха.", + "sectionSubtitle": "Открытый код, без API-ключей, без конфигурации. Просто установите и работайте.", + "getStarted": "Скачать", + "popular": "Бесплатно навсегда", + "note": "100% открытый код. Без API-ключей. Без конфигурации. Работает полностью локально." + }, + "testimonials": { + "sectionTitle": "Что говорят разработчики", + "sectionSubtitle": "Реальные отзывы от реальных разработчиков", + "showMore": "Показать ещё", + "showLess": "Свернуть", + "feedbackCta": "Хотите поделиться опытом? Создайте issue на" + }, + "faq": { + "sectionTitle": "Есть вопросы? У нас есть ответы", + "subtitle": "Всё, что нужно знать о Claude Agent Teams" + }, + "comparison": { + "sectionTitle": "Сравнение с конкурентами", + "sectionSubtitle": "Подробное сравнение возможностей с другими AI-инструментами для разработки.", + "feature": "Возможность", + "features": { + "crossTeam": "Межкомандная коммуникация", + "agentMessaging": "Обмен сообщениями между агентами", + "linkedTasks": "Связанные задачи", + "sessionAnalysis": "Анализ сессий", + "taskAttachments": "Вложения к задачам", + "hunkReview": "Ревью на уровне хунков", + "codeEditor": "Встроенный редактор кода", + "fullAutonomy": "Полная автономность", + "taskDeps": "Зависимости задач", + "reviewWorkflow": "Процесс ревью", + "zeroSetup": "Без настройки", + "kanban": "Канбан-доска", + "execLog": "Просмотр логов выполнения", + "liveProcesses": "Живые процессы", + "perTaskReview": "Код-ревью по задачам", + "flexAutonomy": "Гибкая автономность", + "worktree": "Изоляция Git worktree", + "multiAgent": "Мультиагентный бэкенд", + "price": "Цена" + } + }, + "screenshots": { + "sectionTitle": "Посмотрите в действии", + "sectionSubtitle": "Реальные скриншоты приложения — канбан-доска, код-ревью, команды агентов и многое другое." + }, + "common": { + "learnMore": "Подробнее" + }, + "footer": { + "copyright": "© {year} Claude Agent Teams", + "tagline": "Оркестрация ИИ-агентов для разработчиков", + "links": { + "github": "GitHub", + "docs": "Документация" + } + }, + "meta": { + "homeTitle": "Claude Agent Teams — Оркестрация ИИ-агентов для разработчиков", + "homeDescription": "Бесплатное десктопное приложение с открытым кодом для управления командами ИИ-агентов. Канбан-доска, код-ревью, межкомандная коммуникация. Работает полностью локально." + }, + "error": { + "notFoundTitle": "Страница не найдена", + "notFoundDescription": "Страница, которую вы ищете, не существует или была перемещена.", + "genericTitle": "Что-то пошло не так", + "genericDescription": "Произошла непредвиденная ошибка. Попробуйте позже.", + "goHome": "На главную" + } +} diff --git a/landing/nuxt.config.ts b/landing/nuxt.config.ts new file mode 100644 index 00000000..324a3bca --- /dev/null +++ b/landing/nuxt.config.ts @@ -0,0 +1,99 @@ +import vuetify from "vite-plugin-vuetify"; +import { generateI18nRoutes, supportedLocales } from "./data/i18n"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +declare const process: any; + +const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev"; +const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui"; +const githubReleasesUrl = `https://github.com/${githubRepo}/releases`; + +export default defineNuxtConfig({ + compatibilityDate: "2026-01-19", + ssr: true, + experimental: { + inlineSSRStyles: false + }, + app: { + head: { + link: [ + { rel: "icon", type: "image/x-icon", href: "/favicon.ico" }, + { rel: "preconnect", href: "https://fonts.googleapis.com" }, + { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" }, + { rel: "preload", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap", as: "style" }, + { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" } + ] + } + }, + modules: [ + "@pinia/nuxt", + "@nuxtjs/i18n", + "@vueuse/nuxt", + "nuxt-icon", + "@nuxt/eslint" + ], + css: ["~/assets/styles/main.scss"], + components: [ + { + path: "~/components", + pathPrefix: false + } + ], + build: { + transpile: ["vuetify"] + }, + vue: { + compilerOptions: { + isCustomElement: (tag: string) => tag.startsWith("swiper-") + } + }, + vite: { + plugins: [vuetify({ autoImport: true })] + }, + nitro: { + compressPublicAssets: true, + prerender: { + routes: [ + ...generateI18nRoutes(), + ] + } + }, + routeRules: { + "/_nuxt/**": { + headers: { "Cache-Control": "public, max-age=31536000, immutable" } + } + }, + i18n: { + restructureDir: false, + locales: [...supportedLocales] as any, + defaultLocale: "en", + strategy: "prefix_except_default", + lazy: true, + langDir: "locales", + bundle: { + optimizeTranslationDirective: false + }, + detectBrowserLanguage: { + useCookie: true, + cookieKey: "i18n_redirected", + redirectOn: "root", + alwaysRedirect: false, + fallbackLocale: "en" + } + }, + // @ts-expect-error - field provided by nuxt modules + site: { + url: siteUrl, + name: "Claude Agent Teams" + }, + runtimeConfig: { + github: { + token: process.env.GITHUB_TOKEN + }, + public: { + siteUrl, + githubRepo, + githubReleasesUrl + } + } +}); diff --git a/landing/package.json b/landing/package.json new file mode 100644 index 00000000..a00ce0c9 --- /dev/null +++ b/landing/package.json @@ -0,0 +1,37 @@ +{ + "name": "claude-agent-teams-landing", + "private": true, + "type": "module", + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "generate": "nuxt generate", + "preview": "nuxt preview", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --check", + "format:fix": "prettier . --write" + }, + "dependencies": { + "@mdi/js": "^7.4.47", + "@nuxtjs/i18n": "^9.5.6", + "@pinia/nuxt": "^0.11.3", + "@vueuse/nuxt": "^10.11.1", + "nuxt": "^3.20.2", + "nuxt-icon": "^0.6.10", + "pinia": "^3.0.4", + "swiper": "^12.1.2", + "vue": "^3.5.27", + "vue-i18n": "^11.2.8", + "vue-router": "^4.6.4", + "vuetify": "^3.11.6" + }, + "devDependencies": { + "@mdi/font": "^7.4.47", + "@nuxt/eslint": "^1.12.1", + "eslint": "^9.39.2", + "prettier": "^3.8.0", + "sass": "^1.97.2", + "vite-plugin-vuetify": "^2.1.3" + } +} diff --git a/landing/pages/download.vue b/landing/pages/download.vue new file mode 100644 index 00000000..1998b6d0 --- /dev/null +++ b/landing/pages/download.vue @@ -0,0 +1,13 @@ + + + diff --git a/landing/pages/index.vue b/landing/pages/index.vue new file mode 100644 index 00000000..e2a379a6 --- /dev/null +++ b/landing/pages/index.vue @@ -0,0 +1,24 @@ + + + diff --git a/landing/plugins/init-theme-locale.client.ts b/landing/plugins/init-theme-locale.client.ts new file mode 100644 index 00000000..89e6652b --- /dev/null +++ b/landing/plugins/init-theme-locale.client.ts @@ -0,0 +1,14 @@ +export default defineNuxtPlugin({ + name: "init-theme-locale", + dependsOn: ["vuetify"], + setup(nuxtApp) { + const { initTheme } = useBrowserTheme(); + const { initLocale } = useLocation(); + + // Run after hydration to avoid SSR/CSR mismatches. + nuxtApp.hook("app:mounted", () => { + initTheme(); + initLocale(); + }); + } +}); diff --git a/landing/plugins/vuetify.ts b/landing/plugins/vuetify.ts new file mode 100644 index 00000000..2fbde537 --- /dev/null +++ b/landing/plugins/vuetify.ts @@ -0,0 +1,40 @@ +import "vuetify/styles"; +import { createVuetify } from "vuetify"; +import { aliases, mdi } from "vuetify/iconsets/mdi-svg"; + +export default defineNuxtPlugin({ + name: "vuetify", + setup(nuxtApp) { + const vuetify = createVuetify({ + icons: { + defaultSet: "mdi", + aliases, + sets: { mdi } + }, + theme: { + defaultTheme: "dark", + themes: { + light: { + colors: { + primary: "#00f0ff", + secondary: "#ff00ff", + background: "#f0f2f5", + surface: "#ffffff" + } + }, + dark: { + colors: { + primary: "#00f0ff", + secondary: "#ff00ff", + background: "#0a0a0f", + surface: "#12121a" + } + } + } + } + }); + + nuxtApp.vueApp.use(vuetify); + nuxtApp.provide("vuetifyTheme", vuetify.theme); + } +}); diff --git a/landing/public/favicon-32.png b/landing/public/favicon-32.png new file mode 100644 index 00000000..29f7bc78 Binary files /dev/null and b/landing/public/favicon-32.png differ diff --git a/landing/public/favicon.ico b/landing/public/favicon.ico new file mode 100644 index 00000000..4274409a Binary files /dev/null and b/landing/public/favicon.ico differ diff --git a/landing/public/logo-192.png b/landing/public/logo-192.png new file mode 100644 index 00000000..cfa81f1a Binary files /dev/null and b/landing/public/logo-192.png differ diff --git a/landing/public/og-image.png b/landing/public/og-image.png new file mode 100644 index 00000000..791372d6 Binary files /dev/null and b/landing/public/og-image.png differ diff --git a/landing/public/screenshots/1.jpg b/landing/public/screenshots/1.jpg new file mode 100644 index 00000000..82725b41 Binary files /dev/null and b/landing/public/screenshots/1.jpg differ diff --git a/landing/public/screenshots/2.jpg b/landing/public/screenshots/2.jpg new file mode 100644 index 00000000..61906ba5 Binary files /dev/null and b/landing/public/screenshots/2.jpg differ diff --git a/landing/public/screenshots/3.png b/landing/public/screenshots/3.png new file mode 100644 index 00000000..d5f80a5f Binary files /dev/null and b/landing/public/screenshots/3.png differ diff --git a/landing/public/screenshots/4.png b/landing/public/screenshots/4.png new file mode 100644 index 00000000..0639faf9 Binary files /dev/null and b/landing/public/screenshots/4.png differ diff --git a/landing/public/screenshots/5.png b/landing/public/screenshots/5.png new file mode 100644 index 00000000..10016f27 Binary files /dev/null and b/landing/public/screenshots/5.png differ diff --git a/landing/public/screenshots/6.png b/landing/public/screenshots/6.png new file mode 100644 index 00000000..ec07ad03 Binary files /dev/null and b/landing/public/screenshots/6.png differ diff --git a/landing/public/screenshots/7.png b/landing/public/screenshots/7.png new file mode 100644 index 00000000..33f645e3 Binary files /dev/null and b/landing/public/screenshots/7.png differ diff --git a/landing/public/screenshots/8.png b/landing/public/screenshots/8.png new file mode 100644 index 00000000..5ca7abb5 Binary files /dev/null and b/landing/public/screenshots/8.png differ diff --git a/landing/public/screenshots/9.png b/landing/public/screenshots/9.png new file mode 100644 index 00000000..4c35de66 Binary files /dev/null and b/landing/public/screenshots/9.png differ diff --git a/landing/stores/download.ts b/landing/stores/download.ts new file mode 100644 index 00000000..b2054023 --- /dev/null +++ b/landing/stores/download.ts @@ -0,0 +1,45 @@ +import { defineStore } from "pinia"; +import { downloadAssets } from "~/data/downloads"; +import type { DownloadArch, DownloadOs } from "~/data/downloads"; +import { detectMacArch, detectPlatform } from "~/utils/platform"; + +export const useDownloadStore = defineStore("download", { + state: () => ({ + os: "unknown" as DownloadOs | "unknown", + arch: "unknown" as DownloadArch | "unknown", + selectedId: "" + }), + getters: { + assets: () => downloadAssets, + selectedAsset(state) { + return downloadAssets.find((asset) => asset.id === state.selectedId); + }, + isMacOs(state): boolean { + return state.os === "macos"; + }, + macArch(state): "arm64" | "x64" { + return state.arch === "arm64" ? "arm64" : "x64"; + } + }, + actions: { + init() { + if (!process.client) return; + const ua = navigator.userAgent; + const os = detectPlatform(ua); + this.os = os === "unknown" ? "unknown" : os; + if (this.os === "macos") { + this.arch = detectMacArch(ua) as DownloadArch; + } else if (this.os !== "unknown") { + this.arch = "x64"; + } + // Для macOS — одна карточка, матчим по OS + const match = downloadAssets.find((asset) => asset.os === this.os); + if (match) { + this.selectedId = match.id; + } + }, + setSelected(id: string) { + this.selectedId = id; + } + } +}); diff --git a/landing/stores/locale.ts b/landing/stores/locale.ts new file mode 100644 index 00000000..07a18c01 --- /dev/null +++ b/landing/stores/locale.ts @@ -0,0 +1,16 @@ +import { defineStore } from "pinia"; + +export const useLocaleStore = defineStore("locale", { + state: () => ({ + current: "en", + userSelected: false + }), + actions: { + setLocale(locale: string, fromUser: boolean) { + this.current = locale; + if (fromUser) { + this.userSelected = true; + } + } + } +}); diff --git a/landing/stores/theme.ts b/landing/stores/theme.ts new file mode 100644 index 00000000..5829c993 --- /dev/null +++ b/landing/stores/theme.ts @@ -0,0 +1,31 @@ +import { defineStore } from "pinia"; + +type ThemeName = "light" | "dark"; + +export const useThemeStore = defineStore("theme", { + state: () => ({ + current: "dark" as ThemeName, + userSelected: false + }), + actions: { + getInitialTheme(): ThemeName { + if (!process.client) return "dark"; + const saved = localStorage.getItem("theme"); + if (saved === "dark" || saved === "light") { + this.userSelected = true; + return saved; + } + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + return "dark"; + }, + setTheme(theme: ThemeName, fromUser: boolean) { + this.current = theme; + if (process.client && fromUser) { + this.userSelected = true; + localStorage.setItem("theme", theme); + } + } + } +}); diff --git a/landing/tsconfig.json b/landing/tsconfig.json new file mode 100644 index 00000000..23467aec --- /dev/null +++ b/landing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./.nuxt/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": ["./*"] + } + } +} diff --git a/landing/types/content.ts b/landing/types/content.ts new file mode 100644 index 00000000..8385d53b --- /dev/null +++ b/landing/types/content.ts @@ -0,0 +1,51 @@ +import type { LocaleCode } from "~/data/i18n"; + +export interface FeatureItem { + id: string; + title: string; + description: string; +} + +export interface FaqItem { + id: string; + question: string; + answer: string; +} + +export interface HeroContent { + title: string; + subtitle: string; +} + +export interface DownloadContent { + title: string; + note: string; +} + +export interface PricingPlan { + id: string; + name: string; + price: string; + period: string; + description: string; + features: string[]; + highlighted?: boolean; +} + +export interface TestimonialItem { + id: string; + name: string; + role: string; + text: string; +} + +export interface LandingContent { + hero: HeroContent; + features: FeatureItem[]; + faq: FaqItem[]; + download: DownloadContent; + pricing: PricingPlan[]; + testimonials: TestimonialItem[]; +} + +export type LocalizedContent = Record; diff --git a/landing/types/platform.ts b/landing/types/platform.ts new file mode 100644 index 00000000..095eda9a --- /dev/null +++ b/landing/types/platform.ts @@ -0,0 +1,2 @@ +export type PlatformOs = "macos" | "windows" | "linux" | "unknown"; +export type PlatformArch = "arm64" | "x64" | "universal" | "unknown"; diff --git a/landing/utils/platform.ts b/landing/utils/platform.ts new file mode 100644 index 00000000..c5085150 --- /dev/null +++ b/landing/utils/platform.ts @@ -0,0 +1,34 @@ +import type { PlatformArch, PlatformOs } from "~/types/platform"; + +export const detectPlatform = (userAgent: string): PlatformOs => { + const ua = userAgent.toLowerCase(); + if (ua.includes("mac")) return "macos"; + if (ua.includes("win")) return "windows"; + if (ua.includes("linux")) return "linux"; + return "unknown"; +}; + +export const detectMacArch = (userAgent: string): PlatformArch => { + const ua = userAgent.toLowerCase(); + if (ua.includes("arm") || ua.includes("aarch64")) return "arm64"; + + // Браузеры на Apple Silicon всё равно шлют "Intel Mac OS X" в UA, + // поэтому проверяем GPU через WebGL — Apple Silicon репортится как "Apple M1/M2/..." + if (typeof document !== "undefined") { + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl2") || canvas.getContext("webgl"); + if (gl) { + const dbg = gl.getExtension("WEBGL_debug_renderer_info"); + if (dbg) { + const renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string; + if (/apple\s*m\d|apple\s*gpu/i.test(renderer)) return "arm64"; + } + } + } catch { + // WebGL недоступен — fallback на x64 + } + } + + return "x64"; +};