@lightupai/polaris 0.0.51 → 0.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Makefile +16 -2
- package/README.md +40 -0
- package/docs/seo-geo.md +146 -0
- package/docs/signup-flow.md +167 -0
- package/og-image.png +0 -0
- package/package.json +1 -1
- package/src/service/db.ts +38 -3
- package/src/web/app.ts +100 -13
- package/src/web/layout.ts +38 -4
- package/src/web/pages.ts +32 -22
- package/src/web/views.ts +127 -1
- package/tests/db.test.ts +2 -7
- package/tests/helpers.ts +15 -64
package/Makefile
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev dev-up dev-down api web daemon bridge test clean
|
|
1
|
+
.PHONY: dev dev-up dev-down api web daemon bridge test clean prod
|
|
2
2
|
|
|
3
3
|
# Load .env if it exists
|
|
4
4
|
ifneq (,$(wildcard .env))
|
|
@@ -45,15 +45,29 @@ bridge:
|
|
|
45
45
|
else echo "Skipping bridge (no Slack-connected org found)"; fi; \
|
|
46
46
|
fi
|
|
47
47
|
|
|
48
|
+
# Run web locally against prod DB
|
|
49
|
+
# Opens SSH tunnel to prod postgres, starts web app with hot reload, cleans up on Ctrl-C.
|
|
50
|
+
prod:
|
|
51
|
+
@lsof -ti :3000 | xargs kill -9 2>/dev/null || true
|
|
52
|
+
@lsof -ti :5433 | xargs kill -9 2>/dev/null || true
|
|
53
|
+
@PW=$$(ssh deploy@withpolaris.ai "grep POSTGRES_PASSWORD /opt/polaris/.env" | cut -d= -f2); \
|
|
54
|
+
PG_IP=$$(ssh deploy@withpolaris.ai "docker inspect polaris-postgres-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"); \
|
|
55
|
+
echo "Tunneling prod postgres ($$PG_IP:5432) to localhost:5433"; \
|
|
56
|
+
ssh -f -N -L 5433:$$PG_IP:5432 deploy@withpolaris.ai; \
|
|
57
|
+
echo "Starting web app on http://localhost:3000 (prod DB)"; \
|
|
58
|
+
DATABASE_URL=postgres://polaris:$$PW@localhost:5433/polaris npx bun --hot run src/web/serve.ts; \
|
|
59
|
+
lsof -ti :5433 | xargs kill 2>/dev/null || true
|
|
60
|
+
|
|
48
61
|
# Run tests
|
|
49
62
|
test:
|
|
50
63
|
npx bun test
|
|
51
64
|
|
|
52
|
-
# Stop all background processes and Postgres
|
|
65
|
+
# Stop all background processes, tunnels, and Postgres
|
|
53
66
|
clean:
|
|
54
67
|
@lsof -ti :4321 | xargs kill -9 2>/dev/null || true
|
|
55
68
|
@lsof -ti :4322 | xargs kill -9 2>/dev/null || true
|
|
56
69
|
@lsof -ti :3000 | xargs kill -9 2>/dev/null || true
|
|
70
|
+
@lsof -ti :5433 | xargs kill -9 2>/dev/null || true
|
|
57
71
|
@pgrep -f "bridge.ts" | xargs kill -9 2>/dev/null || true
|
|
58
72
|
docker compose down
|
|
59
73
|
@echo "Cleaned up"
|
package/README.md
CHANGED
|
@@ -97,6 +97,46 @@ Copy `.env.example` to `.env` and fill in your credentials. All settings are loa
|
|
|
97
97
|
| `SLACK_APP_TOKEN` | Slack app-level token (for Socket Mode) |
|
|
98
98
|
| `SLACK_REDIRECT_URI` | Slack OAuth callback URL |
|
|
99
99
|
|
|
100
|
+
### Local Google OAuth setup
|
|
101
|
+
|
|
102
|
+
Login — both `polaris login --local` and the dashboard — uses Google SSO, so you need a Google OAuth client. (The repo ships `scripts/setup-google-oauth.sh`, but it overwrites `.env` and its automation is unreliable; set it up manually.)
|
|
103
|
+
|
|
104
|
+
1. Open the [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials) and create or select a project.
|
|
105
|
+
2. **OAuth consent screen** (now called the *Google Auth Platform*): set User type **External**, fill in an app name and support email, and Save.
|
|
106
|
+
3. **Add yourself as a Test user.** In the redesigned console this moved — it's no longer on the consent screen. Go to **APIs & Services → OAuth consent screen → Audience** (left sidebar) — or directly [console.cloud.google.com/auth/audience](https://console.cloud.google.com/auth/audience) — and under **Test users** click **+ Add users**, add the email you'll sign in with, and Save. This is **required** while the app is in *Testing* mode; without it, sign-in is blocked with `access_denied`.
|
|
107
|
+
4. **Create Credentials → OAuth client ID → Web application**.
|
|
108
|
+
5. Under **Authorized redirect URIs** (not "JavaScript origins"), add this **exactly**:
|
|
109
|
+
```
|
|
110
|
+
http://localhost:3000/auth/google/callback
|
|
111
|
+
```
|
|
112
|
+
It must match character-for-character — no trailing slash, `http` not `https`, `localhost` not `127.0.0.1`, port `3000`. This is the value the app sends by default (override with `GOOGLE_REDIRECT_URI`).
|
|
113
|
+
6. Copy the **Client ID** and **Client Secret** into `.env` — edit the existing empty lines, don't recreate the file (you'd lose `POSTGRES_PASSWORD`, the JWT secret, etc.):
|
|
114
|
+
```
|
|
115
|
+
GOOGLE_CLIENT_ID=<your-client-id>.apps.googleusercontent.com
|
|
116
|
+
GOOGLE_CLIENT_SECRET=<your-secret>
|
|
117
|
+
```
|
|
118
|
+
7. Reload so the values take effect: `make clean && make dev`.
|
|
119
|
+
|
|
120
|
+
> Redirect-URI changes can take a few minutes to propagate on Google's side. If you get `redirect_uri_mismatch` immediately after saving, wait ~5 minutes and retry.
|
|
121
|
+
|
|
122
|
+
### Local Slack app setup (optional)
|
|
123
|
+
|
|
124
|
+
Slack is optional — without `SLACK_APP_TOKEN`, `make dev` just skips the bridge. Set it up to mirror sessions to Slack channels. Slack has no API to create apps, so this is manual (`scripts/setup-slack-app.sh` walks you through it and **appends** to `.env`, so it won't clobber existing values).
|
|
125
|
+
|
|
126
|
+
1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App → From scratch**. Name it "Polaris" and pick your dev workspace.
|
|
127
|
+
2. **OAuth & Permissions**: under **Redirect URLs** add `http://localhost:3000/slack/callback` and Save. Under **Bot Token Scopes**, add: `channels:manage`, `channels:join`, `channels:read`, `chat:write`, `users:read`, `users:read.email`.
|
|
128
|
+
3. **Socket Mode**: toggle **Enable Socket Mode** on, then generate an app-level token (scope `connections:write`). Copy it — this is `SLACK_APP_TOKEN` (starts with `xapp-`).
|
|
129
|
+
4. **Event Subscriptions**: toggle **Enable Events** on, and under **Subscribe to bot events** add `message.channels`, then Save. (This is what lets Slack messages reach a session.)
|
|
130
|
+
5. **Basic Information**: copy the **Client ID** and **Client Secret**.
|
|
131
|
+
6. Add all three to `.env`:
|
|
132
|
+
```
|
|
133
|
+
SLACK_CLIENT_ID=<client-id>
|
|
134
|
+
SLACK_CLIENT_SECRET=<client-secret>
|
|
135
|
+
SLACK_APP_TOKEN=xapp-<socket-mode-token>
|
|
136
|
+
```
|
|
137
|
+
(`SLACK_REDIRECT_URI` defaults to `http://localhost:3000/slack/callback`.)
|
|
138
|
+
7. Reload: `make clean && make dev`, then click **Connect Slack** on the dashboard to install the bot into your workspace.
|
|
139
|
+
|
|
100
140
|
### Optional
|
|
101
141
|
|
|
102
142
|
| Variable | Default | Description |
|
package/docs/seo-geo.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SEO & Geo — Tracker
|
|
2
|
+
|
|
3
|
+
## Completed
|
|
4
|
+
|
|
5
|
+
- [x] **Meta tags** — title, description on every page via `SeoOpts` in layout
|
|
6
|
+
- [x] **Open Graph tags** — og:title, og:description, og:image, og:url for rich link previews on Slack/Twitter/LinkedIn
|
|
7
|
+
- [x] **Twitter cards** — summary_large_image format with title, description, image
|
|
8
|
+
- [x] **OG image** — 1200x630 PNG of hero section served at `/og-image.png` with 24h cache
|
|
9
|
+
- [x] **Canonical URL** — set to `https://app.withpolaris.ai`
|
|
10
|
+
- [x] **robots.txt** — allows all crawlers, points to sitemap
|
|
11
|
+
- [x] **sitemap.xml** — lists the landing page
|
|
12
|
+
- [x] **Cloudflare DNS** — domain managed on Cloudflare with MX + SPF records
|
|
13
|
+
- [x] **Email routing** — `*@withpolaris.ai` forwards to `support@lightup.ai`
|
|
14
|
+
|
|
15
|
+
## SEO — To Do
|
|
16
|
+
|
|
17
|
+
### High Priority
|
|
18
|
+
|
|
19
|
+
- [ ] **Submit sitemap to Google Search Console**
|
|
20
|
+
Register `app.withpolaris.ai` at https://search.google.com/search-console.
|
|
21
|
+
Verify ownership via Cloudflare DNS TXT record. Submit sitemap URL.
|
|
22
|
+
|
|
23
|
+
- [ ] **Add analytics**
|
|
24
|
+
No traffic data currently. Options:
|
|
25
|
+
- Plausible (privacy-friendly, lightweight, ~$9/mo)
|
|
26
|
+
- Google Analytics (free, full-featured, heavier)
|
|
27
|
+
- Cloudflare Web Analytics (free, built into Cloudflare dashboard)
|
|
28
|
+
Add the tracking script to the layout.
|
|
29
|
+
|
|
30
|
+
- [ ] **Self-host Tailwind CSS**
|
|
31
|
+
Currently loading Tailwind CDN (~300KB) on every page. Purge and self-host
|
|
32
|
+
to reduce to ~10KB. Improves page speed and Core Web Vitals score.
|
|
33
|
+
Affects Google ranking.
|
|
34
|
+
|
|
35
|
+
### Medium Priority
|
|
36
|
+
|
|
37
|
+
- [ ] **Schema markup (JSON-LD)**
|
|
38
|
+
Add structured data for SoftwareApplication and Organization.
|
|
39
|
+
Helps Google show rich results (product name, pricing, etc.).
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"@context": "https://schema.org",
|
|
43
|
+
"@type": "SoftwareApplication",
|
|
44
|
+
"name": "Polaris",
|
|
45
|
+
"applicationCategory": "DeveloperApplication",
|
|
46
|
+
"operatingSystem": "macOS, Linux",
|
|
47
|
+
"offers": {
|
|
48
|
+
"@type": "Offer",
|
|
49
|
+
"price": "0",
|
|
50
|
+
"priceCurrency": "USD"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- [ ] **Blog / content marketing**
|
|
56
|
+
Long-tail SEO needs content pages targeting search terms:
|
|
57
|
+
- "How to collaborate on Claude Code sessions"
|
|
58
|
+
- "AI session capture for engineering teams"
|
|
59
|
+
- "Multiplayer AI coding with Slack"
|
|
60
|
+
- "AI agent observability for developers"
|
|
61
|
+
- "Gong for AI coding"
|
|
62
|
+
Options: simple `/blog` route with markdown rendering, or a subdomain `blog.withpolaris.ai`.
|
|
63
|
+
|
|
64
|
+
- [ ] **Heading hierarchy audit**
|
|
65
|
+
Ensure proper h1 → h2 → h3 structure on the landing page.
|
|
66
|
+
Only one h1 per page. Check that section headers use correct levels.
|
|
67
|
+
|
|
68
|
+
### Low Priority
|
|
69
|
+
|
|
70
|
+
- [ ] **Favicon**
|
|
71
|
+
No favicon set. Add one for browser tabs and bookmarks.
|
|
72
|
+
Use the Polaris hub icon or a simplified version.
|
|
73
|
+
|
|
74
|
+
- [ ] **404 page**
|
|
75
|
+
Custom 404 page with navigation back to the landing page.
|
|
76
|
+
Currently returns a default error.
|
|
77
|
+
|
|
78
|
+
- [ ] **Page title for dashboard pages**
|
|
79
|
+
Dashboard, profile, and other authenticated pages should have
|
|
80
|
+
descriptive titles (e.g., "Dashboard — Polaris") instead of just "Polaris".
|
|
81
|
+
|
|
82
|
+
- [ ] **Alt text on images**
|
|
83
|
+
The Claude Code PNG icon and any other images need alt text for
|
|
84
|
+
accessibility and SEO.
|
|
85
|
+
|
|
86
|
+
## Geo — To Do
|
|
87
|
+
|
|
88
|
+
### High Priority
|
|
89
|
+
|
|
90
|
+
- [ ] **Enable Cloudflare proxy (orange cloud)**
|
|
91
|
+
DNS records for `app.withpolaris.ai` and `api.withpolaris.ai` are
|
|
92
|
+
likely DNS-only (gray cloud). Switching to proxied gives:
|
|
93
|
+
- Free global CDN (faster loads worldwide)
|
|
94
|
+
- Automatic caching of static assets
|
|
95
|
+
- DDoS protection
|
|
96
|
+
- Web Application Firewall
|
|
97
|
+
Note: Caddy handles HTTPS on the server. Enabling Cloudflare proxy
|
|
98
|
+
means Cloudflare terminates TLS and connects to Caddy. Need to set
|
|
99
|
+
Cloudflare SSL mode to "Full (strict)" and ensure Caddy's certs
|
|
100
|
+
are valid. Test carefully.
|
|
101
|
+
|
|
102
|
+
### Medium Priority
|
|
103
|
+
|
|
104
|
+
- [ ] **Check server location**
|
|
105
|
+
Verify where the Hetzner VPS is located (likely EU — Finland or Germany).
|
|
106
|
+
If most users are US-based, consider:
|
|
107
|
+
- Migrating to a US Hetzner datacenter (Ashburn, VA)
|
|
108
|
+
- Or relying on Cloudflare proxy to cache static content at edge
|
|
109
|
+
|
|
110
|
+
- [ ] **Cache headers for static assets**
|
|
111
|
+
OG image has 24h cache. Landing page HTML has `Cache-Control: no-store`.
|
|
112
|
+
Consider adding short cache (5-10 min) for the landing page since it
|
|
113
|
+
changes infrequently. CSS/JS should have long cache with versioned URLs.
|
|
114
|
+
|
|
115
|
+
### Low Priority
|
|
116
|
+
|
|
117
|
+
- [ ] **Content localization**
|
|
118
|
+
Not needed yet. English only. Revisit if expanding to non-English markets.
|
|
119
|
+
|
|
120
|
+
- [ ] **CDN for the npm package**
|
|
121
|
+
The CLI is published to npm. npm CDN (unpkg, jsdelivr) handles global
|
|
122
|
+
distribution automatically. No action needed.
|
|
123
|
+
|
|
124
|
+
## Target Keywords
|
|
125
|
+
|
|
126
|
+
Primary:
|
|
127
|
+
- "AI coding session recording"
|
|
128
|
+
- "Claude Code collaboration"
|
|
129
|
+
- "AI agent observability"
|
|
130
|
+
- "Slack integration for AI coding"
|
|
131
|
+
- "Gong for AI coding"
|
|
132
|
+
|
|
133
|
+
Secondary:
|
|
134
|
+
- "AI session capture tool"
|
|
135
|
+
- "multiplayer AI coding"
|
|
136
|
+
- "AI coding agent memory"
|
|
137
|
+
- "context graph for AI agents"
|
|
138
|
+
- "engineering knowledge capture AI"
|
|
139
|
+
|
|
140
|
+
## Useful Links
|
|
141
|
+
|
|
142
|
+
- Google Search Console: https://search.google.com/search-console
|
|
143
|
+
- OG image tester: https://www.opengraph.xyz/
|
|
144
|
+
- Twitter card validator: https://cards-dev.twitter.com/validator
|
|
145
|
+
- PageSpeed Insights: https://pagespeed.web.dev/
|
|
146
|
+
- Cloudflare dashboard: https://dash.cloudflare.com/
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Signup Flow — Design & Todos
|
|
2
|
+
|
|
3
|
+
## Current State
|
|
4
|
+
|
|
5
|
+
User clicks "Get started" on the landing page → Google OAuth → user/org created in DB → redirect to dashboard. No plan differentiation, no onboarding, no Slack connection prompt. New users land on an empty dashboard with no guidance.
|
|
6
|
+
|
|
7
|
+
## Target State
|
|
8
|
+
|
|
9
|
+
A user who signs up should experience a smooth path from landing page to their first live session:
|
|
10
|
+
|
|
11
|
+
1. Choose a plan (or default to free)
|
|
12
|
+
2. Sign up with Google
|
|
13
|
+
3. Connect their Slack workspace
|
|
14
|
+
4. Install the CLI
|
|
15
|
+
5. Join a channel and start their first session
|
|
16
|
+
|
|
17
|
+
Each step should feel intentional, not accidental. The user should never be left wondering "what do I do next?"
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Design
|
|
22
|
+
|
|
23
|
+
### Plan Selection → Signup
|
|
24
|
+
|
|
25
|
+
The pricing cards on the landing page link to `/signup?plan=free` or `/signup?plan=team`. Enterprise goes to `mailto:support@withpolaris.ai`. The plan parameter is stored in the OAuth state, survives the Google redirect, and is persisted on the org record after signup.
|
|
26
|
+
|
|
27
|
+
If no plan is specified (e.g., user clicks the nav "Sign up" button), default to `free`.
|
|
28
|
+
|
|
29
|
+
### Post-Signup Routing
|
|
30
|
+
|
|
31
|
+
After Google OAuth completes, the callback handler should route based on the user's state:
|
|
32
|
+
|
|
33
|
+
| Scenario | Route to |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Existing user (login) | Dashboard |
|
|
36
|
+
| New user, existing org | Dashboard (org already set up) |
|
|
37
|
+
| New user, new org, no Slack | Onboarding: Connect Slack |
|
|
38
|
+
| New user, new org, Slack connected | Onboarding: Install CLI |
|
|
39
|
+
|
|
40
|
+
### Onboarding Flow
|
|
41
|
+
|
|
42
|
+
A dedicated onboarding page replaces the empty dashboard for new orgs. Three steps, shown as a checklist:
|
|
43
|
+
|
|
44
|
+
**Step 1: Connect Slack**
|
|
45
|
+
- Large "Add to Slack" button
|
|
46
|
+
- Explain: "Polaris uses Slack as the collaboration layer. Connect your workspace to get started."
|
|
47
|
+
- After connecting, auto-advance to Step 2
|
|
48
|
+
|
|
49
|
+
**Step 2: Install the CLI**
|
|
50
|
+
- Show `npx @lightupai/polaris` with a copy button
|
|
51
|
+
- Explain: "Run this on your machine to set up hooks and log in."
|
|
52
|
+
- A "I've installed it" button to advance (or auto-detect via CLI auth callback)
|
|
53
|
+
|
|
54
|
+
**Step 3: Start a session**
|
|
55
|
+
- Show `/polaris join #your-channel` with a copy button
|
|
56
|
+
- Explain: "Run this in Claude Code to start streaming your session."
|
|
57
|
+
- Link to dashboard
|
|
58
|
+
|
|
59
|
+
The existing `renderWelcomePage` and `renderSetupView` functions can be repurposed for this flow.
|
|
60
|
+
|
|
61
|
+
### Welcome Email
|
|
62
|
+
|
|
63
|
+
After a new user signs up, send a welcome email to their address from `hello@withpolaris.ai`:
|
|
64
|
+
|
|
65
|
+
- Welcome to Polaris
|
|
66
|
+
- Quick start: Connect Slack → Install CLI → Join a channel
|
|
67
|
+
- Link to community (GitHub Discussions)
|
|
68
|
+
- Link to support (support@withpolaris.ai)
|
|
69
|
+
|
|
70
|
+
Requires an email sending service (Resend, SendGrid, or Postmark). Cloudflare email routing only handles inbound — outbound needs a transactional email provider.
|
|
71
|
+
|
|
72
|
+
### Plan Enforcement
|
|
73
|
+
|
|
74
|
+
Plans are stored on the org record. Limits are enforced at the event ingestion layer:
|
|
75
|
+
|
|
76
|
+
| | Free | Team | Enterprise |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| Users | Unlimited | Unlimited | Unlimited |
|
|
79
|
+
| Prompts/month | 1,000 | 10,000 | Custom |
|
|
80
|
+
| Data captured | 5 GB | 50 GB | Custom |
|
|
81
|
+
| Retention | 7 days | 90 days | Custom |
|
|
82
|
+
|
|
83
|
+
**Enforcement approach:**
|
|
84
|
+
- Count user prompts per org per calendar month (query events table)
|
|
85
|
+
- Track cumulative data volume per org per month
|
|
86
|
+
- Run a daily cleanup job to delete events beyond the retention window
|
|
87
|
+
- When a limit is reached, soft-block: stop capturing new events but keep Slack streaming active. Show upgrade prompt in dashboard and optionally in Slack.
|
|
88
|
+
|
|
89
|
+
### Upgrade Flow
|
|
90
|
+
|
|
91
|
+
When a free-tier org approaches or hits their limit:
|
|
92
|
+
|
|
93
|
+
1. Dashboard shows a usage bar with current/max for prompts and data
|
|
94
|
+
2. At 80%, show a yellow warning: "You've used 80% of your free prompts this month"
|
|
95
|
+
3. At 100%, show upgrade prompt: "You've reached your free plan limit. Upgrade to Team for 10x capacity."
|
|
96
|
+
4. Upgrade button links to Stripe Checkout for the $49/mo Team plan
|
|
97
|
+
5. After successful payment, Stripe webhook updates the org's plan to `team`
|
|
98
|
+
|
|
99
|
+
### Billing (Stripe)
|
|
100
|
+
|
|
101
|
+
**Setup needed:**
|
|
102
|
+
- Stripe product: "Polaris Team"
|
|
103
|
+
- Stripe price: $49/month, recurring
|
|
104
|
+
- Checkout session: created when user clicks "Upgrade" or selects Team plan during signup
|
|
105
|
+
- Webhook endpoint: `/stripe/webhook` to handle `checkout.session.completed`, `customer.subscription.deleted`, `invoice.payment_failed`
|
|
106
|
+
- Org record fields: `stripe_customer_id`, `stripe_subscription_id`, `plan`, `plan_status`
|
|
107
|
+
|
|
108
|
+
**Downgrade:**
|
|
109
|
+
- User cancels in Stripe customer portal
|
|
110
|
+
- Webhook fires `customer.subscription.deleted`
|
|
111
|
+
- Org plan reverts to `free`
|
|
112
|
+
- Free plan limits take effect at next billing cycle
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Todos
|
|
117
|
+
|
|
118
|
+
### Phase 1: Foundation (do first)
|
|
119
|
+
|
|
120
|
+
- [ ] **Store selected plan on org record**
|
|
121
|
+
Add a `plan` column to the orgs table (free/team/enterprise, default 'free'). Populate from the `?plan` query param during signup. Show current plan on the dashboard.
|
|
122
|
+
|
|
123
|
+
- [ ] **Prompt Slack connection immediately after signup**
|
|
124
|
+
For new orgs without Slack connected, route to setup view with prominent "Add to Slack" button instead of empty dashboard. Ensure the flow from signup → setup is seamless.
|
|
125
|
+
|
|
126
|
+
- [ ] **Post-signup onboarding flow**
|
|
127
|
+
After a brand new org signup, redirect to onboarding page (not dashboard). Three steps: Connect Slack → Install CLI → Join a channel. Repurpose existing `renderWelcomePage`/`renderSetupView`. Auto-advance when each step is completed.
|
|
128
|
+
|
|
129
|
+
### Phase 2: Billing
|
|
130
|
+
|
|
131
|
+
- [ ] **Stripe integration for Team plan billing**
|
|
132
|
+
Stripe product/price setup, checkout session on Team plan selection, webhook handler for payment events, store subscription status on org. Handle both new signups selecting Team and existing free users upgrading.
|
|
133
|
+
|
|
134
|
+
- [ ] **Enforce free tier usage limits**
|
|
135
|
+
Prompt counter per org per month, data volume tracking, auto-cleanup of events beyond retention window. Soft-block at limits (stop capture, keep streaming, show upgrade prompt).
|
|
136
|
+
|
|
137
|
+
- [ ] **Upgrade prompt when limits are reached**
|
|
138
|
+
Usage bars on dashboard, warning at 80%, upgrade prompt at 100%. Link to Stripe checkout. Also notify in Slack when org approaches limit.
|
|
139
|
+
|
|
140
|
+
- [ ] **Plan management page in dashboard**
|
|
141
|
+
Show current plan, usage stats (prompts, data, retention), upgrade/downgrade buttons, Stripe customer portal link. Accessible from profile dropdown.
|
|
142
|
+
|
|
143
|
+
### Phase 3: Polish
|
|
144
|
+
|
|
145
|
+
- [ ] **Welcome email on signup**
|
|
146
|
+
Pick a transactional email service (Resend recommended — simple API, good deliverability, free tier). Send welcome email with quick-start steps and community link. Send from hello@withpolaris.ai.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Dependencies
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
Store plan on org ──→ Enforce limits ──→ Upgrade prompt
|
|
154
|
+
──→ Stripe billing ──→ Plan management page
|
|
155
|
+
──→ Onboarding flow
|
|
156
|
+
|
|
157
|
+
Prompt Slack connection ──→ Onboarding flow
|
|
158
|
+
|
|
159
|
+
Welcome email (independent — needs email service selection)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Open Questions
|
|
163
|
+
|
|
164
|
+
- Should we allow plan changes mid-month, or only at billing cycle boundaries?
|
|
165
|
+
- Should free-tier users who hit limits lose access to existing session data, or just stop capturing new data?
|
|
166
|
+
- Do we want a 14-day free trial of Team, or keep it strictly free-then-pay?
|
|
167
|
+
- Should the CLI install step in onboarding auto-detect completion (via the `/auth/cli` callback), or require manual confirmation?
|
package/og-image.png
ADDED
|
Binary file
|
package/package.json
CHANGED
package/src/service/db.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface Org {
|
|
|
10
10
|
name: string;
|
|
11
11
|
slug: string | null;
|
|
12
12
|
domain: string | null;
|
|
13
|
+
plan: string;
|
|
13
14
|
slack_team_id: string | null;
|
|
14
15
|
slack_bot_token: string | null;
|
|
15
16
|
slack_system_channel_id: string | null;
|
|
@@ -29,13 +30,18 @@ export interface User {
|
|
|
29
30
|
|
|
30
31
|
export async function createDb(connectionString?: string): Promise<Sql> {
|
|
31
32
|
const sql = postgres(connectionString ?? process.env.DATABASE_URL ?? "postgres://polaris:polaris@localhost:5432/polaris");
|
|
33
|
+
await ensureSchema(sql);
|
|
34
|
+
return sql;
|
|
35
|
+
}
|
|
32
36
|
|
|
37
|
+
export async function ensureSchema(sql: Sql): Promise<void> {
|
|
33
38
|
await sql`
|
|
34
39
|
CREATE TABLE IF NOT EXISTS orgs (
|
|
35
40
|
id TEXT PRIMARY KEY,
|
|
36
41
|
name TEXT NOT NULL,
|
|
37
42
|
slug TEXT UNIQUE,
|
|
38
43
|
domain TEXT,
|
|
44
|
+
plan TEXT NOT NULL DEFAULT 'free',
|
|
39
45
|
slack_team_id TEXT,
|
|
40
46
|
slack_bot_token TEXT,
|
|
41
47
|
slack_system_channel_id TEXT,
|
|
@@ -43,6 +49,9 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
43
49
|
)
|
|
44
50
|
`;
|
|
45
51
|
|
|
52
|
+
// Migrate: add plan column if missing
|
|
53
|
+
await sql`ALTER TABLE orgs ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`;
|
|
54
|
+
|
|
46
55
|
await sql`
|
|
47
56
|
CREATE TABLE IF NOT EXISTS users (
|
|
48
57
|
id TEXT PRIMARY KEY,
|
|
@@ -119,19 +128,33 @@ export async function createDb(connectionString?: string): Promise<Sql> {
|
|
|
119
128
|
CREATE INDEX IF NOT EXISTS idx_events_session ON events(project_id, session, timestamp)
|
|
120
129
|
`;
|
|
121
130
|
|
|
122
|
-
|
|
131
|
+
await sql`
|
|
132
|
+
CREATE TABLE IF NOT EXISTS plan_changes (
|
|
133
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
134
|
+
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
135
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
136
|
+
from_plan TEXT NOT NULL,
|
|
137
|
+
to_plan TEXT NOT NULL,
|
|
138
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
139
|
+
)
|
|
140
|
+
`;
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
// --- Orgs ---
|
|
126
144
|
|
|
127
|
-
export async function createOrg(sql: Sql, id: string, name: string, domain?: string): Promise<Org> {
|
|
145
|
+
export async function createOrg(sql: Sql, id: string, name: string, domain?: string, plan?: string): Promise<Org> {
|
|
128
146
|
const [row] = await sql`
|
|
129
|
-
INSERT INTO orgs (id, name, domain) VALUES (${id}, ${name}, ${domain ?? null})
|
|
147
|
+
INSERT INTO orgs (id, name, domain, plan) VALUES (${id}, ${name}, ${domain ?? null}, ${plan ?? "free"})
|
|
130
148
|
RETURNING *
|
|
131
149
|
`;
|
|
132
150
|
return { ...row, created_at: row.created_at.toISOString() } as Org;
|
|
133
151
|
}
|
|
134
152
|
|
|
153
|
+
export async function setOrgPlan(sql: Sql, orgId: string, fromPlan: string, toPlan: string, userId: string): Promise<void> {
|
|
154
|
+
await sql`UPDATE orgs SET plan = ${toPlan} WHERE id = ${orgId}`;
|
|
155
|
+
await sql`INSERT INTO plan_changes (org_id, user_id, from_plan, to_plan) VALUES (${orgId}, ${userId}, ${fromPlan}, ${toPlan})`;
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
export async function getOrg(sql: Sql, id: string): Promise<Org | null> {
|
|
136
159
|
const [row] = await sql`SELECT * FROM orgs WHERE id = ${id}`;
|
|
137
160
|
if (!row) return null;
|
|
@@ -343,6 +366,18 @@ export async function getSessionPromptCounts(sql: Sql, orgId: string): Promise<M
|
|
|
343
366
|
return counts;
|
|
344
367
|
}
|
|
345
368
|
|
|
369
|
+
export async function getDailyPromptCounts(sql: Sql, orgId: string, days = 14): Promise<Array<{ date: string; sender: string; count: number }>> {
|
|
370
|
+
const rows = await sql`
|
|
371
|
+
SELECT date_trunc('day', e.timestamp)::date as date, e.sender, count(*)::int as count
|
|
372
|
+
FROM events e
|
|
373
|
+
WHERE e.org_id = ${orgId} AND e.payload->>'hook_event_name' = 'UserPromptSubmit'
|
|
374
|
+
AND e.timestamp >= now() - ${days + ' days'}::interval
|
|
375
|
+
GROUP BY date, e.sender
|
|
376
|
+
ORDER BY date ASC
|
|
377
|
+
`;
|
|
378
|
+
return rows.map((r) => ({ date: r.date.toISOString().slice(0, 10), sender: r.sender, count: r.count }));
|
|
379
|
+
}
|
|
380
|
+
|
|
346
381
|
export async function setDriver(sql: Sql, orgId: string, project: string, session: string, driver: ParticipantId): Promise<void> {
|
|
347
382
|
await sql`
|
|
348
383
|
UPDATE sessions SET driver = ${driver}
|
package/src/web/app.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
getProjectEvents,
|
|
17
17
|
getRecentSignups,
|
|
18
18
|
listUsers,
|
|
19
|
+
setOrgPlan,
|
|
20
|
+
getDailyPromptCounts,
|
|
19
21
|
type Sql,
|
|
20
22
|
} from "../service/db";
|
|
21
23
|
import { layout, nav } from "./layout";
|
|
@@ -36,13 +38,31 @@ import {
|
|
|
36
38
|
|
|
37
39
|
const SIGNUP_CHANNEL = "#alerts-mql-stream";
|
|
38
40
|
|
|
39
|
-
function notifySignup(opts: { name: string; email: string; domain: string; orgName: string; isNewOrg: boolean }): void {
|
|
41
|
+
function notifySignup(opts: { name: string; email: string; domain: string; orgName: string; isNewOrg: boolean; plan?: string }): void {
|
|
40
42
|
const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
|
|
41
43
|
if (!botToken) return;
|
|
42
44
|
|
|
43
45
|
const emoji = opts.isNewOrg ? ":tada:" : ":wave:";
|
|
44
46
|
const action = opts.isNewOrg ? "signed up (new org)" : "joined";
|
|
45
|
-
const
|
|
47
|
+
const planTag = opts.plan ? ` [${opts.plan}]` : "";
|
|
48
|
+
const text = `${emoji} *${opts.name}* (${opts.email}) ${action} — ${opts.orgName} (${opts.domain})${planTag}`;
|
|
49
|
+
|
|
50
|
+
fetch("https://slack.com/api/chat.postMessage", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${botToken}`,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ channel: SIGNUP_CHANNEL, text }),
|
|
57
|
+
}).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function notifyPlanChange(opts: { name: string; email: string; orgName: string; fromPlan: string; toPlan: string }): void {
|
|
61
|
+
const botToken = process.env.SIGNUP_SLACK_BOT_TOKEN;
|
|
62
|
+
if (!botToken) return;
|
|
63
|
+
|
|
64
|
+
const emoji = opts.toPlan === "free" ? ":arrow_down:" : ":arrow_up:";
|
|
65
|
+
const text = `${emoji} *${opts.name}* (${opts.email}) changed plan: ${opts.fromPlan} → ${opts.toPlan} — ${opts.orgName}`;
|
|
46
66
|
|
|
47
67
|
fetch("https://slack.com/api/chat.postMessage", {
|
|
48
68
|
method: "POST",
|
|
@@ -91,7 +111,7 @@ function getGoogle(): Google {
|
|
|
91
111
|
);
|
|
92
112
|
}
|
|
93
113
|
|
|
94
|
-
const oauthStates = new Map<string, { type: "login" | "signup"; codeVerifier: string; timestamp: number }>();
|
|
114
|
+
const oauthStates = new Map<string, { type: "login" | "signup"; codeVerifier: string; timestamp: number; plan?: string }>();
|
|
95
115
|
const cliCallbackPorts = new Map<string, number>(); // state → CLI local server port
|
|
96
116
|
|
|
97
117
|
// If this auth flow was initiated by the CLI, redirect the token to the CLI's local server.
|
|
@@ -120,19 +140,54 @@ export function createApp(sql: Sql) {
|
|
|
120
140
|
// Start hourly signup rollup
|
|
121
141
|
startSignupRollup(sql);
|
|
122
142
|
|
|
143
|
+
// --- SEO ---
|
|
144
|
+
|
|
145
|
+
app.get("/og-image.png", async (c) => {
|
|
146
|
+
const file = Bun.file(new URL("../../og-image.png", import.meta.url).pathname);
|
|
147
|
+
return new Response(await file.arrayBuffer(), {
|
|
148
|
+
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
app.get("/robots.txt", (c) => {
|
|
153
|
+
return new Response(
|
|
154
|
+
`User-agent: *\nAllow: /\n\nSitemap: https://app.withpolaris.ai/sitemap.xml`,
|
|
155
|
+
{ headers: { "Content-Type": "text/plain" } }
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
app.get("/sitemap.xml", (c) => {
|
|
160
|
+
return new Response(
|
|
161
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
162
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
163
|
+
<url>
|
|
164
|
+
<loc>https://app.withpolaris.ai</loc>
|
|
165
|
+
<changefreq>weekly</changefreq>
|
|
166
|
+
<priority>1.0</priority>
|
|
167
|
+
</url>
|
|
168
|
+
</urlset>`,
|
|
169
|
+
{ headers: { "Content-Type": "application/xml" } }
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
123
173
|
// --- Landing page ---
|
|
124
174
|
|
|
125
175
|
app.get("/", (c) => {
|
|
126
|
-
return layout(renderLandingPage()
|
|
176
|
+
return layout(renderLandingPage(), "Polaris — It's like Gong for Claude Code sessions", {
|
|
177
|
+
title: "Polaris — It's like Gong for Claude Code sessions",
|
|
178
|
+
description: "Capture every AI coding session. Stream prompts, responses, and tool calls to Slack in real time. Collaborate across agents. Nothing is lost.",
|
|
179
|
+
canonical: "https://app.withpolaris.ai",
|
|
180
|
+
});
|
|
127
181
|
});
|
|
128
182
|
|
|
129
183
|
// --- Auth: single Google SSO flow for both signup and login ---
|
|
130
184
|
|
|
131
|
-
function startGoogleAuth(c: { redirect: (url: string) => Response }) {
|
|
185
|
+
function startGoogleAuth(c: { req: { query: (k: string) => string | undefined }; redirect: (url: string) => Response }) {
|
|
132
186
|
const google = getGoogle();
|
|
133
187
|
const state = crypto.randomUUID();
|
|
134
188
|
const codeVerifier = crypto.randomUUID();
|
|
135
|
-
|
|
189
|
+
const plan = c.req.query("plan");
|
|
190
|
+
oauthStates.set(state, { type: "login", codeVerifier, timestamp: Date.now(), plan });
|
|
136
191
|
const url = google.createAuthorizationURL(state, codeVerifier, ["openid", "email", "profile"]);
|
|
137
192
|
return c.redirect(url.toString());
|
|
138
193
|
}
|
|
@@ -167,6 +222,14 @@ export function createApp(sql: Sql) {
|
|
|
167
222
|
// 1. Existing user → log in
|
|
168
223
|
const existingUser = await getUserByEmail(sql, email);
|
|
169
224
|
if (existingUser) {
|
|
225
|
+
// Upgrade org plan if signing up for a higher tier
|
|
226
|
+
if (stateData.plan && stateData.plan !== "free") {
|
|
227
|
+
const userOrg = await getOrg(sql, existingUser.org_id);
|
|
228
|
+
if (userOrg && userOrg.plan === "free") {
|
|
229
|
+
await setOrgPlan(sql, existingUser.org_id, "free", stateData.plan, existingUser.id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
170
233
|
const token = await createToken({
|
|
171
234
|
sub: existingUser.id,
|
|
172
235
|
email: existingUser.email,
|
|
@@ -184,6 +247,11 @@ export function createApp(sql: Sql) {
|
|
|
184
247
|
const participantId = `user:${name.toLowerCase().replace(/\s+/g, ".")}`;
|
|
185
248
|
await createUser(sql, userId, email, name, existingOrg.id, participantId);
|
|
186
249
|
|
|
250
|
+
// Upgrade org plan if signing up for a higher tier
|
|
251
|
+
if (stateData.plan && stateData.plan !== "free" && existingOrg.plan === "free") {
|
|
252
|
+
await setOrgPlan(sql, existingOrg.id, "free", stateData.plan, userId);
|
|
253
|
+
}
|
|
254
|
+
|
|
187
255
|
// Notify org's Slack system channel
|
|
188
256
|
postSystemEvent({
|
|
189
257
|
sql,
|
|
@@ -195,7 +263,7 @@ export function createApp(sql: Sql) {
|
|
|
195
263
|
}).catch(() => {});
|
|
196
264
|
|
|
197
265
|
// Notify internal team
|
|
198
|
-
notifySignup({ name, email, domain, orgName: existingOrg.name, isNewOrg: false });
|
|
266
|
+
notifySignup({ name, email, domain, orgName: existingOrg.name, isNewOrg: false, plan: stateData.plan });
|
|
199
267
|
|
|
200
268
|
const token = await createToken({ sub: userId, email, name, org_id: existingOrg.id, participant_id: participantId });
|
|
201
269
|
return authRedirect(c, state, token);
|
|
@@ -205,7 +273,7 @@ export function createApp(sql: Sql) {
|
|
|
205
273
|
const orgName = domain.split(".")[0].charAt(0).toUpperCase() + domain.split(".")[0].slice(1);
|
|
206
274
|
const orgId = crypto.randomUUID();
|
|
207
275
|
try {
|
|
208
|
-
await createOrg(sql, orgId, orgName, domain);
|
|
276
|
+
await createOrg(sql, orgId, orgName, domain, stateData.plan);
|
|
209
277
|
} catch {
|
|
210
278
|
return layout(renderErrorView("Failed to create team. Please try again.", "Try again", "/login"));
|
|
211
279
|
}
|
|
@@ -214,7 +282,7 @@ export function createApp(sql: Sql) {
|
|
|
214
282
|
await createUser(sql, userId, email, name, orgId, participantId);
|
|
215
283
|
|
|
216
284
|
// Notify internal team
|
|
217
|
-
notifySignup({ name, email, domain, orgName, isNewOrg: true });
|
|
285
|
+
notifySignup({ name, email, domain, orgName, isNewOrg: true, plan: stateData.plan });
|
|
218
286
|
|
|
219
287
|
const token = await createToken({ sub: userId, email, name, org_id: orgId, participant_id: participantId });
|
|
220
288
|
return authRedirect(c, state, token);
|
|
@@ -251,8 +319,9 @@ export function createApp(sql: Sql) {
|
|
|
251
319
|
}
|
|
252
320
|
} catch { /* _system project may not exist yet */ }
|
|
253
321
|
|
|
254
|
-
// Query team members, projects, sessions, and
|
|
322
|
+
// Query team members, projects, sessions, prompt counts, and daily activity
|
|
255
323
|
const teamMembers = await listUsers(sql, payload.org_id);
|
|
324
|
+
const dailyPrompts = await getDailyPromptCounts(sql, payload.org_id);
|
|
256
325
|
const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
|
|
257
326
|
const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
|
|
258
327
|
const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
|
|
@@ -291,6 +360,8 @@ export function createApp(sql: Sql) {
|
|
|
291
360
|
hasConnectedSession,
|
|
292
361
|
totalPrompts: Array.from(promptCounts.values()).reduce((a, b) => a + b, 0),
|
|
293
362
|
teamMembers: teamMembers.map((u) => ({ name: u.name, email: u.email })),
|
|
363
|
+
plan: org.plan,
|
|
364
|
+
dailyPrompts,
|
|
294
365
|
};
|
|
295
366
|
|
|
296
367
|
if (hasConnectedSession) {
|
|
@@ -332,10 +403,18 @@ export function createApp(sql: Sql) {
|
|
|
332
403
|
const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
|
|
333
404
|
|
|
334
405
|
const mockTeam = [{ name: mockUser.name, email: mockUser.email }, { name: "Alice Chen", email: "alice@lightup.ai" }, { name: "Laura Mowry", email: "laura@lightup.ai" }];
|
|
406
|
+
const mockSenders = ["user:manu.bansal", "user:alice.chen", "user:laura.mowry"];
|
|
407
|
+
const mockDailyPrompts = mockSenders.flatMap((sender) =>
|
|
408
|
+
Array.from({ length: 14 }, (_, i) => {
|
|
409
|
+
const d = new Date(); d.setDate(d.getDate() - 13 + i);
|
|
410
|
+
return { date: d.toISOString().slice(0, 10), sender, count: Math.floor(Math.random() * 12) + 1 };
|
|
411
|
+
})
|
|
412
|
+
);
|
|
335
413
|
const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
|
|
336
|
-
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam };
|
|
337
|
-
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam };
|
|
338
|
-
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true, totalPrompts: 127, teamMembers: mockTeam };
|
|
414
|
+
const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam, dailyPrompts: mockDailyPrompts };
|
|
415
|
+
const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0, teamMembers: mockTeam, dailyPrompts: mockDailyPrompts };
|
|
416
|
+
const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true, totalPrompts: 127, teamMembers: mockTeam, dailyPrompts: mockDailyPrompts };
|
|
417
|
+
const teamPlan = { ...fresh, plan: "pro" };
|
|
339
418
|
|
|
340
419
|
return layout(`
|
|
341
420
|
<div class="max-w-5xl mx-auto px-6 py-12">
|
|
@@ -375,6 +454,14 @@ export function createApp(sql: Sql) {
|
|
|
375
454
|
</div>
|
|
376
455
|
</section>
|
|
377
456
|
|
|
457
|
+
<section>
|
|
458
|
+
<h2 class="text-lg font-bold text-gray-700 mb-1">Team plan signup</h2>
|
|
459
|
+
<p class="text-sm text-gray-400 mb-4">User signed up via the Team pricing CTA.</p>
|
|
460
|
+
<div class="border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
|
461
|
+
${renderSetupView(teamPlan)}
|
|
462
|
+
</div>
|
|
463
|
+
</section>
|
|
464
|
+
|
|
378
465
|
<section>
|
|
379
466
|
<h2 class="text-lg font-bold text-gray-700 mb-1">Profile view</h2>
|
|
380
467
|
<p class="text-sm text-gray-400 mb-4">User identity, participant ID, API token.</p>
|
package/src/web/layout.ts
CHANGED
|
@@ -4,16 +4,39 @@ export interface NavOpts {
|
|
|
4
4
|
userName?: string;
|
|
5
5
|
orgName?: string;
|
|
6
6
|
email?: string;
|
|
7
|
+
plan?: string;
|
|
8
|
+
banner?: { message: string; style?: "info" | "success" | "warning" };
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
export
|
|
11
|
+
export interface SeoOpts {
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
canonical?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function layout(body: string, title = "Polaris", seo?: SeoOpts): Response {
|
|
18
|
+
const pageTitle = seo?.title ?? title;
|
|
19
|
+
const description = seo?.description ?? "It's like Gong for Claude Code sessions. Capture every prompt, response, and tool call. Stream to Slack. Collaborate in real time.";
|
|
20
|
+
const canonical = seo?.canonical ?? "https://app.withpolaris.ai";
|
|
21
|
+
const ogImage = "https://app.withpolaris.ai/og-image.png";
|
|
10
22
|
return new Response(
|
|
11
23
|
`<!DOCTYPE html>
|
|
12
24
|
<html lang="en">
|
|
13
25
|
<head>
|
|
14
26
|
<meta charset="utf-8">
|
|
15
27
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
-
<title>${
|
|
28
|
+
<title>${pageTitle}</title>
|
|
29
|
+
<meta name="description" content="${description}">
|
|
30
|
+
<link rel="canonical" href="${canonical}">
|
|
31
|
+
<meta property="og:type" content="website">
|
|
32
|
+
<meta property="og:title" content="${pageTitle}">
|
|
33
|
+
<meta property="og:description" content="${description}">
|
|
34
|
+
<meta property="og:image" content="${ogImage}">
|
|
35
|
+
<meta property="og:url" content="${canonical}">
|
|
36
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
37
|
+
<meta name="twitter:title" content="${pageTitle}">
|
|
38
|
+
<meta name="twitter:description" content="${description}">
|
|
39
|
+
<meta name="twitter:image" content="${ogImage}">
|
|
17
40
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
18
41
|
<script>
|
|
19
42
|
tailwind.config = {
|
|
@@ -53,7 +76,7 @@ export function nav(token?: string, opts?: NavOpts): string {
|
|
|
53
76
|
<div class="w-7 h-7 rounded-full bg-polaris-600 flex items-center justify-center text-white text-xs font-bold">${(opts?.userName ?? "U").charAt(0).toUpperCase()}</div>
|
|
54
77
|
<div class="text-left">
|
|
55
78
|
<p class="text-xs font-medium text-gray-900 leading-tight">${opts?.userName ?? ""}</p>
|
|
56
|
-
<p class="text-[10px] text-gray-400 leading-tight">${opts?.orgName ?? ""}</p>
|
|
79
|
+
<p class="text-[10px] text-gray-400 leading-tight">${opts?.orgName ?? ""}${opts?.plan && opts.plan !== "free" ? ` <span class="inline-flex items-center px-1.5 py-0 rounded-full text-[9px] font-semibold bg-polaris-100 text-polaris-700">${opts.plan.charAt(0).toUpperCase() + opts.plan.slice(1)}</span>` : ""}</p>
|
|
57
80
|
</div>
|
|
58
81
|
<svg class="w-3.5 h-3.5 text-gray-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
59
82
|
</button>
|
|
@@ -75,13 +98,24 @@ export function nav(token?: string, opts?: NavOpts): string {
|
|
|
75
98
|
<svg width="14" height="14" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/><path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/><path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/><path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 6.29C4.672 4.163 6.656 2.58 9 3.58z" fill="#EA4335"/></svg>
|
|
76
99
|
Sign up
|
|
77
100
|
</a>`;
|
|
101
|
+
const bannerColors = {
|
|
102
|
+
info: "bg-polaris-50 border-polaris-200 text-polaris-800",
|
|
103
|
+
success: "bg-green-50 border-green-200 text-green-800",
|
|
104
|
+
warning: "bg-amber-50 border-amber-200 text-amber-800",
|
|
105
|
+
};
|
|
106
|
+
const bannerHtml = opts?.banner
|
|
107
|
+
? `<div class="border-b ${bannerColors[opts.banner.style ?? "info"]}">
|
|
108
|
+
<div class="max-w-5xl mx-auto px-6 py-2 text-sm">${opts.banner.message}</div>
|
|
109
|
+
</div>`
|
|
110
|
+
: "";
|
|
111
|
+
|
|
78
112
|
return `
|
|
79
113
|
<nav class="border-b border-gray-200 bg-white">
|
|
80
114
|
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
|
81
115
|
<a href="${token ? `/dashboard?token=${token}` : "/"}" class="text-lg font-bold tracking-tight text-gray-900">Polaris</a>
|
|
82
116
|
<div class="flex items-center gap-4">${right}</div>
|
|
83
117
|
</div>
|
|
84
|
-
</nav
|
|
118
|
+
</nav>${bannerHtml}`;
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
export const slackIcon = `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312zm-2.522 10.124a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.52v-2.523h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.522h-6.313z"/></svg>`;
|
package/src/web/pages.ts
CHANGED
|
@@ -374,54 +374,64 @@ export function renderLandingPage(): string {
|
|
|
374
374
|
<span class="text-4xl font-bold text-gray-900">$0</span>
|
|
375
375
|
<span class="text-gray-500 text-sm ml-1">forever</span>
|
|
376
376
|
</div>
|
|
377
|
-
<p class="mt-2 text-sm text-gray-500">
|
|
378
|
-
<a href="/signup" class="mt-6 block w-full text-center px-4 py-2.5 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition">Get started</a>
|
|
377
|
+
<p class="mt-2 text-sm text-gray-500">Try Polaris with your team. No credit card required.</p>
|
|
378
|
+
<a href="/signup?plan=free" class="mt-6 block w-full text-center px-4 py-2.5 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition">Get started</a>
|
|
379
379
|
<ul class="mt-6 space-y-3 text-sm text-gray-700">
|
|
380
380
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Unlimited users</li>
|
|
381
381
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>1,000</strong> prompts / month</span></li>
|
|
382
382
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>5 GB</strong> data captured</span></li>
|
|
383
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>7 days session history</li>
|
|
383
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>7 days</strong> session history</span></li>
|
|
384
384
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Community support</li>
|
|
385
385
|
</ul>
|
|
386
386
|
</div>
|
|
387
387
|
|
|
388
|
-
<!--
|
|
388
|
+
<!-- Pro -->
|
|
389
389
|
<div class="bg-white border-2 border-polaris-600 rounded-xl p-8">
|
|
390
|
-
<h3 class="text-sm font-semibold text-polaris-600 uppercase tracking-wider">
|
|
390
|
+
<h3 class="text-sm font-semibold text-polaris-600 uppercase tracking-wider">Pro</h3>
|
|
391
391
|
<div class="mt-4">
|
|
392
|
-
<span class="text-4xl font-bold text-gray-900">$
|
|
392
|
+
<span class="text-4xl font-bold text-gray-900">$29</span>
|
|
393
393
|
<span class="text-gray-500 text-sm ml-1">/month</span>
|
|
394
394
|
</div>
|
|
395
|
-
<p class="mt-2 text-sm text-gray-500">For
|
|
396
|
-
<a href="/signup" class="mt-6 block w-full text-center px-4 py-2.5 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Get started</a>
|
|
395
|
+
<p class="mt-2 text-sm text-gray-500">For power users who rely on Polaris daily.</p>
|
|
396
|
+
<a href="/signup?plan=pro" class="mt-6 block w-full text-center px-4 py-2.5 bg-polaris-700 text-white text-sm font-medium rounded-lg hover:bg-polaris-800 transition">Get started</a>
|
|
397
397
|
<ul class="mt-6 space-y-3 text-sm text-gray-700">
|
|
398
398
|
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Unlimited users</li>
|
|
399
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>
|
|
400
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>
|
|
401
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>
|
|
402
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
399
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>5,000</strong> prompts / month</span></li>
|
|
400
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>25 GB</strong> data captured</span></li>
|
|
401
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>30 days</strong> session history</span></li>
|
|
402
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Community support</li>
|
|
403
403
|
</ul>
|
|
404
404
|
</div>
|
|
405
405
|
|
|
406
|
-
<!--
|
|
406
|
+
<!-- Team -->
|
|
407
407
|
<div class="bg-white border border-gray-200 rounded-xl p-8">
|
|
408
|
-
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
|
408
|
+
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Team</h3>
|
|
409
409
|
<div class="mt-4">
|
|
410
|
-
<span class="text-4xl font-bold text-gray-900"
|
|
410
|
+
<span class="text-4xl font-bold text-gray-900">$49</span>
|
|
411
|
+
<span class="text-gray-500 text-sm ml-1">/month</span>
|
|
411
412
|
</div>
|
|
412
|
-
<p class="mt-2 text-sm text-gray-500">For
|
|
413
|
-
<a href="
|
|
413
|
+
<p class="mt-2 text-sm text-gray-500">For teams that need capacity, history, and support.</p>
|
|
414
|
+
<a href="/signup?plan=team" class="mt-6 block w-full text-center px-4 py-2.5 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition">Get started</a>
|
|
414
415
|
<ul class="mt-6 space-y-3 text-sm text-gray-700">
|
|
415
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
416
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
417
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
418
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
419
|
-
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
416
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Unlimited users</li>
|
|
417
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>10,000</strong> prompts / month</span></li>
|
|
418
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>50 GB</strong> data captured</span></li>
|
|
419
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg><span><strong>90 days</strong> session history</span></li>
|
|
420
|
+
<li class="flex items-start gap-2"><svg class="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>Priority support</li>
|
|
420
421
|
</ul>
|
|
421
422
|
</div>
|
|
422
423
|
|
|
423
424
|
</div>
|
|
424
425
|
|
|
426
|
+
<!-- Enterprise banner -->
|
|
427
|
+
<div class="mt-8 bg-white border border-gray-200 rounded-xl p-8 flex flex-col md:flex-row items-center justify-between gap-6">
|
|
428
|
+
<div>
|
|
429
|
+
<h3 class="font-semibold text-gray-900">Enterprise</h3>
|
|
430
|
+
<p class="mt-1 text-sm text-gray-500">Custom limits, SSO, audit logs, compliance, and dedicated support.</p>
|
|
431
|
+
</div>
|
|
432
|
+
<a href="mailto:support@withpolaris.ai?subject=Enterprise%20plan%20inquiry" class="shrink-0 px-5 py-2.5 bg-white border border-gray-300 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-50 transition">Contact us</a>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
425
435
|
<div class="mt-12 text-center text-sm text-gray-400">
|
|
426
436
|
All plans include unlimited users and real-time Slack streaming. No credit card required for Free.
|
|
427
437
|
</div>
|
package/src/web/views.ts
CHANGED
|
@@ -20,10 +20,12 @@ interface ViewContext {
|
|
|
20
20
|
hasConnectedSession: boolean;
|
|
21
21
|
totalPrompts: number;
|
|
22
22
|
teamMembers?: TeamMember[];
|
|
23
|
+
plan?: string;
|
|
24
|
+
dailyPrompts?: Array<{ date: string; sender: string; count: number }>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function navOpts(ctx: ViewContext): NavOpts {
|
|
26
|
-
return { userName: ctx.userName, orgName: ctx.orgName, email: ctx.email };
|
|
28
|
+
return { userName: ctx.userName, orgName: ctx.orgName, email: ctx.email, plan: ctx.plan, banner: bannerForCtx(ctx) };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
// --- Copyable code block ---
|
|
@@ -54,6 +56,16 @@ function statusBadge(label: string, done: boolean): string {
|
|
|
54
56
|
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">${label}</span>`;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// --- Banner helpers ---
|
|
60
|
+
|
|
61
|
+
function bannerForCtx(ctx: ViewContext): { message: string; style: "info" | "success" | "warning" } | undefined {
|
|
62
|
+
if (ctx.plan && ctx.plan !== "free") {
|
|
63
|
+
const label = ctx.plan.charAt(0).toUpperCase() + ctx.plan.slice(1);
|
|
64
|
+
return { message: `<strong>${label} plan</strong> — we'll reach out shortly to get you set up. Full access in the meantime.`, style: "info" };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
// --- Team members ---
|
|
58
70
|
|
|
59
71
|
function renderTeamMembers(members: TeamMember[], currentEmail: string): string {
|
|
@@ -72,6 +84,118 @@ function renderTeamMembers(members: TeamMember[], currentEmail: string): string
|
|
|
72
84
|
</div>`;
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
// --- Daily prompts chart ---
|
|
88
|
+
|
|
89
|
+
function renderDailyPrompts(data: Array<{ date: string; sender: string; count: number }> | undefined): string {
|
|
90
|
+
if (!data || data.length === 0) return "";
|
|
91
|
+
|
|
92
|
+
const id = `heatmap-${Math.random().toString(36).slice(2, 8)}`;
|
|
93
|
+
|
|
94
|
+
// Build date columns for last 14 days
|
|
95
|
+
const dates: string[] = [];
|
|
96
|
+
const now = new Date();
|
|
97
|
+
for (let i = 13; i >= 0; i--) {
|
|
98
|
+
const d = new Date(now);
|
|
99
|
+
d.setDate(d.getDate() - i);
|
|
100
|
+
dates.push(d.toISOString().slice(0, 10));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Totals per day
|
|
104
|
+
const dayTotals = new Map<string, number>();
|
|
105
|
+
let total = 0;
|
|
106
|
+
for (const d of data) {
|
|
107
|
+
dayTotals.set(d.date, (dayTotals.get(d.date) ?? 0) + d.count);
|
|
108
|
+
total += d.count;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Group by sender
|
|
112
|
+
const bySender = new Map<string, Map<string, number>>();
|
|
113
|
+
for (const d of data) {
|
|
114
|
+
if (!bySender.has(d.sender)) bySender.set(d.sender, new Map());
|
|
115
|
+
bySender.get(d.sender)!.set(d.date, d.count);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function intensity(count: number, max: number): string {
|
|
119
|
+
if (count === 0) return "bg-gray-100";
|
|
120
|
+
const ratio = count / max;
|
|
121
|
+
if (ratio < 0.25) return "bg-polaris-100";
|
|
122
|
+
if (ratio < 0.5) return "bg-polaris-200";
|
|
123
|
+
if (ratio < 0.75) return "bg-polaris-400";
|
|
124
|
+
return "bg-polaris-600";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function shortName(sender: string): string {
|
|
128
|
+
const name = sender.replace(/^user:/, "").split(".")[0];
|
|
129
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Team view — histogram bars
|
|
133
|
+
const teamMax = Math.max(...dates.map((d) => dayTotals.get(d) ?? 0), 1);
|
|
134
|
+
const teamBars = dates.map((date) => {
|
|
135
|
+
const count = dayTotals.get(date) ?? 0;
|
|
136
|
+
const pct = Math.max((count / teamMax) * 100, count > 0 ? 12 : 0);
|
|
137
|
+
const label = date.slice(5).replace("-", "/");
|
|
138
|
+
const day = new Date(date + "T12:00:00").toLocaleDateString("en-US", { weekday: "short" });
|
|
139
|
+
return `
|
|
140
|
+
<div class="flex flex-col items-center gap-1 flex-1" title="${day} ${label}: ${count}">
|
|
141
|
+
${count > 0 ? `<span class="text-[9px] text-gray-400 leading-none">${count}</span>` : `<span class="text-[9px] leading-none"> </span>`}
|
|
142
|
+
<div class="w-full flex flex-col justify-end" style="height: 40px">
|
|
143
|
+
<div class="w-full rounded-sm ${count > 0 ? "bg-polaris-400" : "bg-gray-100"}" style="height: ${pct}%"></div>
|
|
144
|
+
</div>
|
|
145
|
+
<span class="text-[8px] text-gray-400 leading-none">${label}</span>
|
|
146
|
+
</div>`;
|
|
147
|
+
}).join("");
|
|
148
|
+
|
|
149
|
+
const teamView = `
|
|
150
|
+
<div class="flex items-end gap-0.5">
|
|
151
|
+
${teamBars}
|
|
152
|
+
</div>`;
|
|
153
|
+
|
|
154
|
+
// Per-user view — each row uses its own max for color scale
|
|
155
|
+
const userRows = Array.from(bySender.entries()).map(([sender, counts]) => {
|
|
156
|
+
const rowMax = Math.max(...dates.map((d) => counts.get(d) ?? 0), 1);
|
|
157
|
+
const cells = dates.map((date) => {
|
|
158
|
+
const count = counts.get(date) ?? 0;
|
|
159
|
+
const day = new Date(date + "T12:00:00").toLocaleDateString("en-US", { weekday: "short" });
|
|
160
|
+
return `<div class="h-5 flex-1 rounded-sm ${intensity(count, rowMax)}" title="${day} ${date.slice(5).replace("-", "/")}: ${count}"></div>`;
|
|
161
|
+
}).join("");
|
|
162
|
+
return `
|
|
163
|
+
<div class="flex items-center gap-2">
|
|
164
|
+
<span class="text-[10px] text-gray-500 w-12 text-right shrink-0 truncate">${shortName(sender)}</span>
|
|
165
|
+
<div class="flex items-center gap-px flex-1">${cells}</div>
|
|
166
|
+
</div>`;
|
|
167
|
+
}).join("");
|
|
168
|
+
|
|
169
|
+
const dateLabels = dates.map((date) => {
|
|
170
|
+
return `<div class="flex-1 text-center text-[8px] text-gray-400 leading-none">${date.slice(5).replace("-", "/")}</div>`;
|
|
171
|
+
}).join("");
|
|
172
|
+
|
|
173
|
+
const userView = `
|
|
174
|
+
<div class="flex flex-col gap-1">
|
|
175
|
+
${userRows}
|
|
176
|
+
<div class="flex items-center gap-2">
|
|
177
|
+
<span class="w-12 shrink-0"></span>
|
|
178
|
+
<div class="flex items-center gap-px flex-1">${dateLabels}</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>`;
|
|
181
|
+
|
|
182
|
+
return `
|
|
183
|
+
<div class="mt-3 bg-white border border-gray-200 rounded-lg px-4 py-3">
|
|
184
|
+
<div class="flex items-center justify-between mb-2">
|
|
185
|
+
<p class="text-xs font-medium text-gray-500">Prompts captured</p>
|
|
186
|
+
<div class="flex items-center gap-2">
|
|
187
|
+
<span class="text-xs text-gray-400">${total} last 14d</span>
|
|
188
|
+
<div class="flex items-center bg-gray-100 rounded-md p-0.5">
|
|
189
|
+
<button id="${id}-btn-team" onclick="document.getElementById('${id}-team').classList.remove('hidden');document.getElementById('${id}-user').classList.add('hidden');this.classList.add('bg-white','shadow-sm','text-gray-700');this.classList.remove('text-gray-400');var o=document.getElementById('${id}-btn-user');o.classList.remove('bg-white','shadow-sm','text-gray-700');o.classList.add('text-gray-400')" class="text-[10px] font-medium px-2 py-0.5 rounded cursor-pointer bg-white shadow-sm text-gray-700">Team</button>
|
|
190
|
+
<button id="${id}-btn-user" onclick="document.getElementById('${id}-user').classList.remove('hidden');document.getElementById('${id}-team').classList.add('hidden');this.classList.add('bg-white','shadow-sm','text-gray-700');this.classList.remove('text-gray-400');var o=document.getElementById('${id}-btn-team');o.classList.remove('bg-white','shadow-sm','text-gray-700');o.classList.add('text-gray-400')" class="text-[10px] font-medium px-2 py-0.5 rounded cursor-pointer text-gray-400">By user</button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="${id}-team">${teamView}</div>
|
|
195
|
+
<div id="${id}-user" class="hidden">${userView}</div>
|
|
196
|
+
</div>`;
|
|
197
|
+
}
|
|
198
|
+
|
|
75
199
|
// --- Floor section ---
|
|
76
200
|
|
|
77
201
|
type StepState = "done" | "active" | "future";
|
|
@@ -107,6 +231,7 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
|
|
|
107
231
|
${ctx.orgSlug ? `<span class="text-xs text-gray-400 font-mono">${ctx.orgSlug}</span>` : ''}
|
|
108
232
|
${promptStat}
|
|
109
233
|
</div>
|
|
234
|
+
${renderDailyPrompts(ctx.dailyPrompts)}
|
|
110
235
|
${ctx.teamMembers ? renderTeamMembers(ctx.teamMembers, ctx.email) : ""}
|
|
111
236
|
</div>`;
|
|
112
237
|
}
|
|
@@ -131,6 +256,7 @@ function renderFloorSection(ctx: ViewContext, compact = false, state: StepState
|
|
|
131
256
|
<p class="text-sm font-medium text-gray-900">Slack</p>
|
|
132
257
|
${slugLabel}
|
|
133
258
|
</div>
|
|
259
|
+
${renderDailyPrompts(ctx.dailyPrompts)}
|
|
134
260
|
${ctx.teamMembers ? renderTeamMembers(ctx.teamMembers, ctx.email) : ""}
|
|
135
261
|
</div>`;
|
|
136
262
|
}
|
package/tests/db.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import { resetTestData } from "./helpers";
|
|
2
3
|
import {
|
|
3
4
|
createDb,
|
|
4
5
|
createOrg,
|
|
@@ -29,13 +30,7 @@ let sql: Sql;
|
|
|
29
30
|
|
|
30
31
|
beforeEach(async () => {
|
|
31
32
|
sql = await createDb(DATABASE_URL);
|
|
32
|
-
await sql
|
|
33
|
-
await sql`DROP TABLE IF EXISTS sessions`;
|
|
34
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
35
|
-
await sql`DROP TABLE IF EXISTS users`;
|
|
36
|
-
await sql`DROP TABLE IF EXISTS orgs`;
|
|
37
|
-
await sql.end();
|
|
38
|
-
sql = await createDb(DATABASE_URL);
|
|
33
|
+
await resetTestData(sql);
|
|
39
34
|
await createOrg(sql, "test-org", "Test Org");
|
|
40
35
|
});
|
|
41
36
|
|
package/tests/helpers.ts
CHANGED
|
@@ -1,71 +1,22 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ensureSchema, type Sql } from "../src/service/db";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Reset test data: drop
|
|
5
|
-
*
|
|
4
|
+
* Reset test data to a clean slate: drop every table in the public schema, then
|
|
5
|
+
* recreate the canonical schema via `ensureSchema` (the same DDL production uses).
|
|
6
|
+
*
|
|
7
|
+
* Dropping dynamically with CASCADE means new tables and foreign keys never break
|
|
8
|
+
* the reset, and recreating via `ensureSchema` means the test schema can't drift
|
|
9
|
+
* from `src/service/db.ts` (previously this hand-maintained its own CREATE TABLEs,
|
|
10
|
+
* which silently went stale when columns/tables like `orgs.plan` and `plan_changes`
|
|
11
|
+
* were added).
|
|
6
12
|
*/
|
|
7
13
|
export async function resetTestData(sql: Sql): Promise<void> {
|
|
8
|
-
await sql
|
|
9
|
-
|
|
10
|
-
await sql`DROP TABLE IF EXISTS projects`;
|
|
11
|
-
await sql`DROP TABLE IF EXISTS users`;
|
|
12
|
-
await sql`DROP TABLE IF EXISTS orgs`;
|
|
13
|
-
await sql`
|
|
14
|
-
CREATE TABLE orgs (
|
|
15
|
-
id TEXT PRIMARY KEY,
|
|
16
|
-
name TEXT NOT NULL,
|
|
17
|
-
slug TEXT UNIQUE,
|
|
18
|
-
domain TEXT,
|
|
19
|
-
slack_team_id TEXT,
|
|
20
|
-
slack_bot_token TEXT,
|
|
21
|
-
slack_system_channel_id TEXT,
|
|
22
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
23
|
-
)
|
|
14
|
+
const tables = await sql<{ tablename: string }[]>`
|
|
15
|
+
SELECT tablename FROM pg_tables WHERE schemaname = 'public'
|
|
24
16
|
`;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
name TEXT NOT NULL,
|
|
30
|
-
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
31
|
-
participant_id TEXT NOT NULL,
|
|
32
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
33
|
-
)
|
|
34
|
-
`;
|
|
35
|
-
await sql`
|
|
36
|
-
CREATE TABLE projects (
|
|
37
|
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
38
|
-
org_id TEXT NOT NULL REFERENCES orgs(id),
|
|
39
|
-
name TEXT NOT NULL,
|
|
40
|
-
slack_channel_id TEXT,
|
|
41
|
-
slack_channel_name TEXT,
|
|
42
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
43
|
-
UNIQUE (org_id, name)
|
|
44
|
-
)
|
|
45
|
-
`;
|
|
46
|
-
await sql`
|
|
47
|
-
CREATE TABLE sessions (
|
|
48
|
-
name TEXT NOT NULL,
|
|
49
|
-
project_id UUID NOT NULL REFERENCES projects(id),
|
|
50
|
-
org_id TEXT NOT NULL,
|
|
51
|
-
driver TEXT,
|
|
52
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
53
|
-
PRIMARY KEY (project_id, name)
|
|
54
|
-
)
|
|
55
|
-
`;
|
|
56
|
-
await sql`
|
|
57
|
-
CREATE TABLE events (
|
|
58
|
-
id UUID PRIMARY KEY,
|
|
59
|
-
org_id TEXT NOT NULL,
|
|
60
|
-
project_id UUID NOT NULL REFERENCES projects(id),
|
|
61
|
-
session TEXT NOT NULL,
|
|
62
|
-
timestamp TIMESTAMPTZ NOT NULL,
|
|
63
|
-
source TEXT NOT NULL,
|
|
64
|
-
sender TEXT NOT NULL,
|
|
65
|
-
payload JSONB NOT NULL
|
|
66
|
-
)
|
|
67
|
-
`;
|
|
68
|
-
await sql`CREATE INDEX idx_events_project ON events(project_id, timestamp)`;
|
|
69
|
-
await sql`CREATE INDEX idx_events_session ON events(project_id, session, timestamp)`;
|
|
17
|
+
for (const { tablename } of tables) {
|
|
18
|
+
await sql`DROP TABLE IF EXISTS ${sql(tablename)} CASCADE`;
|
|
19
|
+
}
|
|
20
|
+
await ensureSchema(sql);
|
|
70
21
|
await sql`INSERT INTO orgs (id, name) VALUES ('default', 'Default') ON CONFLICT DO NOTHING`;
|
|
71
22
|
}
|