fix(tests): improve messageId generation for legacy inbox rows
- Enhanced tests to ensure consistent messageId generation for legacy inbox rows lacking a messageId. - Updated test descriptions for better clarity regarding the new messageId handling. - Adjusted test expectations to align with the updated behavior of relaying legacy inbox rows with generated messageIds.
52
.github/workflows/landing.yml
vendored
Normal file
|
|
@ -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
|
||||
4
landing/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@nuxt/eslint-config"],
|
||||
};
|
||||
9
landing/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.dist
|
||||
.env
|
||||
|
||||
# Large video files
|
||||
public/video/*.mp4
|
||||
assets/video/*.mp4
|
||||
5
landing/.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.dist
|
||||
.env
|
||||
6
landing/.prettierrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
20
landing/README.md
Normal file
|
|
@ -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`.
|
||||
5
landing/app.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
16
landing/assets/images/screenshots/dark.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1f2937" />
|
||||
<stop offset="100%" stop-color="#111827" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="750" rx="32" fill="url(#bg)" />
|
||||
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#0f172a" stroke="#334155" stroke-width="2" />
|
||||
<rect x="120" y="140" width="320" height="24" rx="12" fill="#6366f1" />
|
||||
<rect x="120" y="190" width="420" height="16" rx="8" fill="#334155" />
|
||||
<rect x="120" y="230" width="520" height="16" rx="8" fill="#334155" />
|
||||
<rect x="120" y="270" width="480" height="16" rx="8" fill="#334155" />
|
||||
<circle cx="980" cy="190" r="44" fill="#1e293b" stroke="#4b5563" stroke-width="2" />
|
||||
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#e2e8f0">Dark theme</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 946 B |
16
landing/assets/images/screenshots/light.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f8fafc" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="750" rx="32" fill="url(#bg)" />
|
||||
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#ffffff" stroke="#cbd5f5" stroke-width="2" />
|
||||
<rect x="120" y="140" width="320" height="24" rx="12" fill="#6366f1" />
|
||||
<rect x="120" y="190" width="420" height="16" rx="8" fill="#cbd5e1" />
|
||||
<rect x="120" y="230" width="520" height="16" rx="8" fill="#cbd5e1" />
|
||||
<rect x="120" y="270" width="480" height="16" rx="8" fill="#cbd5e1" />
|
||||
<circle cx="980" cy="190" r="44" fill="#f1f5f9" stroke="#94a3b8" stroke-width="2" />
|
||||
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#1f2937">Light theme</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
BIN
landing/assets/images/screenshots/main_dark.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
landing/assets/images/screenshots/main_light.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
landing/assets/images/screenshots/record_dark.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
landing/assets/images/screenshots/record_light.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
11
landing/assets/images/screenshots/recording.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
|
||||
<rect width="1200" height="750" rx="32" fill="#111827" />
|
||||
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#0f172a" stroke="#334155" stroke-width="2" />
|
||||
<rect x="120" y="140" width="420" height="24" rx="12" fill="#ef4444" />
|
||||
<rect x="120" y="200" width="480" height="16" rx="8" fill="#334155" />
|
||||
<rect x="120" y="240" width="520" height="16" rx="8" fill="#334155" />
|
||||
<rect x="120" y="280" width="420" height="16" rx="8" fill="#334155" />
|
||||
<circle cx="980" cy="260" r="90" fill="#1f2937" stroke="#4b5563" stroke-width="2" />
|
||||
<circle cx="980" cy="260" r="48" fill="#ef4444" />
|
||||
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#e2e8f0">Recording</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 801 B |
10
landing/assets/images/screenshots/settings.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="750" viewBox="0 0 1200 750">
|
||||
<rect width="1200" height="750" rx="32" fill="#0f172a" />
|
||||
<rect x="80" y="90" width="1040" height="570" rx="24" fill="#111827" stroke="#334155" stroke-width="2" />
|
||||
<rect x="120" y="140" width="420" height="24" rx="12" fill="#22c55e" />
|
||||
<rect x="120" y="200" width="360" height="16" rx="8" fill="#334155" />
|
||||
<rect x="120" y="240" width="560" height="16" rx="8" fill="#334155" />
|
||||
<rect x="120" y="280" width="520" height="16" rx="8" fill="#334155" />
|
||||
<rect x="760" y="190" width="300" height="260" rx="16" fill="#1f2937" stroke="#4b5563" stroke-width="2" />
|
||||
<text x="120" y="520" font-family="Arial, sans-serif" font-size="32" fill="#e2e8f0">Settings</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 769 B |
BIN
landing/assets/images/screenshots/settings_dark.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
landing/assets/images/screenshots/settings_light.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
landing/assets/logo-128.webp
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
landing/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
76
landing/assets/styles/main.scss
Normal file
|
|
@ -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);
|
||||
}
|
||||
139
landing/components/PageBackground.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<div class="page-bg" aria-hidden="true">
|
||||
<div class="page-bg__grid" />
|
||||
<div class="page-bg__orb page-bg__orb--1" />
|
||||
<div class="page-bg__orb page-bg__orb--2" />
|
||||
<div class="page-bg__orb page-bg__orb--3" />
|
||||
<div class="page-bg__orb page-bg__orb--4" />
|
||||
<div class="page-bg__orb page-bg__orb--5" />
|
||||
<div class="page-bg__orb page-bg__orb--6" />
|
||||
<div class="page-bg__orb page-bg__orb--7" />
|
||||
<div class="page-bg__scanline" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Grid overlay */
|
||||
.page-bg__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Scanline effect */
|
||||
.page-bg__scanline {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 240, 255, 0.008) 2px,
|
||||
rgba(0, 240, 255, 0.008) 4px
|
||||
);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.page-bg__orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(140px);
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.page-bg__orb--1 {
|
||||
width: 900px;
|
||||
height: 900px;
|
||||
background: #00f0ff;
|
||||
top: -200px;
|
||||
right: -150px;
|
||||
animation: orbDrift1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-bg__orb--2 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #ff00ff;
|
||||
top: 300px;
|
||||
left: -200px;
|
||||
animation: orbDrift2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-bg__orb--3 {
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
background: #39ff14;
|
||||
top: 1200px;
|
||||
right: -100px;
|
||||
opacity: 0.05;
|
||||
animation: orbDrift1 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-bg__orb--4 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #00f0ff;
|
||||
top: 2100px;
|
||||
left: -150px;
|
||||
opacity: 0.06;
|
||||
animation: orbDrift2 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-bg__orb--5 {
|
||||
width: 750px;
|
||||
height: 750px;
|
||||
background: #ff00ff;
|
||||
top: 2900px;
|
||||
right: -120px;
|
||||
opacity: 0.05;
|
||||
animation: orbDrift1 24s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-bg__orb--6 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: #ffd700;
|
||||
top: 3600px;
|
||||
left: -100px;
|
||||
opacity: 0.04;
|
||||
animation: orbDrift2 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-bg__orb--7 {
|
||||
width: 650px;
|
||||
height: 650px;
|
||||
background: #00f0ff;
|
||||
top: 4300px;
|
||||
right: -80px;
|
||||
opacity: 0.05;
|
||||
animation: orbDrift1 17s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes orbDrift1 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(-30px, 20px); }
|
||||
}
|
||||
|
||||
@keyframes orbDrift2 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(25px, -15px); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-bg__orb {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
landing/components/SectionDivider.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
flip: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-wave" :class="{ 'section-wave--flip': flip }" aria-hidden="true">
|
||||
<svg
|
||||
class="section-wave__svg section-wave__svg--1"
|
||||
viewBox="0 0 2880 100"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
class="section-wave__path"
|
||||
d="M0,100 L0,60 C360,10 1080,90 1440,60 C1800,10 2520,90 2880,60 L2880,100 Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
class="section-wave__svg section-wave__svg--2"
|
||||
viewBox="0 0 2880 100"
|
||||
preserveAspectRatio="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
class="section-wave__path section-wave__path--2"
|
||||
d="M0,100 L0,50 C480,85 960,15 1440,50 C1920,85 2400,15 2880,50 L2880,100 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-wave {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
margin: -24px 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 40%, transparent 100%);
|
||||
}
|
||||
|
||||
.section-wave--flip {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.section-wave__svg {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.section-wave__svg--1 {
|
||||
animation: waveFlow 25s linear infinite;
|
||||
}
|
||||
|
||||
.section-wave__svg--2 {
|
||||
animation: waveFlow 18s linear infinite reverse;
|
||||
}
|
||||
|
||||
.section-wave__path {
|
||||
fill: rgba(0, 240, 255, 0.04);
|
||||
}
|
||||
|
||||
.section-wave__path--2 {
|
||||
fill: rgba(255, 0, 255, 0.025);
|
||||
}
|
||||
|
||||
.v-theme--light .section-wave__path {
|
||||
fill: rgba(0, 180, 200, 0.05);
|
||||
}
|
||||
|
||||
.v-theme--light .section-wave__path--2 {
|
||||
fill: rgba(180, 0, 180, 0.03);
|
||||
}
|
||||
|
||||
@keyframes waveFlow {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.section-wave__svg--1,
|
||||
.section-wave__svg--2 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.section-wave {
|
||||
height: 60px;
|
||||
margin: -16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.section-wave {
|
||||
height: 44px;
|
||||
margin: -10px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
52
landing/components/common/AppLogo.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/" class="app-logo">
|
||||
<img
|
||||
src="/logo-192.png"
|
||||
alt="Claude Agent Teams"
|
||||
class="app-logo__img"
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
<span class="app-logo__text">Claude Agent Teams</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-logo {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.02em;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.app-logo__img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-logo__text {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.v-theme--dark .app-logo__text {
|
||||
background: linear-gradient(135deg, #e0e6ff, #00f0ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
39
landing/components/common/ThemeToggle.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiWeatherSunny, mdiWeatherNight } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isDark, toggleTheme } = useBrowserTheme();
|
||||
const { trackThemeToggle } = useAnalytics();
|
||||
|
||||
const tooltip = computed(() => isDark.value ? t('theme.light') : t('theme.dark'));
|
||||
|
||||
const onToggle = () => {
|
||||
toggleTheme();
|
||||
trackThemeToggle(isDark.value ? 'dark' : 'light');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<v-tooltip :text="tooltip" location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
:icon="isDark ? mdiWeatherSunny : mdiWeatherNight"
|
||||
variant="text"
|
||||
size="small"
|
||||
:aria-label="tooltip"
|
||||
@click="onToggle"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<template #fallback>
|
||||
<v-btn
|
||||
:icon="mdiWeatherSunny"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Toggle theme"
|
||||
/>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
73
landing/components/layout/AppFooter.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<v-container class="app-footer__inner">
|
||||
<span class="app-footer__copy">{{ t("footer.copyright", { year }) }} · {{ t("footer.tagline") }}</span>
|
||||
<div class="app-footer__links">
|
||||
<a class="app-footer__link" href="https://github.com/777genius" target="_blank">Author</a>
|
||||
<span class="app-footer__divider" />
|
||||
<a class="app-footer__link" href="https://github.com/777genius/claude_agent_teams_ui" target="_blank">GitHub</a>
|
||||
</div>
|
||||
</v-container>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
border-top: 1px solid rgba(0, 240, 255, 0.08);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.app-footer__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-footer__copy {
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.app-footer__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-footer__link {
|
||||
color: #00f0ff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.app-footer__link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-footer__divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--light .app-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-footer__inner {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
230
landing/components/layout/AppHeader.vue
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiMenu, mdiClose, mdiGithub } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ id: 'comparison', label: t('nav.comparison') },
|
||||
{ id: 'download', label: t('nav.download') },
|
||||
{ id: 'faq', label: t('nav.faq') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<v-container class="app-header__inner">
|
||||
<AppLogo />
|
||||
<nav class="app-header__nav">
|
||||
<v-btn v-for="item in navItems" :key="item.id" variant="text" :href="`#${item.id}`">
|
||||
{{ item.label }}
|
||||
</v-btn>
|
||||
</nav>
|
||||
<div class="app-header__spacer" />
|
||||
<div class="app-header__desktop-actions">
|
||||
<LanguageSwitcher icon-only />
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="small"
|
||||
href="https://github.com/777genius/claude_agent_teams_ui"
|
||||
target="_blank"
|
||||
class="app-header__github-btn"
|
||||
:prepend-icon="mdiGithub"
|
||||
>
|
||||
GitHub
|
||||
</v-btn>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div class="app-header__mobile-actions">
|
||||
<v-btn :icon="mdiMenu" variant="text" @click="menuOpen = true" />
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-menu-fade">
|
||||
<div v-if="menuOpen" class="mobile-menu-overlay" @click.self="menuOpen = false">
|
||||
<div class="mobile-menu">
|
||||
<div class="mobile-menu__header">
|
||||
<AppLogo />
|
||||
<div style="flex: 1" />
|
||||
<v-btn :icon="mdiClose" variant="text" @click="menuOpen = false" />
|
||||
</div>
|
||||
<hr class="mobile-menu__divider" />
|
||||
<nav class="mobile-menu__list">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
class="mobile-menu__link"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/777genius/claude_agent_teams_ui"
|
||||
target="_blank"
|
||||
class="mobile-menu__link"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</nav>
|
||||
<hr class="mobile-menu__divider" />
|
||||
<div class="mobile-menu__actions">
|
||||
<LanguageSwitcher compact />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</v-container>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(0, 240, 255, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .app-header {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.v-theme--dark .app-header {
|
||||
background: rgba(10, 10, 15, 0.9);
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.app-header__nav {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
margin-left: 48px;
|
||||
}
|
||||
|
||||
.app-header__nav :deep(.v-btn) {
|
||||
height: 100% !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-header__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-header__github-btn {
|
||||
border-color: rgba(0, 240, 255, 0.25) !important;
|
||||
color: #00f0ff !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
.app-header__github-btn:hover {
|
||||
border-color: rgba(0, 240, 255, 0.5) !important;
|
||||
background: rgba(0, 240, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
.app-header__mobile-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.app-header__nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__desktop-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-header__mobile-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
padding: 16px 16px 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-menu__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-menu__divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.mobile-menu__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.mobile-menu__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
font-size: 1rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.mobile-menu__link:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.mobile-menu__actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.mobile-menu-fade-enter-active,
|
||||
.mobile-menu-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-fade-enter-from,
|
||||
.mobile-menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
156
landing/components/layout/LanguageSwitcher.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<script setup lang="ts">
|
||||
import { supportedLocales } from "~/data/i18n";
|
||||
import type { LocaleCode } from "~/data/i18n";
|
||||
import { useLocaleStore } from "~/stores/locale";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const nuxtApp = useNuxtApp();
|
||||
const switchLocalePath = useSwitchLocalePath();
|
||||
const props = defineProps<{ fullWidth?: boolean; compact?: boolean; iconOnly?: boolean }>();
|
||||
const localeStore = useLocaleStore();
|
||||
|
||||
const flagIconMap: Record<string, string> = {
|
||||
en: "circle-flags:us",
|
||||
ru: "circle-flags:ru"
|
||||
};
|
||||
|
||||
const items = computed(() =>
|
||||
supportedLocales.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.code as LocaleCode,
|
||||
flagIcon: flagIconMap[item.code] ?? "circle-flags:xx"
|
||||
}))
|
||||
);
|
||||
|
||||
const dropdownItems = computed(() =>
|
||||
items.value.filter((item) => item.value !== locale.value)
|
||||
);
|
||||
|
||||
const currentFlagIcon = computed(() => {
|
||||
return flagIconMap[locale.value as string] ?? "circle-flags:xx";
|
||||
});
|
||||
|
||||
const iconMenuOpen = ref(false);
|
||||
|
||||
const { trackLanguageSwitch } = useAnalytics();
|
||||
|
||||
const onChange = async (value: string | LocaleCode) => {
|
||||
const nextLocale = value as LocaleCode;
|
||||
iconMenuOpen.value = false;
|
||||
trackLanguageSwitch(locale.value as string, nextLocale);
|
||||
localeStore.setLocale(nextLocale, true);
|
||||
if (nuxtApp.$i18n?.setLocale) {
|
||||
await nuxtApp.$i18n.setLocale(nextLocale);
|
||||
} else {
|
||||
locale.value = nextLocale;
|
||||
}
|
||||
const path = switchLocalePath(nextLocale);
|
||||
if (path) {
|
||||
await navigateTo(path);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Icon-only mode -->
|
||||
<v-menu v-if="props.iconOnly" v-model="iconMenuOpen" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn variant="text" v-bind="menuProps" :aria-label="t('language.label')">
|
||||
<Icon :name="currentFlagIcon" class="language-switcher__flag-icon" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" class="language-switcher__menu-list">
|
||||
<v-list-item
|
||||
v-for="item in dropdownItems"
|
||||
:key="item.value"
|
||||
@click="onChange(item.value)"
|
||||
>
|
||||
<template #title>
|
||||
<span class="language-switcher__item">
|
||||
<Icon :name="item.flagIcon" class="language-switcher__flag-icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- Standard mode with search -->
|
||||
<v-autocomplete
|
||||
v-else
|
||||
:label="props.compact ? undefined : t('language.label')"
|
||||
:placeholder="props.compact ? t('language.label') : undefined"
|
||||
:items="dropdownItems"
|
||||
:model-value="locale"
|
||||
density="compact"
|
||||
:variant="props.compact ? 'plain' : 'outlined'"
|
||||
hide-details
|
||||
auto-select-first
|
||||
:menu-props="{ contentClass: 'language-switcher__dropdown' }"
|
||||
@update:model-value="onChange"
|
||||
:style="props.fullWidth ? { maxWidth: '100%', width: '100%' } : { maxWidth: '220px' }"
|
||||
:class="{
|
||||
'language-switcher--full': props.fullWidth,
|
||||
'language-switcher--compact': props.compact
|
||||
}"
|
||||
:aria-label="t('language.label')"
|
||||
:single-line="props.compact"
|
||||
>
|
||||
<template #selection>
|
||||
<Icon :name="currentFlagIcon" class="language-switcher__flag-icon" />
|
||||
</template>
|
||||
<template #item="{ item, props: itemProps }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #title>
|
||||
<span class="language-switcher__item">
|
||||
<Icon :name="item.raw.flagIcon" class="language-switcher__flag-icon" />
|
||||
<span>{{ item.raw.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.language-switcher__flag-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.language-switcher__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.language-switcher--compact :deep(.v-field) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.language-switcher--compact :deep(.v-field__input) {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.language-switcher--compact {
|
||||
min-width: 60px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.language-switcher--compact :deep(.v-field__outline) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.language-switcher--compact :deep(.v-field__overlay) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.language-switcher__menu-list {
|
||||
min-width: 180px;
|
||||
}
|
||||
</style>
|
||||
615
landing/components/sections/ComparisonSection.vue
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CellValue {
|
||||
status: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface ComparisonRow {
|
||||
feature: string
|
||||
us: CellValue
|
||||
vibeKanban: CellValue
|
||||
aperant: CellValue
|
||||
cursor: CellValue
|
||||
claudeCli: CellValue
|
||||
}
|
||||
|
||||
const rows = computed<ComparisonRow[]>(() => [
|
||||
{
|
||||
feature: t('comparison.features.crossTeam'),
|
||||
us: { status: 'yes' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'no' },
|
||||
cursor: { status: 'na' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.agentMessaging'),
|
||||
us: { status: 'yes', note: 'Native real-time mailbox' },
|
||||
vibeKanban: { status: 'no', note: 'Agents are independent' },
|
||||
aperant: { status: 'no', note: 'Fixed pipeline' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'Built-in (no UI)' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.linkedTasks'),
|
||||
us: { status: 'yes', note: 'Cross-references in messages' },
|
||||
vibeKanban: { status: 'partial', note: 'Subtasks only' },
|
||||
aperant: { status: 'no' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.sessionAnalysis'),
|
||||
us: { status: 'yes', note: '6-category token tracking' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'partial', note: 'Execution logs' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskAttachments'),
|
||||
us: { status: 'yes', note: 'Auto-attach, agents read & attach' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'yes', note: 'Images + files' },
|
||||
cursor: { status: 'partial', note: 'Chat session only' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.hunkReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject individual hunks' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'no' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.codeEditor'),
|
||||
us: { status: 'yes', note: 'With Git support' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'no' },
|
||||
cursor: { status: 'yes', note: 'Full IDE' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.fullAutonomy'),
|
||||
us: { status: 'yes', note: 'Create, assign, review end-to-end' },
|
||||
vibeKanban: { status: 'no', note: 'Human manages tasks' },
|
||||
aperant: { status: 'no', note: 'Fixed pipeline' },
|
||||
cursor: { status: 'partial', note: 'Isolated tasks only' },
|
||||
claudeCli: { status: 'partial', note: 'No UI' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.taskDeps'),
|
||||
us: { status: 'yes', note: 'Guaranteed ordering' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'partial', note: 'Within plan only' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'No UI, no notifications' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.reviewWorkflow'),
|
||||
us: { status: 'yes', note: 'Agents review each other' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'partial', note: 'Auto QA pipeline' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'partial', note: 'No UI' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.zeroSetup'),
|
||||
us: { status: 'yes' },
|
||||
vibeKanban: { status: 'no', note: 'Config required' },
|
||||
aperant: { status: 'no', note: 'Config required' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'partial', note: 'CLI install required' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.kanban'),
|
||||
us: { status: 'yes', note: '5 columns, real-time' },
|
||||
vibeKanban: { status: 'yes' },
|
||||
aperant: { status: 'yes', note: '6 columns (pipeline)' },
|
||||
cursor: { status: 'no' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.execLog'),
|
||||
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'yes', note: 'Phase-based logs' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.liveProcesses'),
|
||||
us: { status: 'yes', note: 'View, stop, open URLs' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'yes', note: '12 agent terminals' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.perTaskReview'),
|
||||
us: { status: 'yes', note: 'Accept / reject / comment' },
|
||||
vibeKanban: { status: 'partial', note: 'PR-level only' },
|
||||
aperant: { status: 'partial', note: 'File-level only' },
|
||||
cursor: { status: 'yes', note: 'BugBot on PRs' },
|
||||
claudeCli: { status: 'no' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.flexAutonomy'),
|
||||
us: { status: 'yes', note: 'Granular settings, notifications' },
|
||||
vibeKanban: { status: 'no' },
|
||||
aperant: { status: 'partial', note: 'Plan approval only' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'yes' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.worktree'),
|
||||
us: { status: 'yes', note: 'Optional' },
|
||||
vibeKanban: { status: 'partial', note: 'Mandatory' },
|
||||
aperant: { status: 'partial', note: 'Mandatory' },
|
||||
cursor: { status: 'yes' },
|
||||
claudeCli: { status: 'yes' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.multiAgent'),
|
||||
us: { status: 'soon', note: 'In development' },
|
||||
vibeKanban: { status: 'yes', note: '6+ agents' },
|
||||
aperant: { status: 'yes', note: '11 providers' },
|
||||
cursor: { status: 'yes', note: 'Multi-model' },
|
||||
claudeCli: { status: 'na' },
|
||||
},
|
||||
{
|
||||
feature: t('comparison.features.price'),
|
||||
us: { status: 'free' },
|
||||
vibeKanban: { status: 'text', note: 'Free / $30/mo' },
|
||||
aperant: { status: 'free' },
|
||||
cursor: { status: 'text', note: '$0–$200/mo' },
|
||||
claudeCli: { status: 'text', note: 'Claude subscription' },
|
||||
},
|
||||
])
|
||||
|
||||
const competitors = [
|
||||
{ key: 'us', name: 'Claude Agent Teams', highlight: true },
|
||||
{ key: 'vibeKanban', name: 'Vibe Kanban' },
|
||||
{ key: 'aperant', name: 'Aperant' },
|
||||
{ key: 'cursor', name: 'Cursor' },
|
||||
{ key: 'claudeCli', name: 'Claude Code CLI' },
|
||||
]
|
||||
|
||||
function getCellClass(cell: CellValue): string {
|
||||
switch (cell.status) {
|
||||
case 'yes': return 'comparison-table__cell--yes'
|
||||
case 'no': return 'comparison-table__cell--no'
|
||||
case 'partial': return 'comparison-table__cell--partial'
|
||||
case 'na': return 'comparison-table__cell--na'
|
||||
case 'free': return 'comparison-table__cell--free'
|
||||
case 'soon': return 'comparison-table__cell--soon'
|
||||
case 'text': return 'comparison-table__cell--text'
|
||||
default: return 'comparison-table__cell--text'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'yes': return '\u2713'
|
||||
case 'no': return '\u2717'
|
||||
case 'partial': return '\u25D2'
|
||||
case 'na': return '\u2014'
|
||||
case 'free': return 'Free'
|
||||
case 'soon': return '\uD83D\uDCC5'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="comparison" class="comparison-section section anchor-offset">
|
||||
<v-container>
|
||||
<div class="comparison-section__header">
|
||||
<h2 class="comparison-section__title">
|
||||
{{ t("comparison.sectionTitle") }}
|
||||
</h2>
|
||||
<p class="comparison-section__subtitle">
|
||||
{{ t("comparison.sectionSubtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="comparison-table__wrap">
|
||||
<table class="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="comparison-table__th comparison-table__th--feature">
|
||||
{{ t("comparison.feature") }}
|
||||
</th>
|
||||
<th
|
||||
v-for="comp in competitors"
|
||||
:key="comp.key"
|
||||
class="comparison-table__th"
|
||||
:class="{ 'comparison-table__th--highlight': comp.highlight }"
|
||||
>
|
||||
{{ comp.name }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, index) in rows"
|
||||
:key="index"
|
||||
class="comparison-table__row"
|
||||
>
|
||||
<td class="comparison-table__td comparison-table__td--feature">
|
||||
{{ row.feature }}
|
||||
</td>
|
||||
<td
|
||||
v-for="comp in competitors"
|
||||
:key="comp.key"
|
||||
class="comparison-table__td"
|
||||
:class="[
|
||||
getCellClass(row[comp.key as keyof ComparisonRow] as CellValue),
|
||||
{ 'comparison-table__td--highlight-col': comp.highlight }
|
||||
]"
|
||||
>
|
||||
<div class="comparison-table__cell-inner">
|
||||
<span class="comparison-table__cell-content">
|
||||
<template v-if="(row[comp.key as keyof ComparisonRow] as CellValue).status === 'text'">
|
||||
{{ (row[comp.key as keyof ComparisonRow] as CellValue).note }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ getStatusIcon((row[comp.key as keyof ComparisonRow] as CellValue).status) }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="(row[comp.key as keyof ComparisonRow] as CellValue).note && (row[comp.key as keyof ComparisonRow] as CellValue).status !== 'text'"
|
||||
class="comparison-table__cell-note"
|
||||
>
|
||||
{{ (row[comp.key as keyof ComparisonRow] as CellValue).note }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.comparison-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comparison-section__header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 56px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comparison-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.comparison-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Table wrapper for horizontal scroll on mobile */
|
||||
.comparison-table__wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.15);
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 780px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.comparison-table__th {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #8892b0;
|
||||
border-bottom: 1px solid rgba(0, 240, 255, 0.1);
|
||||
white-space: nowrap;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.comparison-table__th--feature {
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.comparison-table__th--highlight {
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 240, 255, 0.06);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comparison-table__th--highlight::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #00f0ff, #39ff14);
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.comparison-table__row {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.comparison-table__row:hover {
|
||||
background: rgba(0, 240, 255, 0.03);
|
||||
}
|
||||
|
||||
.comparison-table__row:not(:last-child) .comparison-table__td {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Cells */
|
||||
.comparison-table__td {
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.comparison-table__td--feature {
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
color: #e0e6ff;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.comparison-table__td--highlight-col {
|
||||
background: rgba(0, 240, 255, 0.04);
|
||||
}
|
||||
|
||||
.comparison-table__cell-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.comparison-table__cell-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.comparison-table__cell-note {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7994;
|
||||
line-height: 1.3;
|
||||
max-width: 140px;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Cell status variants */
|
||||
.comparison-table__cell--yes .comparison-table__cell-content {
|
||||
color: #39ff14;
|
||||
background: rgba(57, 255, 20, 0.1);
|
||||
text-shadow: 0 0 8px rgba(57, 255, 20, 0.4);
|
||||
}
|
||||
|
||||
.comparison-table__cell--no .comparison-table__cell-content {
|
||||
color: #ff4757;
|
||||
background: rgba(255, 71, 87, 0.08);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.comparison-table__cell--partial .comparison-table__cell-content {
|
||||
color: #ffd700;
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
}
|
||||
|
||||
.comparison-table__cell--na .comparison-table__cell-content {
|
||||
color: #4a5568;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comparison-table__cell--soon .comparison-table__cell-content {
|
||||
width: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75rem;
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.comparison-table__cell--free .comparison-table__cell-content,
|
||||
.comparison-table__cell--text .comparison-table__cell-content {
|
||||
width: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75rem;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.comparison-table__cell--free .comparison-table__cell-content {
|
||||
color: #39ff14;
|
||||
background: rgba(57, 255, 20, 0.1);
|
||||
text-shadow: 0 0 8px rgba(57, 255, 20, 0.4);
|
||||
}
|
||||
|
||||
.comparison-table__cell--text .comparison-table__cell-content {
|
||||
color: #8892b0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Highlight column — our product */
|
||||
.comparison-table__td--highlight-col.comparison-table__cell--yes .comparison-table__cell-content {
|
||||
box-shadow: 0 0 12px rgba(57, 255, 20, 0.2);
|
||||
}
|
||||
|
||||
.comparison-table__td--highlight-col.comparison-table__cell--free .comparison-table__cell-content {
|
||||
box-shadow: 0 0 12px rgba(57, 255, 20, 0.2);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
.v-theme--light .comparison-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__wrap {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(0, 180, 200, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__th {
|
||||
color: #64748b;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__th--highlight {
|
||||
color: #0891b2;
|
||||
background: rgba(8, 145, 178, 0.06);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__th--highlight::after {
|
||||
background: linear-gradient(90deg, #0891b2, #059669);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__td--feature {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__row:hover {
|
||||
background: rgba(8, 145, 178, 0.03);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__row:not(:last-child) .comparison-table__td {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__td--highlight-col {
|
||||
background: rgba(8, 145, 178, 0.04);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell-note {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell--yes .comparison-table__cell-content {
|
||||
color: #059669;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell--no .comparison-table__cell-content {
|
||||
color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.06);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell--partial .comparison-table__cell-content {
|
||||
color: #d97706;
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell--free .comparison-table__cell-content {
|
||||
color: #059669;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell--soon .comparison-table__cell-content {
|
||||
color: #0891b2;
|
||||
background: rgba(8, 145, 178, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .comparison-table__cell--text .comparison-table__cell-content {
|
||||
color: #64748b;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.comparison-section__title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.comparison-section__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.comparison-section__subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison-section__title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.comparison-section__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.comparison-table__th {
|
||||
padding: 12px 8px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.comparison-table__td {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.comparison-table__td--feature {
|
||||
padding-left: 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.comparison-table__cell-note {
|
||||
font-size: 0.7rem;
|
||||
max-width: 110px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
496
landing/components/sections/DownloadSection.vue
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiApple, mdiMicrosoftWindows, mdiPenguin, mdiDownload, mdiCheckCircle } from '@mdi/js';
|
||||
import { downloadAssets } from "~/data/downloads";
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t, locale } = useI18n();
|
||||
const downloadStore = useDownloadStore();
|
||||
const { data: releaseData, resolve } = useReleaseDownloads();
|
||||
const { trackDownloadClick } = useAnalytics();
|
||||
|
||||
onMounted(() => downloadStore.init());
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
macos: mdiApple,
|
||||
windows: mdiMicrosoftWindows,
|
||||
linux: mdiPenguin,
|
||||
};
|
||||
|
||||
const platformColors: Record<string, string> = {
|
||||
macos: "#00f0ff",
|
||||
windows: "#39ff14",
|
||||
linux: "#ffd700",
|
||||
};
|
||||
|
||||
const visibleAssets = computed(() =>
|
||||
downloadAssets.map((asset) => {
|
||||
if (asset.os !== "macos") return { ...asset };
|
||||
if (!downloadStore.isMacOs) return { ...asset };
|
||||
return {
|
||||
...asset,
|
||||
archLabel: downloadStore.macArch === "arm64" ? "Apple Silicon" : "Intel",
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const getDownloadUrl = (asset: (typeof downloadAssets)[number]) => {
|
||||
const arch = asset.os === "macos" ? downloadStore.macArch : asset.arch;
|
||||
return resolve(asset.os, arch)?.url || asset.url;
|
||||
};
|
||||
|
||||
const getDownloadArch = (asset: (typeof downloadAssets)[number]) => {
|
||||
return asset.os === "macos" ? downloadStore.macArch : asset.arch;
|
||||
};
|
||||
|
||||
const releaseVersion = computed(() => releaseData.value?.version || null);
|
||||
const releaseDate = computed(() => {
|
||||
if (!releaseData.value?.pubDate) return '';
|
||||
return new Date(releaseData.value.pubDate).toLocaleDateString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="download" class="download-section section anchor-offset">
|
||||
<v-container>
|
||||
<!-- Header -->
|
||||
<div class="download-section__header">
|
||||
<h2 class="download-section__title">{{ content.download.title }}</h2>
|
||||
<p class="download-section__subtitle">{{ content.download.note }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform cards -->
|
||||
<div class="download-section__cards">
|
||||
<div
|
||||
v-for="(asset, index) in visibleAssets"
|
||||
:key="asset.id"
|
||||
class="download-section__card"
|
||||
:class="{ 'download-section__card--active': downloadStore.selectedId === asset.id }"
|
||||
:style="{
|
||||
'--delay': `${index * 0.1}s`,
|
||||
'--accent': platformColors[asset.os] || '#00f0ff',
|
||||
}"
|
||||
@click="downloadStore.setSelected(asset.id)"
|
||||
>
|
||||
<!-- Card glow effect -->
|
||||
<div class="download-section__card-glow" />
|
||||
|
||||
<!-- Platform icon -->
|
||||
<div class="download-section__card-icon-wrap">
|
||||
<v-icon size="28" class="download-section__card-icon" :icon="platformIcons[asset.os] || mdiDownload" />
|
||||
</div>
|
||||
|
||||
<!-- Platform info -->
|
||||
<div class="download-section__card-info">
|
||||
<h3 class="download-section__card-label">{{ asset.label }}</h3>
|
||||
<span class="download-section__card-arch">{{ asset.archLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Download button -->
|
||||
<a
|
||||
class="download-section__btn"
|
||||
:href="getDownloadUrl(asset)"
|
||||
@click.stop="trackDownloadClick({ os: asset.os, arch: getDownloadArch(asset), version: releaseVersion, source: 'download_section' }); downloadStore.setSelected(asset.id)"
|
||||
>
|
||||
<v-icon size="18" class="download-section__btn-icon" :icon="mdiDownload" />
|
||||
<span>{{ t("download.title") }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Active indicator -->
|
||||
<div
|
||||
v-if="downloadStore.selectedId === asset.id"
|
||||
class="download-section__card-indicator"
|
||||
>
|
||||
<v-icon size="16" :icon="mdiCheckCircle" />
|
||||
<span>{{ t("download.detected") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="releaseVersion" class="download-section__release-info">
|
||||
v{{ releaseVersion }} · {{ releaseDate }}
|
||||
</p>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.download-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.download-section__header {
|
||||
text-align: center;
|
||||
max-width: 560px;
|
||||
margin: 0 auto 56px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.download-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.download-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.download-section__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
overflow: visible;
|
||||
padding: 12px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.download-section__card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 26px 22px 24px;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
border: 1px solid rgba(0, 240, 255, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.35s ease;
|
||||
overflow: hidden;
|
||||
animation: downloadFadeUp 0.5s ease both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.download-section__card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(0, 240, 255, 0.2);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 240, 255, 0.08),
|
||||
0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.download-section__card--active {
|
||||
border-color: rgba(57, 255, 20, 0.4);
|
||||
background: rgba(57, 255, 20, 0.06);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(57, 255, 20, 0.1),
|
||||
0 0 0 2px rgba(57, 255, 20, 0.15);
|
||||
transform: scale(1.06);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.download-section__card--active:hover {
|
||||
transform: scale(1.08);
|
||||
border-color: rgba(57, 255, 20, 0.5);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(57, 255, 20, 0.15),
|
||||
0 0 0 2px rgba(57, 255, 20, 0.2);
|
||||
}
|
||||
|
||||
/* Card glow */
|
||||
.download-section__card-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--accent) 8%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.download-section__card:hover .download-section__card-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.download-section__card--active .download-section__card-glow {
|
||||
opacity: 0.7;
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
rgba(57, 255, 20, 0.1),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
/* Icon wrap */
|
||||
.download-section__card-icon-wrap {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--accent) 12%, transparent),
|
||||
color-mix(in srgb, var(--accent) 6%, transparent)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
margin-bottom: 14px;
|
||||
transition: transform 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.download-section__card:hover .download-section__card-icon-wrap {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.download-section__card-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.download-section__card-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.download-section__card-label {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 3px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #e0e6ff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.download-section__card-arch {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #8892b0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Download button */
|
||||
.download-section__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: #0a0a0f;
|
||||
background: linear-gradient(135deg, #00f0ff, #39ff14);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
filter 0.25s ease;
|
||||
box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.download-section__btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 240, 255, 0.4);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.download-section__btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.download-section__btn-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Active indicator */
|
||||
.download-section__card-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #39ff14;
|
||||
opacity: 0.9;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
/* Release info */
|
||||
.download-section__release-info {
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: #8892b0;
|
||||
opacity: 0.5;
|
||||
margin-top: 24px;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
@keyframes downloadFadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(28px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
.v-theme--light .download-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card:hover {
|
||||
box-shadow: 0 20px 60px rgba(0, 180, 200, 0.1);
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card--active {
|
||||
background: rgba(240, 253, 244, 0.9);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card-label {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card-arch {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__release-info {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.v-theme--light .download-section__card-indicator {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.download-section__cards {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.download-section__card {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
padding: 24px 28px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.download-section__card--active {
|
||||
transform: scale(1.03);
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.download-section__card--active:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.download-section__card-icon-wrap {
|
||||
margin-bottom: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-section__card-info {
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.download-section__card-indicator {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.download-section__title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.download-section__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.download-section__subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.download-section__title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.download-section__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.download-section__card {
|
||||
padding: 20px 22px;
|
||||
gap: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.download-section__card-icon-wrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.download-section__card-label {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.download-section__btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
349
landing/components/sections/FAQSection.vue
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { mdiRobotOutline, mdiCurrencyUsd, mdiLaptop, mdiCogOutline, mdiShieldLockOutline, mdiRocketLaunchOutline, mdiHelpCircleOutline, mdiFrequentlyAskedQuestions } from '@mdi/js'
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
const { trackFaqExpand } = useAnalytics();
|
||||
|
||||
const openPanels = ref<number[]>([]);
|
||||
|
||||
watch(openPanels, (newVal, oldVal) => {
|
||||
const prev = new Set(oldVal ?? []);
|
||||
const opened = (newVal ?? []).filter((i) => !prev.has(i));
|
||||
for (const idx of opened) {
|
||||
const faq = content.value?.faq?.[idx];
|
||||
if (faq) trackFaqExpand(faq.id, faq.question);
|
||||
}
|
||||
});
|
||||
|
||||
const faqIcons = [
|
||||
mdiRobotOutline,
|
||||
mdiCurrencyUsd,
|
||||
mdiLaptop,
|
||||
mdiCogOutline,
|
||||
mdiShieldLockOutline,
|
||||
mdiRocketLaunchOutline,
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="faq" class="faq-section section anchor-offset">
|
||||
<v-container>
|
||||
<div class="faq-section__header">
|
||||
<h2 class="faq-section__title">{{ t('faq.sectionTitle') }}</h2>
|
||||
<p class="faq-section__subtitle">{{ t('faq.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-section__content">
|
||||
<div class="faq-section__list">
|
||||
<v-expansion-panels
|
||||
v-model="openPanels"
|
||||
multiple
|
||||
variant="accordion"
|
||||
class="faq-section__panels"
|
||||
>
|
||||
<v-expansion-panel
|
||||
v-for="(item, index) in content.faq"
|
||||
:key="item.id"
|
||||
class="faq-section__panel"
|
||||
:style="{ '--delay': `${index * 0.08}s` }"
|
||||
elevation="0"
|
||||
>
|
||||
<v-expansion-panel-title class="faq-section__panel-title">
|
||||
<div class="faq-section__panel-header">
|
||||
<div class="faq-section__panel-icon-wrap">
|
||||
<v-icon size="22" class="faq-section__panel-icon" :icon="faqIcons[index] || mdiHelpCircleOutline" />
|
||||
</div>
|
||||
<span class="faq-section__panel-question">{{ item.question }}</span>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text class="faq-section__panel-text">
|
||||
<div class="faq-section__answer" v-html="item.answer" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
|
||||
<div class="faq-section__decoration">
|
||||
<div class="faq-section__deco-circle">
|
||||
<v-icon size="40" class="faq-section__deco-icon" :icon="mdiFrequentlyAskedQuestions" />
|
||||
</div>
|
||||
<div class="faq-section__deco-ring faq-section__deco-ring--1" />
|
||||
<div class="faq-section__deco-ring faq-section__deco-ring--2" />
|
||||
<div class="faq-section__deco-ring faq-section__deco-ring--3" />
|
||||
<div class="faq-section__deco-dots">
|
||||
<span v-for="n in 5" :key="n" class="faq-section__deco-dot" :style="{ '--dot-delay': `${n * 0.3}s` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.faq-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.faq-section__header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 56px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.faq-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #ffd700 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.faq-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.faq-section__content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 56px;
|
||||
align-items: start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.faq-section__list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.faq-section__panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.faq-section__panel {
|
||||
border-radius: 16px !important;
|
||||
background: rgba(10, 10, 15, 0.8) !important;
|
||||
border: 1px solid rgba(0, 240, 255, 0.08) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
overflow: hidden;
|
||||
animation: faqFadeIn 0.5s ease both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.faq-section__panel:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 240, 255, 0.2) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 240, 255, 0.06);
|
||||
}
|
||||
|
||||
.faq-section__panel::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.faq-section__panel .v-expansion-panel__shadow) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-section__panel-title {
|
||||
padding: 20px 24px !important;
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
:deep(.faq-section__panel-title .v-expansion-panel-title__overlay) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.faq-section__panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.faq-section__panel-icon-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.1), rgba(255, 0, 255, 0.08));
|
||||
border: 1px solid rgba(0, 240, 255, 0.12);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.faq-section__panel:hover .faq-section__panel-icon-wrap {
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.16), rgba(255, 0, 255, 0.12));
|
||||
}
|
||||
|
||||
.faq-section__panel-icon {
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.faq-section__panel-question {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #e0e6ff;
|
||||
}
|
||||
|
||||
:deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) {
|
||||
padding: 0 24px 20px 82px !important;
|
||||
}
|
||||
|
||||
.faq-section__answer {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.faq-section__answer :deep(a) {
|
||||
color: #00f0ff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.faq-section__answer :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Decoration */
|
||||
.faq-section__decoration {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 280px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.faq-section__deco-circle {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.1), rgba(255, 0, 255, 0.1));
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.faq-section__deco-icon {
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.faq-section__deco-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
animation: faqPulseRing 3.5s ease-in-out infinite;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.faq-section__deco-ring--1 { width: 148px; height: 148px; animation-delay: 0s; }
|
||||
.faq-section__deco-ring--2 { width: 200px; height: 200px; animation-delay: 0.7s; }
|
||||
.faq-section__deco-ring--3 { width: 256px; height: 256px; animation-delay: 1.4s; }
|
||||
|
||||
.faq-section__deco-dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.faq-section__deco-dot {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 240, 255, 0.4);
|
||||
animation: faqDotFloat 4s ease-in-out infinite;
|
||||
animation-delay: var(--dot-delay, 0s);
|
||||
}
|
||||
|
||||
.faq-section__deco-dot:nth-child(1) { top: 10%; left: 20%; }
|
||||
.faq-section__deco-dot:nth-child(2) { top: 25%; right: 10%; }
|
||||
.faq-section__deco-dot:nth-child(3) { bottom: 30%; left: 8%; }
|
||||
.faq-section__deco-dot:nth-child(4) { bottom: 15%; right: 22%; }
|
||||
.faq-section__deco-dot:nth-child(5) { top: 50%; right: 2%; }
|
||||
|
||||
@keyframes faqFadeIn {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes faqPulseRing {
|
||||
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 0.7; transform: translate(-50%, -50%) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes faqDotFloat {
|
||||
0%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
50% { opacity: 0.8; transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
.v-theme--light .faq-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #d97706 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__panel {
|
||||
background: rgba(255, 255, 255, 0.75) !important;
|
||||
border-color: rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__panel:hover {
|
||||
box-shadow: 0 8px 32px rgba(0, 180, 200, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__panel-question {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-theme--light .faq-section__answer {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.faq-section__header { margin-bottom: 40px; }
|
||||
.faq-section__title { font-size: 1.85rem; }
|
||||
.faq-section__subtitle { font-size: 1rem; }
|
||||
.faq-section__content { grid-template-columns: 1fr; gap: 40px; }
|
||||
.faq-section__decoration { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.faq-section__header { margin-bottom: 32px; }
|
||||
.faq-section__title { font-size: 1.6rem; }
|
||||
.faq-section__panel-title { padding: 16px 18px !important; }
|
||||
.faq-section__panel-icon-wrap { width: 36px; height: 36px; border-radius: 10px; }
|
||||
.faq-section__panel-header { gap: 12px; }
|
||||
.faq-section__panel-question { font-size: 0.92rem; }
|
||||
:deep(.faq-section__panel-text .v-expansion-panel-text__wrapper) { padding: 0 18px 16px 66px !important; }
|
||||
.faq-section__answer { font-size: 0.9rem; }
|
||||
}
|
||||
</style>
|
||||
139
landing/components/sections/FeaturesSection.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script setup lang="ts">
|
||||
import { features } from '~/data/features'
|
||||
import { useLandingContent } from '~/composables/useLandingContent'
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
|
||||
const items = computed(() =>
|
||||
features
|
||||
.map((feature) => {
|
||||
const contentItem = content.value.features.find((item) => item.id === feature.id);
|
||||
if (!contentItem) return null;
|
||||
return { ...contentItem, icon: feature.icon, accent: feature.accent };
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="features" class="features-section section anchor-offset">
|
||||
<v-container>
|
||||
<div class="features-section__header">
|
||||
<h2 class="features-section__title">
|
||||
{{ t("features.sectionTitle") }}
|
||||
</h2>
|
||||
<p class="features-section__subtitle">
|
||||
{{ t("features.sectionSubtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-row justify="center">
|
||||
<v-col
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
lg="4"
|
||||
>
|
||||
<div
|
||||
class="features-section__card-wrap"
|
||||
:style="{ '--delay': `${index * 0.06}s` }"
|
||||
>
|
||||
<FeatureCard
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:icon="item.icon"
|
||||
:accent="item.accent"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.features-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.features-section__header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 56px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.features-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.features-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.features-section__card-wrap {
|
||||
animation: fadeInUp 0.5s ease both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.v-theme--light .features-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--light .features-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.features-section__title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.features-section__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.features-section__subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.features-section__title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.features-section__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
350
landing/components/sections/HeroSection.vue
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiRobotOutline, mdiViewDashboardOutline, mdiOpenSourceInitiative } from '@mdi/js';
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="hero" class="hero-section section anchor-offset">
|
||||
<v-container class="hero-section__container">
|
||||
<v-row align="center" justify="space-between">
|
||||
<!-- Left: Text content -->
|
||||
<v-col cols="12" md="6" class="hero-section__content">
|
||||
<h1 class="hero-section__title">
|
||||
<img
|
||||
src="/logo-192.png"
|
||||
alt=""
|
||||
class="hero-section__logo"
|
||||
width="56"
|
||||
height="56"
|
||||
/>
|
||||
{{ content.hero.title }}
|
||||
</h1>
|
||||
|
||||
<p class="hero-section__subtitle">
|
||||
{{ content.hero.subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="hero-section__actions">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
size="large"
|
||||
href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"
|
||||
target="_blank"
|
||||
class="hero-section__btn-primary"
|
||||
>
|
||||
{{ t('hero.downloadNow') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
size="large"
|
||||
href="#features"
|
||||
class="hero-section__btn-secondary"
|
||||
>
|
||||
{{ t('hero.ctaSecondary') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Trust indicators -->
|
||||
<div class="hero-section__trust">
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiRobotOutline" />
|
||||
<span>{{ t("hero.trust.agentTeams") }}</span>
|
||||
</div>
|
||||
<div class="hero-section__trust-divider" />
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiViewDashboardOutline" />
|
||||
<span>{{ t("hero.trust.kanban") }}</span>
|
||||
</div>
|
||||
<div class="hero-section__trust-divider" />
|
||||
<div class="hero-section__trust-item">
|
||||
<v-icon size="16" class="hero-section__trust-icon" :icon="mdiOpenSourceInitiative" />
|
||||
<span>{{ t("hero.trust.openSource") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Right: Demo video -->
|
||||
<v-col cols="12" md="5" class="hero-section__demo-col">
|
||||
<div class="hero-section__preview">
|
||||
<div class="hero-section__preview-glow" />
|
||||
<ClientOnly>
|
||||
<HeroDemoVideo />
|
||||
<template #fallback>
|
||||
<div class="hero-demo-fallback" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-section {
|
||||
position: relative;
|
||||
min-height: 85vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-section__container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-section__content {
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
}
|
||||
|
||||
/* ─── Title ─── */
|
||||
.hero-section__title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 50%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
animation-delay: 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-section__logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
-webkit-text-fill-color: initial;
|
||||
background: none;
|
||||
-webkit-background-clip: initial;
|
||||
background-clip: initial;
|
||||
}
|
||||
|
||||
/* ─── Subtitle ─── */
|
||||
.hero-section__subtitle {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.7;
|
||||
color: #8892b0;
|
||||
opacity: 0.9;
|
||||
max-width: 480px;
|
||||
margin-bottom: 36px;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
/* ─── Actions ─── */
|
||||
.hero-section__actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 40px;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.hero-section__btn-primary {
|
||||
background: linear-gradient(135deg, #00f0ff, #ff00ff) !important;
|
||||
color: #0a0a0f !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.3) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.hero-section__btn-primary:hover {
|
||||
box-shadow: 0 6px 30px rgba(0, 240, 255, 0.5) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
.hero-section__btn-secondary {
|
||||
border-color: rgba(0, 240, 255, 0.3) !important;
|
||||
color: #00f0ff !important;
|
||||
font-weight: 600 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.hero-section__btn-secondary:hover {
|
||||
border-color: rgba(0, 240, 255, 0.5) !important;
|
||||
background: rgba(0, 240, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
/* ─── Trust indicators ─── */
|
||||
.hero-section__trust {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
animation: heroFadeIn 0.8s ease both;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.hero-section__trust-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.hero-section__trust-icon {
|
||||
color: #00f0ff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hero-section__trust-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
/* ─── Preview Card ─── */
|
||||
.hero-section__preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
animation: heroSlideUp 0.9s ease both;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.hero-section__preview-glow {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2), rgba(255, 0, 255, 0.2), rgba(57, 255, 20, 0.1));
|
||||
filter: blur(20px);
|
||||
opacity: 0.4;
|
||||
z-index: 0;
|
||||
animation: glowPulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.02); }
|
||||
}
|
||||
|
||||
/* ─── SSR Fallback ─── */
|
||||
.hero-demo-fallback {
|
||||
border-radius: 16px;
|
||||
background: #0a0a0f;
|
||||
min-height: 330px;
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-demo-fallback {
|
||||
min-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Entrance animations ─── */
|
||||
@keyframes heroFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Demo column ─── */
|
||||
.hero-section__demo-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (max-width: 959px) {
|
||||
.hero-section__demo-col {
|
||||
margin-top: 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 960px) {
|
||||
.hero-section {
|
||||
min-height: auto;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.hero-section__title {
|
||||
font-size: 2rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-section__logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.hero-section__subtitle {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.hero-section__trust {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-section__preview {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-section__title {
|
||||
font-size: 1.6rem;
|
||||
white-space: nowrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-section__logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-section__subtitle {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.hero-section__actions {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.hero-section__trust {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-section__trust-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-section__trust-item {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
278
landing/components/sections/PricingSection.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiOpenSourceInitiative } from '@mdi/js'
|
||||
import { useLandingContent } from '~/composables/useLandingContent'
|
||||
|
||||
const { content } = useLandingContent()
|
||||
const { t } = useI18n()
|
||||
|
||||
function onGetStarted() {
|
||||
const el = document.getElementById('download')
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="pricing" class="pricing-section section anchor-offset">
|
||||
<v-container>
|
||||
<div class="pricing-section__header">
|
||||
<h2 class="pricing-section__title">
|
||||
{{ t("pricing.sectionTitle") }}
|
||||
</h2>
|
||||
<p class="pricing-section__subtitle">
|
||||
{{ t("pricing.sectionSubtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-row justify="center">
|
||||
<v-col cols="12" sm="8" lg="5">
|
||||
<div
|
||||
v-for="plan in content.pricing"
|
||||
:key="plan.id"
|
||||
class="pricing-card pricing-card--highlighted"
|
||||
>
|
||||
<div class="pricing-card__popular">
|
||||
{{ t("pricing.popular") }}
|
||||
</div>
|
||||
|
||||
<div class="pricing-card__header">
|
||||
<h3 class="pricing-card__name">{{ plan.name }}</h3>
|
||||
<div class="pricing-card__price-wrap">
|
||||
<span class="pricing-card__price">{{ plan.price }}</span>
|
||||
<span class="pricing-card__period">/ {{ plan.period }}</span>
|
||||
</div>
|
||||
<p class="pricing-card__description">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="pricing-card__btn pricing-card__btn--primary"
|
||||
@click="onGetStarted()"
|
||||
>
|
||||
{{ t("pricing.getStarted") }}
|
||||
</button>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<p class="pricing-section__note">
|
||||
<v-icon size="16" class="pricing-section__note-icon" :icon="mdiOpenSourceInitiative" />
|
||||
{{ t('pricing.note') }}
|
||||
</p>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pricing-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pricing-section__header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 56px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pricing-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #39ff14 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.pricing-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Pricing Card */
|
||||
.pricing-card {
|
||||
position: relative;
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 36px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 0 40px rgba(0, 240, 255, 0.05);
|
||||
}
|
||||
|
||||
.pricing-card--highlighted {
|
||||
border-color: rgba(0, 240, 255, 0.3);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 240, 255, 0.06) 0%,
|
||||
rgba(255, 0, 255, 0.03) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Popular badge */
|
||||
.pricing-card__popular {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: 24px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(135deg, #00f0ff, #39ff14);
|
||||
color: #0a0a0f;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.pricing-card__header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pricing-card__name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #e0e6ff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.pricing-card__price-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pricing-card__price {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #00f0ff, #39ff14);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.pricing-card__period {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.5;
|
||||
font-weight: 500;
|
||||
color: #8892b0;
|
||||
}
|
||||
|
||||
.pricing-card__description {
|
||||
font-size: 0.9rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pricing-card__btn--primary {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
background: linear-gradient(135deg, #00f0ff, #ff00ff);
|
||||
color: #0a0a0f;
|
||||
border: none;
|
||||
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.3);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.pricing-card__btn--primary:hover {
|
||||
box-shadow: 0 6px 30px rgba(0, 240, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pricing-section__note {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
font-size: 0.85rem;
|
||||
color: #8892b0;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pricing-section__note-icon {
|
||||
color: #39ff14;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
.v-theme--light .pricing-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #059669 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--light .pricing-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.v-theme--light .pricing-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(0, 180, 200, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--light .pricing-card__name {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-theme--light .pricing-card__description {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.pricing-section__title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.pricing-section__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pricing-section__subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.pricing-section__title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.pricing-section__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.pricing-card__price {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
499
landing/components/sections/ScreenshotsSection.vue
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { register } from 'swiper/element/bundle';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
register();
|
||||
|
||||
const screenshots = [
|
||||
{ src: '/screenshots/1.jpg', alt: 'Kanban board with agent tasks' },
|
||||
{ src: '/screenshots/2.jpg', alt: 'Agent team communication' },
|
||||
{ src: '/screenshots/3.png', alt: 'Code review diff view' },
|
||||
{ src: '/screenshots/4.png', alt: 'Team management dashboard' },
|
||||
{ src: '/screenshots/5.png', alt: 'Live process monitoring' },
|
||||
{ src: '/screenshots/6.png', alt: 'Session context analysis' },
|
||||
{ src: '/screenshots/7.png', alt: 'Cross-team messaging' },
|
||||
{ src: '/screenshots/8.png', alt: 'Task details and comments' },
|
||||
{ src: '/screenshots/9.png', alt: 'Built-in code editor' },
|
||||
];
|
||||
|
||||
const swiperRef = ref<HTMLElement | null>(null);
|
||||
const lightboxOpen = ref(false);
|
||||
const lightboxIndex = ref(0);
|
||||
|
||||
function openLightbox(index: number) {
|
||||
lightboxIndex.value = index;
|
||||
lightboxOpen.value = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxOpen.value = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function lightboxPrev() {
|
||||
lightboxIndex.value = (lightboxIndex.value - 1 + screenshots.length) % screenshots.length;
|
||||
}
|
||||
|
||||
function lightboxNext() {
|
||||
lightboxIndex.value = (lightboxIndex.value + 1) % screenshots.length;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!lightboxOpen.value) return;
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
if (e.key === 'ArrowLeft') lightboxPrev();
|
||||
if (e.key === 'ArrowRight') lightboxNext();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
|
||||
if (swiperRef.value) {
|
||||
Object.assign(swiperRef.value, {
|
||||
slidesPerView: 1.2,
|
||||
spaceBetween: 16,
|
||||
centeredSlides: true,
|
||||
loop: true,
|
||||
grabCursor: true,
|
||||
autoplay: {
|
||||
delay: 4000,
|
||||
disableOnInteraction: true,
|
||||
pauseOnMouseEnter: true,
|
||||
},
|
||||
pagination: {
|
||||
clickable: true,
|
||||
},
|
||||
breakpoints: {
|
||||
600: {
|
||||
slidesPerView: 1.5,
|
||||
spaceBetween: 20,
|
||||
},
|
||||
960: {
|
||||
slidesPerView: 2.2,
|
||||
spaceBetween: 24,
|
||||
},
|
||||
1264: {
|
||||
slidesPerView: 2.5,
|
||||
spaceBetween: 28,
|
||||
},
|
||||
},
|
||||
});
|
||||
(swiperRef.value as any).initialize();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
if (lightboxOpen.value) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
function slidePrev() {
|
||||
(swiperRef.value as any)?.swiper?.slidePrev();
|
||||
}
|
||||
|
||||
function slideNext() {
|
||||
(swiperRef.value as any)?.swiper?.slideNext();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="screenshots" class="screenshots-section section anchor-offset">
|
||||
<v-container>
|
||||
<div class="screenshots-section__header">
|
||||
<h2 class="screenshots-section__title">
|
||||
{{ t('screenshots.sectionTitle') }}
|
||||
</h2>
|
||||
<p class="screenshots-section__subtitle">
|
||||
{{ t('screenshots.sectionSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<div class="screenshots-section__carousel-wrap">
|
||||
<swiper-container
|
||||
ref="swiperRef"
|
||||
init="false"
|
||||
class="screenshots-section__swiper"
|
||||
>
|
||||
<swiper-slide
|
||||
v-for="(shot, idx) in screenshots"
|
||||
:key="idx"
|
||||
class="screenshots-section__slide"
|
||||
>
|
||||
<div class="screenshots-section__card" @click="openLightbox(idx)">
|
||||
<img
|
||||
:src="shot.src"
|
||||
:alt="shot.alt"
|
||||
class="screenshots-section__img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="screenshots-section__card-overlay">
|
||||
<v-icon :icon="mdiArrowExpand" size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
</swiper-container>
|
||||
|
||||
<!-- Nav buttons -->
|
||||
<button
|
||||
class="screenshots-section__nav screenshots-section__nav--prev"
|
||||
aria-label="Previous"
|
||||
@click="slidePrev"
|
||||
>
|
||||
<v-icon :icon="mdiChevronLeft" size="28" />
|
||||
</button>
|
||||
<button
|
||||
class="screenshots-section__nav screenshots-section__nav--next"
|
||||
aria-label="Next"
|
||||
@click="slideNext"
|
||||
>
|
||||
<v-icon :icon="mdiChevronRight" size="28" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<Transition name="lightbox-fade">
|
||||
<div
|
||||
v-if="lightboxOpen"
|
||||
class="screenshots-lightbox"
|
||||
@click.self="closeLightbox"
|
||||
>
|
||||
<button class="screenshots-lightbox__close" @click="closeLightbox">
|
||||
<v-icon :icon="mdiClose" size="28" />
|
||||
</button>
|
||||
|
||||
<button class="screenshots-lightbox__nav screenshots-lightbox__nav--prev" @click="lightboxPrev">
|
||||
<v-icon :icon="mdiChevronLeft" size="36" />
|
||||
</button>
|
||||
|
||||
<div class="screenshots-lightbox__content">
|
||||
<img
|
||||
:src="screenshots[lightboxIndex].src"
|
||||
:alt="screenshots[lightboxIndex].alt"
|
||||
class="screenshots-lightbox__img"
|
||||
/>
|
||||
<div class="screenshots-lightbox__counter">
|
||||
{{ lightboxIndex + 1 }} / {{ screenshots.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="screenshots-lightbox__nav screenshots-lightbox__nav--next" @click="lightboxNext">
|
||||
<v-icon :icon="mdiChevronRight" size="36" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screenshots-section {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.screenshots-section__header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 48px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screenshots-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #00f0ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.screenshots-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── Carousel ─── */
|
||||
.screenshots-section__carousel-wrap {
|
||||
position: relative;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 60px 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.screenshots-section__swiper {
|
||||
overflow: visible;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
.screenshots-section__slide {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.screenshots-section__card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 240, 255, 0.1);
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.screenshots-section__card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(0, 240, 255, 0.3);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(0, 240, 255, 0.08);
|
||||
}
|
||||
|
||||
.screenshots-section__card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.screenshots-section__card:hover .screenshots-section__card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.screenshots-section__img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ─── Nav buttons ─── */
|
||||
.screenshots-section__nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(calc(-50% - 20px));
|
||||
z-index: 10;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 240, 255, 0.2);
|
||||
background: rgba(10, 10, 15, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: #00f0ff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.screenshots-section__nav:hover {
|
||||
background: rgba(0, 240, 255, 0.12);
|
||||
border-color: rgba(0, 240, 255, 0.4);
|
||||
box-shadow: 0 0 16px rgba(0, 240, 255, 0.15);
|
||||
}
|
||||
|
||||
.screenshots-section__nav--prev {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.screenshots-section__nav--next {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
/* ─── Swiper pagination ─── */
|
||||
.screenshots-section__swiper::part(pagination) {
|
||||
bottom: -20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screenshots-section__swiper::part(bullet) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: rgba(0, 240, 255, 0.3);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.screenshots-section__swiper::part(bullet-active) {
|
||||
background: #00f0ff;
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ─── Lightbox ─── */
|
||||
.screenshots-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__img {
|
||||
max-width: 100%;
|
||||
max-height: calc(85vh - 40px);
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.screenshots-lightbox__counter {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ─── Lightbox transition ─── */
|
||||
.lightbox-fade-enter-active,
|
||||
.lightbox-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.lightbox-fade-enter-from,
|
||||
.lightbox-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ─── Light theme ─── */
|
||||
.v-theme--light .screenshots-section__title {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0891b2 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--light .screenshots-section__subtitle {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.v-theme--light .screenshots-section__card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.v-theme--light .screenshots-section__card:hover {
|
||||
border-color: rgba(0, 139, 178, 0.3);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.v-theme--light .screenshots-section__nav {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.v-theme--light .screenshots-section__nav:hover {
|
||||
background: rgba(0, 139, 178, 0.1);
|
||||
border-color: rgba(0, 139, 178, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 960px) {
|
||||
.screenshots-section__title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.screenshots-section__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.screenshots-section__subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.screenshots-section__nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.screenshots-section__title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.screenshots-section__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.screenshots-lightbox__nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screenshots-lightbox {
|
||||
padding: 60px 8px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
339
landing/components/sections/TestimonialsSection.vue
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { testimonials } from '~/data/testimonials'
|
||||
import { useLandingContent } from '~/composables/useLandingContent'
|
||||
|
||||
const { content } = useLandingContent();
|
||||
const { t } = useI18n();
|
||||
const { smAndUp } = useDisplay();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
const items = computed(() =>
|
||||
testimonials
|
||||
.map((entry) => {
|
||||
const contentItem = content.value.testimonials?.find((item) => item.id === entry.id);
|
||||
if (!contentItem) return null;
|
||||
return { ...contentItem, avatar: entry.avatar };
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
if (expanded.value) return items.value;
|
||||
return items.value.slice(0, smAndUp.value ? 4 : 2);
|
||||
});
|
||||
|
||||
const hasMore = computed(() =>
|
||||
!expanded.value && items.value.length > (smAndUp.value ? 4 : 2)
|
||||
);
|
||||
|
||||
const getInitial = (name: string) => name.charAt(0).toUpperCase();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="testimonials" class="testimonials-section section anchor-offset">
|
||||
<v-container>
|
||||
<div class="testimonials-section__header">
|
||||
<h2 class="testimonials-section__title">
|
||||
{{ t("testimonials.sectionTitle") }}
|
||||
</h2>
|
||||
<p class="testimonials-section__subtitle">
|
||||
{{ t("testimonials.sectionSubtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-row justify="center">
|
||||
<v-col
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="item.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<div
|
||||
class="testimonials-section__card-wrap"
|
||||
:style="{ '--delay': `${index * 0.08}s` }"
|
||||
>
|
||||
<div class="testimonial-card">
|
||||
<div class="testimonial-card__quote">"</div>
|
||||
<p class="testimonial-card__text">{{ item.text }}</p>
|
||||
<div class="testimonial-card__author">
|
||||
<div
|
||||
class="testimonial-card__avatar"
|
||||
:style="{ background: item.avatar }"
|
||||
>
|
||||
{{ getInitial(item.name) }}
|
||||
</div>
|
||||
<div class="testimonial-card__info">
|
||||
<span class="testimonial-card__name">{{ item.name }}</span>
|
||||
<span class="testimonial-card__role">{{ item.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div v-if="hasMore || expanded" class="testimonials-section__toggle">
|
||||
<button
|
||||
class="testimonials-section__toggle-btn"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
{{ expanded ? t('testimonials.showLess') : t('testimonials.showMore') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="testimonials-section__feedback-cta">
|
||||
{{ t('testimonials.feedbackCta') }}
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/issues" target="_blank" class="testimonials-section__email">GitHub</a>
|
||||
</p>
|
||||
</v-container>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.testimonials-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.testimonials-section__header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 56px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.testimonials-section__title {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #e0e6ff 0%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.testimonials-section__subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #8892b0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.testimonials-section__card-wrap {
|
||||
animation: fadeInUp 0.5s ease both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
position: relative;
|
||||
padding: 32px 28px 28px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.testimonial-card__quote {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 24px;
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.testimonial-card__text {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
opacity: 0.85;
|
||||
margin: 0 0 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.testimonial-card__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.testimonial-card__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.testimonial-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.testimonial-card__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.testimonial-card__role {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.testimonials-section__toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.testimonials-section__toggle-btn {
|
||||
padding: 10px 28px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.testimonials-section__toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonials-section__toggle-btn {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonials-section__toggle-btn:hover {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.testimonials-section__feedback-cta {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.5;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.testimonials-section__email {
|
||||
color: #00f0ff;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.testimonials-section__email:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonials-section__email {
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
.v-theme--dark .testimonials-section__title {
|
||||
background: linear-gradient(135deg, #e2e8f0 0%, #a5b4fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonials-section__subtitle {
|
||||
color: #94a3b8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonial-card {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonial-card:hover {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonial-card__text {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonial-card__name {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.v-theme--dark .testimonial-card__role {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.testimonials-section__title {
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.testimonials-section__header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.testimonials-section__subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.testimonials-section__title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.testimonials-section__header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
landing/components/ui/FeatureCard.vue
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
accent?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feature-card" :style="{ '--accent': accent || '#6366f1' }">
|
||||
<div class="feature-card__header">
|
||||
<div class="feature-card__icon-wrap">
|
||||
<div class="feature-card__icon-bg" />
|
||||
<v-icon :icon="icon" size="22" class="feature-card__icon" />
|
||||
</div>
|
||||
<h3 class="feature-card__title">{{ title }}</h3>
|
||||
</div>
|
||||
<p class="feature-card__desc">{{ description }}</p>
|
||||
<div class="feature-card__shine" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feature-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.35s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 16px 32px -10px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 50px -20px var(--accent);
|
||||
}
|
||||
|
||||
.feature-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.feature-card__icon-wrap {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-card__icon-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
opacity: 0.12;
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover .feature-card__icon-bg {
|
||||
opacity: 0.22;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.feature-card__icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
.feature-card__title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.feature-card__desc {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.feature-card__shine {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(255, 255, 255, 0.04) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.35s ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.feature-card:hover .feature-card__shine {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
.v-theme--dark .feature-card {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-color: rgba(148, 163, 184, 0.1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.v-theme--dark .feature-card:hover {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
box-shadow: 0 16px 32px -10px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 50px -20px var(--accent);
|
||||
}
|
||||
|
||||
.v-theme--dark .feature-card__title {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.v-theme--dark .feature-card__desc {
|
||||
color: #94a3b8;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.v-theme--dark .feature-card__icon-bg {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.v-theme--dark .feature-card:hover .feature-card__icon-bg {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.v-theme--dark .feature-card__shine {
|
||||
background: radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(255, 255, 255, 0.06) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
|
||||
/* Light theme adjustments */
|
||||
.v-theme--light .feature-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.v-theme--light .feature-card:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 16px 32px -10px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 50px -20px var(--accent);
|
||||
}
|
||||
|
||||
.v-theme--light .feature-card__desc {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.feature-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.feature-card__icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.feature-card__icon-bg {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.feature-card__header {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
513
landing/components/ui/HeroDemo.vue
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { mdiRobotOutline, mdiCheckCircleOutline, mdiCodeBraces, mdiMessageTextOutline } from '@mdi/js';
|
||||
|
||||
// ─── State machine for demo cycle ───
|
||||
type DemoState = 'idle' | 'working' | 'reviewing' | 'done';
|
||||
const state = ref<DemoState>('idle');
|
||||
|
||||
// ─── Animated task text ───
|
||||
const currentTask = ref('');
|
||||
const taskFading = ref(false);
|
||||
const TASKS = [
|
||||
'Implementing auth middleware...',
|
||||
'Writing unit tests for API...',
|
||||
'Reviewing PR #42 changes...',
|
||||
'Setting up CI/CD pipeline...',
|
||||
'Refactoring database layer...',
|
||||
];
|
||||
let taskIndex = 0;
|
||||
let charTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Agent activity indicators ───
|
||||
const agents = ref([
|
||||
{ name: 'Lead', color: '#00f0ff', status: 'idle' as string, icon: mdiRobotOutline },
|
||||
{ name: 'Dev-1', color: '#ff00ff', status: 'idle' as string, icon: mdiCodeBraces },
|
||||
{ name: 'Dev-2', color: '#39ff14', status: 'idle' as string, icon: mdiMessageTextOutline },
|
||||
]);
|
||||
|
||||
// ─── Kanban mini-board ───
|
||||
const kanbanTasks = ref([
|
||||
{ id: 1, text: 'Auth API', col: 'todo' as string },
|
||||
{ id: 2, text: 'Unit tests', col: 'todo' as string },
|
||||
{ id: 3, text: 'CI setup', col: 'todo' as string },
|
||||
]);
|
||||
|
||||
function typeNextChar(text: string, index: number) {
|
||||
if (index >= text.length) { charTimer = null; return; }
|
||||
currentTask.value = text.slice(0, index + 1);
|
||||
const ch = text[index];
|
||||
let delay = 30;
|
||||
if (ch === '.' || ch === ',') delay = 100;
|
||||
else if (ch === ' ') delay = 10;
|
||||
charTimer = setTimeout(() => typeNextChar(text, index + 1), delay);
|
||||
}
|
||||
|
||||
function stopTextAnimation() {
|
||||
if (charTimer) { clearTimeout(charTimer); charTimer = null; }
|
||||
}
|
||||
|
||||
// ─── Timer management ───
|
||||
const timers: number[] = [];
|
||||
function safeTimeout(fn: () => void, ms: number) {
|
||||
const id = window.setTimeout(fn, ms);
|
||||
timers.push(id);
|
||||
return id;
|
||||
}
|
||||
function clearAllTimers() {
|
||||
timers.forEach(clearTimeout);
|
||||
timers.length = 0;
|
||||
stopTextAnimation();
|
||||
}
|
||||
|
||||
// ─── IntersectionObserver ───
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const isVisible = ref(false);
|
||||
let intObserver: IntersectionObserver | null = null;
|
||||
|
||||
// ─── Demo cycle ───
|
||||
let cycleRunning = false;
|
||||
|
||||
function runCycle() {
|
||||
if (!cycleRunning) return;
|
||||
|
||||
// Reset
|
||||
state.value = 'idle';
|
||||
currentTask.value = '';
|
||||
taskFading.value = false;
|
||||
kanbanTasks.value = [
|
||||
{ id: 1, text: 'Auth API', col: 'todo' },
|
||||
{ id: 2, text: 'Unit tests', col: 'todo' },
|
||||
{ id: 3, text: 'CI setup', col: 'todo' },
|
||||
];
|
||||
agents.value.forEach(a => a.status = 'idle');
|
||||
|
||||
safeTimeout(() => {
|
||||
if (!cycleRunning) return;
|
||||
|
||||
// Phase 1: Working
|
||||
state.value = 'working';
|
||||
agents.value[0].status = 'active';
|
||||
agents.value[1].status = 'active';
|
||||
kanbanTasks.value[0].col = 'progress';
|
||||
|
||||
const task = TASKS[taskIndex % TASKS.length];
|
||||
taskIndex++;
|
||||
typeNextChar(task, 0);
|
||||
|
||||
safeTimeout(() => {
|
||||
if (!cycleRunning) return;
|
||||
kanbanTasks.value[1].col = 'progress';
|
||||
agents.value[2].status = 'active';
|
||||
}, 1200);
|
||||
|
||||
safeTimeout(() => {
|
||||
if (!cycleRunning) return;
|
||||
|
||||
// Phase 2: Reviewing
|
||||
state.value = 'reviewing';
|
||||
kanbanTasks.value[0].col = 'review';
|
||||
agents.value[0].status = 'reviewing';
|
||||
|
||||
safeTimeout(() => {
|
||||
if (!cycleRunning) return;
|
||||
|
||||
// Phase 3: Done
|
||||
state.value = 'done';
|
||||
kanbanTasks.value[0].col = 'done';
|
||||
kanbanTasks.value[1].col = 'review';
|
||||
agents.value[0].status = 'done';
|
||||
agents.value[1].status = 'reviewing';
|
||||
|
||||
safeTimeout(() => {
|
||||
if (!cycleRunning) return;
|
||||
kanbanTasks.value[1].col = 'done';
|
||||
kanbanTasks.value[2].col = 'progress';
|
||||
agents.value[1].status = 'done';
|
||||
|
||||
safeTimeout(() => {
|
||||
taskFading.value = true;
|
||||
safeTimeout(() => {
|
||||
if (cycleRunning) runCycle();
|
||||
}, 800);
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
}, 1500);
|
||||
}, 2500);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function startDemo() {
|
||||
if (cycleRunning) return;
|
||||
cycleRunning = true;
|
||||
runCycle();
|
||||
}
|
||||
|
||||
function stopDemo() {
|
||||
cycleRunning = false;
|
||||
clearAllTimers();
|
||||
state.value = 'idle';
|
||||
currentTask.value = '';
|
||||
taskFading.value = false;
|
||||
}
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) startDemo();
|
||||
else stopDemo();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
intObserver = new IntersectionObserver(
|
||||
([entry]) => { isVisible.value = entry.isIntersecting; },
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (containerRef.value) intObserver.observe(containerRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopDemo();
|
||||
if (intObserver) { intObserver.disconnect(); intObserver = null; }
|
||||
});
|
||||
|
||||
function colColor(col: string) {
|
||||
switch (col) {
|
||||
case 'todo': return '#64748b';
|
||||
case 'progress': return '#00f0ff';
|
||||
case 'review': return '#ffd700';
|
||||
case 'done': return '#39ff14';
|
||||
default: return '#64748b';
|
||||
}
|
||||
}
|
||||
|
||||
function statusDotColor(status: string) {
|
||||
switch (status) {
|
||||
case 'active': return '#00f0ff';
|
||||
case 'reviewing': return '#ffd700';
|
||||
case 'done': return '#39ff14';
|
||||
default: return '#64748b';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="hero-demo" role="img" aria-label="Agent team demo">
|
||||
<div class="hero-demo__content">
|
||||
<!-- Header -->
|
||||
<div class="hero-demo__header">
|
||||
<div class="hero-demo__title-row">
|
||||
<span class="hero-demo__title">Agent Teams</span>
|
||||
<span class="hero-demo__badge-live">
|
||||
<span class="hero-demo__live-dot" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents row -->
|
||||
<div class="hero-demo__agents">
|
||||
<div
|
||||
v-for="agent in agents"
|
||||
:key="agent.name"
|
||||
class="hero-demo__agent"
|
||||
>
|
||||
<div class="hero-demo__agent-avatar" :style="{ borderColor: agent.color }">
|
||||
<v-icon :icon="agent.icon" size="16" :style="{ color: agent.color }" />
|
||||
</div>
|
||||
<span class="hero-demo__agent-name">{{ agent.name }}</span>
|
||||
<span
|
||||
class="hero-demo__agent-dot"
|
||||
:style="{ background: statusDotColor(agent.status) }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini kanban -->
|
||||
<div class="hero-demo__kanban">
|
||||
<div v-for="col in ['todo', 'progress', 'review', 'done']" :key="col" class="hero-demo__kanban-col">
|
||||
<div class="hero-demo__kanban-label" :style="{ color: colColor(col) }">
|
||||
{{ col === 'progress' ? 'IN PROGRESS' : col.toUpperCase() }}
|
||||
</div>
|
||||
<div class="hero-demo__kanban-cards">
|
||||
<TransitionGroup name="kanban-card">
|
||||
<div
|
||||
v-for="task in kanbanTasks.filter(t => t.col === col)"
|
||||
:key="task.id"
|
||||
class="hero-demo__kanban-card"
|
||||
:style="{ borderLeftColor: colColor(col) }"
|
||||
>
|
||||
{{ task.text }}
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity log -->
|
||||
<div class="hero-demo__log">
|
||||
<div class="hero-demo__log-line">
|
||||
<v-icon :icon="mdiCheckCircleOutline" size="14" style="color: #39ff14; flex-shrink: 0" />
|
||||
<span
|
||||
class="hero-demo__log-text"
|
||||
:class="{ 'hero-demo__log-text--fading': taskFading }"
|
||||
>{{ currentTask || 'Waiting for tasks...' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-demo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 240, 255, 0.15);
|
||||
overflow: hidden;
|
||||
min-height: 330px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 0 30px rgba(0, 240, 255, 0.05),
|
||||
inset 0 1px 0 rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
|
||||
.hero-demo__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
min-height: 330px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
.hero-demo__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-demo__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-demo__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #e0e6ff;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.hero-demo__badge-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 100px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: #39ff14;
|
||||
background: rgba(57, 255, 20, 0.1);
|
||||
border: 1px solid rgba(57, 255, 20, 0.2);
|
||||
}
|
||||
|
||||
.hero-demo__live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #39ff14;
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 4px rgba(57, 255, 20, 0.6); }
|
||||
50% { opacity: 0.4; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* ─── Agents ─── */
|
||||
.hero-demo__agents {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-demo__agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-demo__agent-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-demo__agent-name {
|
||||
font-size: 11px;
|
||||
color: #a0a8c0;
|
||||
font-weight: 600;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.hero-demo__agent-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
transition: background 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Kanban ─── */
|
||||
.hero-demo__kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-demo__kanban-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hero-demo__kanban-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 0;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hero-demo__kanban-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.hero-demo__kanban-card {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 2px solid;
|
||||
color: #c8d6e5;
|
||||
transition: all 0.4s ease;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
/* Card transition */
|
||||
.kanban-card-enter-active,
|
||||
.kanban-card-leave-active {
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
.kanban-card-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
.kanban-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(8px);
|
||||
}
|
||||
|
||||
/* ─── Log ─── */
|
||||
.hero-demo__log {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 240, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-demo__log-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-demo__log-text {
|
||||
font-size: 12px;
|
||||
color: #a0a8c0;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.hero-demo__log-text--fading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 960px) {
|
||||
.hero-demo {
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-demo {
|
||||
border-radius: 12px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.hero-demo__content {
|
||||
padding: 12px;
|
||||
min-height: 280px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-demo__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hero-demo__agents {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-demo__agent {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.hero-demo__agent-name {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.hero-demo__kanban-label {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.hero-demo__kanban-card {
|
||||
font-size: 9px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.hero-demo__log-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
444
landing/components/ui/HeroDemoVideo.vue
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { mdiPlay, mdiPause, mdiVolumeHigh, mdiVolumeOff, mdiFullscreen } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const isPlaying = ref(false);
|
||||
const isMuted = ref(true);
|
||||
const showControls = ref(true);
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
const progress = ref(0);
|
||||
const hideTimer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
let intObserver: IntersectionObserver | null = null;
|
||||
|
||||
function togglePlay() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
isPlaying.value = true;
|
||||
} else {
|
||||
video.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
const video = videoRef.value;
|
||||
if (!video) return;
|
||||
video.muted = !video.muted;
|
||||
isMuted.value = video.muted;
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
container.requestFullscreen();
|
||||
}
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
const video = videoRef.value;
|
||||
if (!video || !video.duration) return;
|
||||
progress.value = (video.currentTime / video.duration) * 100;
|
||||
}
|
||||
|
||||
function onSeek(e: MouseEvent) {
|
||||
const video = videoRef.value;
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
if (!video || !target) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
video.currentTime = ratio * video.duration;
|
||||
showControlsBriefly();
|
||||
}
|
||||
|
||||
function showControlsBriefly() {
|
||||
showControls.value = true;
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
hideTimer.value = setTimeout(() => {
|
||||
if (isPlaying.value) showControls.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
showControls.value = true;
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (isPlaying.value) {
|
||||
hideTimer.value = setTimeout(() => {
|
||||
showControls.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const video = videoRef.value;
|
||||
if (video) {
|
||||
video.addEventListener('loadeddata', () => { isLoaded.value = true; });
|
||||
video.addEventListener('error', () => { hasError.value = true; });
|
||||
video.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
showControls.value = true;
|
||||
progress.value = 0;
|
||||
video.currentTime = 0;
|
||||
});
|
||||
}
|
||||
|
||||
intObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting && videoRef.value && !videoRef.value.paused) {
|
||||
videoRef.value.pause();
|
||||
isPlaying.value = false;
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
if (containerRef.value) intObserver.observe(containerRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (hideTimer.value) clearTimeout(hideTimer.value);
|
||||
if (intObserver) { intObserver.disconnect(); intObserver = null; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="hero-video"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="!isLoaded && !hasError" class="hero-video__skeleton">
|
||||
<div class="hero-video__skeleton-pulse" />
|
||||
<v-icon :icon="mdiPlay" size="48" class="hero-video__skeleton-icon" />
|
||||
</div>
|
||||
|
||||
<!-- Error fallback -->
|
||||
<div v-if="hasError" class="hero-video__error">
|
||||
<v-icon :icon="mdiPlay" size="36" class="hero-video__error-icon" />
|
||||
<span class="hero-video__error-text">{{ t('hero.videoUnavailable') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Video element -->
|
||||
<video
|
||||
v-show="!hasError"
|
||||
ref="videoRef"
|
||||
class="hero-video__player"
|
||||
:class="{ 'hero-video__player--loaded': isLoaded }"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
@timeupdate="onTimeUpdate"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<source src="/video/demo.mp4" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<!-- Play overlay (when paused) -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="!isPlaying && isLoaded"
|
||||
class="hero-video__play-overlay"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<div class="hero-video__play-btn">
|
||||
<v-icon :icon="mdiPlay" size="36" color="white" />
|
||||
</div>
|
||||
<span class="hero-video__play-label">{{ t('hero.watchDemo') }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Controls bar -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="isLoaded && showControls"
|
||||
class="hero-video__controls"
|
||||
>
|
||||
<!-- Progress bar -->
|
||||
<div class="hero-video__progress" @click="onSeek">
|
||||
<div class="hero-video__progress-track">
|
||||
<div
|
||||
class="hero-video__progress-fill"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-video__controls-row">
|
||||
<button class="hero-video__control-btn" @click.stop="togglePlay" :aria-label="isPlaying ? 'Pause' : 'Play'">
|
||||
<v-icon :icon="isPlaying ? mdiPause : mdiPlay" size="18" />
|
||||
</button>
|
||||
|
||||
<button class="hero-video__control-btn" @click.stop="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
|
||||
<v-icon :icon="isMuted ? mdiVolumeOff : mdiVolumeHigh" size="18" />
|
||||
</button>
|
||||
|
||||
<div class="hero-video__spacer" />
|
||||
|
||||
<button class="hero-video__control-btn" @click.stop="toggleFullscreen" aria-label="Fullscreen">
|
||||
<v-icon :icon="mdiFullscreen" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-video {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 240, 255, 0.15);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 0 30px rgba(0, 240, 255, 0.05),
|
||||
inset 0 1px 0 rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ─── Video player ─── */
|
||||
.hero-video__player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.hero-video__player--loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Loading skeleton ─── */
|
||||
.hero-video__skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 15, 0.95);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-video__skeleton-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 240, 255, 0.03) 0%,
|
||||
rgba(255, 0, 255, 0.03) 50%,
|
||||
rgba(0, 240, 255, 0.03) 100%
|
||||
);
|
||||
animation: skeletonPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hero-video__skeleton-icon {
|
||||
color: rgba(0, 240, 255, 0.4);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
/* ─── Error fallback ─── */
|
||||
.hero-video__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 280px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.hero-video__error-icon {
|
||||
color: rgba(0, 240, 255, 0.3);
|
||||
}
|
||||
|
||||
.hero-video__error-text {
|
||||
font-size: 13px;
|
||||
color: #8892b0;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Play overlay ─── */
|
||||
.hero-video__play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-video__play-btn {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
border: 2px solid rgba(0, 240, 255, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 0 30px rgba(0, 240, 255, 0.2);
|
||||
}
|
||||
|
||||
.hero-video__play-btn:hover {
|
||||
background: rgba(0, 240, 255, 0.25);
|
||||
border-color: rgba(0, 240, 255, 0.6);
|
||||
box-shadow: 0 0 40px rgba(0, 240, 255, 0.35);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hero-video__play-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ─── Controls bar ─── */
|
||||
.hero-video__controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
padding: 16px 12px 8px;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.hero-video__controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hero-video__control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-video__control-btn:hover {
|
||||
background: rgba(0, 240, 255, 0.15);
|
||||
color: #00f0ff;
|
||||
}
|
||||
|
||||
.hero-video__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── Progress bar ─── */
|
||||
.hero-video__progress {
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hero-video__progress-track {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-video__progress:hover .hero-video__progress-track {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.hero-video__progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00f0ff, #ff00ff);
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* ─── Transitions ─── */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 960px) {
|
||||
.hero-video {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-video {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.hero-video__player {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.hero-video__play-btn {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.hero-video__play-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
landing/composables/useAnalytics.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
70
landing/composables/useBrowserTheme.ts
Normal file
|
|
@ -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<string>; current: import("vue").Ref<any> };
|
||||
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
|
||||
};
|
||||
};
|
||||
10
landing/composables/useLandingContent.ts
Normal file
|
|
@ -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 };
|
||||
};
|
||||
39
landing/composables/useLocation.ts
Normal file
|
|
@ -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 };
|
||||
};
|
||||
165
landing/composables/usePageSeo.ts
Normal file
|
|
@ -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<PageSeoImage>(() => {
|
||||
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)
|
||||
}))
|
||||
};
|
||||
});
|
||||
};
|
||||
60
landing/composables/useParallaxSections.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
|
||||
/**
|
||||
* Параллакс-эффект для фоновых орбов через одну секцию.
|
||||
* На мобилке отключён — мешает touch-скроллу и жрёт батарею.
|
||||
*/
|
||||
export const useParallaxSections = (speed = 0.1) => {
|
||||
const containerRef = ref<HTMLElement | null>(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<HTMLElement>('[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 };
|
||||
};
|
||||
24
landing/composables/usePlatform.ts
Normal file
|
|
@ -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 };
|
||||
};
|
||||
174
landing/composables/useReleaseDownloads.ts
Normal file
|
|
@ -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<DownloadsApiResponse>("releases", async () => {
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
|
||||
const release = await $fetch<GitHubRelease>(
|
||||
`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 };
|
||||
};
|
||||
35
landing/composables/useTrackSections.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { sectionOrder } from "~/data/sections";
|
||||
|
||||
export const useTrackSections = () => {
|
||||
if (!import.meta.client) return;
|
||||
|
||||
const { trackSectionView } = useAnalytics();
|
||||
const seen = new Set<string>();
|
||||
|
||||
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();
|
||||
});
|
||||
};
|
||||
102
landing/content/en.json
Normal file
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
102
landing/content/ru.json
Normal file
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
13
landing/data/content.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
29
landing/data/downloads.ts
Normal file
|
|
@ -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;
|
||||
8
landing/data/faq.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const faqItems = [
|
||||
{ id: "whatIsIt" },
|
||||
{ id: "isFree" },
|
||||
{ id: "platforms" },
|
||||
{ id: "howItWorks" },
|
||||
{ id: "privacy" },
|
||||
{ id: "requirements" }
|
||||
] as const;
|
||||
10
landing/data/features.ts
Normal file
|
|
@ -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;
|
||||
38
landing/data/i18n.ts
Normal file
|
|
@ -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);
|
||||
9
landing/data/sections.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const sectionOrder = [
|
||||
"hero",
|
||||
"features",
|
||||
"screenshots",
|
||||
"pricing",
|
||||
"testimonials",
|
||||
"download",
|
||||
"faq"
|
||||
] as const;
|
||||
8
landing/data/testimonials.ts
Normal file
|
|
@ -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;
|
||||
49
landing/docs/ARCHITECTURE_GUARDRAILS.md
Normal file
|
|
@ -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 — не “по настроению”, а обязательный критерий готовности.
|
||||
|
||||
1027
landing/docs/LANDING_PLAN.md
Normal file
52
landing/docs/iterations/ITERATION_00_REQUIREMENTS.md
Normal file
|
|
@ -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 не конфликтуют с выбранными модулями.
|
||||
|
||||
47
landing/docs/iterations/ITERATION_01_SCAFFOLDING.md
Normal file
|
|
@ -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 (хотя бы смысловой контент).
|
||||
|
||||
45
landing/docs/iterations/ITERATION_02_UI_SECTIONS.md
Normal file
|
|
@ -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.
|
||||
|
||||
### Чеклист перепроверки
|
||||
- Нет дублирования конфигов секций.
|
||||
- Секции легко переставить/скрыть, не ломая остальное.
|
||||
- Нет лишних сторонних зависимостей “на всякий случай”.
|
||||
- На мобильных нет “сломанных” отступов/переполнений (особенно в таблицах/списках).
|
||||
|
||||
67
landing/docs/iterations/ITERATION_03_SWIPER_AND_DOWNLOAD.md
Normal file
|
|
@ -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 остаётся понятным (никакой “магии”, только явный выбор).
|
||||
|
||||
|
|
@ -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 на всех локалях.
|
||||
- Статические ассеты оптимизированы (разумные размеры).
|
||||
- Нет трекинга до пользовательского действия, если потребуется баннер согласия (это заранее решаем политикой проекта).
|
||||
|
||||
18
landing/docs/iterations/README.md
Normal file
|
|
@ -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).
|
||||
245
landing/error.vue
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<script setup lang="ts">
|
||||
import { mdiHome } from '@mdi/js'
|
||||
import type { NuxtError } from "#app";
|
||||
|
||||
const props = defineProps<{
|
||||
error: NuxtError;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const statusCode = computed(() => props.error?.statusCode || 404);
|
||||
const isNotFound = computed(() => statusCode.value === 404);
|
||||
|
||||
const handleGoHome = () => clearError({ redirect: "/" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<div class="error-page">
|
||||
<!-- Фоновые орбы -->
|
||||
<div class="error-page__bg">
|
||||
<div class="error-page__orb error-page__orb--1" />
|
||||
<div class="error-page__orb error-page__orb--2" />
|
||||
<div class="error-page__grid-pattern" />
|
||||
</div>
|
||||
|
||||
<v-container class="error-page__container">
|
||||
<!-- Код ошибки -->
|
||||
<span class="error-page__code">{{ statusCode }}</span>
|
||||
|
||||
<!-- Заголовок -->
|
||||
<h1 class="error-page__title">
|
||||
{{ isNotFound ? t("error.notFoundTitle") : t("error.genericTitle") }}
|
||||
</h1>
|
||||
|
||||
<!-- Описание -->
|
||||
<p class="error-page__description">
|
||||
{{ isNotFound ? t("error.notFoundDescription") : t("error.genericDescription") }}
|
||||
</p>
|
||||
|
||||
<!-- Кнопка -->
|
||||
<v-btn
|
||||
size="large"
|
||||
color="primary"
|
||||
class="error-page__btn"
|
||||
@click="handleGoHome"
|
||||
>
|
||||
<v-icon start :icon="mdiHome" />
|
||||
{{ t("error.goHome") }}
|
||||
</v-btn>
|
||||
</v-container>
|
||||
</div>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Background ─── */
|
||||
.error-page__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.error-page__orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.error-page__orb--1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: #6366f1;
|
||||
top: -200px;
|
||||
right: -100px;
|
||||
animation: orbDrift1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.error-page__orb--2 {
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
background: #ec4899;
|
||||
bottom: -150px;
|
||||
left: -80px;
|
||||
animation: orbDrift2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.error-page__grid-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black, transparent);
|
||||
}
|
||||
|
||||
@keyframes orbDrift1 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
33% { transform: translate(25px, 15px); }
|
||||
66% { transform: translate(-15px, 10px); }
|
||||
}
|
||||
|
||||
@keyframes orbDrift2 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
33% { transform: translate(-20px, -10px); }
|
||||
66% { transform: translate(10px, -20px); }
|
||||
}
|
||||
|
||||
/* ─── Content ─── */
|
||||
.error-page__container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.error-page__code {
|
||||
display: block;
|
||||
font-size: 8rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #ec4899 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: fadeIn 0.6s ease both;
|
||||
}
|
||||
|
||||
.error-page__title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 12px;
|
||||
animation: fadeIn 0.6s ease both;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.error-page__description {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 36px;
|
||||
animation: fadeIn 0.6s ease both;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.error-page__btn {
|
||||
font-weight: 600 !important;
|
||||
animation: fadeIn 0.6s ease both;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Dark ─── */
|
||||
.v-theme--dark .error-page__orb {
|
||||
opacity: 0.14;
|
||||
}
|
||||
|
||||
.v-theme--dark .error-page__orb--1 {
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.v-theme--dark .error-page__orb--2 {
|
||||
background: #f472b6;
|
||||
}
|
||||
|
||||
.v-theme--dark .error-page__code {
|
||||
background: linear-gradient(135deg, #a5b4fc 0%, #f9a8d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.v-theme--dark .error-page__title {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.v-theme--dark .error-page__description {
|
||||
color: #94a3b8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.v-theme--dark .error-page__grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(129, 140, 248, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(129, 140, 248, 0.04) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* ─── Light ─── */
|
||||
.v-theme--light .error-page__orb {
|
||||
opacity: 0.06;
|
||||
}
|
||||
|
||||
.v-theme--light .error-page__code {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #db2777 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.v-theme--light .error-page__title {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.v-theme--light .error-page__description {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 600px) {
|
||||
.error-page__code {
|
||||
font-size: 5rem;
|
||||
}
|
||||
|
||||
.error-page__title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error-page__description {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
6
landing/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
16
landing/layouts/default.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<v-app class="app-layout">
|
||||
<AppHeader />
|
||||
<main class="app-layout__main">
|
||||
<slot />
|
||||
</main>
|
||||
<AppFooter />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-layout__main {
|
||||
flex: 1;
|
||||
padding-top: 64px;
|
||||
}
|
||||
</style>
|
||||
110
landing/locales/en.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
110
landing/locales/ru.json
Normal file
|
|
@ -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": "На главную"
|
||||
}
|
||||
}
|
||||
99
landing/nuxt.config.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
37
landing/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
13
landing/pages/download.vue
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
const { content } = useLandingContent();
|
||||
|
||||
usePageSeo("meta.homeTitle", "meta.homeDescription");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container class="section">
|
||||
<h1 class="text-h4 section-title">{{ content.download.title }}</h1>
|
||||
<p class="text-body-2 mb-6">{{ content.download.note }}</p>
|
||||
<DownloadSection />
|
||||
</v-container>
|
||||
</template>
|
||||
24
landing/pages/index.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
usePageSeo("meta.homeTitle", "meta.homeDescription");
|
||||
useTrackSections();
|
||||
const { containerRef } = useParallaxSections();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="page">
|
||||
<PageBackground />
|
||||
<HeroSection />
|
||||
<SectionDivider />
|
||||
<LazyScreenshotsSection />
|
||||
<SectionDivider :flip="true" />
|
||||
<LazyDownloadSection />
|
||||
<SectionDivider />
|
||||
<LazyComparisonSection />
|
||||
<SectionDivider :flip="true" />
|
||||
<LazyPricingSection />
|
||||
<SectionDivider />
|
||||
<LazyFAQSection />
|
||||
<!-- <LazyFeaturesSection /> -->
|
||||
<!-- <LazyTestimonialsSection /> -->
|
||||
</div>
|
||||
</template>
|
||||
14
landing/plugins/init-theme-locale.client.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
40
landing/plugins/vuetify.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
BIN
landing/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
landing/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
landing/public/logo-192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
landing/public/og-image.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
landing/public/screenshots/1.jpg
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
landing/public/screenshots/2.jpg
Normal file
|
After Width: | Height: | Size: 314 KiB |
BIN
landing/public/screenshots/3.png
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
landing/public/screenshots/4.png
Normal file
|
After Width: | Height: | Size: 664 KiB |
BIN
landing/public/screenshots/5.png
Normal file
|
After Width: | Height: | Size: 704 KiB |
BIN
landing/public/screenshots/6.png
Normal file
|
After Width: | Height: | Size: 674 KiB |
BIN
landing/public/screenshots/7.png
Normal file
|
After Width: | Height: | Size: 597 KiB |
BIN
landing/public/screenshots/8.png
Normal file
|
After Width: | Height: | Size: 666 KiB |
BIN
landing/public/screenshots/9.png
Normal file
|
After Width: | Height: | Size: 770 KiB |
45
landing/stores/download.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
16
landing/stores/locale.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
31
landing/stores/theme.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
9
landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
51
landing/types/content.ts
Normal file
|
|
@ -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<LocaleCode, LandingContent>;
|
||||
2
landing/types/platform.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type PlatformOs = "macos" | "windows" | "linux" | "unknown";
|
||||
export type PlatformArch = "arm64" | "x64" | "universal" | "unknown";
|
||||
34
landing/utils/platform.ts
Normal file
|
|
@ -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";
|
||||
};
|
||||