@rubytech/create-maxy 0.3.3 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Install Maxy — your personal AI assistant",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -145,7 +145,7 @@ export default function AdminPage() {
145
145
  return (
146
146
  <div className="chat-page admin-page">
147
147
  <header className="chat-header">
148
- <img src="/maxy-black.png" alt="Maxy" className="chat-logo" />
148
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
149
149
  <h1 className="chat-tagline">Maxy</h1>
150
150
  <p className="chat-intro">Convenience as standard.</p>
151
151
  </header>
@@ -176,7 +176,7 @@ export default function AdminPage() {
176
176
  return (
177
177
  <div className="chat-page admin-page">
178
178
  <header className="chat-header">
179
- <img src="/maxy-black.png" alt="Maxy" className="chat-logo" />
179
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
180
180
  <h1 className="chat-tagline">Maxy</h1>
181
181
  <p className="chat-intro">Convenience as standard.</p>
182
182
  </header>
@@ -103,7 +103,7 @@ export default function ProductPage() {
103
103
  {/* Hero */}
104
104
  <div className="hero-section">
105
105
  <div className="hero">
106
- <img src="/maxy-black.png" alt="Maxy" className="hero-logo" />
106
+ <img src="/brand/maxy-black.png" alt="Maxy" className="hero-logo" />
107
107
  <h1 className="hero-tagline">No Stress. Quiet Life.</h1>
108
108
  <p className="hero-description">
109
109
  Maxy is a personal AI assistant that lives in your home.
@@ -119,7 +119,7 @@ export default function ProductPage() {
119
119
  </div>
120
120
  </div>
121
121
  <div className="hero-image">
122
- <img src="/maxy-family.png" alt="A family interacting with Maxy" />
122
+ <img src="/marketing/maxy-family.png" alt="A family interacting with Maxy" />
123
123
  </div>
124
124
  </div>
125
125
 
@@ -165,7 +165,7 @@ export default function ProductPage() {
165
165
  <h2 className="section-heading">Before and after Maxy.</h2>
166
166
  <div className="before-after-layout">
167
167
  <div className="section-image">
168
- <img src="/maxy-woman.png" alt="A woman relaxing at home with Maxy" />
168
+ <img src="/marketing/maxy-woman.png" alt="A woman relaxing at home with Maxy" />
169
169
  </div>
170
170
  <div className="scenarios">
171
171
  <div className="scenario">
@@ -209,7 +209,7 @@ export default function ProductPage() {
209
209
  <div className="hardware-options">
210
210
  <div className="hardware-card">
211
211
  <div className="hardware-image">
212
- <img src="/maxy-pi.png" alt="Maxy Pi device" />
212
+ <img src="/marketing/maxy-pi.png" alt="Maxy Pi device" />
213
213
  </div>
214
214
  <h3>Maxy Pi</h3>
215
215
  <p className="hardware-price">From &pound;125</p>
@@ -222,7 +222,7 @@ export default function ProductPage() {
222
222
  </div>
223
223
  <div className="hardware-card featured">
224
224
  <div className="hardware-image">
225
- <img src="/maxy-girl.png" alt="A girl chatting with Maxy Mini" />
225
+ <img src="/marketing/maxy-girl.png" alt="A girl chatting with Maxy Mini" />
226
226
  </div>
227
227
  <h3>Maxy Mini</h3>
228
228
  <p className="hardware-price">
@@ -40,7 +40,7 @@ export default async function OGImage({
40
40
  }}
41
41
  >
42
42
  <div style={{ width: '50%', position: 'relative', flexShrink: 0 }}>
43
- <Image src="/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
43
+ <Image src="/marketing/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
44
44
  </div>
45
45
  <div
46
46
  style={{
@@ -52,7 +52,7 @@ export default async function OGImage({
52
52
  padding: '32px 120px',
53
53
  }}
54
54
  >
55
- <Image src="/maxy-black.png" alt="Maxy" width={80} height={80} style={{ objectFit: 'contain' }} />
55
+ <Image src="/brand/maxy-black.png" alt="Maxy" width={80} height={80} style={{ objectFit: 'contain' }} />
56
56
  <h1
57
57
  style={{
58
58
  fontFamily: "'Cormorant', Georgia, serif",
@@ -90,7 +90,7 @@ export default async function OGImage({
90
90
  }}
91
91
  >
92
92
  <div style={{ position: 'relative', width: '100%', height: '60%', flexShrink: 0 }}>
93
- <Image src="/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
93
+ <Image src="/marketing/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
94
94
  </div>
95
95
  <div
96
96
  style={{
@@ -102,7 +102,7 @@ export default async function OGImage({
102
102
  padding: '40px 60px',
103
103
  }}
104
104
  >
105
- <Image src="/maxy-black.png" alt="Maxy" width={64} height={64} style={{ objectFit: 'contain', marginBottom: 20 }} />
105
+ <Image src="/brand/maxy-black.png" alt="Maxy" width={64} height={64} style={{ objectFit: 'contain', marginBottom: 20 }} />
106
106
  <h1
107
107
  style={{
108
108
  fontFamily: "'Cormorant', Georgia, serif",
@@ -151,7 +151,7 @@ export default async function OGImage({
151
151
  }}
152
152
  >
153
153
  <div style={{ position: 'relative', width: '100%', height: '55%', flexShrink: 0 }}>
154
- <Image src="/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
154
+ <Image src="/marketing/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
155
155
  </div>
156
156
  <div
157
157
  style={{
@@ -163,7 +163,7 @@ export default async function OGImage({
163
163
  padding: '32px 60px',
164
164
  }}
165
165
  >
166
- <Image src="/maxy-black.png" alt="Maxy" width={72} height={72} style={{ objectFit: 'contain', marginBottom: 20 }} />
166
+ <Image src="/brand/maxy-black.png" alt="Maxy" width={72} height={72} style={{ objectFit: 'contain', marginBottom: 20 }} />
167
167
  <h1
168
168
  style={{
169
169
  fontFamily: "'Cormorant', Georgia, serif",
@@ -210,7 +210,7 @@ export default async function OGImage({
210
210
  }}
211
211
  >
212
212
  <div style={{ position: 'relative', width: '45%', flexShrink: 0 }}>
213
- <Image src="/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
213
+ <Image src="/marketing/maxy-family.png" alt="" fill style={{ objectFit: 'cover' }} />
214
214
  </div>
215
215
  <div
216
216
  style={{
@@ -221,7 +221,7 @@ export default async function OGImage({
221
221
  padding: '40px 48px',
222
222
  }}
223
223
  >
224
- <Image src="/maxy-black.png" alt="Maxy" width={64} height={64} style={{ objectFit: 'contain', marginBottom: 20 }} />
224
+ <Image src="/brand/maxy-black.png" alt="Maxy" width={64} height={64} style={{ objectFit: 'contain', marginBottom: 20 }} />
225
225
  <h1
226
226
  style={{
227
227
  fontFamily: "'Cormorant', Georgia, serif",
@@ -1,176 +1,132 @@
1
1
  'use client'
2
2
 
3
- import { useState, useRef, useEffect, useCallback, FormEvent } from 'react'
3
+ import { useState, useRef, useEffect, FormEvent } from 'react'
4
+ import { ActivityEvent, type AdminEvent } from './admin/components/ActivityEvent'
4
5
 
5
6
  interface Message {
6
- role: 'maxy' | 'visitor'
7
- content: string
7
+ role: 'admin' | 'maxy'
8
+ content?: string
9
+ events?: AdminEvent[]
8
10
  }
9
11
 
10
- const suggestions = [
11
- 'What is Maxy and how is it different from just using ChatGPT?',
12
- 'What do I need to buy and how much will it all cost me each month?',
13
- 'How do I actually set it up and talk to it — at home and when I\u2019m out?',
14
- 'Is my family\u2019s data private, and can you see what we\u2019re talking about?',
15
- 'What can Maxy actually do for me day-to-day?',
16
- ]
17
-
18
- /** Generate a random session ID (Taskmaster prepends its own anon- prefix). */
19
- function generateSessionId(): string {
20
- return crypto.randomUUID()
21
- }
22
-
23
- export default function ChatPage() {
12
+ export default function AdminPage() {
13
+ const [authenticated, setAuthenticated] = useState(false)
14
+ const [pin, setPin] = useState('')
15
+ const [pinError, setPinError] = useState('')
16
+ const [sessionKey, setSessionKey] = useState<string | null>(null)
24
17
  const [messages, setMessages] = useState<Message[]>([])
25
- const [remainingSuggestions, setRemainingSuggestions] = useState(suggestions)
26
18
  const [input, setInput] = useState('')
27
19
  const [isStreaming, setIsStreaming] = useState(false)
28
20
  const messagesEndRef = useRef<HTMLDivElement>(null)
29
21
  const inputRef = useRef<HTMLInputElement>(null)
30
- const nudgeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
31
- const sessionKeyRef = useRef<string | null>(null)
32
- const sessionPendingRef = useRef<Promise<string | null> | null>(null)
22
+ const pinInputRef = useRef<HTMLInputElement>(null)
33
23
 
34
- // Scroll to bottom when messages change
35
24
  useEffect(() => {
36
25
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
37
26
  }, [messages])
38
27
 
39
- // Nudge timer — if no input after 15s, show gentle prompt
40
28
  useEffect(() => {
41
- nudgeTimerRef.current = setTimeout(() => {
42
- setMessages(prev => {
43
- if (prev.length > 0) return prev // user already started chatting
44
- return [{ role: 'maxy', content: 'No wrong answers. Just tell me what\'s on your mind.' }]
45
- })
46
- }, 15000)
47
- return () => {
48
- if (nudgeTimerRef.current) clearTimeout(nudgeTimerRef.current)
49
- }
50
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
51
-
52
- // Focus input on mount
53
- useEffect(() => {
54
- inputRef.current?.focus()
55
- }, [])
29
+ if (authenticated) inputRef.current?.focus()
30
+ else pinInputRef.current?.focus()
31
+ }, [authenticated])
56
32
 
57
- /** Ensure we have a Taskmaster session, creating one if needed. */
58
- const ensureSession = useCallback(async (): Promise<string | null> => {
59
- if (sessionKeyRef.current) return sessionKeyRef.current
33
+ async function handlePinSubmit(e: FormEvent) {
34
+ e.preventDefault()
35
+ setPinError('')
60
36
 
61
- // Deduplicate concurrent calls
62
- if (sessionPendingRef.current) return sessionPendingRef.current
37
+ try {
38
+ const res = await fetch('/api/admin/session', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ pin }),
42
+ })
63
43
 
64
- const promise = (async () => {
65
- try {
66
- const res = await fetch('/api/session', {
67
- method: 'POST',
68
- headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify({ session_id: generateSessionId() }),
70
- })
71
- if (!res.ok) return null
72
- const data = await res.json()
73
- sessionKeyRef.current = data.session_key
74
- return data.session_key as string
75
- } catch {
76
- return null
77
- } finally {
78
- sessionPendingRef.current = null
44
+ if (!res.ok) {
45
+ const data = await res.json().catch(() => ({}))
46
+ setPinError(data.error || 'Invalid PIN')
47
+ return
79
48
  }
80
- })()
81
49
 
82
- sessionPendingRef.current = promise
83
- return promise
84
- }, [])
85
-
86
- async function sendMessage(text: string) {
87
- if (!text || isStreaming) return
88
-
89
- // Clear nudge timer
90
- if (nudgeTimerRef.current) {
91
- clearTimeout(nudgeTimerRef.current)
92
- nudgeTimerRef.current = null
50
+ const data = await res.json()
51
+ setSessionKey(data.session_key)
52
+ setAuthenticated(true)
53
+ } catch {
54
+ setPinError('Could not connect.')
93
55
  }
56
+ }
94
57
 
95
- // Remove this suggestion if it was from the list
96
- setRemainingSuggestions(prev => prev.filter(s => s !== text))
58
+ async function sendMessage(text: string) {
59
+ if (!text || isStreaming || !sessionKey) return
97
60
 
98
- // Add visitor message
99
- const visitorMessage: Message = { role: 'visitor', content: text }
100
- const updatedMessages = [...messages, visitorMessage]
101
- setMessages(updatedMessages)
61
+ const adminMessage: Message = { role: 'admin', content: text }
62
+ const currentLength = messages.length
63
+ setMessages(prev => [...prev, adminMessage])
102
64
  setInput('')
103
65
  setIsStreaming(true)
104
66
 
105
- // Add placeholder for Maxy's response
106
- const placeholderIndex = updatedMessages.length
107
- setMessages(prev => [...prev, { role: 'maxy', content: '' }])
67
+ const maxyMessage: Message = { role: 'maxy', events: [] }
68
+ setMessages(prev => [...prev, maxyMessage])
69
+ const messageIndex = currentLength + 1
108
70
 
109
71
  try {
110
- // Ensure we have a session before sending
111
- const sessionKey = await ensureSession()
112
- if (!sessionKey) {
113
- throw new Error('session')
114
- }
115
-
116
- const res = await fetch('/api/chat', {
72
+ const res = await fetch('/api/admin/chat', {
117
73
  method: 'POST',
118
74
  headers: { 'Content-Type': 'application/json' },
119
75
  body: JSON.stringify({ message: text, session_key: sessionKey }),
120
76
  })
121
77
 
122
- if (!res.ok) {
123
- const errorData = await res.json().catch(() => ({}))
124
- throw new Error(errorData.error || 'Something went wrong')
125
- }
78
+ if (!res.ok) throw new Error('Chat request failed')
126
79
 
127
80
  const reader = res.body?.getReader()
128
- const decoder = new TextDecoder()
129
-
130
81
  if (!reader) throw new Error('No response stream')
131
82
 
132
- let accumulated = ''
133
- let sseBuffer = ''
83
+ const decoder = new TextDecoder()
84
+ let buffer = ''
134
85
 
135
86
  while (true) {
136
87
  const { done, value } = await reader.read()
137
88
  if (done) break
138
89
 
139
- sseBuffer += decoder.decode(value, { stream: true })
140
- const parts = sseBuffer.split('\n')
141
- sseBuffer = parts.pop()! // keep incomplete trailing segment
90
+ buffer += decoder.decode(value, { stream: true })
91
+ const parts = buffer.split('\n')
92
+ buffer = parts.pop()!
142
93
 
143
94
  for (const line of parts) {
144
95
  if (!line.startsWith('data: ')) continue
145
- const data = line.slice(6)
146
- if (data === '[DONE]') continue
96
+ const payload = line.slice(6)
97
+ if (payload === '[DONE]') continue
98
+
99
+ let parsed: { type: string; [key: string]: unknown }
100
+ try {
101
+ parsed = JSON.parse(payload)
102
+ } catch {
103
+ continue
104
+ }
147
105
 
148
- let parsed
149
- try { parsed = JSON.parse(data) } catch { continue }
106
+ if (parsed.type === 'done' || parsed.type === 'session_init') continue
150
107
 
151
- if (parsed.error) throw new Error(parsed.error)
152
- if (parsed.text) {
153
- accumulated += parsed.text
154
- setMessages(prev => {
155
- const updated = [...prev]
156
- updated[placeholderIndex] = {
157
- role: 'maxy',
158
- content: accumulated,
108
+ const event = parsed as AdminEvent
109
+
110
+ setMessages(prev => {
111
+ const updated = [...prev]
112
+ const msg = updated[messageIndex]
113
+ if (msg && msg.role === 'maxy') {
114
+ updated[messageIndex] = {
115
+ ...msg,
116
+ events: [...(msg.events ?? []), event],
159
117
  }
160
- return updated
161
- })
162
- }
118
+ }
119
+ return updated
120
+ })
163
121
  }
164
122
  }
165
123
  } catch (err) {
166
124
  const errorMessage = err instanceof Error ? err.message : 'Something went wrong'
167
125
  setMessages(prev => {
168
126
  const updated = [...prev]
169
- updated[placeholderIndex] = {
127
+ updated[messageIndex] = {
170
128
  role: 'maxy',
171
- content: errorMessage === 'session'
172
- ? "I'm having trouble connecting right now. Try refreshing the page."
173
- : "Sorry, I hit a snag. Try again in a moment.",
129
+ events: [{ type: 'text', content: `Error: ${errorMessage}` }],
174
130
  }
175
131
  return updated
176
132
  })
@@ -185,56 +141,67 @@ export default function ChatPage() {
185
141
  sendMessage(input.trim())
186
142
  }
187
143
 
144
+ if (!authenticated) {
145
+ return (
146
+ <div className="chat-page admin-page">
147
+ <header className="chat-header">
148
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
149
+ <h1 className="chat-tagline">Maxy</h1>
150
+ <p className="chat-intro">Convenience as standard.</p>
151
+ </header>
152
+ <div className="admin-pin-form">
153
+ <form onSubmit={handlePinSubmit}>
154
+ <input
155
+ ref={pinInputRef}
156
+ type="password"
157
+ value={pin}
158
+ onChange={e => setPin(e.target.value)}
159
+ placeholder="Enter PIN"
160
+ className="chat-input"
161
+ autoFocus
162
+ />
163
+ <button type="submit" className="chat-send" disabled={!pin}>
164
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
165
+ <line x1="5" y1="12" x2="19" y2="12" />
166
+ <polyline points="12 5 19 12 12 19" />
167
+ </svg>
168
+ </button>
169
+ </form>
170
+ {pinError && <p className="admin-pin-error">{pinError}</p>}
171
+ </div>
172
+ </div>
173
+ )
174
+ }
175
+
188
176
  return (
189
- <div className="chat-page">
177
+ <div className="chat-page admin-page">
190
178
  <header className="chat-header">
191
- <img src="/maxy-black.png" alt="Maxy" className="chat-logo" />
192
- <h1 className="chat-tagline">No Stress. Quiet Life.</h1>
193
- <p className="chat-intro">
194
- Hey, I{'\u2019'}m Maxy. I{'\u2019'}m a personal AI that handles the
195
- stuff you don{'\u2019'}t want to think about. I{'\u2019'}m not a product
196
- yet{'\u2009'}{'\u2014'}{'\u2009'}I{'\u2019'}m learning what stresses people
197
- most so I can be built right. Tell me what{'\u2019'}s on your mind.
198
- No advice, no judgment, just a conversation.
199
- </p>
200
- <div className="chat-trust">
201
- <span className="chat-trust-item">Anonymous</span>
202
- <span className="chat-trust-sep">{'\u00B7'}</span>
203
- <span className="chat-trust-item">Private</span>
204
- <span className="chat-trust-sep">{'\u00B7'}</span>
205
- <span className="chat-trust-item">No sign-up</span>
206
- <span className="chat-trust-sep">{'\u00B7'}</span>
207
- <span className="chat-trust-item">Free</span>
208
- </div>
179
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
180
+ <h1 className="chat-tagline">Maxy</h1>
181
+ <p className="chat-intro">Convenience as standard.</p>
209
182
  </header>
210
183
 
211
184
  <div className="chat-messages">
212
185
  {messages.map((msg, i) => (
213
- <div key={i} className={`message ${msg.role}`}>
214
- <div className="bubble">
215
- {msg.content || (
216
- <span className="typing-indicator">
217
- <span className="typing-dot" />
218
- <span className="typing-dot" />
219
- <span className="typing-dot" />
220
- </span>
221
- )}
222
- </div>
186
+ <div key={i} className={`message ${msg.role === 'admin' ? 'visitor' : 'maxy'}`}>
187
+ {msg.role === 'admin' ? (
188
+ <div className="bubble">{msg.content}</div>
189
+ ) : (
190
+ <div className="admin-activity">
191
+ {msg.events?.map((event, j) => (
192
+ <ActivityEvent key={j} event={event} />
193
+ ))}
194
+ {isStreaming && i === messages.length - 1 && (
195
+ <span className="typing-indicator">
196
+ <span className="typing-dot" />
197
+ <span className="typing-dot" />
198
+ <span className="typing-dot" />
199
+ </span>
200
+ )}
201
+ </div>
202
+ )}
223
203
  </div>
224
204
  ))}
225
- {!isStreaming && remainingSuggestions.length > 0 && (
226
- <div className="chat-suggestions">
227
- {remainingSuggestions.map(s => (
228
- <button
229
- key={s}
230
- className="chat-suggestion"
231
- onClick={() => sendMessage(s)}
232
- >
233
- {s}
234
- </button>
235
- ))}
236
- </div>
237
- )}
238
205
  <div ref={messagesEndRef} />
239
206
  </div>
240
207
 
@@ -246,7 +213,7 @@ export default function ChatPage() {
246
213
  type="text"
247
214
  value={input}
248
215
  onChange={e => setInput(e.target.value)}
249
- placeholder="Tell me what you need taking care of in your life!"
216
+ placeholder="Tell Maxy what you need..."
250
217
  disabled={isStreaming}
251
218
  aria-label="Type your message"
252
219
  />
@@ -263,10 +230,6 @@ export default function ChatPage() {
263
230
  </button>
264
231
  </form>
265
232
  </div>
266
-
267
- <footer className="chat-footer">
268
- <a href="https://maxy.bot">maxy.bot {'\u2197'}</a>
269
- </footer>
270
233
  </div>
271
234
  )
272
235
  }
@@ -0,0 +1,266 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useCallback, FormEvent } from 'react'
4
+
5
+ interface Message {
6
+ role: 'maxy' | 'visitor'
7
+ content: string
8
+ }
9
+
10
+ const suggestions: string[] = []
11
+
12
+ /** Generate a random session ID (Taskmaster prepends its own anon- prefix). */
13
+ function generateSessionId(): string {
14
+ return crypto.randomUUID()
15
+ }
16
+
17
+ export default function ChatPage() {
18
+ const [messages, setMessages] = useState<Message[]>([])
19
+ const [remainingSuggestions, setRemainingSuggestions] = useState(suggestions)
20
+ const [input, setInput] = useState('')
21
+ const [isStreaming, setIsStreaming] = useState(false)
22
+ const messagesEndRef = useRef<HTMLDivElement>(null)
23
+ const inputRef = useRef<HTMLInputElement>(null)
24
+ const nudgeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
25
+ const sessionKeyRef = useRef<string | null>(null)
26
+ const sessionPendingRef = useRef<Promise<string | null> | null>(null)
27
+
28
+ // Scroll to bottom when messages change
29
+ useEffect(() => {
30
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
31
+ }, [messages])
32
+
33
+ // Nudge timer — if no input after 15s, show gentle prompt
34
+ useEffect(() => {
35
+ nudgeTimerRef.current = setTimeout(() => {
36
+ setMessages(prev => {
37
+ if (prev.length > 0) return prev // user already started chatting
38
+ return [{ role: 'maxy', content: 'No wrong answers. Just tell me what\'s on your mind.' }]
39
+ })
40
+ }, 15000)
41
+ return () => {
42
+ if (nudgeTimerRef.current) clearTimeout(nudgeTimerRef.current)
43
+ }
44
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
45
+
46
+ // Focus input on mount
47
+ useEffect(() => {
48
+ inputRef.current?.focus()
49
+ }, [])
50
+
51
+ /** Ensure we have a Taskmaster session, creating one if needed. */
52
+ const ensureSession = useCallback(async (): Promise<string | null> => {
53
+ if (sessionKeyRef.current) return sessionKeyRef.current
54
+
55
+ // Deduplicate concurrent calls
56
+ if (sessionPendingRef.current) return sessionPendingRef.current
57
+
58
+ const promise = (async () => {
59
+ try {
60
+ const res = await fetch('/api/session', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ session_id: generateSessionId() }),
64
+ })
65
+ if (!res.ok) return null
66
+ const data = await res.json()
67
+ sessionKeyRef.current = data.session_key
68
+ return data.session_key as string
69
+ } catch {
70
+ return null
71
+ } finally {
72
+ sessionPendingRef.current = null
73
+ }
74
+ })()
75
+
76
+ sessionPendingRef.current = promise
77
+ return promise
78
+ }, [])
79
+
80
+ async function sendMessage(text: string) {
81
+ if (!text || isStreaming) return
82
+
83
+ // Clear nudge timer
84
+ if (nudgeTimerRef.current) {
85
+ clearTimeout(nudgeTimerRef.current)
86
+ nudgeTimerRef.current = null
87
+ }
88
+
89
+ // Remove this suggestion if it was from the list
90
+ setRemainingSuggestions(prev => prev.filter(s => s !== text))
91
+
92
+ // Add visitor message
93
+ const visitorMessage: Message = { role: 'visitor', content: text }
94
+ const updatedMessages = [...messages, visitorMessage]
95
+ setMessages(updatedMessages)
96
+ setInput('')
97
+ setIsStreaming(true)
98
+
99
+ // Add placeholder for Maxy's response
100
+ const placeholderIndex = updatedMessages.length
101
+ setMessages(prev => [...prev, { role: 'maxy', content: '' }])
102
+
103
+ try {
104
+ // Ensure we have a session before sending
105
+ const sessionKey = await ensureSession()
106
+ if (!sessionKey) {
107
+ throw new Error('session')
108
+ }
109
+
110
+ const res = await fetch('/api/chat', {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ message: text, session_key: sessionKey }),
114
+ })
115
+
116
+ if (!res.ok) {
117
+ const errorData = await res.json().catch(() => ({}))
118
+ throw new Error(errorData.error || 'Something went wrong')
119
+ }
120
+
121
+ const reader = res.body?.getReader()
122
+ const decoder = new TextDecoder()
123
+
124
+ if (!reader) throw new Error('No response stream')
125
+
126
+ let accumulated = ''
127
+ let sseBuffer = ''
128
+
129
+ while (true) {
130
+ const { done, value } = await reader.read()
131
+ if (done) break
132
+
133
+ sseBuffer += decoder.decode(value, { stream: true })
134
+ const parts = sseBuffer.split('\n')
135
+ sseBuffer = parts.pop()! // keep incomplete trailing segment
136
+
137
+ for (const line of parts) {
138
+ if (!line.startsWith('data: ')) continue
139
+ const data = line.slice(6)
140
+ if (data === '[DONE]') continue
141
+
142
+ let parsed
143
+ try { parsed = JSON.parse(data) } catch { continue }
144
+
145
+ if (parsed.error) throw new Error(parsed.error)
146
+ if (parsed.text) {
147
+ accumulated += parsed.text
148
+ setMessages(prev => {
149
+ const updated = [...prev]
150
+ updated[placeholderIndex] = {
151
+ role: 'maxy',
152
+ content: accumulated,
153
+ }
154
+ return updated
155
+ })
156
+ }
157
+ }
158
+ }
159
+ } catch (err) {
160
+ const errorMessage = err instanceof Error ? err.message : 'Something went wrong'
161
+ setMessages(prev => {
162
+ const updated = [...prev]
163
+ updated[placeholderIndex] = {
164
+ role: 'maxy',
165
+ content: errorMessage === 'session'
166
+ ? "I'm having trouble connecting right now. Try refreshing the page."
167
+ : "Sorry, I hit a snag. Try again in a moment.",
168
+ }
169
+ return updated
170
+ })
171
+ } finally {
172
+ setIsStreaming(false)
173
+ inputRef.current?.focus()
174
+ }
175
+ }
176
+
177
+ function handleSubmit(e: FormEvent) {
178
+ e.preventDefault()
179
+ sendMessage(input.trim())
180
+ }
181
+
182
+ return (
183
+ <div className="chat-page">
184
+ <header className="chat-header">
185
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
186
+ <h1 className="chat-tagline">No Stress. Quiet Life.</h1>
187
+ <p className="chat-intro">
188
+ Hey, I{'\u2019'}m Maxy. I{'\u2019'}m a personal AI that handles the
189
+ stuff you don{'\u2019'}t want to think about. I{'\u2019'}m not a product
190
+ yet{'\u2009'}{'\u2014'}{'\u2009'}I{'\u2019'}m learning what stresses people
191
+ most so I can be built right. Tell me what{'\u2019'}s on your mind.
192
+ No advice, no judgment, just a conversation.
193
+ </p>
194
+ <div className="chat-trust">
195
+ <span className="chat-trust-item">Anonymous</span>
196
+ <span className="chat-trust-sep">{'\u00B7'}</span>
197
+ <span className="chat-trust-item">Private</span>
198
+ <span className="chat-trust-sep">{'\u00B7'}</span>
199
+ <span className="chat-trust-item">No sign-up</span>
200
+ <span className="chat-trust-sep">{'\u00B7'}</span>
201
+ <span className="chat-trust-item">Free</span>
202
+ </div>
203
+ </header>
204
+
205
+ <div className="chat-messages">
206
+ {messages.map((msg, i) => (
207
+ <div key={i} className={`message ${msg.role}`}>
208
+ <div className="bubble">
209
+ {msg.content || (
210
+ <span className="typing-indicator">
211
+ <span className="typing-dot" />
212
+ <span className="typing-dot" />
213
+ <span className="typing-dot" />
214
+ </span>
215
+ )}
216
+ </div>
217
+ </div>
218
+ ))}
219
+ {!isStreaming && remainingSuggestions.length > 0 && (
220
+ <div className="chat-suggestions">
221
+ {remainingSuggestions.map(s => (
222
+ <button
223
+ key={s}
224
+ className="chat-suggestion"
225
+ onClick={() => sendMessage(s)}
226
+ >
227
+ {s}
228
+ </button>
229
+ ))}
230
+ </div>
231
+ )}
232
+ <div ref={messagesEndRef} />
233
+ </div>
234
+
235
+ <div className="chat-input-area">
236
+ <form className="chat-form" onSubmit={handleSubmit}>
237
+ <input
238
+ ref={inputRef}
239
+ className="chat-input"
240
+ type="text"
241
+ value={input}
242
+ onChange={e => setInput(e.target.value)}
243
+ placeholder="Tell me what you need taking care of in your life!"
244
+ disabled={isStreaming}
245
+ aria-label="Type your message"
246
+ />
247
+ <button
248
+ className="chat-send"
249
+ type="submit"
250
+ disabled={isStreaming || !input.trim()}
251
+ aria-label="Send message"
252
+ >
253
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
254
+ <line x1="5" y1="12" x2="19" y2="12" />
255
+ <polyline points="12 5 19 12 12 19" />
256
+ </svg>
257
+ </button>
258
+ </form>
259
+ </div>
260
+
261
+ <footer className="chat-footer">
262
+ <a href="https://maxy.bot">maxy.bot {'\u2197'}</a>
263
+ </footer>
264
+ </div>
265
+ )
266
+ }
@@ -1,42 +1,20 @@
1
1
  import { NextRequest, NextResponse } from 'next/server'
2
2
 
3
- function isLocalAccess(host: string): boolean {
4
- return host.includes('.local') ||
5
- host.includes('localhost') ||
6
- host.includes('127.0.0.1') ||
7
- host.includes('192.168.') ||
8
- host.includes('10.') ||
9
- host.startsWith('172.')
10
- }
11
-
12
3
  export function proxy(request: NextRequest) {
13
4
  const host = request.headers.get('host') || ''
14
5
  const { pathname } = request.nextUrl
15
6
 
16
- // Local network access (maxy.local:19200, 192.168.x.x, etc.) admin interface
17
- // On the local network, the user IS the admin. No public chat on local.
18
- if (isLocalAccess(host) && pathname === '/') {
19
- return NextResponse.rewrite(new URL('/admin', request.url))
20
- }
21
-
22
- // Allow admin API from local network and admin.maxy.bot
23
- if (pathname.startsWith('/api/admin/')) {
24
- if (!isLocalAccess(host) && !host.includes('admin.maxy.bot')) {
25
- return new NextResponse(JSON.stringify({ error: 'Admin API not available on this domain' }), {
26
- status: 403,
27
- headers: { 'Content-Type': 'application/json' },
28
- })
29
- }
30
- }
31
-
32
- // public.maxy.bot → serve public chat
33
- if (host.includes('public.maxy.bot')) {
34
- return NextResponse.next()
7
+ // public.maxy.bot serve public chat (not admin)
8
+ if (host.includes('public.maxy.bot') && pathname === '/') {
9
+ return NextResponse.rewrite(new URL('/public', request.url))
35
10
  }
36
11
 
37
- // admin.maxy.bot serve admin page
38
- if (host.includes('admin.maxy.bot') && pathname === '/') {
39
- return NextResponse.rewrite(new URL('/admin', request.url))
12
+ // Block admin API from public subdomain
13
+ if (host.includes('public.maxy.bot') && pathname.startsWith('/api/admin/')) {
14
+ return new NextResponse(JSON.stringify({ error: 'Not available' }), {
15
+ status: 403,
16
+ headers: { 'Content-Type': 'application/json' },
17
+ })
40
18
  }
41
19
 
42
20
  // getmaxy.com → serve landing page
@@ -45,7 +23,7 @@ export function proxy(request: NextRequest) {
45
23
  }
46
24
 
47
25
  // maxy.bot (bare) → redirect to public.maxy.bot
48
- if ((host === 'maxy.bot' || host === 'www.maxy.bot') && !host.includes('public.') && !host.includes('admin.')) {
26
+ if (host === 'maxy.bot' || host === 'www.maxy.bot') {
49
27
  return NextResponse.redirect(`https://public.maxy.bot${pathname}`, 301)
50
28
  }
51
29
 
@@ -54,6 +32,7 @@ export function proxy(request: NextRequest) {
54
32
  return NextResponse.redirect(`https://public.maxy.bot${pathname}`, 301)
55
33
  }
56
34
 
35
+ // Everything else (local access, admin.maxy.bot) → root page (admin)
57
36
  return NextResponse.next()
58
37
  }
59
38
 
Binary file