diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daab196..1ed6173 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ packages/ core/ - @chatcops/core (AI providers, tools, knowledge base) widget/ - @chatcops/widget (embeddable chat UI) server/ - @chatcops/server (server handler + adapters) +website/ - Marketing site + Starlight docs (Astro) ``` ## Development Workflow @@ -71,6 +72,26 @@ ANTHROPIC_API_KEY=sk-... npx tsx src/examples/express-server.ts 3. Register in `packages/core/src/i18n/index.ts` 4. Add to the test in `tests/i18n/locales.test.ts` +## Website Development + +```bash +cd website +pnpm dev +``` + +The website requires a `.env.local` file with: + +```bash +# Required — powers the live chat demo on the landing page +OPENAI_API_KEY=sk-... + +# Optional — visitor counter (Upstash Redis) +UPSTASH_REDIS_REST_URL=https://your-endpoint.upstash.io +UPSTASH_REDIS_REST_TOKEN=your-token-here +``` + +The visitor counter gracefully degrades without Upstash credentials (displays `-`). Google Analytics (`G-GLYL9J6QYX`) is hardcoded in the layout and Starlight config. + ## Commit Convention Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:` diff --git a/packages/widget/src/dom/panel.ts b/packages/widget/src/dom/panel.ts index 749a2e6..fbd83fe 100644 --- a/packages/widget/src/dom/panel.ts +++ b/packages/widget/src/dom/panel.ts @@ -20,10 +20,12 @@ export interface PanelOptions { export class Panel { private el: HTMLDivElement; + private inline: boolean; messages: Messages; input: Input; constructor(root: ShadowRoot, options: PanelOptions) { + this.inline = options.inline ?? false; this.el = document.createElement('div'); this.el.className = 'cc-panel'; if (options.inline) { @@ -86,7 +88,9 @@ export class Panel { show(): void { this.el.classList.add('cc-visible'); - this.input.focus(); + if (!this.inline) { + this.input.focus(); + } } hide(): void { diff --git a/website/astro.config.mjs b/website/astro.config.mjs index b73eb01..2b82d17 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -7,7 +7,7 @@ import vercel from '@astrojs/vercel'; export default defineConfig({ output: 'static', adapter: vercel(), - site: 'https://chatcops.codercops.com', + site: 'https://chat.codercops.com', vite: { plugins: [tailwindcss()], }, @@ -23,6 +23,17 @@ export default defineConfig({ { icon: 'github', label: 'GitHub', href: 'https://github.com/codercops/chatcops' }, ], head: [ + { + tag: 'script', + attrs: { + src: 'https://www.googletagmanager.com/gtag/js?id=G-GLYL9J6QYX', + async: true, + }, + }, + { + tag: 'script', + content: `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-GLYL9J6QYX');`, + }, { tag: 'script', attrs: { diff --git a/website/src/components/landing/Footer.astro b/website/src/components/landing/Footer.astro index 0f05792..edb65f7 100644 --- a/website/src/components/landing/Footer.astro +++ b/website/src/components/landing/Footer.astro @@ -60,7 +60,51 @@ const year = new Date().getFullYear(); Built by CODERCOPS +
+ + + Today: - + + + + Total: - + +
MIT License · {year} + + diff --git a/website/src/components/landing/Header.astro b/website/src/components/landing/Header.astro index 7d64d27..41196bc 100644 --- a/website/src/components/landing/Header.astro +++ b/website/src/components/landing/Header.astro @@ -21,8 +21,13 @@ Docs - - GitHub + + + + +
diff --git a/website/src/components/landing/Hero.astro b/website/src/components/landing/Hero.astro index 72e131f..01108d6 100644 --- a/website/src/components/landing/Hero.astro +++ b/website/src/components/landing/Hero.astro @@ -62,6 +62,7 @@ Star on GitHub + diff --git a/website/src/components/landing/SupportBanner.astro b/website/src/components/landing/SupportBanner.astro new file mode 100644 index 0000000..8f5ae57 --- /dev/null +++ b/website/src/components/landing/SupportBanner.astro @@ -0,0 +1,231 @@ +--- +--- + + +
+ + + + + + Support Us + + + +
+ Support this project + + + Buy Me a Coffee + + + + PayPal + +
+
+ + + + diff --git a/website/src/layouts/Landing.astro b/website/src/layouts/Landing.astro index e6be90e..424fc57 100644 --- a/website/src/layouts/Landing.astro +++ b/website/src/layouts/Landing.astro @@ -37,6 +37,15 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site); + + + + + + + diff --git a/website/src/lib/upstash.ts b/website/src/lib/upstash.ts new file mode 100644 index 0000000..1b45697 --- /dev/null +++ b/website/src/lib/upstash.ts @@ -0,0 +1,60 @@ +const UPSTASH_URL = import.meta.env.UPSTASH_REDIS_REST_URL; +const UPSTASH_TOKEN = import.meta.env.UPSTASH_REDIS_REST_TOKEN; + +async function redis(command: string[]): Promise { + const res = await fetch(UPSTASH_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${UPSTASH_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(command), + }); + if (!res.ok) throw new Error(`Upstash error: ${res.status}`); + const data = await res.json(); + return data.result; +} + +/** + * Increments visitor count and returns today + total in a single EVAL command. + * Uses one Redis command per call to stay within Upstash free tier (10K/day). + */ +export async function incrementVisitors(site: string): Promise<{ today: number; total: number }> { + const todayKey = `${site}:today:${new Date().toISOString().slice(0, 10)}`; + const totalKey = `${site}:total`; + const ttl = 90 * 24 * 60 * 60; // 90 days TTL on daily keys + + const script = ` + local today = redis.call('INCR', KEYS[1]) + if today == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end + local total = redis.call('INCR', KEYS[2]) + return {today, total} + `; + + const result = await redis(['EVAL', script, '2', todayKey, totalKey, String(ttl)]); + return { today: result[0], total: result[1] }; +} + +/** + * Returns current counts without incrementing. + */ +export async function getVisitors(site: string): Promise<{ today: number; total: number }> { + const todayKey = `${site}:today:${new Date().toISOString().slice(0, 10)}`; + const totalKey = `${site}:total`; + + const res = await fetch(`${UPSTASH_URL}/pipeline`, { + method: 'POST', + headers: { + Authorization: `Bearer ${UPSTASH_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([['GET', todayKey], ['GET', totalKey]]), + }); + + if (!res.ok) return { today: 0, total: 0 }; + const data = await res.json(); + return { + today: parseInt(data[0]?.result || '0', 10), + total: parseInt(data[1]?.result || '0', 10), + }; +} diff --git a/website/src/pages/api/visitors.ts b/website/src/pages/api/visitors.ts new file mode 100644 index 0000000..73ea76a --- /dev/null +++ b/website/src/pages/api/visitors.ts @@ -0,0 +1,28 @@ +import type { APIRoute } from 'astro'; +import { incrementVisitors, getVisitors } from '../../lib/upstash'; + +export const prerender = false; + +const SITE = 'chatcops'; +const HEADERS = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', +}; + +export const POST: APIRoute = async () => { + try { + const counts = await incrementVisitors(SITE); + return new Response(JSON.stringify(counts), { headers: HEADERS }); + } catch { + return new Response(JSON.stringify({ today: 0, total: 0 }), { status: 500, headers: HEADERS }); + } +}; + +export const GET: APIRoute = async () => { + try { + const counts = await getVisitors(SITE); + return new Response(JSON.stringify(counts), { headers: HEADERS }); + } catch { + return new Response(JSON.stringify({ today: 0, total: 0 }), { status: 500, headers: HEADERS }); + } +}; diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 557ccf5..13b2bd0 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -9,6 +9,7 @@ import CodeExamples from '../components/landing/CodeExamples.astro'; import Providers from '../components/landing/Providers.astro'; import OpenSourceCTA from '../components/landing/OpenSourceCTA.astro'; import Footer from '../components/landing/Footer.astro'; +import SupportBanner from '../components/landing/SupportBanner.astro'; ---
+