Skip to content

Commit b23f5c9

Browse files
[feat] Added demo terminal to landing page + PostHog for web traffic analytics (#67)
1 parent 25db5e4 commit b23f5c9

21 files changed

+2373
-498
lines changed

bun.lockb

1.28 KB
Binary file not shown.

web/knowledge.md

Lines changed: 460 additions & 8 deletions
Large diffs are not rendered by default.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"next-themes": "^0.3.0",
5555
"nextjs-linkedin-insight-tag": "^0.0.6",
5656
"pg": "^8.13.0",
57+
"posthog-js": "^1.205.0",
5758
"react": "^18",
5859
"react-dom": "^18",
5960
"react-hook-form": "^7.53.0",

web/src/app/analytics.knowledge.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,113 @@
11
# Analytics Implementation
22

3+
## PostHog Integration
4+
5+
Important: When integrating PostHog:
6+
- Initialize after user consent
7+
- Respect Do Not Track browser setting
8+
- Anonymize IP addresses by setting `$ip: null`
9+
- Use React Context to expose reinitialization function instead of reloading page
10+
- Place PostHogProvider above other providers in component tree
11+
- Track events with additional context (theme, referrer, etc.)
12+
- For cookie consent:
13+
- Avoid page reloads which cause UI flicker
14+
- Use context to expose reinitialize function
15+
- Keep consent UI components inside PostHogProvider
16+
- Keep components simple - prefer single component over wrapper when possible
17+
- Place consent UI inside PostHogProvider to access context directly
18+
19+
Example event tracking:
20+
```typescript
21+
posthog.capture('event_name', {
22+
referrer: document.referrer,
23+
theme: theme,
24+
// Add other relevant context
25+
})
26+
```
27+
28+
## Event Tracking Patterns
29+
30+
Important event tracking considerations:
31+
- Include theme context with all events via useTheme hook
32+
- Track location/source of identical actions (e.g., 'copy_action' from different places)
33+
- For terminal interactions, track both the command and its result
34+
- When tracking theme changes, include both old and new theme values
35+
- Avoid theme variable naming conflicts with component state by using aliases (e.g., colorTheme)
36+
- Pass event handlers down as props rather than accessing global posthog in child components
37+
38+
## Event Naming Convention
39+
40+
Event names should be verb-forward, past tense, using spaces. Examples:
41+
- clicked get started
42+
- opened demo video
43+
- viewed terminal help
44+
- changed terminal theme
45+
- executed terminal command
46+
47+
Example event properties:
48+
```typescript
49+
// Click events
50+
{
51+
location: 'hero_section' | 'cta_section' | 'install_dialog',
52+
theme: string,
53+
referrer?: string
54+
}
55+
56+
// Copy events
57+
{
58+
command: string,
59+
location: string,
60+
theme: string
61+
}
62+
63+
// Theme change events
64+
{
65+
from_theme: string,
66+
to_theme: string
67+
}
68+
```
69+
70+
## Component Patterns
71+
72+
When adding analytics to React components:
73+
- Pass event handlers as props (e.g., `onTestimonialClick`) rather than using global PostHog directly
74+
- Avoid naming conflicts with component state by using aliases (e.g., `colorTheme` for theme context)
75+
- Keep all analytics event handlers in the parent component
76+
- Use consistent property names across similar events
77+
- Include component-specific context in event properties (location, action type)
78+
79+
## TypeScript Integration
80+
81+
Important: When integrating PostHog with Next.js:
82+
- Use the official PostHog React provider from 'posthog-js/react'
83+
- Wrap the provider with the PostHog client instance: `<PostHogProvider client={posthog}>`
84+
- Initialize PostHog before using the provider
85+
- Handle cleanup with posthog.shutdown() in useEffect cleanup function
86+
- Respect Do Not Track and user consent before initialization
87+
- Consider disabling automatic pageview tracking and handling it manually for more control
88+
89+
Example setup:
90+
```typescript
91+
'use client'
92+
import posthog from 'posthog-js'
93+
import { PostHogProvider as PHProvider } from 'posthog-js/react'
94+
95+
export function PostHogProvider({ children }) {
96+
useEffect(() => {
97+
if (hasConsent && !doNotTrack) {
98+
posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY, {
99+
api_host: 'https://app.posthog.com',
100+
capture_pageview: false,
101+
})
102+
posthog.capture('$pageview')
103+
}
104+
return () => posthog.shutdown()
105+
}, [])
106+
107+
return <PHProvider client={posthog}>{children}</PHProvider>
108+
}
109+
```
110+
3111
## LinkedIn Conversion Tracking
4112

5113
The application implements LinkedIn conversion tracking using a multi-step flow:

web/src/app/api/demo/route.ts

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { env } from '@/env.mjs'
2+
import OpenAI from 'openai'
3+
import { headers } from 'next/headers'
4+
import { z } from 'zod'
5+
6+
// Rate limit: 10 requests per minute per IP
7+
const RATE_LIMIT = 10
8+
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute in milliseconds
9+
10+
// Store IP addresses and their request timestamps
11+
const ipRequests = new Map<string, { count: number; resetTime: number }>()
12+
13+
// Clean up old entries every minute
14+
setInterval(() => {
15+
const now = Date.now()
16+
for (const [ip, data] of ipRequests.entries()) {
17+
if (data.resetTime < now) {
18+
ipRequests.delete(ip)
19+
}
20+
}
21+
}, RATE_LIMIT_WINDOW)
22+
23+
const deepseekClient = new OpenAI({
24+
apiKey: env.DEEPSEEK_API_KEY,
25+
baseURL: 'https://api.deepseek.com',
26+
})
27+
28+
const corsHeaders = {
29+
'Access-Control-Allow-Origin': env.NEXT_PUBLIC_APP_URL,
30+
'Access-Control-Allow-Methods': 'POST',
31+
'Access-Control-Allow-Headers': 'Content-Type',
32+
'Access-Control-Allow-Credentials': 'true',
33+
}
34+
35+
// Handle OPTIONS preflight request
36+
export async function OPTIONS() {
37+
return new Response(null, {
38+
status: 204,
39+
headers: corsHeaders,
40+
})
41+
}
42+
43+
// Define request schema
44+
const RequestSchema = z.object({
45+
prompt: z.union([
46+
z.string().min(1, { message: 'Prompt is required' }),
47+
z.array(z.string()).min(1, { message: 'At least one prompt is required' }),
48+
]),
49+
})
50+
51+
const cleanCodeBlocks = (
52+
content: string
53+
): { html: string; message: string } => {
54+
// Extract all code blocks
55+
const codeMatches = content.match(/```[\w-]*\n([\s\S]*?)\n```/g) || []
56+
57+
// Get just the code content from each block
58+
const codeContent = codeMatches.reduce((acc, curr) => {
59+
const match = curr.match(/```[\w-]*\n([\s\S]*?)\n```/)
60+
return match ? acc + '\n' + match[1] : acc
61+
}, '')
62+
63+
return {
64+
html: codeContent,
65+
// Keep the original text for the message, replacing code blocks with a placeholder
66+
message: content.replace(
67+
/```[\w-]*\n[\s\S]*?\n```/g,
68+
'- Editing file: web/src/app/page.tsx'
69+
),
70+
}
71+
}
72+
73+
const checkRateLimit = (ip: string): Response | null => {
74+
const now = Date.now()
75+
const requestData = ipRequests.get(ip)
76+
77+
if (requestData) {
78+
// If window has expired, reset count
79+
if (requestData.resetTime < now) {
80+
ipRequests.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW })
81+
} else {
82+
// If we're still within the window, increment count
83+
if (requestData.count >= RATE_LIMIT) {
84+
return new Response(
85+
JSON.stringify({
86+
error: 'Rate limit exceeded. Please try again later.',
87+
}),
88+
{
89+
status: 429,
90+
headers: {
91+
'Content-Type': 'application/json',
92+
...corsHeaders,
93+
},
94+
}
95+
)
96+
}
97+
requestData.count++
98+
}
99+
} else {
100+
// First request from this IP
101+
ipRequests.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW })
102+
}
103+
104+
return null
105+
}
106+
107+
const validateRequest = async (
108+
request: Request
109+
): Promise<{
110+
data: z.infer<typeof RequestSchema> | null
111+
error: Response | null
112+
}> => {
113+
try {
114+
const body = await request.json()
115+
const result = RequestSchema.safeParse(body)
116+
117+
if (!result.success) {
118+
return {
119+
data: null,
120+
error: new Response(
121+
JSON.stringify({
122+
error: result.error.issues[0].message,
123+
}),
124+
{
125+
status: 400,
126+
headers: {
127+
'Content-Type': 'application/json',
128+
...corsHeaders,
129+
},
130+
}
131+
),
132+
}
133+
}
134+
135+
return { data: result.data, error: null }
136+
} catch (error) {
137+
console.error('Error parsing request:', error)
138+
return {
139+
data: null,
140+
error: new Response(
141+
JSON.stringify({
142+
error: 'Invalid request. Please check your input.',
143+
}),
144+
{
145+
status: 400,
146+
headers: {
147+
'Content-Type': 'application/json',
148+
...corsHeaders,
149+
},
150+
}
151+
),
152+
}
153+
}
154+
}
155+
156+
const callDeepseekAPI = async (
157+
prompts: string[]
158+
): Promise<{
159+
html: string
160+
message: string
161+
error: Response | null
162+
}> => {
163+
try {
164+
const response = await deepseekClient.chat.completions.create({
165+
model: 'deepseek-chat',
166+
messages: [
167+
{
168+
role: 'system',
169+
content:
170+
"You are a helpful assistant. Respond with valid HTML body that can be injected into an iframe based on the user's messages below. Don't write comments in the code. Be succinct with your response and focus just on the code, with a brief explanation beforehand. Make sure to wrap the code in backticks (```).",
171+
},
172+
...prompts.map((p) => ({
173+
role: 'user' as const,
174+
content: p,
175+
})),
176+
],
177+
temperature: 0,
178+
})
179+
180+
const { html, message } = cleanCodeBlocks(
181+
response.choices[0]?.message?.content || 'No response generated'
182+
)
183+
184+
return { html, message, error: null }
185+
} catch (error) {
186+
console.error('Error calling Deepseek:', error)
187+
return {
188+
html: '',
189+
message: '',
190+
error: new Response(
191+
JSON.stringify({ error: 'Failed to generate response' }),
192+
{
193+
status: 500,
194+
headers: {
195+
'Content-Type': 'application/json',
196+
...corsHeaders,
197+
},
198+
}
199+
),
200+
}
201+
}
202+
}
203+
204+
export async function POST(request: Request) {
205+
// Check origin
206+
const origin = request.headers.get('origin')
207+
if (origin !== env.NEXT_PUBLIC_APP_URL) {
208+
return new Response(JSON.stringify({ error: 'Unauthorized origin' }), {
209+
status: 403,
210+
headers: {
211+
'Content-Type': 'application/json',
212+
...corsHeaders,
213+
},
214+
})
215+
}
216+
217+
// Get IP address from headers
218+
const forwardedFor = headers().get('x-forwarded-for')
219+
const ip = forwardedFor?.split(',')[0] || 'unknown'
220+
221+
// Check rate limit
222+
const rateLimitError = checkRateLimit(ip)
223+
if (rateLimitError) return rateLimitError
224+
225+
// Validate request
226+
const { data, error: validationError } = await validateRequest(request)
227+
if (validationError) return validationError
228+
if (!data) {
229+
return new Response(JSON.stringify({ error: 'Invalid request data' }), {
230+
status: 400,
231+
headers: {
232+
'Content-Type': 'application/json',
233+
...corsHeaders,
234+
},
235+
})
236+
}
237+
238+
// Call Deepseek API
239+
const prompts = Array.isArray(data.prompt) ? data.prompt : [data.prompt]
240+
const { html, message, error: apiError } = await callDeepseekAPI(prompts)
241+
if (apiError) return apiError
242+
243+
return new Response(JSON.stringify({ html, message }), {
244+
headers: {
245+
'Content-Type': 'application/json',
246+
...corsHeaders,
247+
},
248+
})
249+
}

0 commit comments

Comments
 (0)